@burdenoff/microfe-auth 2026.1103.1 → 2026.1107.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/hooks/useAuthOperations.d.ts +1 -4
- package/dist/auth/hooks/useDelegationOperations.d.ts +2 -8
- package/dist/auth/hooks/useMyWorkspaces.d.ts +1 -4
- package/dist/auth/hooks/useSessionOperations.d.ts +7 -14
- package/dist/auth/hooks/useSsoOperations.d.ts +1 -4
- package/dist/auth/pages/MagicLinkPage.js +1 -1
- package/dist/auth/pages/MagicLinkPage.js.map +1 -1
- package/dist/auth/pages/MfaVerifyPage.js +1 -1
- package/dist/auth/pages/MfaVerifyPage.js.map +1 -1
- package/dist/auth/pages/SSORelayPage.js.map +1 -1
- package/dist/auth/utils/validation.js.map +1 -1
- package/dist/generated/global-operations.d.ts +602 -553
- package/dist/generated/global-operations.js.map +1 -1
- package/dist/generated/global-types.d.ts +2 -21
- package/dist/generated/wspace-operations.d.ts +89 -14
- package/dist/generated/wspace-operations.js.map +1 -1
- package/dist/generated/wspace-types.d.ts +4 -23
- package/dist/microfe-auth.css +2 -2
- package/dist/test/happydom.d.ts +1 -0
- package/package.json +73 -57
|
@@ -32,10 +32,7 @@ export declare function useAuthOperations(options?: UseAuthOperationsOptions): {
|
|
|
32
32
|
success: boolean;
|
|
33
33
|
message: string;
|
|
34
34
|
}>;
|
|
35
|
-
refetchCurrentUser: (
|
|
36
|
-
data: CurrentUserResponse | undefined;
|
|
37
|
-
error?: import('@apollo/client').ErrorLike;
|
|
38
|
-
}>;
|
|
35
|
+
refetchCurrentUser: import('@apollo/client/react/internal').RefetchFunction<CurrentUserResponse, import('@apollo/client').OperationVariables, undefined>;
|
|
39
36
|
login: (input: SignInInput) => Promise<AuthResult>;
|
|
40
37
|
register: (input: SignUpInput) => Promise<AuthPayload | {
|
|
41
38
|
requiresEmailVerification: boolean;
|
|
@@ -21,14 +21,8 @@ export declare function useDelegationOperations(): {
|
|
|
21
21
|
revokeDelegation: (id: string, reason?: string) => Promise<boolean>;
|
|
22
22
|
getDelegationsForApp: (appId: string) => AppUserDelegation[];
|
|
23
23
|
hasWorkspaceDelegation: (appId: string, workspaceId: string) => boolean;
|
|
24
|
-
refetchDelegations: (
|
|
25
|
-
|
|
26
|
-
error?: import('@apollo/client').ErrorLike;
|
|
27
|
-
}>;
|
|
28
|
-
refetchGrantedDelegations: (variables?: Partial<import('@apollo/client').OperationVariables> | undefined) => Promise<{
|
|
29
|
-
data: CurrentUserGrantedDelegationsResponse | undefined;
|
|
30
|
-
error?: import('@apollo/client').ErrorLike;
|
|
31
|
-
}>;
|
|
24
|
+
refetchDelegations: import('@apollo/client/react/internal').RefetchFunction<CurrentUserDelegationsResponse, import('@apollo/client').OperationVariables, undefined>;
|
|
25
|
+
refetchGrantedDelegations: import('@apollo/client/react/internal').RefetchFunction<CurrentUserGrantedDelegationsResponse, import('@apollo/client').OperationVariables, undefined>;
|
|
32
26
|
delegations: AppUserDelegation[];
|
|
33
27
|
grantedDelegations: AppUserDelegation[];
|
|
34
28
|
isLoading: boolean;
|
|
@@ -38,9 +38,6 @@ export declare function useMyWorkspaces(): {
|
|
|
38
38
|
workspace?: Workspace;
|
|
39
39
|
error?: string;
|
|
40
40
|
}>;
|
|
41
|
-
refetch: (
|
|
42
|
-
data: MyWorkspacesResponse | undefined;
|
|
43
|
-
error?: import('@apollo/client').ErrorLike;
|
|
44
|
-
}>;
|
|
41
|
+
refetch: import('@apollo/client/react/internal').RefetchFunction<MyWorkspacesResponse, import('@apollo/client').OperationVariables, undefined>;
|
|
45
42
|
};
|
|
46
43
|
export {};
|
|
@@ -22,24 +22,20 @@ export declare function useSessionOperations(options?: UseSessionOperationsOptio
|
|
|
22
22
|
revokedCount: number;
|
|
23
23
|
}>;
|
|
24
24
|
activeSessions: {
|
|
25
|
-
__typename?: "Session";
|
|
26
25
|
id: string;
|
|
27
26
|
status: import('../../generated/global-types').SessionStatus;
|
|
28
|
-
ipAddress
|
|
29
|
-
userAgent
|
|
30
|
-
location
|
|
27
|
+
ipAddress: string | null;
|
|
28
|
+
userAgent: string | null;
|
|
29
|
+
location: string | null;
|
|
31
30
|
lastActivityAt: string;
|
|
32
31
|
expiresAt: string;
|
|
33
32
|
createdAt: string;
|
|
34
33
|
isCurrent: boolean;
|
|
35
34
|
}[];
|
|
36
35
|
sessionsLoading: boolean;
|
|
37
|
-
refetchSessions:
|
|
38
|
-
pagination?: import('../../generated/global-types').
|
|
39
|
-
}
|
|
40
|
-
data: ActiveSessionsQuery | undefined;
|
|
41
|
-
error?: import('@apollo/client').ErrorLike;
|
|
42
|
-
}>;
|
|
36
|
+
refetchSessions: import('@apollo/client/react/internal').RefetchFunction<ActiveSessionsQuery, {
|
|
37
|
+
pagination?: import('../../generated/global-types').PaginationInput | null | undefined;
|
|
38
|
+
}, undefined>;
|
|
43
39
|
page: number;
|
|
44
40
|
pageSize: number;
|
|
45
41
|
totalCount: number;
|
|
@@ -48,9 +44,6 @@ export declare function useSessionOperations(options?: UseSessionOperationsOptio
|
|
|
48
44
|
goToNextPage: () => void;
|
|
49
45
|
goToPrevPage: () => void;
|
|
50
46
|
isLoading: boolean;
|
|
51
|
-
refetch: (
|
|
52
|
-
data: TokenInfoQueryResponse | undefined;
|
|
53
|
-
error?: import('@apollo/client').ErrorLike;
|
|
54
|
-
}>;
|
|
47
|
+
refetch: import('@apollo/client/react/internal').RefetchFunction<TokenInfoQueryResponse, import('@apollo/client').OperationVariables, undefined>;
|
|
55
48
|
};
|
|
56
49
|
export {};
|
|
@@ -58,9 +58,6 @@ export declare function useSsoOperations(tenantId?: string): {
|
|
|
58
58
|
isSaving: boolean;
|
|
59
59
|
isDeleting: boolean;
|
|
60
60
|
isGeneratingOidcUrl: boolean;
|
|
61
|
-
refetchConfig: (
|
|
62
|
-
data: GetSSOConfigResponse | undefined;
|
|
63
|
-
error?: import('@apollo/client').ErrorLike;
|
|
64
|
-
}>;
|
|
61
|
+
refetchConfig: import('@apollo/client/react/internal').RefetchFunction<GetSSOConfigResponse, GetSSOConfigVariables, undefined>;
|
|
65
62
|
};
|
|
66
63
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MagicLinkPage.js","names":["toProfileUser","user","setShellSsoCookie","domain","domainPart","securePart","ssoValue","userId","MagicLinkPage","tr","useTr","navigate","useNavigate","searchParams","useSearchParams","token","context","useAuth","eventBus","useEventBus","email","setEmail","useState","status","setStatus","message","setMessage","hasVerifiedRef","useRef","sendMagicLink","sendLoading","useMutation","SendMagicLinkDocument","authenticateMagicLink","AuthenticateMagicLinkDocument","handleVerifyToken","useCallback","store","useAuthStore","tokenValue","result","data","expiresAt","loginProfile","err","errorMessage","useEffect","AuthLayout","CheckIcon","AlertIcon","EmailIcon","PagePurpose","Button"],"sources":["../../../src/auth/pages/MagicLinkPage.tsx"],"sourcesContent":["import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { useNavigate, useSearchParams } from \"react-router\";\nimport { useMutation } from \"@apollo/client/react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport {\n loginProfile,\n type ProfileUser,\n} from \"@burdenoff/fe-libs/shared/providers/shell/ProfileStorageManager\";\nimport { AuthLayout } from \"../components/AuthLayout\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport { PagePurpose } from \"@burdenoff/fe-libs/ui\";\nimport { EmailIcon, CheckIcon, AlertIcon } from \"../components/AuthIcons\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { useAuth } from \"../providers/AuthProvider\";\nimport type { MagicLinkSendResult, AuthResult, User } from \"../types\";\n\nfunction toProfileUser(user: User): ProfileUser {\n return { ...user };\n}\n\nfunction setShellSsoCookie(userId: string): void {\n try {\n const hostname = window.location.hostname;\n const domain = hostname.endsWith(\".local.burdenoff.com\")\n ? \".local.burdenoff.com\"\n : undefined;\n const domainPart = domain ? `; domain=${domain}` : \"\";\n const securePart = window.location.protocol === \"https:\" ? \"; secure\" : \"\";\n const ssoValue = JSON.stringify({\n id: userId,\n origin: window.location.origin,\n });\n document.cookie = `bf-sso=${encodeURIComponent(ssoValue)}${domainPart}; path=/; samesite=lax${securePart}; max-age=86400`;\n document.cookie = `bf-sso-profile=${encodeURIComponent(ssoValue)}${domainPart}; path=/; samesite=lax${securePart}; max-age=86400`;\n } catch {\n // Best effort only.\n }\n}\n\nimport {\n SendMagicLinkDocument,\n AuthenticateMagicLinkDocument,\n} from \"../../generated/global-operations\";\n// Response types for GraphQL mutations\ninterface SendMagicLinkResponse {\n sendMagicLink: MagicLinkSendResult;\n}\n\ninterface AuthenticateMagicLinkResponse {\n authenticateMagicLink: AuthResult;\n}\n\n// GraphQL Operations\n\ntype PageStatus = \"form\" | \"sent\" | \"verifying\" | \"success\" | \"error\";\n\n/**\n * Magic Link authentication page\n * Supports both sending magic links and verifying them via token\n */\nexport function MagicLinkPage() {\n const tr = useTr();\n\n const navigate = useNavigate();\n const [searchParams] = useSearchParams();\n const token = searchParams.get(\"token\");\n const context = useAuth();\n const eventBus = useEventBus();\n\n const [email, setEmail] = useState(\"\");\n const [status, setStatus] = useState<PageStatus>(\n token ? \"verifying\" : \"form\",\n );\n const [message, setMessage] = useState(\"\");\n const hasVerifiedRef = useRef(false);\n\n const [sendMagicLink, { loading: sendLoading }] =\n useMutation<SendMagicLinkResponse>(SendMagicLinkDocument);\n const [authenticateMagicLink] = useMutation<AuthenticateMagicLinkResponse>(\n AuthenticateMagicLinkDocument,\n );\n\n const handleVerifyToken = useCallback(\n async (tokenValue: string) => {\n const store = useAuthStore.getState();\n\n try {\n setStatus(\"verifying\");\n store.setLoading(true);\n\n const { data } = await authenticateMagicLink({\n variables: { token: tokenValue },\n });\n const result = data!.authenticateMagicLink;\n\n if (!result.success || !result.user) {\n throw new Error(result.error || \"Authentication failed\");\n }\n\n store.setUser(result.user);\n store.setAuthTokens(result.token ?? \"\", result.refreshToken ?? \"\");\n store.setAuthenticated(true);\n store.setMfaRequired(false);\n\n const expiresAt = result.expiresIn\n ? Date.now() + result.expiresIn * 1000\n : undefined;\n loginProfile(toProfileUser(result.user), {\n accessToken: result.token ?? \"\",\n refreshToken: result.refreshToken ?? \"\",\n expiresAt,\n });\n setShellSsoCookie(result.user.id);\n\n eventBus.emit(\n \"auth.signed_in\",\n {\n userId: result.user.id,\n tenantId: result.user.tenantId,\n workspaceId: result.user.defaultWorkspaceId,\n mfaEnabled: result.user.mfaEnabled,\n emailVerified: result.user.emailVerified,\n },\n { component: \"MagicLinkPage\" },\n );\n\n context.onAuthSuccess?.(result.user, result.token, result.refreshToken);\n\n setStatus(\"success\");\n setMessage(\n tr(\n \"auth.magicLink.signedInMessage\",\n \"You have been signed in successfully!\",\n ),\n );\n\n // Redirect to dashboard after successful authentication\n setTimeout(() => {\n navigate(\"/\", { replace: true });\n }, 1500);\n } catch (err) {\n // If user is already authenticated (token was already used), redirect to dashboard\n if (store.isAuthenticated && store.user) {\n console.info(\n \"[MagicLink] Token already used but user is authenticated, redirecting...\",\n );\n navigate(\"/\", { replace: true });\n return;\n }\n\n setStatus(\"error\");\n const errorMessage =\n err instanceof Error\n ? err.message\n : tr(\n \"auth.magicLink.invalidOrExpired\",\n \"Invalid or expired magic link\",\n );\n setMessage(errorMessage);\n } finally {\n store.setLoading(false);\n }\n },\n [authenticateMagicLink, context, eventBus, navigate, tr],\n );\n\n // Handle token verification on page load (only once)\n useEffect(() => {\n if (token && !hasVerifiedRef.current) {\n hasVerifiedRef.current = true;\n handleVerifyToken(token);\n }\n }, [token, handleVerifyToken]);\n\n const handleSendMagicLink = async (e: React.FormEvent) => {\n e.preventDefault();\n\n if (!email) {\n setMessage(\n tr(\"auth.magicLink.enterEmail\", \"Please enter your email address\"),\n );\n return;\n }\n\n try {\n const { data } = await sendMagicLink({ variables: { email } });\n\n if (data!.sendMagicLink.success) {\n setStatus(\"sent\");\n setMessage(\n data!.sendMagicLink.message ||\n tr(\n \"auth.magicLink.sentSuccess\",\n \"Magic link sent! Check your email.\",\n ),\n );\n } else {\n setMessage(\n data!.sendMagicLink.message ||\n tr(\"auth.magicLink.sendFailed\", \"Failed to send magic link\"),\n );\n }\n } catch (err) {\n const errorMessage =\n err instanceof Error\n ? err.message\n : tr(\"auth.magicLink.sendFailed\", \"Failed to send magic link\");\n setMessage(errorMessage);\n }\n };\n\n // Verifying state\n if (status === \"verifying\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.signingIn\", \"Signing you in...\")}\n subtitle={tr(\n \"auth.magicLink.verifyingSubtitle\",\n \"Please wait while we verify your magic link\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"animate-spin rounded-full size-12 border-b-2 border-[var(--color-accent-primary)]\" />\n <p className=\"mt-4 text-[var(--color-text-secondary)]\">\n {tr(\"auth.magicLink.verifying\", \"Verifying magic link...\")}\n </p>\n </div>\n </AuthLayout>\n );\n }\n\n // Success state\n if (status === \"success\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.welcomeBack\", \"Welcome back!\")}\n subtitle={tr(\n \"auth.magicLink.signedInSuccess\",\n \"You have been signed in successfully\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-success-subtle)] flex items-center justify-center mb-4\">\n <CheckIcon className=\"size-8 text-[var(--color-status-success)]\" />\n </div>\n <p className=\"text-[var(--color-text-primary)] text-center mb-4\">\n {message}\n </p>\n <p className=\"text-sm text-[var(--color-text-secondary)]\">\n {tr(\"auth.magicLink.redirecting\", \"Redirecting to application...\")}\n </p>\n </div>\n </AuthLayout>\n );\n }\n\n // Error state\n if (status === \"error\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.oops\", \"Oops!\")}\n subtitle={tr(\"auth.magicLink.somethingWrong\", \"Something went wrong\")}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-error-subtle)] flex items-center justify-center mb-4\">\n <AlertIcon className=\"size-8 text-[var(--color-status-error)]\" />\n </div>\n <p className=\"text-[var(--color-status-error)] text-center mb-4\">\n {message}\n </p>\n <button\n type=\"button\"\n onClick={() => {\n setStatus(\"form\");\n setMessage(\"\");\n }}\n className=\"px-4 py-2 rounded-lg bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)]\"\n >\n {tr(\"auth.magicLink.tryAgain\", \"Try again\")}\n </button>\n </div>\n </AuthLayout>\n );\n }\n\n // Sent state\n if (status === \"sent\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.checkEmail\", \"Check your email\")}\n subtitle={tr(\n \"auth.magicLink.sentMagicLink\",\n \"We've sent you a magic link\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-success-subtle)] flex items-center justify-center mb-4\">\n <EmailIcon className=\"size-8 text-[var(--color-status-success)]\" />\n </div>\n <p className=\"text-[var(--color-text-primary)] text-center mb-2\">\n {tr(\"auth.magicLink.sentTo\", \"We've sent a magic link to:\")}\n </p>\n <p className=\"text-[var(--color-accent-primary)] font-medium mb-4\">\n {email}\n </p>\n <p className=\"text-sm text-[var(--color-text-secondary)] text-center mb-6\">\n {tr(\n \"auth.magicLink.linkExpires\",\n \"Click the link in the email to sign in. The link expires in 24 hours.\",\n )}\n </p>\n <button\n type=\"button\"\n onClick={() => {\n setStatus(\"form\");\n setMessage(\"\");\n }}\n className=\"text-[var(--color-accent-primary)] hover:underline\"\n >\n {tr(\"auth.magicLink.differentEmail\", \"Use a different email\")}\n </button>\n </div>\n </AuthLayout>\n );\n }\n\n // Form state\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.title\", \"Sign in with Magic Link\")}\n subtitle={tr(\n \"auth.magicLink.subtitle\",\n \"No password needed - we'll email you a link\",\n )}\n >\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.magicLink.purpose\",\n \"Skip the password — we'll email you a one-time link that signs you in instantly. Great when you don't remember your password or are on a trusted device.\",\n )}\n </PagePurpose>\n <form onSubmit={handleSendMagicLink} className=\"space-y-6\">\n <div>\n <label\n htmlFor=\"email\"\n className=\"block text-sm font-medium text-[var(--color-text-primary)] mb-2\"\n >\n {tr(\"auth.magicLink.emailLabel\", \"Email address\")}\n </label>\n <div className=\"relative\">\n <div className=\"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none\">\n <EmailIcon className=\"size-5 text-[var(--color-text-secondary)]\" />\n </div>\n <input\n id=\"email\"\n type=\"email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder={tr(\n \"auth.magicLink.emailPlaceholder\",\n \"you@example.com\",\n )}\n aria-label={tr(\"auth.magicLink.emailLabel\", \"Email address\")}\n className=\"block w-full pl-[3.25rem] pr-4 py-3 border border-[var(--color-border-primary)] rounded-lg bg-[var(--color-background-base)] text-[var(--color-text-primary)] placeholder-[var(--color-text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-primary)] focus:border-transparent\"\n required\n />\n </div>\n </div>\n\n {message && (\n <p className=\"text-sm text-[var(--color-status-error)]\">{message}</p>\n )}\n\n <Button\n type=\"submit\"\n disabled={sendLoading}\n variant=\"default\"\n size=\"lg\"\n className=\"w-full font-semibold\"\n >\n {sendLoading\n ? tr(\"auth.magicLink.sending\", \"Sending...\")\n : tr(\"auth.magicLink.sendButton\", \"Send Magic Link\")}\n </Button>\n\n <div className=\"text-center\">\n <button\n type=\"button\"\n onClick={() => navigate(\"/auth/login\")}\n className=\"text-sm text-[var(--color-accent-primary)] hover:underline\"\n >\n {tr(\n \"auth.magicLink.passwordInstead\",\n \"Sign in with password instead\",\n )}\n </button>\n </div>\n </form>\n </AuthLayout>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"MagicLinkPage.js","names":["toProfileUser","user","setShellSsoCookie","domain","domainPart","securePart","ssoValue","userId","MagicLinkPage","tr","useTr","navigate","useNavigate","searchParams","useSearchParams","token","context","useAuth","eventBus","useEventBus","email","setEmail","useState","status","setStatus","message","setMessage","hasVerifiedRef","useRef","sendMagicLink","sendLoading","useMutation","SendMagicLinkDocument","authenticateMagicLink","AuthenticateMagicLinkDocument","handleVerifyToken","useCallback","store","useAuthStore","tokenValue","result","data","expiresAt","loginProfile","err","errorMessage","useEffect","AuthLayout","CheckIcon","AlertIcon","EmailIcon","PagePurpose","Button"],"sources":["../../../src/auth/pages/MagicLinkPage.tsx"],"sourcesContent":["import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { useNavigate, useSearchParams } from \"react-router\";\nimport { useMutation } from \"@apollo/client/react\";\nimport { useEventBus } from \"@burdenoff/fe-libs/shared/events\";\nimport {\n loginProfile,\n type ProfileUser,\n} from \"@burdenoff/fe-libs/shared/providers/shell/ProfileStorageManager\";\nimport { AuthLayout } from \"../components/AuthLayout\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport { PagePurpose } from \"@burdenoff/fe-libs/ui\";\nimport { EmailIcon, CheckIcon, AlertIcon } from \"../components/AuthIcons\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { useAuth } from \"../providers/AuthProvider\";\nimport type { MagicLinkSendResult, AuthResult, User } from \"../types\";\n\nfunction toProfileUser(user: User): ProfileUser {\n return { ...user };\n}\n\nfunction setShellSsoCookie(userId: string): void {\n try {\n const hostname = window.location.hostname;\n const domain = hostname.endsWith(\".local.burdenoff.com\")\n ? \".local.burdenoff.com\"\n : undefined;\n const domainPart = domain ? `; domain=${domain}` : \"\";\n const securePart = window.location.protocol === \"https:\" ? \"; secure\" : \"\";\n const ssoValue = JSON.stringify({\n id: userId,\n origin: window.location.origin,\n });\n document.cookie = `bf-sso=${encodeURIComponent(ssoValue)}${domainPart}; path=/; samesite=lax${securePart}; max-age=86400`;\n document.cookie = `bf-sso-profile=${encodeURIComponent(ssoValue)}${domainPart}; path=/; samesite=lax${securePart}; max-age=86400`;\n } catch {\n // Best effort only.\n }\n}\n\nimport {\n SendMagicLinkDocument,\n AuthenticateMagicLinkDocument,\n} from \"../../generated/global-operations\";\n// Response types for GraphQL mutations\ninterface SendMagicLinkResponse {\n sendMagicLink: MagicLinkSendResult;\n}\n\ninterface AuthenticateMagicLinkResponse {\n authenticateMagicLink: AuthResult;\n}\n\n// GraphQL Operations\n\ntype PageStatus = \"form\" | \"sent\" | \"verifying\" | \"success\" | \"error\";\n\n/**\n * Magic Link authentication page\n * Supports both sending magic links and verifying them via token\n */\nexport function MagicLinkPage() {\n const tr = useTr();\n\n const navigate = useNavigate();\n const [searchParams] = useSearchParams();\n const token = searchParams.get(\"token\");\n const context = useAuth();\n const eventBus = useEventBus();\n\n const [email, setEmail] = useState(\"\");\n const [status, setStatus] = useState<PageStatus>(\n token ? \"verifying\" : \"form\",\n );\n const [message, setMessage] = useState(\"\");\n const hasVerifiedRef = useRef(false);\n\n const [sendMagicLink, { loading: sendLoading }] =\n useMutation<SendMagicLinkResponse>(SendMagicLinkDocument);\n const [authenticateMagicLink] = useMutation<AuthenticateMagicLinkResponse>(\n AuthenticateMagicLinkDocument,\n );\n\n const handleVerifyToken = useCallback(\n async (tokenValue: string) => {\n const store = useAuthStore.getState();\n\n try {\n setStatus(\"verifying\");\n store.setLoading(true);\n\n const { data } = await authenticateMagicLink({\n variables: { token: tokenValue },\n });\n const result = data!.authenticateMagicLink;\n\n if (!result.success || !result.user) {\n throw new Error(result.error || \"Authentication failed\");\n }\n\n store.setUser(result.user);\n store.setAuthTokens(result.token ?? \"\", result.refreshToken ?? \"\");\n store.setAuthenticated(true);\n store.setMfaRequired(false);\n\n const expiresAt = result.expiresIn\n ? Date.now() + result.expiresIn * 1000\n : undefined;\n loginProfile(toProfileUser(result.user), {\n accessToken: result.token ?? \"\",\n refreshToken: result.refreshToken ?? \"\",\n expiresAt,\n });\n setShellSsoCookie(result.user.id);\n\n eventBus.emit(\n \"auth.signed_in\",\n {\n userId: result.user.id,\n tenantId: result.user.tenantId,\n workspaceId: result.user.defaultWorkspaceId,\n mfaEnabled: result.user.mfaEnabled,\n emailVerified: result.user.emailVerified,\n },\n { component: \"MagicLinkPage\" },\n );\n\n context.onAuthSuccess?.(result.user, result.token, result.refreshToken);\n\n setStatus(\"success\");\n setMessage(\n tr(\n \"auth.magicLink.signedInMessage\",\n \"You have been signed in successfully!\",\n ),\n );\n\n // Redirect to dashboard after successful authentication\n setTimeout(() => {\n navigate(\"/\", { replace: true });\n }, 1500);\n } catch (err) {\n // If user is already authenticated (token was already used), redirect to dashboard\n if (store.isAuthenticated && store.user) {\n console.info(\n \"[MagicLink] Token already used but user is authenticated, redirecting...\",\n );\n navigate(\"/\", { replace: true });\n return;\n }\n\n setStatus(\"error\");\n const errorMessage =\n err instanceof Error\n ? err.message\n : tr(\n \"auth.magicLink.invalidOrExpired\",\n \"Invalid or expired magic link\",\n );\n setMessage(errorMessage);\n } finally {\n store.setLoading(false);\n }\n },\n [authenticateMagicLink, context, eventBus, navigate, tr],\n );\n\n // Handle token verification on page load (only once)\n useEffect(() => {\n if (token && !hasVerifiedRef.current) {\n hasVerifiedRef.current = true;\n handleVerifyToken(token);\n }\n }, [token, handleVerifyToken]);\n\n const handleSendMagicLink = async (e: React.FormEvent) => {\n e.preventDefault();\n\n if (!email) {\n setMessage(\n tr(\"auth.magicLink.enterEmail\", \"Please enter your email address\"),\n );\n return;\n }\n\n try {\n const { data } = await sendMagicLink({ variables: { email } });\n\n if (data!.sendMagicLink.success) {\n setStatus(\"sent\");\n setMessage(\n data!.sendMagicLink.message ||\n tr(\n \"auth.magicLink.sentSuccess\",\n \"Magic link sent! Check your email.\",\n ),\n );\n } else {\n setMessage(\n data!.sendMagicLink.message ||\n tr(\"auth.magicLink.sendFailed\", \"Failed to send magic link\"),\n );\n }\n } catch (err) {\n const errorMessage =\n err instanceof Error\n ? err.message\n : tr(\"auth.magicLink.sendFailed\", \"Failed to send magic link\");\n setMessage(errorMessage);\n }\n };\n\n // Verifying state\n if (status === \"verifying\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.signingIn\", \"Signing you in...\")}\n subtitle={tr(\n \"auth.magicLink.verifyingSubtitle\",\n \"Please wait while we verify your magic link\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"animate-spin rounded-full size-12 border-b-2 border-[var(--color-accent-primary)]\" />\n <p className=\"mt-4 text-[var(--color-text-secondary)]\">\n {tr(\"auth.magicLink.verifying\", \"Verifying magic link...\")}\n </p>\n </div>\n </AuthLayout>\n );\n }\n\n // Success state\n if (status === \"success\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.welcomeBack\", \"Welcome back!\")}\n subtitle={tr(\n \"auth.magicLink.signedInSuccess\",\n \"You have been signed in successfully\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-success-subtle)] flex items-center justify-center mb-4\">\n <CheckIcon className=\"size-8 text-[var(--color-status-success)]\" />\n </div>\n <p className=\"text-[var(--color-text-primary)] text-center mb-4\">\n {message}\n </p>\n <p className=\"text-sm text-[var(--color-text-secondary)]\">\n {tr(\"auth.magicLink.redirecting\", \"Redirecting to application...\")}\n </p>\n </div>\n </AuthLayout>\n );\n }\n\n // Error state\n if (status === \"error\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.oops\", \"Oops!\")}\n subtitle={tr(\"auth.magicLink.somethingWrong\", \"Something went wrong\")}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-error-subtle)] flex items-center justify-center mb-4\">\n <AlertIcon className=\"size-8 text-[var(--color-status-error)]\" />\n </div>\n <p className=\"text-[var(--color-status-error)] text-center mb-4\">\n {message}\n </p>\n <button\n type=\"button\"\n onClick={() => {\n setStatus(\"form\");\n setMessage(\"\");\n }}\n className=\"px-4 py-2 rounded-lg bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)]\"\n >\n {tr(\"auth.magicLink.tryAgain\", \"Try again\")}\n </button>\n </div>\n </AuthLayout>\n );\n }\n\n // Sent state\n if (status === \"sent\") {\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.checkEmail\", \"Check your email\")}\n subtitle={tr(\n \"auth.magicLink.sentMagicLink\",\n \"We've sent you a magic link\",\n )}\n >\n <div className=\"flex flex-col items-center py-8\">\n <div className=\"size-16 rounded-full bg-[var(--color-status-success-subtle)] flex items-center justify-center mb-4\">\n <EmailIcon className=\"size-8 text-[var(--color-status-success)]\" />\n </div>\n <p className=\"text-[var(--color-text-primary)] text-center mb-2\">\n {tr(\"auth.magicLink.sentTo\", \"We've sent a magic link to:\")}\n </p>\n <p className=\"text-[var(--color-accent-primary)] font-medium mb-4\">\n {email}\n </p>\n <p className=\"text-sm text-[var(--color-text-secondary)] text-center mb-6\">\n {tr(\n \"auth.magicLink.linkExpires\",\n \"Click the link in the email to sign in. The link expires in 24 hours.\",\n )}\n </p>\n <button\n type=\"button\"\n onClick={() => {\n setStatus(\"form\");\n setMessage(\"\");\n }}\n className=\"text-[var(--color-accent-primary)] hover:underline\"\n >\n {tr(\"auth.magicLink.differentEmail\", \"Use a different email\")}\n </button>\n </div>\n </AuthLayout>\n );\n }\n\n // Form state\n return (\n <AuthLayout\n title={tr(\"auth.magicLink.title\", \"Sign in with Magic Link\")}\n subtitle={tr(\n \"auth.magicLink.subtitle\",\n \"No password needed - we'll email you a link\",\n )}\n >\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.magicLink.purpose\",\n \"Skip the password — we'll email you a one-time link that signs you in instantly. Great when you don't remember your password or are on a trusted device.\",\n )}\n </PagePurpose>\n <form onSubmit={handleSendMagicLink} className=\"space-y-6\">\n <div>\n <label\n htmlFor=\"email\"\n className=\"block text-sm font-medium text-[var(--color-text-primary)] mb-2\"\n >\n {tr(\"auth.magicLink.emailLabel\", \"Email address\")}\n </label>\n <div className=\"relative\">\n <div className=\"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none\">\n <EmailIcon className=\"size-5 text-[var(--color-text-secondary)]\" />\n </div>\n <input\n id=\"email\"\n type=\"email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder={tr(\n \"auth.magicLink.emailPlaceholder\",\n \"you@example.com\",\n )}\n aria-label={tr(\"auth.magicLink.emailLabel\", \"Email address\")}\n className=\"block w-full pl-[3.25rem] pr-4 py-3 border border-[var(--color-border-primary)] rounded-lg bg-[var(--color-background-base)] text-[var(--color-text-primary)] placeholder-[var(--color-text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-primary)] focus:border-transparent\"\n required\n />\n </div>\n </div>\n\n {message && (\n <p className=\"text-sm text-[var(--color-status-error)]\">{message}</p>\n )}\n\n <Button\n type=\"submit\"\n disabled={sendLoading}\n variant=\"default\"\n size=\"lg\"\n className=\"w-full font-semibold\"\n >\n {sendLoading\n ? tr(\"auth.magicLink.sending\", \"Sending...\")\n : tr(\"auth.magicLink.sendButton\", \"Send Magic Link\")}\n </Button>\n\n <div className=\"text-center\">\n <button\n type=\"button\"\n onClick={() => navigate(\"/auth/login\")}\n className=\"text-sm text-[var(--color-accent-primary)] hover:underline\"\n >\n {tr(\n \"auth.magicLink.passwordInstead\",\n \"Sign in with password instead\",\n )}\n </button>\n </div>\n </form>\n </AuthLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAiBA,SAASA,EAAc,GAAyB;CAC9C,OAAO,EAAE,GAAGC,EAAK;AACnB;AAEA,SAASC,EAAkB,GAAsB;CAC/C,IAAI;EAEF,IAAMC,IADW,OAAO,SAAS,SACT,SAAS,sBAAsB,IACnD,yBACA,KAAA,GACEC,IAAaD,IAAS,YAAYA,MAAW,IAC7CE,IAAa,OAAO,SAAS,aAAa,WAAW,aAAa,IAClEC,IAAW,KAAK,UAAU;GAC9B,IAAIC;GACJ,QAAQ,OAAO,SAAS;EAC1B,CAAC;EAED,AADA,SAAS,SAAS,UAAU,mBAAmBD,CAAQ,IAAIF,EAAW,wBAAwBC,EAAW,kBACzG,SAAS,SAAS,kBAAkB,mBAAmBC,CAAQ,IAAIF,EAAW,wBAAwBC,EAAW;CACnH,QAAQ,CAER;AACF;AAuBA,SAAgBG,IAAgB;CAC9B,IAAMC,IAAKC,EAAM,GAEXC,IAAWC,EAAY,GACvB,CAACC,KAAgBC,EAAgB,GACjCC,IAAQF,EAAa,IAAI,OAAO,GAChCG,IAAUC,EAAQ,GAClBC,IAAWC,EAAY,GAEvB,CAACC,GAAOC,KAAYC,EAAS,EAAE,GAC/B,CAACC,GAAQC,KAAaF,EAC1BP,IAAQ,cAAc,MACxB,GACM,CAACU,GAASC,KAAcJ,EAAS,EAAE,GACnCK,IAAiBC,EAAO,EAAK,GAE7B,CAACC,GAAe,EAAE,SAASC,OAC/BC,EAAmCC,CAAqB,GACpD,CAACC,KAAyBF,EAC9BG,CACF,GAEMC,IAAoBC,EACxB,OAAO,MAAuB;EAC5B,IAAMC,IAAQC,EAAa,SAAS;EAEpC,IAAI;GAEF,AADAd,EAAU,WAAW,GACrBa,EAAM,WAAW,EAAI;GAErB,IAAM,EAAE,YAAS,MAAMJ,EAAsB,EAC3C,WAAW,EAAE,OAAOM,EAAW,EACjC,CAAC,GACKC,IAASC,EAAM;GAErB,IAAI,CAACD,EAAO,WAAW,CAACA,EAAO,MAC7B,MAAU,MAAMA,EAAO,SAAS,uBAAuB;GAMzD,AAHAH,EAAM,QAAQG,EAAO,IAAI,GACzBH,EAAM,cAAcG,EAAO,SAAS,IAAIA,EAAO,gBAAgB,EAAE,GACjEH,EAAM,iBAAiB,EAAI,GAC3BA,EAAM,eAAe,EAAK;GAE1B,IAAMK,IAAYF,EAAO,YACrB,KAAK,IAAI,IAAIA,EAAO,YAAY,MAChC,KAAA;GA+BJ,AA9BAG,EAAa3C,EAAcwC,EAAO,IAAI,GAAG;IACvC,aAAaA,EAAO,SAAS;IAC7B,cAAcA,EAAO,gBAAgB;IACrC;GACF,CAAC,GACDtC,EAAkBsC,EAAO,KAAK,EAAE,GAEhCtB,EAAS,KACP,kBACA;IACE,QAAQsB,EAAO,KAAK;IACpB,UAAUA,EAAO,KAAK;IACtB,aAAaA,EAAO,KAAK;IACzB,YAAYA,EAAO,KAAK;IACxB,eAAeA,EAAO,KAAK;GAC7B,GACA,EAAE,WAAW,gBAAgB,CAC/B,GAEAxB,EAAQ,gBAAgBwB,EAAO,MAAMA,EAAO,OAAOA,EAAO,YAAY,GAEtEhB,EAAU,SAAS,GACnBE,EACEjB,EACE,kCACA,uCACF,CACF,GAGA,iBAAiB;IACf,EAAS,KAAK,EAAE,SAAS,GAAK,CAAC;GACjC,GAAG,IAAI;EACT,SAASmC,GAAK;GAEZ,IAAIP,EAAM,mBAAmBA,EAAM,MAAM;IAIvC,EAAS,KAAK,EAAE,SAAS,GAAK,CAAC;IAC/B;GACF;GAEA,EAAU,OAAO;GACjB,IAAMQ,IACJD,aAAe,QACXA,EAAI,UACJnC,EACE,mCACA,+BACF;GACN,EAAWoC,CAAY;EACzB,UAAU;GACR,EAAM,WAAW,EAAK;EACxB;CACF,GACA;EAACZ;EAAuBjB;EAASE;EAAUP;EAAUF;CAAE,CACzD;CAmKA,OAhKAqC,QAAgB;EACd,AAAI/B,KAAS,CAACY,EAAe,YAC3B,EAAe,UAAU,IACzBQ,EAAkBpB,CAAK;CAE3B,GAAG,CAACA,GAAOoB,CAAiB,CAAC,GAwCzBZ,MAAW,cAEX,kBAACwB,GAAD;EACE,OAAOtC,EAAG,4BAA4B,mBAAmB;EACzD,UAAUA,EACR,oCACA,6CACF;EAEA,UAAA,kBAAC,OAAD;GAAK,WAAU;GAAf,UAAA,CACE,kBAAC,OAAD,EAAK,WAAU,oFAAqF,CAAA,GACpG,kBAAC,KAAD;IAAG,WAAU;IACV,UAAAA,EAAG,4BAA4B,yBAAyB;GACxD,CAAA,CACA;;CACK,CAAA,IAKZc,MAAW,YAEX,kBAACwB,GAAD;EACE,OAAOtC,EAAG,8BAA8B,eAAe;EACvD,UAAUA,EACR,kCACA,sCACF;EAEA,UAAA,kBAAC,OAAD;GAAK,WAAU;GAAf,UAAA;IACE,kBAAC,OAAD;KAAK,WAAU;KACb,UAAA,kBAACuC,GAAD,EAAW,WAAU,4CAA6C,CAAA;IAC/D,CAAA;IACL,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAvB;IACA,CAAA;IACH,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAhB,EAAG,8BAA8B,+BAA+B;IAChE,CAAA;GACA;;CACK,CAAA,IAKZc,MAAW,UAEX,kBAACwB,GAAD;EACE,OAAOtC,EAAG,uBAAuB,OAAO;EACxC,UAAUA,EAAG,iCAAiC,sBAAsB;EAEpE,UAAA,kBAAC,OAAD;GAAK,WAAU;GAAf,UAAA;IACE,kBAAC,OAAD;KAAK,WAAU;KACb,UAAA,kBAACwC,GAAD,EAAW,WAAU,0CAA2C,CAAA;IAC7D,CAAA;IACL,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAxB;IACA,CAAA;IACH,kBAAC,UAAD;KACE,MAAK;KACL,eAAe;MAEb,AADAD,EAAU,MAAM,GAChBE,EAAW,EAAE;KACf;KACA,WAAU;KAET,UAAAjB,EAAG,2BAA2B,WAAW;IACpC,CAAA;GACL;;CACK,CAAA,IAKZc,MAAW,SAEX,kBAACwB,GAAD;EACE,OAAOtC,EAAG,6BAA6B,kBAAkB;EACzD,UAAUA,EACR,gCACA,6BACF;EAEA,UAAA,kBAAC,OAAD;GAAK,WAAU;GAAf,UAAA;IACE,kBAAC,OAAD;KAAK,WAAU;KACb,UAAA,kBAACyC,GAAD,EAAW,WAAU,4CAA6C,CAAA;IAC/D,CAAA;IACL,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAzC,EAAG,yBAAyB,6BAA6B;IACzD,CAAA;IACH,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAW;IACA,CAAA;IACH,kBAAC,KAAD;KAAG,WAAU;KACV,UAAAX,EACC,8BACA,uEACF;IACC,CAAA;IACH,kBAAC,UAAD;KACE,MAAK;KACL,eAAe;MAEb,AADAe,EAAU,MAAM,GAChBE,EAAW,EAAE;KACf;KACA,WAAU;KAET,UAAAjB,EAAG,iCAAiC,uBAAuB;IACtD,CAAA;GACL;;CACK,CAAA,IAMd,kBAACsC,GAAD;EACE,OAAOtC,EAAG,wBAAwB,yBAAyB;EAC3D,UAAUA,EACR,2BACA,6CACF;EALF,UAAA,CAOE,kBAAC0C,GAAD;GAAa,WAAU;GACpB,UAAA1C,EACC,0BACA,0JACF;EACW,CAAA,GACb,kBAAC,QAAD;GAAM,UAAU,OAvKe,MAAuB;IAGxD,IAFA,EAAE,eAAe,GAEb,CAACW,GACH,EACEX,EAAG,6BAA6B,iCAAiC,CACnE;SAIF,IAAI;KACF,IAAM,EAAE,YAAS,MAAMoB,EAAc,EAAE,WAAW,EAAE,SAAM,EAAE,CAAC;KAE7D,AAAIY,EAAM,cAAc,WACtBjB,EAAU,MAAM,GAChBE,EACEe,EAAM,cAAc,WAClBhC,EACE,8BACA,oCACF,CACJ,KAEAiB,EACEe,EAAM,cAAc,WAClBhC,EAAG,6BAA6B,2BAA2B,CAC/D;IAEJ,SAASmC,GAAK;KACZ,IAAMC,IACJD,aAAe,QACXA,EAAI,UACJnC,EAAG,6BAA6B,2BAA2B;KACjE,EAAWoC,CAAY;IACzB;GACF;GAoIyC,WAAU;GAA/C,UAAA;IACE,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,SAAD;KACE,SAAQ;KACR,WAAU;KAET,UAAApC,EAAG,6BAA6B,eAAe;IAC3C,CAAA,GACP,kBAAC,OAAD;KAAK,WAAU;KAAf,UAAA,CACE,kBAAC,OAAD;MAAK,WAAU;MACb,UAAA,kBAACyC,GAAD,EAAW,WAAU,4CAA6C,CAAA;KAC/D,CAAA,GACL,kBAAC,SAAD;MACE,IAAG;MACH,MAAK;MACL,OAAO9B;MACP,WAAW,MAAMC,EAAS,EAAE,OAAO,KAAK;MACxC,aAAaZ,EACX,mCACA,iBACF;MACA,cAAYA,EAAG,6BAA6B,eAAe;MAC3D,WAAU;MACV,UAAA;KACD,CAAA,CACE;IACF,CAAA,CAAA,EAAA,CAAA;IAEJgB,KACC,kBAAC,KAAD;KAAG,WAAU;KAA4C,UAAAA;IAAW,CAAA;IAGtE,kBAAC2B,GAAD;KACE,MAAK;KACL,UAAUtB;KACV,SAAQ;KACR,MAAK;KACL,WAAU;KAET,UAAAA,IACGrB,EAAG,0BAA0B,YAAY,IACzCA,EAAG,6BAA6B,iBAAiB;IAC/C,CAAA;IAER,kBAAC,OAAD;KAAK,WAAU;KACb,UAAA,kBAAC,UAAD;MACE,MAAK;MACL,eAAeE,EAAS,aAAa;MACrC,WAAU;MAET,UAAAF,EACC,kCACA,+BACF;KACM,CAAA;IACL,CAAA;GACD;EACI,CAAA,CAAA;;AAEhB"}
|
|
@@ -20,7 +20,7 @@ function y() {
|
|
|
20
20
|
""
|
|
21
21
|
]), [E, D] = f(null), O = d([]), k = typeof window < "u" ? sessionStorage.getItem(o) : null, A = S || k;
|
|
22
22
|
u(() => {
|
|
23
|
-
O.current[0]?.focus()
|
|
23
|
+
O.current[0]?.focus();
|
|
24
24
|
}, [k]);
|
|
25
25
|
let j = (e, t) => {
|
|
26
26
|
let n = t.replace(/\D/g, "").slice(-1), r = [...w];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MfaVerifyPage.js","names":["_Fragment","MfaVerifyPage","tr","useTr","navigate","useNavigate","storeChallengeId","useMfaOperations","code","setCode","useState","error","setError","inputRefs","useRef","sessionChallengeId","MFA_CHALLENGE_ID","mfaChallengeId","MFA_PENDING_TOKEN","MFA_REDIRECT","handleChange","digit","value","newCode","index","fullCode","handleSubmit","handleKeyDown","e","handlePaste","pastedData","i","char","lastIndex","codeToSubmit","result","verifyMFA","ssoRedirect","MFA_PENDING_REFRESH","useAuthStore","rawErr","isTechnical","err","AuthLayout","PagePurpose","AlertIcon","el","Button","isLoading","Fragment","SpinnerIcon"],"sources":["../../../src/auth/pages/MfaVerifyPage.tsx"],"sourcesContent":["import { useState, useRef, useEffect } from \"react\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { useNavigate } from \"react-router\";\nimport { useMfaOperations } from \"../hooks/useMfaOperations\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { AuthLayout } from \"../components/AuthLayout\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport { PagePurpose } from \"@burdenoff/fe-libs/ui\";\nimport { AlertIcon, SpinnerIcon } from \"../components/AuthIcons\";\nimport {\n MFA_CHALLENGE_ID,\n MFA_PENDING_TOKEN,\n MFA_PENDING_REFRESH,\n MFA_REDIRECT,\n} from \"../constants/sessionKeys\";\n\n/**\n * MFA verification page with professional design\n * Shown when user needs to verify MFA code during login\n * Handles both regular login MFA and SSO MFA flows\n */\nexport function MfaVerifyPage() {\n const tr = useTr();\n\n const navigate = useNavigate();\n const {\n verifyMFA,\n mfaChallengeId: storeChallengeId,\n isLoading,\n } = useMfaOperations();\n const [code, setCode] = useState([\"\", \"\", \"\", \"\", \"\", \"\"]);\n const [error, setError] = useState<string | null>(null);\n const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n\n // Get challenge ID from store or sessionStorage (SSO flow)\n const sessionChallengeId =\n typeof window !== \"undefined\"\n ? sessionStorage.getItem(MFA_CHALLENGE_ID)\n : null;\n const mfaChallengeId = storeChallengeId || sessionChallengeId;\n\n // Focus first input on mount and detect SSO flow\n useEffect(() => {\n inputRefs.current[0]?.focus();\n\n // Check if this is an SSO flow (has pending tokens in sessionStorage)\n const hasPendingToken = sessionStorage.getItem(MFA_PENDING_TOKEN);\n const hasRedirect = sessionStorage.getItem(MFA_REDIRECT);\n if (hasPendingToken || hasRedirect || sessionChallengeId) {\n }\n }, [sessionChallengeId]);\n\n const handleChange = (index: number, value: string) => {\n // Only allow digits\n const digit = value.replace(/\\D/g, \"\").slice(-1);\n\n const newCode = [...code];\n newCode[index] = digit;\n setCode(newCode);\n\n // Auto-focus next input\n if (digit && index < 5) {\n inputRefs.current[index + 1]?.focus();\n }\n\n // Auto-submit when all digits are entered\n if (digit && index === 5) {\n const fullCode = newCode.join(\"\");\n if (fullCode.length === 6) {\n handleSubmit(fullCode);\n }\n }\n };\n\n const handleKeyDown = (index: number, e: React.KeyboardEvent) => {\n if (e.key === \"Backspace\" && !code[index] && index > 0) {\n inputRefs.current[index - 1]?.focus();\n }\n };\n\n const handlePaste = (e: React.ClipboardEvent) => {\n e.preventDefault();\n const pastedData = e.clipboardData\n .getData(\"text\")\n .replace(/\\D/g, \"\")\n .slice(0, 6);\n const newCode = [...code];\n pastedData.split(\"\").forEach((char, i) => {\n if (i < 6) newCode[i] = char;\n });\n setCode(newCode);\n\n // Focus last filled input or first empty\n const lastIndex = Math.min(pastedData.length - 1, 5);\n inputRefs.current[lastIndex]?.focus();\n\n // Auto-submit if full code pasted\n if (pastedData.length === 6) {\n handleSubmit(pastedData);\n }\n };\n\n const handleSubmit = async (fullCode?: string) => {\n const codeToSubmit = fullCode || code.join(\"\");\n if (codeToSubmit.length !== 6) return;\n\n setError(null);\n\n if (!mfaChallengeId) {\n setError(\n tr(\n \"auth.mfaVerify.sessionExpired\",\n \"MFA session expired. Please log in again.\",\n ),\n );\n return;\n }\n\n try {\n const result = await verifyMFA({\n challengeId: mfaChallengeId,\n code: codeToSubmit,\n method: \"TOTP\",\n });\n\n if (result.success) {\n // Clean up SSO session data\n const ssoRedirect = sessionStorage.getItem(MFA_REDIRECT);\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n\n // Redirect to SSO redirect URL or default to dashboard\n const redirectUrl = ssoRedirect || \"/\";\n navigate(redirectUrl, { replace: true });\n } else {\n // Handle expired challenge - redirect to login\n if (result.error === \"mfa_challenge_expired\") {\n // Clear all MFA state\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n\n // Redirect to login with message\n navigate(\"/auth/login\", {\n replace: true,\n state: {\n message: tr(\n \"auth.mfaVerify.sessionExpiredMessage\",\n \"Your verification session expired. Please sign in again.\",\n ),\n },\n });\n return;\n }\n\n // Sanitize technical / null-deref-style messages bubbling up from\n // resolver internals — always show a user-friendly fallback.\n const rawErr = result.error ?? \"\";\n const isTechnical =\n /cannot read propert|null|undefined|typeerror/i.test(rawErr);\n setError(\n !rawErr || isTechnical\n ? tr(\n \"auth.mfaVerify.invalidCode\",\n \"Invalid verification code. Please try again.\",\n )\n : rawErr,\n );\n setCode([\"\", \"\", \"\", \"\", \"\", \"\"]);\n inputRefs.current[0]?.focus();\n }\n } catch (err) {\n console.error(\"MFA verification failed:\", err);\n\n // Check if the error is about expired challenge\n const errorMessage = err instanceof Error ? err.message : String(err);\n if (errorMessage.includes(\"mfa_challenge_expired\")) {\n // Clear all MFA state\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n\n // Redirect to login with message\n navigate(\"/auth/login\", {\n replace: true,\n state: {\n message: tr(\n \"auth.mfaVerify.sessionExpiredMessage\",\n \"Your verification session expired. Please sign in again.\",\n ),\n },\n });\n return;\n }\n\n setError(\n tr(\n \"auth.mfaVerify.verificationFailed\",\n \"Verification failed. Please try again.\",\n ),\n );\n setCode([\"\", \"\", \"\", \"\", \"\", \"\"]);\n inputRefs.current[0]?.focus();\n }\n };\n\n return (\n <AuthLayout\n title={tr(\"auth.mfaVerify.title\", \"Two-factor authentication\")}\n subtitle={tr(\n \"auth.mfaVerify.subtitle\",\n \"Enter the 6-digit code from your authenticator app\",\n )}\n >\n {/* Shield Icon */}\n <div className=\"flex justify-center mb-6\">\n <div className=\"size-16 rounded-full bg-[var(--color-bg-accent)] flex items-center justify-center\">\n <svg\n className=\"size-8 text-[var(--color-text-link)]\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z\"\n />\n </svg>\n </div>\n </div>\n\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.mfaVerify.purpose\",\n \"This extra step confirms it's really you. Open your authenticator app and enter the current 6-digit code to finish signing in.\",\n )}\n </PagePurpose>\n <form\n onSubmit={(e) => {\n e.preventDefault();\n handleSubmit();\n }}\n className=\"space-y-6\"\n >\n {/* Error Alert */}\n {error && (\n <div className=\"flex items-start gap-3 p-4 rounded-xl bg-[var(--color-status-error-bg-subtle)] border border-[var(--color-status-error-border)]\">\n <AlertIcon className=\"size-5 text-[var(--color-status-error-text)] flex-shrink-0 mt-0.5\" />\n <p className=\"text-sm text-[var(--color-status-error-text)]\">\n {error}\n </p>\n </div>\n )}\n\n {/* Code Input */}\n <div className=\"space-y-2\">\n <label className=\"block text-sm font-medium text-[var(--color-text-primary)] text-center\">\n {tr(\"auth.mfaVerify.verificationCode\", \"Verification Code\")}\n </label>\n <div className=\"flex justify-center gap-2 sm:gap-3\">\n {code.map((digit, index) => (\n <input\n key={index}\n ref={(el) => {\n inputRefs.current[index] = el;\n }}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={1}\n aria-label={\n tr(\n \"auth.mfaVerify.digitAriaLabel\",\n \"Verification code digit\",\n ) + ` ${index + 1}`\n }\n value={digit}\n onChange={(e) => handleChange(index, e.target.value)}\n onKeyDown={(e) => handleKeyDown(index, e)}\n onPaste={handlePaste}\n className=\"w-10 h-12 sm:w-12 sm:h-14 text-center text-xl sm:text-2xl font-semibold rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n ))}\n </div>\n </div>\n\n {/* Submit Button */}\n <Button\n type=\"submit\"\n disabled={isLoading || code.join(\"\").length !== 6}\n variant=\"default\"\n size=\"lg\"\n className=\"w-full font-semibold\"\n >\n {isLoading ? (\n <>\n <SpinnerIcon className=\"size-5 animate-spin\" />\n <span>{tr(\"auth.mfaVerify.verifying\", \"Verifying…\")}</span>\n </>\n ) : (\n tr(\"auth.mfaVerify.verify\", \"Verify\")\n )}\n </Button>\n\n {/* Recovery Options */}\n <div className=\"text-center space-y-3\">\n <p className=\"text-sm text-[var(--color-text-secondary)]\">\n {tr(\n \"auth.mfaVerify.cantAccess\",\n \"Can't access your authenticator app?\",\n )}\n </p>\n <button\n type=\"button\"\n onClick={() => navigate(\"/auth/mfa-recovery\")}\n className=\"text-sm font-medium text-text-link hover:text-text-linkHover transition-colors\"\n >\n {tr(\"auth.mfaVerify.useRecoveryCode\", \"Use a recovery code\")}\n </button>\n </div>\n </form>\n\n {/* Back to Login */}\n <p className=\"mt-8 text-center text-sm text-[var(--color-text-secondary)]\">\n <button\n type=\"button\"\n onClick={() => {\n // Clear MFA state so LoginPage doesn't immediately redirect back here\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n navigate(\"/auth/login\");\n }}\n className=\"font-semibold text-text-link hover:text-text-linkHover transition-colors\"\n >\n {tr(\"auth.mfaVerify.backToSignIn\", \"Back to sign in\")}\n </button>\n </p>\n </AuthLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAqBA,SAAgBC,IAAgB;CAC9B,IAAMC,IAAKC,EAAM,GAEXC,IAAWC,EAAY,GACvB,EACJ,cACA,gBAAgBC,GAChB,iBACEC,EAAiB,GACf,CAACC,GAAMC,KAAWC,EAAS;EAAC;EAAI;EAAI;EAAI;EAAI;EAAI;CAAE,CAAC,GACnD,CAACC,GAAOC,KAAYF,EAAwB,IAAI,GAChDG,IAAYC,EAAoC,CAAC,CAAC,GAGlDC,IACJ,OAAO,SAAW,MACd,eAAe,QAAQC,CAAgB,IACvC,MACAC,IAAiBX,KAAoBS;CAG3C,QAAgB;EACd,EAAU,QAAQ,EAAE,EAAE,MAAM,GAGJ,eAAe,QAAQG,CAAiB,GAC5C,eAAe,QAAQC,CAAY;CAGzD,GAAG,CAACJ,CAAkB,CAAC;CAEvB,IAAMK,KAAgB,GAAe,MAAkB;EAErD,IAAMC,IAAQC,EAAM,QAAQ,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,GAEzCC,IAAU,CAAC,GAAGf,CAAI;EAUxB,IATA,EAAQgB,KAASH,GACjBZ,EAAQc,CAAO,GAGXF,KAASG,IAAQ,KACnBX,EAAU,QAAQW,IAAQ,EAAE,EAAE,MAAM,GAIlCH,KAASG,MAAU,GAAG;GACxB,IAAMC,IAAWF,EAAQ,KAAK,EAAE;GAChC,AAAIE,EAAS,WAAW,KACtBC,EAAaD,CAAQ;EAEzB;CACF,GAEME,KAAiB,GAAe,MAA2B;EAC/D,AAAIC,EAAE,QAAQ,eAAe,CAACpB,EAAKgB,MAAUA,IAAQ,KACnDX,EAAU,QAAQW,IAAQ,EAAE,EAAE,MAAM;CAExC,GAEMK,KAAe,MAA4B;EAC/C,EAAE,eAAe;EACjB,IAAMC,IAAa,EAAE,cAClB,QAAQ,MAAM,CAAC,CACf,QAAQ,OAAO,EAAE,CAAC,CAClB,MAAM,GAAG,CAAC,GACPP,IAAU,CAAC,GAAGf,CAAI;EAIxB,AAHAsB,EAAW,MAAM,EAAE,CAAC,CAAC,SAAS,GAAM,MAAM;GACxC,AAAIC,IAAI,MAAG,EAAQA,KAAKC;EAC1B,CAAC,GACDvB,EAAQc,CAAO;EAGf,IAAMU,IAAY,KAAK,IAAIH,EAAW,SAAS,GAAG,CAAC;EAInD,AAHAjB,EAAU,QAAQoB,EAAU,EAAE,MAAM,GAGhCH,EAAW,WAAW,KACxBJ,EAAaI,CAAU;CAE3B,GAEMJ,IAAe,OAAO,MAAsB;EAChD,IAAMQ,IAAeT,KAAYjB,EAAK,KAAK,EAAE;EAC7C,IAAI0B,EAAa,WAAW,GAI5B;OAFAtB,EAAS,IAAI,GAET,CAACK,GACH,EACEf,EACE,iCACA,2CACF,CACF;QAIF,IAAI;IACF,IAAMiC,IAAS,MAAMC,EAAU;KAC7B,aAAanB;KACb,MAAMiB;KACN,QAAQ;IACV,CAAC;IAED,IAAIC,EAAO,SAAS;KAElB,IAAME,IAAc,eAAe,QAAQlB,CAAY;KAQvD,AAPA,eAAe,WAAWH,CAAgB,GAC1C,eAAe,WAAWE,CAAiB,GAC3C,eAAe,WAAWoB,CAAmB,GAC7C,eAAe,WAAWnB,CAAY,GAItCf,EADoBiC,KAAe,KACb,EAAE,SAAS,GAAK,CAAC;IACzC,OAAO;KAEL,IAAIF,EAAO,UAAU,yBAAyB;MAS5C,AAPA,eAAe,WAAWnB,CAAgB,GAC1C,eAAe,WAAWE,CAAiB,GAC3C,eAAe,WAAWoB,CAAmB,GAC7C,eAAe,WAAWnB,CAAY,GACtCoB,EAAa,SAAS,CAAC,CAAC,eAAe,EAAK,GAG5CnC,EAAS,eAAe;OACtB,SAAS;OACT,OAAO,EACL,SAASF,EACP,wCACA,0DACF,EACF;MACF,CAAC;MACD;KACF;KAIA,IAAMsC,IAASL,EAAO,SAAS,IACzBM,IACJ,gDAAgD,KAAKD,CAAM;KAU7D,AATA5B,EACE,CAAC4B,KAAUC,IACPvC,EACE,8BACA,8CACF,IACAsC,CACN,GACA/B,EAAQ;MAAC;MAAI;MAAI;MAAI;MAAI;MAAI;KAAE,CAAC,GAChCI,EAAU,QAAQ,EAAE,EAAE,MAAM;IAC9B;GACF,SAAS6B,GAAK;IAKZ,IAJA,QAAQ,MAAM,4BAA4BA,CAAG,IAGxBA,aAAe,QAAQA,EAAI,UAAU,OAAOA,CAAG,EAAA,CACnD,SAAS,uBAAuB,GAAG;KASlD,AAPA,eAAe,WAAW1B,CAAgB,GAC1C,eAAe,WAAWE,CAAiB,GAC3C,eAAe,WAAWoB,CAAmB,GAC7C,eAAe,WAAWnB,CAAY,GACtCoB,EAAa,SAAS,CAAC,CAAC,eAAe,EAAK,GAG5CnC,EAAS,eAAe;MACtB,SAAS;MACT,OAAO,EACL,SAASF,EACP,wCACA,0DACF,EACF;KACF,CAAC;KACD;IACF;IASA,AAPAU,EACEV,EACE,qCACA,wCACF,CACF,GACAO,EAAQ;KAAC;KAAI;KAAI;KAAI;KAAI;KAAI;IAAE,CAAC,GAChCI,EAAU,QAAQ,EAAE,EAAE,MAAM;GAC9B;EA7FA;CA8FF;CAEA,OACE,kBAAC8B,GAAD;EACE,OAAOzC,EAAG,wBAAwB,2BAA2B;EAC7D,UAAUA,EACR,2BACA,oDACF;EALF,UAAA;GAQE,kBAAC,OAAD;IAAK,WAAU;IACb,UAAA,kBAAC,OAAD;KAAK,WAAU;KACb,UAAA,kBAAC,OAAD;MACE,WAAU;MACV,MAAK;MACL,SAAQ;MACR,QAAO;MAEP,UAAA,kBAAC,QAAD;OACE,eAAc;OACd,gBAAe;OACf,aAAa;OACb,GAAE;MACH,CAAA;KACE,CAAA;IACF,CAAA;GACF,CAAA;GAEL,kBAAC0C,GAAD;IAAa,WAAU;IACpB,UAAA1C,EACC,0BACA,gIACF;GACW,CAAA;GACb,kBAAC,QAAD;IACE,WAAW,MAAM;KAEf,AADA,EAAE,eAAe,GACjBwB,EAAa;IACf;IACA,WAAU;IALZ,UAAA;KAQGf,KACC,kBAAC,OAAD;MAAK,WAAU;MAAf,UAAA,CACE,kBAACkC,GAAD,EAAW,WAAU,oEAAqE,CAAA,GAC1F,kBAAC,KAAD;OAAG,WAAU;OACV,UAAAlC;MACA,CAAA,CACA;;KAIP,kBAAC,OAAD;MAAK,WAAU;MAAf,UAAA,CACE,kBAAC,SAAD;OAAO,WAAU;OACd,UAAAT,EAAG,mCAAmC,mBAAmB;MACrD,CAAA,GACP,kBAAC,OAAD;OAAK,WAAU;OACZ,UAAAM,EAAK,KAAK,GAAO,MAChB,kBAAC,SAAD;QAEE,MAAM,MAAO;SACX,EAAU,QAAQgB,KAASsB;QAC7B;QACA,MAAK;QACL,WAAU;QACV,cAAa;QACb,WAAW;QACX,cACE5C,EACE,iCACA,yBACF,IAAI,IAAIsB,IAAQ;QAElB,OAAOH;QACP,WAAW,MAAMD,EAAaI,GAAO,EAAE,OAAO,KAAK;QACnD,YAAY,MAAMG,EAAcH,GAAO,CAAC;QACxC,SAASK;QACT,WAAU;OACX,GAnBML,CAmBN,CACF;MACE,CAAA,CACF;;KAGL,kBAACuB,GAAD;MACE,MAAK;MACL,UAAUC,KAAaxC,EAAK,KAAK,EAAE,CAAC,CAAC,WAAW;MAChD,SAAQ;MACR,MAAK;MACL,WAAU;MAET,UAAAwC,IACC,kBAAAhD,GAAA,EAAA,UAAA,CACE,kBAACkD,GAAD,EAAa,WAAU,sBAAuB,CAAA,GAC9C,kBAAC,QAAD,EAAA,UAAOhD,EAAG,4BAA4B,YAAY,EAAQ,CAAA,CAC1D,EAAA,CAAA,IAEFA,EAAG,yBAAyB,QAAQ;KAEhC,CAAA;KAGR,kBAAC,OAAD;MAAK,WAAU;MAAf,UAAA,CACE,kBAAC,KAAD;OAAG,WAAU;OACV,UAAAA,EACC,6BACA,sCACF;MACC,CAAA,GACH,kBAAC,UAAD;OACE,MAAK;OACL,eAAeE,EAAS,oBAAoB;OAC5C,WAAU;OAET,UAAAF,EAAG,kCAAkC,qBAAqB;MACrD,CAAA,CACL;;IACD;;GAGN,kBAAC,KAAD;IAAG,WAAU;IACX,UAAA,kBAAC,UAAD;KACE,MAAK;KACL,eAAe;MAOb,AALA,eAAe,WAAWc,CAAgB,GAC1C,eAAe,WAAWE,CAAiB,GAC3C,eAAe,WAAWoB,CAAmB,GAC7C,eAAe,WAAWnB,CAAY,GACtCoB,EAAa,SAAS,CAAC,CAAC,eAAe,EAAK,GAC5CnC,EAAS,aAAa;KACxB;KACA,WAAU;KAET,UAAAF,EAAG,+BAA+B,iBAAiB;IAC9C,CAAA;GACP,CAAA;EACO;;AAEhB"}
|
|
1
|
+
{"version":3,"file":"MfaVerifyPage.js","names":["_Fragment","MfaVerifyPage","tr","useTr","navigate","useNavigate","storeChallengeId","useMfaOperations","code","setCode","useState","error","setError","inputRefs","useRef","sessionChallengeId","MFA_CHALLENGE_ID","mfaChallengeId","handleChange","digit","value","newCode","index","fullCode","handleSubmit","handleKeyDown","e","handlePaste","pastedData","i","char","lastIndex","codeToSubmit","result","verifyMFA","ssoRedirect","MFA_REDIRECT","MFA_PENDING_TOKEN","MFA_PENDING_REFRESH","useAuthStore","rawErr","isTechnical","err","AuthLayout","PagePurpose","AlertIcon","el","Button","isLoading","Fragment","SpinnerIcon"],"sources":["../../../src/auth/pages/MfaVerifyPage.tsx"],"sourcesContent":["import { useState, useRef, useEffect } from \"react\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { useNavigate } from \"react-router\";\nimport { useMfaOperations } from \"../hooks/useMfaOperations\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { AuthLayout } from \"../components/AuthLayout\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport { PagePurpose } from \"@burdenoff/fe-libs/ui\";\nimport { AlertIcon, SpinnerIcon } from \"../components/AuthIcons\";\nimport {\n MFA_CHALLENGE_ID,\n MFA_PENDING_TOKEN,\n MFA_PENDING_REFRESH,\n MFA_REDIRECT,\n} from \"../constants/sessionKeys\";\n\n/**\n * MFA verification page with professional design\n * Shown when user needs to verify MFA code during login\n * Handles both regular login MFA and SSO MFA flows\n */\nexport function MfaVerifyPage() {\n const tr = useTr();\n\n const navigate = useNavigate();\n const {\n verifyMFA,\n mfaChallengeId: storeChallengeId,\n isLoading,\n } = useMfaOperations();\n const [code, setCode] = useState([\"\", \"\", \"\", \"\", \"\", \"\"]);\n const [error, setError] = useState<string | null>(null);\n const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n\n // Get challenge ID from store or sessionStorage (SSO flow)\n const sessionChallengeId =\n typeof window !== \"undefined\"\n ? sessionStorage.getItem(MFA_CHALLENGE_ID)\n : null;\n const mfaChallengeId = storeChallengeId || sessionChallengeId;\n\n // Focus the first input on mount (and when the SSO challenge changes).\n useEffect(() => {\n inputRefs.current[0]?.focus();\n }, [sessionChallengeId]);\n\n const handleChange = (index: number, value: string) => {\n // Only allow digits\n const digit = value.replace(/\\D/g, \"\").slice(-1);\n\n const newCode = [...code];\n newCode[index] = digit;\n setCode(newCode);\n\n // Auto-focus next input\n if (digit && index < 5) {\n inputRefs.current[index + 1]?.focus();\n }\n\n // Auto-submit when all digits are entered\n if (digit && index === 5) {\n const fullCode = newCode.join(\"\");\n if (fullCode.length === 6) {\n handleSubmit(fullCode);\n }\n }\n };\n\n const handleKeyDown = (index: number, e: React.KeyboardEvent) => {\n if (e.key === \"Backspace\" && !code[index] && index > 0) {\n inputRefs.current[index - 1]?.focus();\n }\n };\n\n const handlePaste = (e: React.ClipboardEvent) => {\n e.preventDefault();\n const pastedData = e.clipboardData\n .getData(\"text\")\n .replace(/\\D/g, \"\")\n .slice(0, 6);\n const newCode = [...code];\n pastedData.split(\"\").forEach((char, i) => {\n if (i < 6) newCode[i] = char;\n });\n setCode(newCode);\n\n // Focus last filled input or first empty\n const lastIndex = Math.min(pastedData.length - 1, 5);\n inputRefs.current[lastIndex]?.focus();\n\n // Auto-submit if full code pasted\n if (pastedData.length === 6) {\n handleSubmit(pastedData);\n }\n };\n\n const handleSubmit = async (fullCode?: string) => {\n const codeToSubmit = fullCode || code.join(\"\");\n if (codeToSubmit.length !== 6) return;\n\n setError(null);\n\n if (!mfaChallengeId) {\n setError(\n tr(\n \"auth.mfaVerify.sessionExpired\",\n \"MFA session expired. Please log in again.\",\n ),\n );\n return;\n }\n\n try {\n const result = await verifyMFA({\n challengeId: mfaChallengeId,\n code: codeToSubmit,\n method: \"TOTP\",\n });\n\n if (result.success) {\n // Clean up SSO session data\n const ssoRedirect = sessionStorage.getItem(MFA_REDIRECT);\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n\n // Redirect to SSO redirect URL or default to dashboard\n const redirectUrl = ssoRedirect || \"/\";\n navigate(redirectUrl, { replace: true });\n } else {\n // Handle expired challenge - redirect to login\n if (result.error === \"mfa_challenge_expired\") {\n // Clear all MFA state\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n\n // Redirect to login with message\n navigate(\"/auth/login\", {\n replace: true,\n state: {\n message: tr(\n \"auth.mfaVerify.sessionExpiredMessage\",\n \"Your verification session expired. Please sign in again.\",\n ),\n },\n });\n return;\n }\n\n // Sanitize technical / null-deref-style messages bubbling up from\n // resolver internals — always show a user-friendly fallback.\n const rawErr = result.error ?? \"\";\n const isTechnical =\n /cannot read propert|null|undefined|typeerror/i.test(rawErr);\n setError(\n !rawErr || isTechnical\n ? tr(\n \"auth.mfaVerify.invalidCode\",\n \"Invalid verification code. Please try again.\",\n )\n : rawErr,\n );\n setCode([\"\", \"\", \"\", \"\", \"\", \"\"]);\n inputRefs.current[0]?.focus();\n }\n } catch (err) {\n console.error(\"MFA verification failed:\", err);\n\n // Check if the error is about expired challenge\n const errorMessage = err instanceof Error ? err.message : String(err);\n if (errorMessage.includes(\"mfa_challenge_expired\")) {\n // Clear all MFA state\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n\n // Redirect to login with message\n navigate(\"/auth/login\", {\n replace: true,\n state: {\n message: tr(\n \"auth.mfaVerify.sessionExpiredMessage\",\n \"Your verification session expired. Please sign in again.\",\n ),\n },\n });\n return;\n }\n\n setError(\n tr(\n \"auth.mfaVerify.verificationFailed\",\n \"Verification failed. Please try again.\",\n ),\n );\n setCode([\"\", \"\", \"\", \"\", \"\", \"\"]);\n inputRefs.current[0]?.focus();\n }\n };\n\n return (\n <AuthLayout\n title={tr(\"auth.mfaVerify.title\", \"Two-factor authentication\")}\n subtitle={tr(\n \"auth.mfaVerify.subtitle\",\n \"Enter the 6-digit code from your authenticator app\",\n )}\n >\n {/* Shield Icon */}\n <div className=\"flex justify-center mb-6\">\n <div className=\"size-16 rounded-full bg-[var(--color-bg-accent)] flex items-center justify-center\">\n <svg\n className=\"size-8 text-[var(--color-text-link)]\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z\"\n />\n </svg>\n </div>\n </div>\n\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.mfaVerify.purpose\",\n \"This extra step confirms it's really you. Open your authenticator app and enter the current 6-digit code to finish signing in.\",\n )}\n </PagePurpose>\n <form\n onSubmit={(e) => {\n e.preventDefault();\n handleSubmit();\n }}\n className=\"space-y-6\"\n >\n {/* Error Alert */}\n {error && (\n <div className=\"flex items-start gap-3 p-4 rounded-xl bg-[var(--color-status-error-bg-subtle)] border border-[var(--color-status-error-border)]\">\n <AlertIcon className=\"size-5 text-[var(--color-status-error-text)] flex-shrink-0 mt-0.5\" />\n <p className=\"text-sm text-[var(--color-status-error-text)]\">\n {error}\n </p>\n </div>\n )}\n\n {/* Code Input */}\n <div className=\"space-y-2\">\n <label className=\"block text-sm font-medium text-[var(--color-text-primary)] text-center\">\n {tr(\"auth.mfaVerify.verificationCode\", \"Verification Code\")}\n </label>\n <div className=\"flex justify-center gap-2 sm:gap-3\">\n {code.map((digit, index) => (\n <input\n key={index}\n ref={(el) => {\n inputRefs.current[index] = el;\n }}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n maxLength={1}\n aria-label={\n tr(\n \"auth.mfaVerify.digitAriaLabel\",\n \"Verification code digit\",\n ) + ` ${index + 1}`\n }\n value={digit}\n onChange={(e) => handleChange(index, e.target.value)}\n onKeyDown={(e) => handleKeyDown(index, e)}\n onPaste={handlePaste}\n className=\"w-10 h-12 sm:w-12 sm:h-14 text-center text-xl sm:text-2xl font-semibold rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n ))}\n </div>\n </div>\n\n {/* Submit Button */}\n <Button\n type=\"submit\"\n disabled={isLoading || code.join(\"\").length !== 6}\n variant=\"default\"\n size=\"lg\"\n className=\"w-full font-semibold\"\n >\n {isLoading ? (\n <>\n <SpinnerIcon className=\"size-5 animate-spin\" />\n <span>{tr(\"auth.mfaVerify.verifying\", \"Verifying…\")}</span>\n </>\n ) : (\n tr(\"auth.mfaVerify.verify\", \"Verify\")\n )}\n </Button>\n\n {/* Recovery Options */}\n <div className=\"text-center space-y-3\">\n <p className=\"text-sm text-[var(--color-text-secondary)]\">\n {tr(\n \"auth.mfaVerify.cantAccess\",\n \"Can't access your authenticator app?\",\n )}\n </p>\n <button\n type=\"button\"\n onClick={() => navigate(\"/auth/mfa-recovery\")}\n className=\"text-sm font-medium text-text-link hover:text-text-linkHover transition-colors\"\n >\n {tr(\"auth.mfaVerify.useRecoveryCode\", \"Use a recovery code\")}\n </button>\n </div>\n </form>\n\n {/* Back to Login */}\n <p className=\"mt-8 text-center text-sm text-[var(--color-text-secondary)]\">\n <button\n type=\"button\"\n onClick={() => {\n // Clear MFA state so LoginPage doesn't immediately redirect back here\n sessionStorage.removeItem(MFA_CHALLENGE_ID);\n sessionStorage.removeItem(MFA_PENDING_TOKEN);\n sessionStorage.removeItem(MFA_PENDING_REFRESH);\n sessionStorage.removeItem(MFA_REDIRECT);\n useAuthStore.getState().setMfaRequired(false);\n navigate(\"/auth/login\");\n }}\n className=\"font-semibold text-text-link hover:text-text-linkHover transition-colors\"\n >\n {tr(\"auth.mfaVerify.backToSignIn\", \"Back to sign in\")}\n </button>\n </p>\n </AuthLayout>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SSORelayPage.js","names":["RELAY_CHAIN_PARAM","RELAY_FAILED_PARAM","LOCAL_HOSTNAMES","SSO_COOKIE_PATTERNS","clearRelayCookies","hostname","domain","cookieName","getRelayChain","origin","getFailedReturnTo","targetUrl","returnTo","isAllowedSsoOrigin","readSsoCookieProfileId","readSsoCookieData","match","parsed","resolveBridgeOrigin","resolveAuthBridgeConfig","redirectToCookieRelayOrigin","ssoCookie","relayUrl","bridgeOrigin","relayChain","nextRelayUrl","readProfilesRaw","readProfileIds","profilesRaw","profile","id","resolveRelayProfileId","candidates","profileId","tokensRaw","userRaw","clearStaleRelayProfile","parseStoredTokens","SSORelayPage","searchParams","useSearchParams","useEffect","cancelled","stored","relayTokensJson","resolveGatewayUrls","minted","mintSsoSessionTokens","globalBaseUrl","denied","encoded","returnPath","callbackUrl"],"sources":["../../../src/auth/pages/SSORelayPage.tsx"],"sourcesContent":["/**\n * SSORelayPage — Cross-domain SSO token relay.\n *\n * This page is loaded on the ORIGIN where the user is logged in.\n * It reads tokens from localStorage and redirects to the TARGET domain's\n * /auth/sso-callback with the tokens in a URL fragment (not query param\n * for security — fragments are not sent to servers).\n *\n * Flow:\n * 1. User visits productA.com → not logged in → detects SSO cookie\n * 2. Redirect to productB.com/auth/sso-relay?returnTo=productA.com/dashboard\n * (productB is where the user IS logged in — identified by cookie)\n * 3. This page reads tokens from productB's localStorage\n * 4. Redirects to productA.com/auth/sso-callback#token=<base64-encoded-tokens>\n * 5. SSORelayCallbackPage on productA decodes and stores tokens, redirects to /dashboard\n */\n\nimport { useEffect } from \"react\";\nimport { useSearchParams } from \"react-router\";\nimport { resolveAuthBridgeConfig } from \"@burdenoff/fe-libs/shared/config\";\nimport { resolveGatewayUrls } from \"@burdenoff/fe-libs/shared/config/gatewayUrls\";\nimport { mintSsoSessionTokens } from \"@burdenoff/fe-libs/shared/utils\";\nimport { isAllowedSsoOrigin } from \"../utils/ssoOrigins\";\n\nconst RELAY_CHAIN_PARAM = \"relayChain\";\nconst RELAY_FAILED_PARAM = \"sso_failed\";\nconst LOCAL_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\"]);\n\nconst SSO_COOKIE_PATTERNS = {\n \"bf-sso\": /(?:^|;\\s*)bf-sso=([^;]*)/,\n \"bf-sso-profile\": /(?:^|;\\s*)bf-sso-profile=([^;]*)/,\n} as const;\n\nfunction clearRelayCookies(): void {\n try {\n const hostname = window.location.hostname;\n const domain = hostname.endsWith(\".local.burdenoff.com\")\n ? \".local.burdenoff.com\"\n : undefined;\n for (const cookieName of [\"bf-sso\", \"bf-sso-profile\"] as const) {\n document.cookie = `${cookieName}=; path=/; max-age=0`;\n if (domain) {\n document.cookie = `${cookieName}=; path=/; domain=${domain}; max-age=0`;\n }\n if (hostname && !LOCAL_HOSTNAMES.has(hostname)) {\n document.cookie = `${cookieName}=; path=/; domain=${hostname}; max-age=0`;\n }\n }\n } catch {\n /* best effort */\n }\n}\n\nfunction getRelayChain(): string[] {\n try {\n return (\n new URLSearchParams(window.location.search).get(RELAY_CHAIN_PARAM) || \"\"\n )\n .split(\"|\")\n .filter((origin) => origin.length > 0);\n } catch {\n return [];\n }\n}\n\nfunction getFailedReturnTo(returnTo: string): string {\n try {\n const targetUrl = new URL(returnTo, window.location.origin);\n if (\n targetUrl.origin !== window.location.origin &&\n !isAllowedSsoOrigin(targetUrl)\n ) {\n return \"/auth/login\";\n }\n targetUrl.searchParams.set(RELAY_FAILED_PARAM, \"1\");\n return targetUrl.href;\n } catch {\n return \"/auth/login?sso_failed=1\";\n }\n}\n\nfunction readSsoCookieProfileId(): string | null {\n return readSsoCookieData()?.id ?? null;\n}\n\nfunction readSsoCookieData(): {\n id: string | null;\n origin: string | null;\n} | null {\n try {\n for (const cookieName of [\"bf-sso\", \"bf-sso-profile\"] as const) {\n const match = document.cookie.match(SSO_COOKIE_PATTERNS[cookieName]);\n if (!match) continue;\n\n const parsed = JSON.parse(decodeURIComponent(match[1])) as\n | string\n | { id?: unknown; origin?: unknown };\n if (typeof parsed === \"string\") return { id: parsed, origin: null };\n if (typeof parsed.id === \"string\" && parsed.id.length > 0) {\n return {\n id: parsed.id,\n origin: typeof parsed.origin === \"string\" ? parsed.origin : null,\n };\n }\n }\n } catch {\n return null;\n }\n return null;\n}\n\nfunction resolveBridgeOrigin(): string | null {\n try {\n return new URL(resolveAuthBridgeConfig().url).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Follow the SSO cookie's origin hint only when it is safe to do so.\n *\n * - On a product app shell, the only safe cross-origin redirect is to the\n * central bridge host. A stale cookie pointing at a different product must\n * not strand the user on another product's login page (BOFF-6701).\n * - On the central bridge host, the cookie may legitimately point at a product\n * origin that holds the session, so we allow the hop out to that product.\n */\nexport function redirectToCookieRelayOrigin(returnTo: string): boolean {\n try {\n const ssoCookie = readSsoCookieData();\n if (!ssoCookie?.origin) return false;\n\n const relayUrl = new URL(ssoCookie.origin);\n if (!isAllowedSsoOrigin(relayUrl)) return false;\n if (relayUrl.origin === window.location.origin) return false;\n\n const bridgeOrigin = resolveBridgeOrigin();\n const currentOrigin = window.location.origin;\n const isBridgeHost = bridgeOrigin === currentOrigin;\n if (!isBridgeHost && relayUrl.origin !== bridgeOrigin) {\n return false;\n }\n\n const relayChain = getRelayChain();\n if (relayChain.includes(relayUrl.origin) || relayChain.length >= 3) {\n return false;\n }\n\n const nextRelayUrl = new URL(\"/auth/sso-relay\", relayUrl.origin);\n nextRelayUrl.searchParams.set(\"returnTo\", returnTo);\n nextRelayUrl.searchParams.set(\n RELAY_CHAIN_PARAM,\n Array.from(new Set([...relayChain, window.location.origin])).join(\"|\"),\n );\n\n window.location.replace(nextRelayUrl.href);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction readProfilesRaw(): string | null {\n return localStorage.getItem(\"bf-profiles\");\n}\n\nfunction readProfileIds(): string[] {\n try {\n const profilesRaw = readProfilesRaw();\n const profiles = profilesRaw\n ? (JSON.parse(profilesRaw) as Array<{ id?: unknown }>)\n : [];\n return profiles\n .map((profile) => profile.id)\n .filter((id): id is string => typeof id === \"string\" && id.length > 0);\n } catch {\n return [];\n }\n}\n\nfunction resolveRelayProfileId(): string | null {\n const candidates = [\n readSsoCookieProfileId(),\n sessionStorage.getItem(\"bf-active-profile\"),\n ...readProfileIds(),\n ].filter((id): id is string => typeof id === \"string\" && id.length > 0);\n\n for (const profileId of Array.from(new Set(candidates))) {\n const tokensRaw = localStorage.getItem(`bf-p-${profileId}-tokens`);\n const userRaw = localStorage.getItem(`bf-p-${profileId}-user`);\n if (tokensRaw && userRaw) {\n sessionStorage.setItem(\"bf-active-profile\", profileId);\n return profileId;\n }\n }\n\n return null;\n}\n\n/** Wipe a dead/stale profile session from THIS origin's storage so future\n * relays stop offering it (BOFF-4463). */\nfunction clearStaleRelayProfile(profileId: string): void {\n try {\n localStorage.removeItem(`bf-p-${profileId}-tokens`);\n localStorage.removeItem(`bf-p-${profileId}-user`);\n localStorage.removeItem(`bf-p-${profileId}-workspace-token`);\n localStorage.removeItem(`bf-p-${profileId}-auth-storage`);\n sessionStorage.removeItem(\"bf-active-profile\");\n } catch {\n /* best effort */\n }\n}\n\nfunction parseStoredTokens(tokensRaw: string): {\n accessToken?: string;\n refreshToken?: string;\n expiresAt?: number;\n} | null {\n try {\n return JSON.parse(tokensRaw) as {\n accessToken?: string;\n refreshToken?: string;\n expiresAt?: number;\n };\n } catch {\n return null;\n }\n}\n\nexport function SSORelayPage() {\n const [searchParams] = useSearchParams();\n\n useEffect(() => {\n let cancelled = false;\n\n const run = async () => {\n const returnTo = searchParams.get(\"returnTo\");\n if (!returnTo) {\n // No relay target — there is nothing to relay to. Do NOT strand the user\n // on a perpetual spinner (HM-016): send them to the app root, which routes\n // to the dashboard when authenticated or the landing/login when not.\n window.location.replace(\"/\");\n return;\n }\n\n // Read tokens from localStorage (this origin has them). Prefer the shared\n // SSO cookie profile because a relay often runs in a new tab with empty\n // sessionStorage and stale historical profiles in localStorage.\n const profileId = resolveRelayProfileId();\n if (!profileId) {\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n\n const tokensRaw = localStorage.getItem(`bf-p-${profileId}-tokens`);\n const userRaw = localStorage.getItem(`bf-p-${profileId}-user`);\n const profilesRaw = readProfilesRaw();\n\n if (!tokensRaw || !userRaw) {\n // No tokens — redirect back without them\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n\n // Validate the target BEFORE any server round-trip.\n let targetUrl: URL;\n try {\n targetUrl = new URL(returnTo);\n } catch {\n // returnTo is a relative path — can't do cross-domain relay\n window.location.replace(returnTo);\n return;\n }\n if (!isAllowedSsoOrigin(targetUrl)) {\n window.location.replace(\"/\");\n return;\n }\n\n // BOFF-4463: relay a FRESH, dedicated token lineage to the target\n // instead of copying this origin's pair. Copied pairs share one\n // rotating refresh token — the first origin to refresh revoked it for\n // every other origin, and this page previously relayed even EXPIRED\n // pairs, dropping the target into a session that force-logged-out\n // minutes later.\n const stored = parseStoredTokens(tokensRaw);\n let relayTokensJson: string | null = null;\n\n if (stored?.refreshToken) {\n const { globalBaseUrl } = resolveGatewayUrls();\n const { tokens: minted, denied } = await mintSsoSessionTokens(\n `${globalBaseUrl}/global/graphql`,\n stored.refreshToken,\n );\n if (cancelled) return;\n if (minted) {\n relayTokensJson = JSON.stringify(minted);\n } else if (denied) {\n // The session behind this stored pair is dead (revoked/rotated\n // away). Never hand it to the target — clear it and fail over.\n clearStaleRelayProfile(profileId);\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n }\n\n if (!relayTokensJson) {\n // Mint unavailable (rollout gap / transient). Legacy fallback is only\n // safe while the stored access token is actually still valid.\n const accessStillValid =\n !!stored?.accessToken &&\n typeof stored.expiresAt === \"number\" &&\n stored.expiresAt - Date.now() > 60_000;\n if (accessStillValid) {\n relayTokensJson = tokensRaw;\n } else {\n clearStaleRelayProfile(profileId);\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n }\n\n // Encode the auth state as a base64 URL fragment\n const payload = {\n profileId,\n tokens: relayTokensJson,\n user: userRaw,\n profiles: profilesRaw,\n };\n const encoded = btoa(JSON.stringify(payload));\n\n const returnPath = targetUrl.pathname + targetUrl.search;\n const callbackUrl = `${targetUrl.origin}/auth/sso-callback?returnTo=${encodeURIComponent(returnPath)}#sso=${encoded}`;\n\n // Redirect to the callback\n window.location.replace(callbackUrl);\n };\n\n void run();\n return () => {\n cancelled = true;\n };\n }, [searchParams]);\n\n return (\n <div className=\"flex items-center justify-center min-h-screen bg-bg-canvas\">\n <div className=\"animate-spin rounded-full size-8 border-2 border-action-primary-bg border-t-transparent\" />\n </div>\n );\n}\n"],"mappings":";;;;;;;;AAwBA,IAAMA,IAAoB,cACpBC,IAAqB,cACrBC,oBAAkB,IAAI,IAAI,CAAC,aAAa,WAAW,CAAC,GAEpDC,IAAsB;CAC1B,UAAU;CACV,kBAAkB;AACpB;AAEA,SAASC,IAA0B;CACjC,IAAI;EACF,IAAMC,IAAW,OAAO,SAAS,UAC3BC,IAASD,EAAS,SAAS,sBAAsB,IACnD,yBACA,KAAA;EACJ,KAAK,IAAME,KAAc,CAAC,UAAU,gBAAgB,GAKlD,AAJA,SAAS,SAAS,GAAGA,EAAW,uBAC5BD,MACF,SAAS,SAAS,GAAGC,EAAW,oBAAoBD,EAAO,eAEzDD,KAAY,CAACH,EAAgB,IAAIG,CAAQ,MAC3C,SAAS,SAAS,GAAGE,EAAW,oBAAoBF,EAAS;CAGnE,QAAQ,CAER;AACF;AAEA,SAASG,IAA0B;CACjC,IAAI;EACF,QACE,IAAI,gBAAgB,OAAO,SAAS,MAAM,CAAC,CAAC,IAAIR,CAAiB,KAAK,GAAA,CAErE,MAAM,GAAG,CAAC,CACV,QAAQ,MAAWS,EAAO,SAAS,CAAC;CACzC,QAAQ;EACN,OAAO,CAAC;CACV;AACF;AAEA,SAASC,EAAkB,GAA0B;CACnD,IAAI;EACF,IAAMC,IAAY,IAAI,IAAIC,GAAU,OAAO,SAAS,MAAM;EAQ1D,OANED,EAAU,WAAW,OAAO,SAAS,UACrC,CAACE,EAAmBF,CAAS,IAEtB,iBAETA,EAAU,aAAa,IAAIV,GAAoB,GAAG,GAC3CU,EAAU;CACnB,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAASG,IAAwC;CAC/C,OAAOC,EAAkB,CAAC,EAAE,MAAM;AACpC;AAEA,SAASA,IAGA;CACP,IAAI;EACF,KAAK,IAAMR,KAAc,CAAC,UAAU,gBAAgB,GAAY;GAC9D,IAAMS,IAAQ,SAAS,OAAO,MAAMb,EAAoBI,EAAW;GACnE,IAAI,CAACS,GAAO;GAEZ,IAAMC,IAAS,KAAK,MAAM,mBAAmBD,EAAM,EAAE,CAAC;GAGtD,IAAI,OAAOC,KAAW,UAAU,OAAO;IAAE,IAAIA;IAAQ,QAAQ;GAAK;GAClE,IAAI,OAAOA,EAAO,MAAO,YAAYA,EAAO,GAAG,SAAS,GACtD,OAAO;IACL,IAAIA,EAAO;IACX,QAAQ,OAAOA,EAAO,UAAW,WAAWA,EAAO,SAAS;GAC9D;EAEJ;CACF,QAAQ;EACN,OAAO;CACT;CACA,OAAO;AACT;AAEA,SAASC,IAAqC;CAC5C,IAAI;EACF,OAAO,IAAI,IAAIC,EAAwB,CAAC,CAAC,GAAG,CAAC,CAAC;CAChD,QAAQ;EACN,OAAO;CACT;AACF;AAWA,SAAgBC,EAA4B,GAA2B;CACrE,IAAI;EACF,IAAMC,IAAYN,EAAkB;EACpC,IAAI,CAACM,GAAW,QAAQ,OAAO;EAE/B,IAAMC,IAAW,IAAI,IAAID,EAAU,MAAM;EAEzC,IADI,CAACR,EAAmBS,CAAQ,KAC5BA,EAAS,WAAW,OAAO,SAAS,QAAQ,OAAO;EAEvD,IAAMC,IAAeL,EAAoB;EAGzC,IADqBK,MADC,OAAO,SAAS,UAEjBD,EAAS,WAAWC,GACvC,OAAO;EAGT,IAAMC,IAAahB,EAAc;EACjC,IAAIgB,EAAW,SAASF,EAAS,MAAM,KAAKE,EAAW,UAAU,GAC/D,OAAO;EAGT,IAAMC,IAAe,IAAI,IAAI,mBAAmBH,EAAS,MAAM;EAQ/D,OAPAG,EAAa,aAAa,IAAI,YAAYb,CAAQ,GAClDa,EAAa,aAAa,IACxBzB,GACA,MAAM,qBAAK,IAAI,IAAI,CAAC,GAAGwB,GAAY,OAAO,SAAS,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CACvE,GAEA,OAAO,SAAS,QAAQC,EAAa,IAAI,GAClC;CACT,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAASC,IAAiC;CACxC,OAAO,aAAa,QAAQ,aAAa;AAC3C;AAEA,SAASC,IAA2B;CAClC,IAAI;EACF,IAAMC,IAAcF,EAAgB;EAIpC,QAHiBE,IACZ,KAAK,MAAMA,CAAW,IACvB,CAAC,EAAA,CAEF,KAAK,MAAYC,EAAQ,EAAE,CAAC,CAC5B,QAAQ,MAAqB,OAAOC,KAAO,YAAYA,EAAG,SAAS,CAAC;CACzE,QAAQ;EACN,OAAO,CAAC;CACV;AACF;AAEA,SAASC,IAAuC;CAC9C,IAAMC,IAAa;EACjBlB,EAAuB;EACvB,eAAe,QAAQ,mBAAmB;EAC1C,GAAGa,EAAe;CACpB,CAAC,CAAC,QAAQ,MAAqB,OAAOG,KAAO,YAAYA,EAAG,SAAS,CAAC;CAEtE,KAAK,IAAMG,KAAa,MAAM,KAAK,IAAI,IAAID,CAAU,CAAC,GAAG;EACvD,IAAME,IAAY,aAAa,QAAQ,QAAQD,EAAU,QAAQ,GAC3DE,IAAU,aAAa,QAAQ,QAAQF,EAAU,MAAM;EAC7D,IAAIC,KAAaC,GAEf,OADA,eAAe,QAAQ,qBAAqBF,CAAS,GAC9CA;CAEX;CAEA,OAAO;AACT;AAIA,SAASG,EAAuB,GAAyB;CACvD,IAAI;EAKF,AAJA,aAAa,WAAW,QAAQH,EAAU,QAAQ,GAClD,aAAa,WAAW,QAAQA,EAAU,MAAM,GAChD,aAAa,WAAW,QAAQA,EAAU,iBAAiB,GAC3D,aAAa,WAAW,QAAQA,EAAU,cAAc,GACxD,eAAe,WAAW,mBAAmB;CAC/C,QAAQ,CAER;AACF;AAEA,SAASI,EAAkB,GAIlB;CACP,IAAI;EACF,OAAO,KAAK,MAAMH,CAAS;CAK7B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAgBI,IAAe;CAC7B,IAAM,CAACC,KAAgBC,EAAgB;CAyHvC,OAvHAC,QAAgB;EACd,IAAIC,IAAY;EAiHhB,QADA,YA9GwB;GACtB,IAAM9B,IAAW2B,EAAa,IAAI,UAAU;GAC5C,IAAI,CAAC3B,GAAU;IAIb,OAAO,SAAS,QAAQ,GAAG;IAC3B;GACF;GAKA,IAAMqB,IAAYF,EAAsB;GACxC,IAAI,CAACE,GAAW;IACd,IAAIb,EAA4BR,CAAQ,GAAG;IAE3C,AADAR,EAAkB,GAClB,OAAO,SAAS,QAAQM,EAAkBE,CAAQ,CAAC;IACnD;GACF;GAEA,IAAMsB,IAAY,aAAa,QAAQ,QAAQD,EAAU,QAAQ,GAC3DE,IAAU,aAAa,QAAQ,QAAQF,EAAU,MAAM,GACvDL,IAAcF,EAAgB;GAEpC,IAAI,CAACQ,KAAa,CAACC,GAAS;IAE1B,IAAIf,EAA4BR,CAAQ,GAAG;IAE3C,AADAR,EAAkB,GAClB,OAAO,SAAS,QAAQM,EAAkBE,CAAQ,CAAC;IACnD;GACF;GAGA,IAAID;GACJ,IAAI;IACF,IAAY,IAAI,IAAIC,CAAQ;GAC9B,QAAQ;IAEN,OAAO,SAAS,QAAQA,CAAQ;IAChC;GACF;GACA,IAAI,CAACC,EAAmBF,CAAS,GAAG;IAClC,OAAO,SAAS,QAAQ,GAAG;IAC3B;GACF;GAQA,IAAMgC,IAASN,EAAkBH,CAAS,GACtCU,IAAiC;GAErC,IAAID,GAAQ,cAAc;IACxB,IAAM,EAAE,qBAAkBE,EAAmB,GACvC,EAAE,QAAQC,GAAQ,cAAW,MAAMC,EACvC,GAAGC,EAAc,kBACjBL,EAAO,YACT;IACA,IAAID,GAAW;IACf,IAAII,GACF,IAAkB,KAAK,UAAUA,CAAM;SAClC,IAAIG,GAAQ;KAIjB,IADAb,EAAuBH,CAAS,GAC5Bb,EAA4BR,CAAQ,GAAG;KAE3C,AADAR,EAAkB,GAClB,OAAO,SAAS,QAAQM,EAAkBE,CAAQ,CAAC;KACnD;IACF;GACF;GAEA,IAAI,CAACgC,GAAiB;IAOpB,IAHID,GAAQ,eACV,OAAOA,EAAO,aAAc,YAC5BA,EAAO,YAAY,KAAK,IAAI,IAAI,KAEhC,IAAkBT;SACb;KAEL,IADAE,EAAuBH,CAAS,GAC5Bb,EAA4BR,CAAQ,GAAG;KAE3C,AADAR,EAAkB,GAClB,OAAO,SAAS,QAAQM,EAAkBE,CAAQ,CAAC;KACnD;IACF;GACF;GASA,IAAMsC,IAAU,KAAK,KAAK,UAAU;IALlC;IACA,QAAQN;IACR,MAAMT;IACN,UAAUP;GAEwB,CAAO,CAAC,GAEtCuB,IAAaxC,EAAU,WAAWA,EAAU,QAC5CyC,IAAc,GAAGzC,EAAU,OAAO,8BAA8B,mBAAmBwC,CAAU,EAAE,OAAOD;GAG5G,OAAO,SAAS,QAAQE,CAAW;EACrC,EAEK,CAAI,SACI;GACX,IAAY;EACd;CACF,GAAG,CAACb,CAAY,CAAC,GAGf,kBAAC,OAAD;EAAK,WAAU;EACb,UAAA,kBAAC,OAAD,EAAK,WAAU,0FAA2F,CAAA;CACvG,CAAA;AAET"}
|
|
1
|
+
{"version":3,"file":"SSORelayPage.js","names":["RELAY_CHAIN_PARAM","RELAY_FAILED_PARAM","LOCAL_HOSTNAMES","SSO_COOKIE_PATTERNS","clearRelayCookies","hostname","domain","cookieName","getRelayChain","origin","getFailedReturnTo","targetUrl","returnTo","isAllowedSsoOrigin","readSsoCookieProfileId","readSsoCookieData","match","parsed","resolveBridgeOrigin","resolveAuthBridgeConfig","redirectToCookieRelayOrigin","ssoCookie","relayUrl","bridgeOrigin","relayChain","nextRelayUrl","readProfilesRaw","readProfileIds","profilesRaw","profile","id","resolveRelayProfileId","candidates","profileId","tokensRaw","userRaw","clearStaleRelayProfile","parseStoredTokens","SSORelayPage","searchParams","useSearchParams","useEffect","cancelled","stored","relayTokensJson","resolveGatewayUrls","minted","mintSsoSessionTokens","globalBaseUrl","denied","encoded","returnPath","callbackUrl"],"sources":["../../../src/auth/pages/SSORelayPage.tsx"],"sourcesContent":["/**\n * SSORelayPage — Cross-domain SSO token relay.\n *\n * This page is loaded on the ORIGIN where the user is logged in.\n * It reads tokens from localStorage and redirects to the TARGET domain's\n * /auth/sso-callback with the tokens in a URL fragment (not query param\n * for security — fragments are not sent to servers).\n *\n * Flow:\n * 1. User visits productA.com → not logged in → detects SSO cookie\n * 2. Redirect to productB.com/auth/sso-relay?returnTo=productA.com/dashboard\n * (productB is where the user IS logged in — identified by cookie)\n * 3. This page reads tokens from productB's localStorage\n * 4. Redirects to productA.com/auth/sso-callback#token=<base64-encoded-tokens>\n * 5. SSORelayCallbackPage on productA decodes and stores tokens, redirects to /dashboard\n */\n\nimport { useEffect } from \"react\";\nimport { useSearchParams } from \"react-router\";\nimport { resolveAuthBridgeConfig } from \"@burdenoff/fe-libs/shared/config\";\nimport { resolveGatewayUrls } from \"@burdenoff/fe-libs/shared/config/gatewayUrls\";\nimport { mintSsoSessionTokens } from \"@burdenoff/fe-libs/shared/utils\";\nimport { isAllowedSsoOrigin } from \"../utils/ssoOrigins\";\n\nconst RELAY_CHAIN_PARAM = \"relayChain\";\nconst RELAY_FAILED_PARAM = \"sso_failed\";\nconst LOCAL_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\"]);\n\nconst SSO_COOKIE_PATTERNS = {\n \"bf-sso\": /(?:^|;\\s*)bf-sso=([^;]*)/,\n \"bf-sso-profile\": /(?:^|;\\s*)bf-sso-profile=([^;]*)/,\n} as const;\n\nfunction clearRelayCookies(): void {\n try {\n const hostname = window.location.hostname;\n const domain = hostname.endsWith(\".local.burdenoff.com\")\n ? \".local.burdenoff.com\"\n : undefined;\n for (const cookieName of [\"bf-sso\", \"bf-sso-profile\"] as const) {\n document.cookie = `${cookieName}=; path=/; max-age=0`;\n if (domain) {\n document.cookie = `${cookieName}=; path=/; domain=${domain}; max-age=0`;\n }\n if (hostname && !LOCAL_HOSTNAMES.has(hostname)) {\n document.cookie = `${cookieName}=; path=/; domain=${hostname}; max-age=0`;\n }\n }\n } catch {\n /* best effort */\n }\n}\n\nfunction getRelayChain(): string[] {\n try {\n return (\n new URLSearchParams(window.location.search).get(RELAY_CHAIN_PARAM) || \"\"\n )\n .split(\"|\")\n .filter((origin) => origin.length > 0);\n } catch {\n return [];\n }\n}\n\nfunction getFailedReturnTo(returnTo: string): string {\n try {\n const targetUrl = new URL(returnTo, window.location.origin);\n if (\n targetUrl.origin !== window.location.origin &&\n !isAllowedSsoOrigin(targetUrl)\n ) {\n return \"/auth/login\";\n }\n targetUrl.searchParams.set(RELAY_FAILED_PARAM, \"1\");\n return targetUrl.href;\n } catch {\n return \"/auth/login?sso_failed=1\";\n }\n}\n\nfunction readSsoCookieProfileId(): string | null {\n return readSsoCookieData()?.id ?? null;\n}\n\nfunction readSsoCookieData(): {\n id: string | null;\n origin: string | null;\n} | null {\n try {\n for (const cookieName of [\"bf-sso\", \"bf-sso-profile\"] as const) {\n const match = document.cookie.match(SSO_COOKIE_PATTERNS[cookieName]);\n if (!match) continue;\n\n const parsed = JSON.parse(decodeURIComponent(match[1])) as\n string | { id?: unknown; origin?: unknown };\n if (typeof parsed === \"string\") return { id: parsed, origin: null };\n if (typeof parsed.id === \"string\" && parsed.id.length > 0) {\n return {\n id: parsed.id,\n origin: typeof parsed.origin === \"string\" ? parsed.origin : null,\n };\n }\n }\n } catch {\n return null;\n }\n return null;\n}\n\nfunction resolveBridgeOrigin(): string | null {\n try {\n return new URL(resolveAuthBridgeConfig().url).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Follow the SSO cookie's origin hint only when it is safe to do so.\n *\n * - On a product app shell, the only safe cross-origin redirect is to the\n * central bridge host. A stale cookie pointing at a different product must\n * not strand the user on another product's login page (BOFF-6701).\n * - On the central bridge host, the cookie may legitimately point at a product\n * origin that holds the session, so we allow the hop out to that product.\n */\nexport function redirectToCookieRelayOrigin(returnTo: string): boolean {\n try {\n const ssoCookie = readSsoCookieData();\n if (!ssoCookie?.origin) return false;\n\n const relayUrl = new URL(ssoCookie.origin);\n if (!isAllowedSsoOrigin(relayUrl)) return false;\n if (relayUrl.origin === window.location.origin) return false;\n\n const bridgeOrigin = resolveBridgeOrigin();\n const currentOrigin = window.location.origin;\n const isBridgeHost = bridgeOrigin === currentOrigin;\n if (!isBridgeHost && relayUrl.origin !== bridgeOrigin) {\n return false;\n }\n\n const relayChain = getRelayChain();\n if (relayChain.includes(relayUrl.origin) || relayChain.length >= 3) {\n return false;\n }\n\n const nextRelayUrl = new URL(\"/auth/sso-relay\", relayUrl.origin);\n nextRelayUrl.searchParams.set(\"returnTo\", returnTo);\n nextRelayUrl.searchParams.set(\n RELAY_CHAIN_PARAM,\n Array.from(new Set([...relayChain, window.location.origin])).join(\"|\"),\n );\n\n window.location.replace(nextRelayUrl.href);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction readProfilesRaw(): string | null {\n return localStorage.getItem(\"bf-profiles\");\n}\n\nfunction readProfileIds(): string[] {\n try {\n const profilesRaw = readProfilesRaw();\n const profiles = profilesRaw\n ? (JSON.parse(profilesRaw) as Array<{ id?: unknown }>)\n : [];\n return profiles\n .map((profile) => profile.id)\n .filter((id): id is string => typeof id === \"string\" && id.length > 0);\n } catch {\n return [];\n }\n}\n\nfunction resolveRelayProfileId(): string | null {\n const candidates = [\n readSsoCookieProfileId(),\n sessionStorage.getItem(\"bf-active-profile\"),\n ...readProfileIds(),\n ].filter((id): id is string => typeof id === \"string\" && id.length > 0);\n\n for (const profileId of Array.from(new Set(candidates))) {\n const tokensRaw = localStorage.getItem(`bf-p-${profileId}-tokens`);\n const userRaw = localStorage.getItem(`bf-p-${profileId}-user`);\n if (tokensRaw && userRaw) {\n sessionStorage.setItem(\"bf-active-profile\", profileId);\n return profileId;\n }\n }\n\n return null;\n}\n\n/** Wipe a dead/stale profile session from THIS origin's storage so future\n * relays stop offering it (BOFF-4463). */\nfunction clearStaleRelayProfile(profileId: string): void {\n try {\n localStorage.removeItem(`bf-p-${profileId}-tokens`);\n localStorage.removeItem(`bf-p-${profileId}-user`);\n localStorage.removeItem(`bf-p-${profileId}-workspace-token`);\n localStorage.removeItem(`bf-p-${profileId}-auth-storage`);\n sessionStorage.removeItem(\"bf-active-profile\");\n } catch {\n /* best effort */\n }\n}\n\nfunction parseStoredTokens(tokensRaw: string): {\n accessToken?: string;\n refreshToken?: string;\n expiresAt?: number;\n} | null {\n try {\n return JSON.parse(tokensRaw) as {\n accessToken?: string;\n refreshToken?: string;\n expiresAt?: number;\n };\n } catch {\n return null;\n }\n}\n\nexport function SSORelayPage() {\n const [searchParams] = useSearchParams();\n\n useEffect(() => {\n let cancelled = false;\n\n const run = async () => {\n const returnTo = searchParams.get(\"returnTo\");\n if (!returnTo) {\n // No relay target — there is nothing to relay to. Do NOT strand the user\n // on a perpetual spinner (HM-016): send them to the app root, which routes\n // to the dashboard when authenticated or the landing/login when not.\n window.location.replace(\"/\");\n return;\n }\n\n // Read tokens from localStorage (this origin has them). Prefer the shared\n // SSO cookie profile because a relay often runs in a new tab with empty\n // sessionStorage and stale historical profiles in localStorage.\n const profileId = resolveRelayProfileId();\n if (!profileId) {\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n\n const tokensRaw = localStorage.getItem(`bf-p-${profileId}-tokens`);\n const userRaw = localStorage.getItem(`bf-p-${profileId}-user`);\n const profilesRaw = readProfilesRaw();\n\n if (!tokensRaw || !userRaw) {\n // No tokens — redirect back without them\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n\n // Validate the target BEFORE any server round-trip.\n let targetUrl: URL;\n try {\n targetUrl = new URL(returnTo);\n } catch {\n // returnTo is a relative path — can't do cross-domain relay\n window.location.replace(returnTo);\n return;\n }\n if (!isAllowedSsoOrigin(targetUrl)) {\n window.location.replace(\"/\");\n return;\n }\n\n // BOFF-4463: relay a FRESH, dedicated token lineage to the target\n // instead of copying this origin's pair. Copied pairs share one\n // rotating refresh token — the first origin to refresh revoked it for\n // every other origin, and this page previously relayed even EXPIRED\n // pairs, dropping the target into a session that force-logged-out\n // minutes later.\n const stored = parseStoredTokens(tokensRaw);\n let relayTokensJson: string | null = null;\n\n if (stored?.refreshToken) {\n const { globalBaseUrl } = resolveGatewayUrls();\n const { tokens: minted, denied } = await mintSsoSessionTokens(\n `${globalBaseUrl}/global/graphql`,\n stored.refreshToken,\n );\n if (cancelled) return;\n if (minted) {\n relayTokensJson = JSON.stringify(minted);\n } else if (denied) {\n // The session behind this stored pair is dead (revoked/rotated\n // away). Never hand it to the target — clear it and fail over.\n clearStaleRelayProfile(profileId);\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n }\n\n if (!relayTokensJson) {\n // Mint unavailable (rollout gap / transient). Legacy fallback is only\n // safe while the stored access token is actually still valid.\n const accessStillValid =\n !!stored?.accessToken &&\n typeof stored.expiresAt === \"number\" &&\n stored.expiresAt - Date.now() > 60_000;\n if (accessStillValid) {\n relayTokensJson = tokensRaw;\n } else {\n clearStaleRelayProfile(profileId);\n if (redirectToCookieRelayOrigin(returnTo)) return;\n clearRelayCookies();\n window.location.replace(getFailedReturnTo(returnTo));\n return;\n }\n }\n\n // Encode the auth state as a base64 URL fragment\n const payload = {\n profileId,\n tokens: relayTokensJson,\n user: userRaw,\n profiles: profilesRaw,\n };\n const encoded = btoa(JSON.stringify(payload));\n\n const returnPath = targetUrl.pathname + targetUrl.search;\n const callbackUrl = `${targetUrl.origin}/auth/sso-callback?returnTo=${encodeURIComponent(returnPath)}#sso=${encoded}`;\n\n // Redirect to the callback\n window.location.replace(callbackUrl);\n };\n\n void run();\n return () => {\n cancelled = true;\n };\n }, [searchParams]);\n\n return (\n <div className=\"flex items-center justify-center min-h-screen bg-bg-canvas\">\n <div className=\"animate-spin rounded-full size-8 border-2 border-action-primary-bg border-t-transparent\" />\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation.js","names":["validateEmail","email","validatePassword","errors","password","lengthInCodePoints","s","NAME_ALLOWED_CHARS","TYPOGRAPHIC_APOSTROPHES","TYPOGRAPHIC_HYPHENS","validateName","trimmed","name","USERNAME_ALLOWED_CHARS","validateUsername","value","username","validateUserFields","requireAll","options","isBlank","v","firstName","input","lastName","valueOr","result","fallback","validatePhoneNumber","phone","digits"],"sources":["../../../src/auth/utils/validation.ts"],"sourcesContent":["import type { PasswordStrength } from \"../types\";\n\n/**\n * Validate email format\n */\nexport function validateEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\n/**\n * Validate password requirements\n */\nexport function validatePassword(password: string): {\n valid: boolean;\n errors: string[];\n} {\n const errors: string[] = [];\n\n if (password.length < 12) {\n errors.push(\"Password must be at least 12 characters long\");\n }\n\n if (!/[A-Z]/.test(password)) {\n errors.push(\"Password must contain at least one uppercase letter\");\n }\n\n if (!/[a-z]/.test(password)) {\n errors.push(\"Password must contain at least one lowercase letter\");\n }\n\n if (!/[0-9]/.test(password)) {\n errors.push(\"Password must contain at least one number\");\n }\n\n if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password)) {\n errors.push(\"Password must contain at least one special character\");\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Calculate password strength using Zxcvbn-like algorithm\n * Returns a score from 0-4 (0 = very weak, 4 = very strong)\n */\nexport function calculatePasswordStrength(password: string): PasswordStrength {\n let score: 0 | 1 | 2 | 3 | 4 = 0;\n const feedback: string[] = [];\n\n // Length check\n if (password.length >= 8) score++;\n if (password.length >= 12) score++;\n if (password.length >= 16) score++;\n\n // Complexity checks\n if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;\n if (/[0-9]/.test(password)) score++;\n if (/[!@#$%^&*(),.?\":{}|<>]/.test(password)) score++;\n\n // Common patterns\n if (/^[a-z]+$/.test(password.toLowerCase())) {\n feedback.push(\"Add uppercase letters, numbers, and symbols\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n if (/^[0-9]+$/.test(password)) {\n feedback.push(\"Avoid using only numbers\");\n score = 0;\n }\n\n if (/(.)\\1{2,}/.test(password)) {\n feedback.push(\"Avoid repeating characters\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n // Common passwords\n const commonPasswords = [\"password\", \"123456\", \"qwerty\", \"admin\", \"letmein\"];\n if (commonPasswords.some((p) => password.toLowerCase().includes(p))) {\n feedback.push(\"Avoid using common passwords\");\n score = 0;\n }\n\n // Ensure score is 0-4\n score = Math.min(4, Math.max(0, score)) as 0 | 1 | 2 | 3 | 4;\n\n // Crack time estimation\n const crackTime =\n score === 0\n ? \"less than 1 second\"\n : score === 1\n ? \"less than 1 minute\"\n : score === 2\n ? \"less than 1 hour\"\n : score === 3\n ? \"less than 1 day\"\n : \"centuries\";\n\n return { score, feedback, crackTime };\n}\n\n/**\n * Validate MFA code format (6 digits)\n */\nexport function validateMfaCode(code: string): boolean {\n return /^\\d{6}$/.test(code);\n}\n\n/**\n * Validate API key name\n */\nexport function validateApiKeyName(name: string): {\n valid: boolean;\n error?: string;\n} {\n if (!name || name.trim().length === 0) {\n return { valid: false, error: \"API key name is required\" };\n }\n\n if (name.length > 100) {\n return {\n valid: false,\n error: \"API key name must be less than 100 characters\",\n };\n }\n\n return { valid: true };\n}\n/**\n * Minimum/maximum lengths for a person's name (first or last), measured in\n * Unicode code points (NOT JavaScript `.length`, which counts UTF-16 code\n * units — see `lengthInCodePoints`).\n *\n * Picked to stay well under the 255-char Prisma `User.firstName/lastName`\n * columns with headroom.\n */\nconst NAME_MIN_LENGTH = 1;\nconst NAME_MAX_LENGTH = 100;\n\n/**\n * Count Unicode code points in `s` instead of UTF-16 code units. Names\n * written in scripts that live in the supplementary plane (Adlam 𞤁𞤔𞤣,\n * many CJK extensions, etc.) have each letter encoded as two UTF-16 units,\n * so a 51-letter Adlam name would otherwise be miscounted as 102 and\n * rejected as too long (Codex P2 review).\n *\n * Code-point counting is sufficient for the name validator — grapheme-\n * cluster counting would also handle emoji ZWJ sequences, but the name\n * regex already disallows emoji.\n */\nfunction lengthInCodePoints(s: string): number {\n return [...s].length;\n}\n\n/**\n * Allowed-character regex; a separate letter check rejects \"12345\",\n * \"12 34\", and \"---\" without overlapping repetitions (BOFF-8547).\n * Every character must be a letter, mark, literal\n * space, hyphen, apostrophe, period, middle-dot, or join control —\n * diacritics-friendly across scripts (Latin, Tamil, Chinese, etc.).\n *\n * Note: a literal space, not `\\s` — `\\s` matches tabs/newlines/other Unicode\n * whitespace which has no business being inside a person's name (review #2).\n *\n * Two middle-dot codepoints are allowed as-is, NOT folded to `.` — they're\n * orthographically distinct and silently rewriting the user's name would\n * corrupt their identity (Codex P2 reviews):\n * U+00B7 — middle dot, used in Catalan (`Joan·Maria`), Chinese\n * (`阿布杜拉·阿扎姆`), and Russian.\n * U+30FB — katakana middle dot, the standard Japanese separator for\n * foreign personal names (`ジョン・スミス`).\n *\n * Zero-width join controls are also allowed as-is:\n * U+200C ZERO WIDTH NON-JOINER — required for Persian compound names\n * like `محمدرضا` so the letters don't ligate.\n * U+200D ZERO WIDTH JOINER — used in some Indic scripts and emoji\n * sequences (we don't allow emoji ZWJ, but the codepoint is\n * accepted as a generic join marker).\n *\n * Typographic apostrophes (U+2018 / U+2019) and modifier letter apostrophe\n * (U+02BC), and typographic hyphens (U+2010 / U+2011), ARE folded to their\n * ASCII equivalents before validation — they're typographic variants of the\n * same character, not semantically distinct (Codex P2 review).\n *\n * BOFF-7214.\n */\nconst NAME_ALLOWED_CHARS = /^[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]+$/u;\nconst TYPOGRAPHIC_APOSTROPHES = /[\\u2018\\u2019\\u02BC]/g;\nconst TYPOGRAPHIC_HYPHENS = /[\\u2010\\u2011]/g;\n\n/**\n * Machine-readable name-validation error codes. The caller maps each code\n * to a distinct i18n key (review #1) so that one validator error doesn't\n * collapse N different messages into one translated string.\n */\nexport type NameErrorCode =\n | \"required\"\n | \"too_long\"\n | \"no_letter\"\n | \"invalid_chars\";\n\nexport type NameValidationResult =\n | { valid: true; value: string }\n | { valid: false; code: NameErrorCode; message: string };\n\n/**\n * Validate a person's name (first or last).\n * Accepts `string | undefined | null` because the signup form types name\n * fields as optional (matching the `SignUpInput` contract); an absent value\n * is treated as \"missing\" and fails the validation.\n * On success the result carries the canonicalised `value` (trimmed, with\n * typographic apostrophes folded to ASCII) so the caller can submit the\n * exact same string the validator approved — otherwise the validated\n * string and the value sent to `signUp()` would diverge (e.g. user types\n * `\" O'Connor \"`, validator normalises to `\"O'Connor\"`, but signUp\n * receives the original raw value).\n * Returns a structured result so the caller can decide how to render errors.\n */\nexport function validateName(\n name: string | undefined | null,\n): NameValidationResult {\n // Normalise typographic apostrophes and hyphens to their ASCII\n // equivalents before testing — these are typographic variants of the\n // same character, so the canonical ASCII form is what reaches the DB.\n // (Middle-dot U+00B7 is preserved as-is: see NAME_ALLOWED_CHARS comment.)\n // NFC normalisation happens before trim/length so combining-mark\n // sequences (e.g. `e\\u0301` for `é`) fold to their precomposed\n // form first — otherwise `(\"e\\u0301\").repeat(51)` would over-count\n // to 102 code points and reject a valid 51-character name (Codex P2).\n const trimmed = (name ?? \"\")\n .normalize(\"NFC\")\n .replace(TYPOGRAPHIC_APOSTROPHES, \"'\")\n .replace(TYPOGRAPHIC_HYPHENS, \"-\")\n .trim();\n\n if (lengthInCodePoints(trimmed) < NAME_MIN_LENGTH) {\n return { valid: false, code: \"required\", message: \"Name is required\" };\n }\n\n if (lengthInCodePoints(trimmed) > NAME_MAX_LENGTH) {\n return {\n valid: false,\n code: \"too_long\",\n message: `Name must be ${NAME_MAX_LENGTH} characters or fewer`,\n };\n }\n\n if (!/\\p{L}/u.test(trimmed) || !NAME_ALLOWED_CHARS.test(trimmed)) {\n // Independent linear scans distinguish the two failure modes that matter\n // to the user: a value with no letters vs. a value with disallowed\n // characters. Surface the right hint for each.\n if (!/\\p{L}/u.test(trimmed)) {\n return {\n valid: false,\n code: \"no_letter\",\n message: \"Name must contain at least one letter\",\n };\n }\n return {\n valid: false,\n code: \"invalid_chars\",\n message:\n \"Name can only contain letters, spaces, periods, hyphens, and apostrophes\",\n };\n }\n\n return { valid: true, value: trimmed };\n}\n\n/**\n * Regex for characters allowed in a username. Letters, digits, underscores,\n * hyphens — the existing rule. The new rule layered on top (BOFF-7214):\n * a username must contain at least one letter, so an attacker cannot create\n * an account like \"12345\" with a throwaway email.\n */\nconst USERNAME_ALLOWED_CHARS = /^[a-zA-Z0-9_-]+$/;\nconst USERNAME_MIN_LENGTH = 3;\nconst USERNAME_MAX_LENGTH = 30;\n/**\n * Machine-readable username-validation error codes — one per failing rule\n * (review #1) so each can map to its own i18n key.\n */\nexport type UsernameErrorCode =\n | \"required\"\n | \"too_short\"\n | \"too_long\"\n | \"invalid_chars\"\n | \"no_letter\";\n\nexport type UsernameValidationResult =\n | { valid: true; value: string }\n | {\n valid: false;\n errors: Array<{ code: UsernameErrorCode; message: string }>;\n };\n\n/**\n * Validate a username.\n * Accepts `string | undefined | null` for parity with `validateName` and the\n * optional `SignUpInput.username` contract.\n *\n * Surrounding whitespace is trimmed before the length / character checks —\n * a pasted `\" alice_01 \"` would otherwise fail with a misleading\n * \"can only contain letters, numbers, underscores, and hyphens\" error\n * when the actual problem is invisible whitespace. On success the result\n * carries the trimmed `value` so the caller submits the same string the\n * validator approved (Claude LOW review).\n * Returns a structured result with a list of errors so the caller can\n * render every failing rule at once (mirrors `validatePassword`).\n */\nexport function validateUsername(\n username: string | undefined | null,\n): UsernameValidationResult {\n const errors: Array<{ code: UsernameErrorCode; message: string }> = [];\n const value = (username ?? \"\").trim();\n\n if (lengthInCodePoints(value) === 0) {\n errors.push({\n code: \"required\",\n message: \"Username is required\",\n });\n } else {\n if (lengthInCodePoints(value) < USERNAME_MIN_LENGTH) {\n errors.push({\n code: \"too_short\",\n message: `Username must be at least ${USERNAME_MIN_LENGTH} characters`,\n });\n }\n\n if (lengthInCodePoints(value) > USERNAME_MAX_LENGTH) {\n errors.push({\n code: \"too_long\",\n message: `Username must be ${USERNAME_MAX_LENGTH} characters or fewer`,\n });\n }\n\n if (!USERNAME_ALLOWED_CHARS.test(value)) {\n errors.push({\n code: \"invalid_chars\",\n message:\n \"Username can only contain letters, numbers, underscores, and hyphens\",\n });\n }\n\n // Must contain at least one letter — guards against all-numeric or\n // all-symbol usernames slipping through. Pure numeric values like\n // \"12345\" previously passed the character allow-list alone.\n if (\n lengthInCodePoints(value) >= USERNAME_MIN_LENGTH &&\n !/[a-zA-Z]/.test(value)\n ) {\n errors.push({\n code: \"no_letter\",\n message: \"Username must contain at least one letter\",\n });\n }\n }\n\n return errors.length === 0\n ? { valid: true, value }\n : { valid: false, errors };\n}\n\n/**\n * Bundle validation across all three user-identity fields so callers\n * (RegisterPage, UserProfilePage, UsersPage) can fail fast on the whole\n * form instead of piecemeal — and so the BOFF-7214 invariant\n * (firstName / lastName contain a letter; username contains a letter)\n * is enforced at every mutation surface, not just signup.\n *\n * On success, the result carries the canonicalised values from each\n * individual validator so the caller submits the same strings the\n * validators approved (no validate/submit divergence).\n *\n * The caller is responsible for mapping each `{ field, code, message }`\n * error to its own i18n key — different surfaces have different key\n * namespaces (`auth.register.*`, `auth.profile.*`, `auth.users.*`).\n */\nexport type UserFieldKey = \"firstName\" | \"lastName\" | \"username\";\n\nexport interface UserFieldError {\n field: UserFieldKey;\n code: string;\n message: string;\n}\n\nexport interface ValidatedUserFields {\n firstName: string;\n lastName: string;\n username: string;\n}\n\nexport type UserFieldsValidationResult =\n | { valid: true; values: ValidatedUserFields }\n | { valid: false; errors: UserFieldError[] };\n\nexport function validateUserFields(\n input: {\n firstName?: string | null;\n lastName?: string | null;\n username?: string | null;\n },\n options: { requireAll?: boolean } = {},\n): UserFieldsValidationResult {\n // Default to requiring every field. Profile-edit surfaces pass\n // `requireAll: false` so an existing user whose optional firstName /\n // lastName is unset (e.g. provisioned via an identity provider) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const requireAll = options.requireAll ?? true;\n const errors: UserFieldError[] = [];\n\n // Skip the validator entirely when a field is empty AND requireAll is\n // false — that means \"user is leaving this field blank\". The signup\n // form always requires every field; profile edit does not.\n const isBlank = (v: string | null | undefined): boolean =>\n !v || v.trim().length === 0;\n\n const firstName =\n !isBlank(input.firstName) || requireAll\n ? validateName(input.firstName)\n : null;\n if (firstName && !firstName.valid) {\n errors.push({\n field: \"firstName\",\n code: firstName.code,\n message: firstName.message,\n });\n }\n\n const lastName =\n !isBlank(input.lastName) || requireAll\n ? validateName(input.lastName)\n : null;\n if (lastName && !lastName.valid) {\n errors.push({\n field: \"lastName\",\n code: lastName.code,\n message: lastName.message,\n });\n }\n\n const username =\n !isBlank(input.username) || requireAll\n ? validateUsername(input.username)\n : null;\n if (username && !username.valid) {\n for (const e of username.errors) {\n errors.push({ field: \"username\", code: e.code, message: e.message });\n }\n }\n\n if (errors.length > 0) return { valid: false, errors };\n\n // Return canonical values: trimmed/NFC-normalised when validated, the\n // original trimmed value when the field was optional and left blank.\n // The cast is sound because the `errors.length > 0` early-return above\n // guarantees each result, when non-null, is the `{ valid: true }` arm.\n const valueOr = (\n result: { valid: true; value: string } | null,\n fallback: string | null | undefined,\n ): string => (result ? result.value : (fallback ?? \"\").trim());\n\n return {\n valid: true,\n values: {\n firstName: valueOr(\n firstName as { valid: true; value: string } | null,\n input.firstName,\n ),\n lastName: valueOr(\n lastName as { valid: true; value: string } | null,\n input.lastName,\n ),\n username: valueOr(\n username as { valid: true; value: string } | null,\n input.username,\n ),\n },\n };\n}\n\n/**\n * Validate a phone number (BOFF-7216).\n *\n * Phone numbers are optional on profile / admin edit forms — an empty input\n * is accepted. When a value is supplied it must:\n * - contain only digits, spaces, and the formatting characters + - ( )\n * - include 7–15 digits (E.164-style length bounds; we don't strip \"+\"\n * country prefix here, we just count digits)\n * - start with +, a digit, or (\n *\n * Returns `{ valid: true }` on an empty / whitespace-only input.\n * Returns `{ valid: false, error }` with a human-readable message on\n * rejection. The message is intentionally plain English so the caller can\n * either display it verbatim or pipe it through `tr()` with an i18n key.\n */\nexport function validatePhoneNumber(phone: string): {\n valid: boolean;\n error?: string;\n} {\n const trimmed = phone.trim();\n\n if (trimmed.length === 0) {\n return { valid: true };\n }\n\n if (!/^[0-9+\\-() ]+$/.test(trimmed)) {\n return {\n valid: false,\n error:\n \"Phone number can only contain digits, spaces, and the characters + - ( )\",\n };\n }\n\n const digits = trimmed.replace(/[^\\d]/g, \"\");\n if (digits.length < 7) {\n return { valid: false, error: \"Phone number is too short\" };\n }\n\n if (digits.length > 15) {\n return { valid: false, error: \"Phone number is too long\" };\n }\n\n if (!/^[+\\d(]/.test(trimmed)) {\n return {\n valid: false,\n error: \"Phone number must start with +, a digit, or (\",\n };\n }\n\n return { valid: true };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"validation.js","names":["validateEmail","email","validatePassword","errors","password","lengthInCodePoints","s","NAME_ALLOWED_CHARS","TYPOGRAPHIC_APOSTROPHES","TYPOGRAPHIC_HYPHENS","validateName","trimmed","name","USERNAME_ALLOWED_CHARS","validateUsername","value","username","validateUserFields","requireAll","options","isBlank","v","firstName","input","lastName","valueOr","result","fallback","validatePhoneNumber","phone","digits"],"sources":["../../../src/auth/utils/validation.ts"],"sourcesContent":["import type { PasswordStrength } from \"../types\";\n\n/**\n * Validate email format\n */\nexport function validateEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\n/**\n * Validate password requirements\n */\nexport function validatePassword(password: string): {\n valid: boolean;\n errors: string[];\n} {\n const errors: string[] = [];\n\n if (password.length < 12) {\n errors.push(\"Password must be at least 12 characters long\");\n }\n\n if (!/[A-Z]/.test(password)) {\n errors.push(\"Password must contain at least one uppercase letter\");\n }\n\n if (!/[a-z]/.test(password)) {\n errors.push(\"Password must contain at least one lowercase letter\");\n }\n\n if (!/[0-9]/.test(password)) {\n errors.push(\"Password must contain at least one number\");\n }\n\n if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password)) {\n errors.push(\"Password must contain at least one special character\");\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Calculate password strength using Zxcvbn-like algorithm\n * Returns a score from 0-4 (0 = very weak, 4 = very strong)\n */\nexport function calculatePasswordStrength(password: string): PasswordStrength {\n let score: 0 | 1 | 2 | 3 | 4 = 0;\n const feedback: string[] = [];\n\n // Length check\n if (password.length >= 8) score++;\n if (password.length >= 12) score++;\n if (password.length >= 16) score++;\n\n // Complexity checks\n if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;\n if (/[0-9]/.test(password)) score++;\n if (/[!@#$%^&*(),.?\":{}|<>]/.test(password)) score++;\n\n // Common patterns\n if (/^[a-z]+$/.test(password.toLowerCase())) {\n feedback.push(\"Add uppercase letters, numbers, and symbols\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n if (/^[0-9]+$/.test(password)) {\n feedback.push(\"Avoid using only numbers\");\n score = 0;\n }\n\n if (/(.)\\1{2,}/.test(password)) {\n feedback.push(\"Avoid repeating characters\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n // Common passwords\n const commonPasswords = [\"password\", \"123456\", \"qwerty\", \"admin\", \"letmein\"];\n if (commonPasswords.some((p) => password.toLowerCase().includes(p))) {\n feedback.push(\"Avoid using common passwords\");\n score = 0;\n }\n\n // Ensure score is 0-4\n score = Math.min(4, Math.max(0, score)) as 0 | 1 | 2 | 3 | 4;\n\n // Crack time estimation\n const crackTime =\n score === 0\n ? \"less than 1 second\"\n : score === 1\n ? \"less than 1 minute\"\n : score === 2\n ? \"less than 1 hour\"\n : score === 3\n ? \"less than 1 day\"\n : \"centuries\";\n\n return { score, feedback, crackTime };\n}\n\n/**\n * Validate MFA code format (6 digits)\n */\nexport function validateMfaCode(code: string): boolean {\n return /^\\d{6}$/.test(code);\n}\n\n/**\n * Validate API key name\n */\nexport function validateApiKeyName(name: string): {\n valid: boolean;\n error?: string;\n} {\n if (!name || name.trim().length === 0) {\n return { valid: false, error: \"API key name is required\" };\n }\n\n if (name.length > 100) {\n return {\n valid: false,\n error: \"API key name must be less than 100 characters\",\n };\n }\n\n return { valid: true };\n}\n/**\n * Minimum/maximum lengths for a person's name (first or last), measured in\n * Unicode code points (NOT JavaScript `.length`, which counts UTF-16 code\n * units — see `lengthInCodePoints`).\n *\n * Picked to stay well under the 255-char Prisma `User.firstName/lastName`\n * columns with headroom.\n */\nconst NAME_MIN_LENGTH = 1;\nconst NAME_MAX_LENGTH = 100;\n\n/**\n * Count Unicode code points in `s` instead of UTF-16 code units. Names\n * written in scripts that live in the supplementary plane (Adlam 𞤁𞤔𞤣,\n * many CJK extensions, etc.) have each letter encoded as two UTF-16 units,\n * so a 51-letter Adlam name would otherwise be miscounted as 102 and\n * rejected as too long (Codex P2 review).\n *\n * Code-point counting is sufficient for the name validator — grapheme-\n * cluster counting would also handle emoji ZWJ sequences, but the name\n * regex already disallows emoji.\n */\nfunction lengthInCodePoints(s: string): number {\n return [...s].length;\n}\n\n/**\n * Allowed-character regex; a separate letter check rejects \"12345\",\n * \"12 34\", and \"---\" without overlapping repetitions (BOFF-8547).\n * Every character must be a letter, mark, literal\n * space, hyphen, apostrophe, period, middle-dot, or join control —\n * diacritics-friendly across scripts (Latin, Tamil, Chinese, etc.).\n *\n * Note: a literal space, not `\\s` — `\\s` matches tabs/newlines/other Unicode\n * whitespace which has no business being inside a person's name (review #2).\n *\n * Two middle-dot codepoints are allowed as-is, NOT folded to `.` — they're\n * orthographically distinct and silently rewriting the user's name would\n * corrupt their identity (Codex P2 reviews):\n * U+00B7 — middle dot, used in Catalan (`Joan·Maria`), Chinese\n * (`阿布杜拉·阿扎姆`), and Russian.\n * U+30FB — katakana middle dot, the standard Japanese separator for\n * foreign personal names (`ジョン・スミス`).\n *\n * Zero-width join controls are also allowed as-is:\n * U+200C ZERO WIDTH NON-JOINER — required for Persian compound names\n * like `محمدرضا` so the letters don't ligate.\n * U+200D ZERO WIDTH JOINER — used in some Indic scripts and emoji\n * sequences (we don't allow emoji ZWJ, but the codepoint is\n * accepted as a generic join marker).\n *\n * Typographic apostrophes (U+2018 / U+2019) and modifier letter apostrophe\n * (U+02BC), and typographic hyphens (U+2010 / U+2011), ARE folded to their\n * ASCII equivalents before validation — they're typographic variants of the\n * same character, not semantically distinct (Codex P2 review).\n *\n * BOFF-7214.\n */\nconst NAME_ALLOWED_CHARS = /^[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]+$/u;\nconst TYPOGRAPHIC_APOSTROPHES = /[\\u2018\\u2019\\u02BC]/g;\nconst TYPOGRAPHIC_HYPHENS = /[\\u2010\\u2011]/g;\n\n/**\n * Machine-readable name-validation error codes. The caller maps each code\n * to a distinct i18n key (review #1) so that one validator error doesn't\n * collapse N different messages into one translated string.\n */\nexport type NameErrorCode =\n \"required\" | \"too_long\" | \"no_letter\" | \"invalid_chars\";\n\nexport type NameValidationResult =\n | { valid: true; value: string }\n | { valid: false; code: NameErrorCode; message: string };\n\n/**\n * Validate a person's name (first or last).\n * Accepts `string | undefined | null` because the signup form types name\n * fields as optional (matching the `SignUpInput` contract); an absent value\n * is treated as \"missing\" and fails the validation.\n * On success the result carries the canonicalised `value` (trimmed, with\n * typographic apostrophes folded to ASCII) so the caller can submit the\n * exact same string the validator approved — otherwise the validated\n * string and the value sent to `signUp()` would diverge (e.g. user types\n * `\" O'Connor \"`, validator normalises to `\"O'Connor\"`, but signUp\n * receives the original raw value).\n * Returns a structured result so the caller can decide how to render errors.\n */\nexport function validateName(\n name: string | undefined | null,\n): NameValidationResult {\n // Normalise typographic apostrophes and hyphens to their ASCII\n // equivalents before testing — these are typographic variants of the\n // same character, so the canonical ASCII form is what reaches the DB.\n // (Middle-dot U+00B7 is preserved as-is: see NAME_ALLOWED_CHARS comment.)\n // NFC normalisation happens before trim/length so combining-mark\n // sequences (e.g. `e\\u0301` for `é`) fold to their precomposed\n // form first — otherwise `(\"e\\u0301\").repeat(51)` would over-count\n // to 102 code points and reject a valid 51-character name (Codex P2).\n const trimmed = (name ?? \"\")\n .normalize(\"NFC\")\n .replace(TYPOGRAPHIC_APOSTROPHES, \"'\")\n .replace(TYPOGRAPHIC_HYPHENS, \"-\")\n .trim();\n\n if (lengthInCodePoints(trimmed) < NAME_MIN_LENGTH) {\n return { valid: false, code: \"required\", message: \"Name is required\" };\n }\n\n if (lengthInCodePoints(trimmed) > NAME_MAX_LENGTH) {\n return {\n valid: false,\n code: \"too_long\",\n message: `Name must be ${NAME_MAX_LENGTH} characters or fewer`,\n };\n }\n\n if (!/\\p{L}/u.test(trimmed) || !NAME_ALLOWED_CHARS.test(trimmed)) {\n // Independent linear scans distinguish the two failure modes that matter\n // to the user: a value with no letters vs. a value with disallowed\n // characters. Surface the right hint for each.\n if (!/\\p{L}/u.test(trimmed)) {\n return {\n valid: false,\n code: \"no_letter\",\n message: \"Name must contain at least one letter\",\n };\n }\n return {\n valid: false,\n code: \"invalid_chars\",\n message:\n \"Name can only contain letters, spaces, periods, hyphens, and apostrophes\",\n };\n }\n\n return { valid: true, value: trimmed };\n}\n\n/**\n * Regex for characters allowed in a username. Letters, digits, underscores,\n * hyphens — the existing rule. The new rule layered on top (BOFF-7214):\n * a username must contain at least one letter, so an attacker cannot create\n * an account like \"12345\" with a throwaway email.\n */\nconst USERNAME_ALLOWED_CHARS = /^[a-zA-Z0-9_-]+$/;\nconst USERNAME_MIN_LENGTH = 3;\nconst USERNAME_MAX_LENGTH = 30;\n/**\n * Machine-readable username-validation error codes — one per failing rule\n * (review #1) so each can map to its own i18n key.\n */\nexport type UsernameErrorCode =\n \"required\" | \"too_short\" | \"too_long\" | \"invalid_chars\" | \"no_letter\";\n\nexport type UsernameValidationResult =\n | { valid: true; value: string }\n | {\n valid: false;\n errors: Array<{ code: UsernameErrorCode; message: string }>;\n };\n\n/**\n * Validate a username.\n * Accepts `string | undefined | null` for parity with `validateName` and the\n * optional `SignUpInput.username` contract.\n *\n * Surrounding whitespace is trimmed before the length / character checks —\n * a pasted `\" alice_01 \"` would otherwise fail with a misleading\n * \"can only contain letters, numbers, underscores, and hyphens\" error\n * when the actual problem is invisible whitespace. On success the result\n * carries the trimmed `value` so the caller submits the same string the\n * validator approved (Claude LOW review).\n * Returns a structured result with a list of errors so the caller can\n * render every failing rule at once (mirrors `validatePassword`).\n */\nexport function validateUsername(\n username: string | undefined | null,\n): UsernameValidationResult {\n const errors: Array<{ code: UsernameErrorCode; message: string }> = [];\n const value = (username ?? \"\").trim();\n\n if (lengthInCodePoints(value) === 0) {\n errors.push({\n code: \"required\",\n message: \"Username is required\",\n });\n } else {\n if (lengthInCodePoints(value) < USERNAME_MIN_LENGTH) {\n errors.push({\n code: \"too_short\",\n message: `Username must be at least ${USERNAME_MIN_LENGTH} characters`,\n });\n }\n\n if (lengthInCodePoints(value) > USERNAME_MAX_LENGTH) {\n errors.push({\n code: \"too_long\",\n message: `Username must be ${USERNAME_MAX_LENGTH} characters or fewer`,\n });\n }\n\n if (!USERNAME_ALLOWED_CHARS.test(value)) {\n errors.push({\n code: \"invalid_chars\",\n message:\n \"Username can only contain letters, numbers, underscores, and hyphens\",\n });\n }\n\n // Must contain at least one letter — guards against all-numeric or\n // all-symbol usernames slipping through. Pure numeric values like\n // \"12345\" previously passed the character allow-list alone.\n if (\n lengthInCodePoints(value) >= USERNAME_MIN_LENGTH &&\n !/[a-zA-Z]/.test(value)\n ) {\n errors.push({\n code: \"no_letter\",\n message: \"Username must contain at least one letter\",\n });\n }\n }\n\n return errors.length === 0\n ? { valid: true, value }\n : { valid: false, errors };\n}\n\n/**\n * Bundle validation across all three user-identity fields so callers\n * (RegisterPage, UserProfilePage, UsersPage) can fail fast on the whole\n * form instead of piecemeal — and so the BOFF-7214 invariant\n * (firstName / lastName contain a letter; username contains a letter)\n * is enforced at every mutation surface, not just signup.\n *\n * On success, the result carries the canonicalised values from each\n * individual validator so the caller submits the same strings the\n * validators approved (no validate/submit divergence).\n *\n * The caller is responsible for mapping each `{ field, code, message }`\n * error to its own i18n key — different surfaces have different key\n * namespaces (`auth.register.*`, `auth.profile.*`, `auth.users.*`).\n */\nexport type UserFieldKey = \"firstName\" | \"lastName\" | \"username\";\n\nexport interface UserFieldError {\n field: UserFieldKey;\n code: string;\n message: string;\n}\n\nexport interface ValidatedUserFields {\n firstName: string;\n lastName: string;\n username: string;\n}\n\nexport type UserFieldsValidationResult =\n | { valid: true; values: ValidatedUserFields }\n | { valid: false; errors: UserFieldError[] };\n\nexport function validateUserFields(\n input: {\n firstName?: string | null;\n lastName?: string | null;\n username?: string | null;\n },\n options: { requireAll?: boolean } = {},\n): UserFieldsValidationResult {\n // Default to requiring every field. Profile-edit surfaces pass\n // `requireAll: false` so an existing user whose optional firstName /\n // lastName is unset (e.g. provisioned via an identity provider) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const requireAll = options.requireAll ?? true;\n const errors: UserFieldError[] = [];\n\n // Skip the validator entirely when a field is empty AND requireAll is\n // false — that means \"user is leaving this field blank\". The signup\n // form always requires every field; profile edit does not.\n const isBlank = (v: string | null | undefined): boolean =>\n !v || v.trim().length === 0;\n\n const firstName =\n !isBlank(input.firstName) || requireAll\n ? validateName(input.firstName)\n : null;\n if (firstName && !firstName.valid) {\n errors.push({\n field: \"firstName\",\n code: firstName.code,\n message: firstName.message,\n });\n }\n\n const lastName =\n !isBlank(input.lastName) || requireAll\n ? validateName(input.lastName)\n : null;\n if (lastName && !lastName.valid) {\n errors.push({\n field: \"lastName\",\n code: lastName.code,\n message: lastName.message,\n });\n }\n\n const username =\n !isBlank(input.username) || requireAll\n ? validateUsername(input.username)\n : null;\n if (username && !username.valid) {\n for (const e of username.errors) {\n errors.push({ field: \"username\", code: e.code, message: e.message });\n }\n }\n\n if (errors.length > 0) return { valid: false, errors };\n\n // Return canonical values: trimmed/NFC-normalised when validated, the\n // original trimmed value when the field was optional and left blank.\n // The cast is sound because the `errors.length > 0` early-return above\n // guarantees each result, when non-null, is the `{ valid: true }` arm.\n const valueOr = (\n result: { valid: true; value: string } | null,\n fallback: string | null | undefined,\n ): string => (result ? result.value : (fallback ?? \"\").trim());\n\n return {\n valid: true,\n values: {\n firstName: valueOr(\n firstName as { valid: true; value: string } | null,\n input.firstName,\n ),\n lastName: valueOr(\n lastName as { valid: true; value: string } | null,\n input.lastName,\n ),\n username: valueOr(\n username as { valid: true; value: string } | null,\n input.username,\n ),\n },\n };\n}\n\n/**\n * Validate a phone number (BOFF-7216).\n *\n * Phone numbers are optional on profile / admin edit forms — an empty input\n * is accepted. When a value is supplied it must:\n * - contain only digits, spaces, and the formatting characters + - ( )\n * - include 7–15 digits (E.164-style length bounds; we don't strip \"+\"\n * country prefix here, we just count digits)\n * - start with +, a digit, or (\n *\n * Returns `{ valid: true }` on an empty / whitespace-only input.\n * Returns `{ valid: false, error }` with a human-readable message on\n * rejection. The message is intentionally plain English so the caller can\n * either display it verbatim or pipe it through `tr()` with an i18n key.\n */\nexport function validatePhoneNumber(phone: string): {\n valid: boolean;\n error?: string;\n} {\n const trimmed = phone.trim();\n\n if (trimmed.length === 0) {\n return { valid: true };\n }\n\n if (!/^[0-9+\\-() ]+$/.test(trimmed)) {\n return {\n valid: false,\n error:\n \"Phone number can only contain digits, spaces, and the characters + - ( )\",\n };\n }\n\n const digits = trimmed.replace(/[^\\d]/g, \"\");\n if (digits.length < 7) {\n return { valid: false, error: \"Phone number is too short\" };\n }\n\n if (digits.length > 15) {\n return { valid: false, error: \"Phone number is too long\" };\n }\n\n if (!/^[+\\d(]/.test(trimmed)) {\n return {\n valid: false,\n error: \"Phone number must start with +, a digit, or (\",\n };\n }\n\n return { valid: true };\n}\n"],"mappings":"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"}
|