Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(secrets): restore unsaved-changes guard for settings tab navigation#4009
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
94d5adeec6a8e5e2ed88b263744dee9812c522f3e2File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,18 @@ | ||
| 'use client' | ||
| import { useCallback, useMemo } from 'react' | ||
| import { useCallback, useMemo, useState } from 'react' | ||
| import { useQueryClient } from '@tanstack/react-query' | ||
| import { useParams, usePathname, useRouter } from 'next/navigation' | ||
| import { ChevronDown, Skeleton } from '@/components/emcn' | ||
| import { | ||
| Button, | ||
| ChevronDown, | ||
| Modal, | ||
| ModalBody, | ||
| ModalContent, | ||
| ModalFooter, | ||
| ModalHeader, | ||
| Skeleton, | ||
| } from '@/components/emcn' | ||
| import { useSession } from '@/lib/auth/auth-client' | ||
| import { getSubscriptionAccessState } from '@/lib/billing/client' | ||
| import { isHosted } from '@/lib/core/config/feature-flags' | ||
| @@ -23,6 +32,7 @@ import { useOrganizations } from '@/hooks/queries/organization' | ||
| import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription' | ||
| import { usePermissionConfig } from '@/hooks/use-permission-config' | ||
| import { useSettingsNavigation } from '@/hooks/use-settings-navigation' | ||
| import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' | ||
| const SKELETON_SECTIONS = [3, 2, 2] as const | ||
| @@ -41,6 +51,13 @@ export function SettingsSidebar({ | ||
| const router = useRouter() | ||
| const queryClient = useQueryClient() | ||
| const requestNavigation = useSettingsDirtyStore((s) => s.requestNavigation) | ||
| const confirmNavigation = useSettingsDirtyStore((s) => s.confirmNavigation) | ||
| const cancelNavigation = useSettingsDirtyStore((s) => s.cancelNavigation) | ||
| const isDirty = useSettingsDirtyStore((s) => s.isDirty) | ||
| const [showDiscardDialog, setShowDiscardDialog] = useState(false) | ||
| const { data: session, isPending: sessionLoading } = useSession() | ||
| const { data: organizationsData, isLoading: orgsLoading } = useOrganizations() | ||
| const { data: generalSettings } = useGeneralSettings() | ||
| @@ -180,8 +197,27 @@ export function SettingsSidebar({ | ||
| const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation() | ||
| const handleBack = useCallback(() => { | ||
| if (isDirty) { | ||
| setShowDiscardDialog(true) | ||
| return | ||
| } | ||
| router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`)) | ||
| }, [router, popSettingsReturnUrl, workspaceId]) | ||
| }, [router, popSettingsReturnUrl, workspaceId, isDirty]) | ||
| const handleConfirmDiscard = useCallback(() => { | ||
| const section = confirmNavigation() | ||
| setShowDiscardDialog(false) | ||
| if (section) { | ||
| router.replace(getSettingsHref({ section }), { scroll: false }) | ||
| } else { | ||
| router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`)) | ||
| } | ||
| }, [confirmNavigation, router, getSettingsHref, popSettingsReturnUrl, workspaceId]) | ||
| const handleCancelDiscard = useCallback(() => { | ||
| cancelNavigation() | ||
| setShowDiscardDialog(false) | ||
| }, [cancelNavigation]) | ||
| return ( | ||
| <> | ||
| @@ -286,11 +322,15 @@ export function SettingsSidebar({ | ||
| className={itemClassName} | ||
| onMouseEnter={() => handlePrefetch(item.id)} | ||
| onFocus={() => handlePrefetch(item.id)} | ||
| onClick={() => | ||
| router.replace(getSettingsHref({ section: item.id as SettingsSection }), { | ||
| scroll: false, | ||
| }) | ||
| } | ||
| onClick={() => { | ||
| const section = item.id as SettingsSection | ||
| if (section === activeSection) return | ||
| if (!requestNavigation(section)) { | ||
| setShowDiscardDialog(true) | ||
| return | ||
| } | ||
| router.replace(getSettingsHref({ section }), { scroll: false }) | ||
| }} | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| > | ||
| {content} | ||
| </button> | ||
| @@ -312,6 +352,25 @@ export function SettingsSidebar({ | ||
| }) | ||
| )} | ||
| </div> | ||
| <Modal open={showDiscardDialog} onOpenChange={(open) => !open && handleCancelDiscard()}> | ||
| <ModalContent size='sm'> | ||
| <ModalHeader>Unsaved Changes</ModalHeader> | ||
| <ModalBody> | ||
| <p className='text-[var(--text-secondary)]'> | ||
| You have unsaved changes. Are you sure you want to discard them? | ||
| </p> | ||
| </ModalBody> | ||
| <ModalFooter> | ||
| <Button variant='default' onClick={handleCancelDiscard}> | ||
| Keep Editing | ||
| </Button> | ||
| <Button variant='destructive' onClick={handleConfirmDiscard}> | ||
| Discard Changes | ||
| </Button> | ||
| </ModalFooter> | ||
| </ModalContent> | ||
| </Modal> | ||
| </> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -741,18 +741,8 @@ export function validateExternalUrl( | ||
| } | ||
| } | ||
| // Block suspicious ports commonly used for internal services | ||
| const port = parsedUrl.port | ||
| const blockedPorts = [ | ||
| '22', // SSH | ||
| '23', // Telnet | ||
| '25', // SMTP | ||
| '3306', // MySQL | ||
| '5432', // PostgreSQL | ||
| '6379', // Redis | ||
| '27017', // MongoDB | ||
| '9200', // Elasticsearch | ||
| ] | ||
| const blockedPorts = ['22', '23', '25', '3306', '5432', '6379', '27017', '9200'] | ||
| if (port && blockedPorts.includes(port)) { | ||
| return { | ||
| @@ -842,7 +832,6 @@ export function validateAirtableId( | ||
| } | ||
| } | ||
| // Airtable IDs: prefix (3 chars) + 14 alphanumeric characters = 17 chars total | ||
| const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`) | ||
| if (!airtableIdPattern.test(value)) { | ||
| @@ -893,11 +882,6 @@ export function validateAwsRegion( | ||
| } | ||
| } | ||
| // AWS region patterns: | ||
| // - Standard: af|ap|ca|eu|me|sa|us|il followed by direction and number | ||
| // - GovCloud: us-gov-east-1, us-gov-west-1 | ||
| // - China: cn-north-1, cn-northwest-1 | ||
| // - ISO: us-iso-east-1, us-iso-west-1, us-isob-east-1 | ||
| const awsRegionPattern = | ||
| /^(af|ap|ca|cn|eu|il|me|sa|us|us-gov|us-iso|us-isob)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ | ||
| @@ -1156,7 +1140,6 @@ export function validatePaginationCursor( | ||
| } | ||
| } | ||
| // Allow alphanumeric, base64 chars (+, /, =), and URL-safe chars (-, _, ., ~, %) | ||
| const cursorPattern = /^[A-Za-z0-9+/=\-_.~%]+$/ | ||
| if (!cursorPattern.test(value)) { | ||
| logger.warn('Pagination cursor contains disallowed characters', { | ||
| @@ -1224,3 +1207,43 @@ export function validateOktaDomain(rawDomain: string): string { | ||
| } | ||
| return domain | ||
| } | ||
| const MICROSOFT_CONTENT_SUFFIXES = [ | ||
| 'sharepoint.com', | ||
| 'sharepoint.us', | ||
| 'sharepoint.de', | ||
| 'sharepoint.cn', | ||
| 'sharepointonline.com', | ||
| 'onedrive.com', | ||
| 'onedrive.live.com', | ||
| '1drv.ms', | ||
| '1drv.com', | ||
| 'microsoftpersonalcontent.com', | ||
| ] as const | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| /** | ||
| * Returns true if the given URL is hosted on a trusted Microsoft SharePoint or | ||
| * OneDrive domain. Validates the parsed hostname against an allowlist using exact | ||
| * match or subdomain suffix, preventing incomplete-substring bypasses. | ||
| * | ||
| * Covers SharePoint Online (commercial, GCC/GCC High/DoD, Germany, China), | ||
| * OneDrive business and consumer, OneDrive short-link and CDN domains, | ||
| * and Microsoft personal content CDN. | ||
| * | ||
| * @see https://learn.microsoft.com/en-us/sharepoint/required-urls-and-ports | ||
| * @see https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-u-s-government-gcc-high-endpoints | ||
| * | ||
| * @param url - The URL to check | ||
| * @returns Whether the URL belongs to a trusted Microsoft content host | ||
| */ | ||
| export function isMicrosoftContentUrl(url: string): boolean { | ||
| let hostname: string | ||
| try { | ||
| hostname = new URL(url).hostname.toLowerCase() | ||
| } catch { | ||
| return false | ||
| } | ||
| return MICROSOFT_CONTENT_SUFFIXES.some( | ||
| (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`) | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.