@shipfox/client-auth 2.0.0 → 4.0.0

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.
Files changed (76) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +40 -0
  3. package/dist/components/auth-shell.d.ts +6 -4
  4. package/dist/components/auth-shell.d.ts.map +1 -1
  5. package/dist/components/auth-shell.js +6 -1
  6. package/dist/components/auth-shell.js.map +1 -1
  7. package/dist/components/email-code-verification.d.ts +20 -0
  8. package/dist/components/email-code-verification.d.ts.map +1 -0
  9. package/dist/components/email-code-verification.js +136 -0
  10. package/dist/components/email-code-verification.js.map +1 -0
  11. package/dist/components/redirect-context.d.ts +11 -0
  12. package/dist/components/redirect-context.d.ts.map +1 -0
  13. package/dist/components/redirect-context.js +22 -0
  14. package/dist/components/redirect-context.js.map +1 -0
  15. package/dist/components/redirect-target.d.ts.map +1 -1
  16. package/dist/components/redirect-target.js +13 -12
  17. package/dist/components/redirect-target.js.map +1 -1
  18. package/dist/continuation.d.ts +4 -0
  19. package/dist/continuation.d.ts.map +1 -0
  20. package/dist/continuation.js +5 -0
  21. package/dist/continuation.js.map +1 -0
  22. package/dist/hooks/api/login-auth.d.ts.map +1 -1
  23. package/dist/hooks/api/login-auth.js +4 -28
  24. package/dist/hooks/api/login-auth.js.map +1 -1
  25. package/dist/hooks/api/logout-auth.d.ts.map +1 -1
  26. package/dist/hooks/api/logout-auth.js +4 -14
  27. package/dist/hooks/api/logout-auth.js.map +1 -1
  28. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
  29. package/dist/hooks/api/password-reset-auth.js +4 -23
  30. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  31. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
  32. package/dist/hooks/api/verify-email-auth.js +4 -23
  33. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  34. package/dist/pages/form-utils.js +1 -1
  35. package/dist/pages/form-utils.js.map +1 -1
  36. package/dist/pages/invitation-context.d.ts +0 -5
  37. package/dist/pages/invitation-context.d.ts.map +1 -1
  38. package/dist/pages/invitation-context.js +3 -19
  39. package/dist/pages/invitation-context.js.map +1 -1
  40. package/dist/pages/signup-page.d.ts.map +1 -1
  41. package/dist/pages/signup-page.js +50 -144
  42. package/dist/pages/signup-page.js.map +1 -1
  43. package/dist/state/auth.d.ts +1 -1
  44. package/dist/state/auth.d.ts.map +1 -1
  45. package/dist/state/auth.js +1 -1
  46. package/dist/state/auth.js.map +1 -1
  47. package/dist/tsconfig.test.tsbuildinfo +1 -1
  48. package/package.json +10 -40
  49. package/src/components/auth-provider.test.tsx +173 -3
  50. package/src/components/auth-shell.tsx +20 -5
  51. package/src/components/email-code-verification.test.tsx +159 -0
  52. package/src/components/email-code-verification.tsx +177 -0
  53. package/src/components/redirect-context.test.ts +51 -0
  54. package/src/components/redirect-context.ts +26 -0
  55. package/src/components/redirect-target.test.ts +8 -0
  56. package/src/components/redirect-target.ts +13 -12
  57. package/src/continuation.test.ts +15 -0
  58. package/src/continuation.ts +6 -0
  59. package/src/hooks/api/login-auth.ts +4 -28
  60. package/src/hooks/api/logout-auth.ts +4 -10
  61. package/src/hooks/api/password-reset-auth.ts +4 -23
  62. package/src/hooks/api/verify-email-auth.ts +4 -23
  63. package/src/pages/form-errors.test.ts +1 -1
  64. package/src/pages/form-utils.test.ts +1 -4
  65. package/src/pages/form-utils.ts +1 -1
  66. package/src/pages/invitation-context.ts +2 -20
  67. package/src/pages/signup-page.tsx +33 -129
  68. package/src/state/auth.ts +1 -0
  69. package/src/state/last-workspace.test.ts +16 -0
  70. package/tsconfig.build.tsbuildinfo +1 -1
  71. package/dist/pages/email-verification-resend-model.d.ts +0 -6
  72. package/dist/pages/email-verification-resend-model.d.ts.map +0 -1
  73. package/dist/pages/email-verification-resend-model.js +0 -11
  74. package/dist/pages/email-verification-resend-model.js.map +0 -1
  75. package/src/pages/email-verification-resend-model.test.ts +0 -42
  76. package/src/pages/email-verification-resend-model.ts +0 -15
@@ -0,0 +1,177 @@
1
+ import {Button} from '@shipfox/react-ui/button';
2
+ import {Callout} from '@shipfox/react-ui/callout';
3
+ import {FormField, FormFieldInput} from '@shipfox/react-ui/form-field';
4
+ import type {HeaderProps} from '@shipfox/react-ui/typography';
5
+ import type {FormEvent, ReactNode, Ref} from 'react';
6
+ import {useCallback, useEffect, useRef, useState} from 'react';
7
+ import {AuthShell} from './auth-shell.js';
8
+
9
+ const EIGHT_DIGIT_CODE_RE = /^\d{8}$/u;
10
+
11
+ export interface EmailCodeVerificationProps {
12
+ destination: string;
13
+ expiresAt?: string | undefined;
14
+ nextResendAvailableAt?: string | undefined;
15
+ isResending?: boolean;
16
+ isVerifying?: boolean;
17
+ error?: string | undefined;
18
+ title?: string;
19
+ description?: string;
20
+ children?: ReactNode;
21
+ headingProps?: Omit<HeaderProps, 'children' | 'id'> | undefined;
22
+ headingRef?: Ref<HTMLHeadingElement> | undefined;
23
+ onResend: () => void | Promise<void>;
24
+ onUseAnotherEmail: () => void;
25
+ onVerify: (code: string) => void | Promise<void>;
26
+ }
27
+
28
+ function timestamp(value: string | undefined): number | undefined {
29
+ if (!value) return undefined;
30
+ const parsed = Date.parse(value);
31
+ return Number.isNaN(parsed) ? undefined : parsed;
32
+ }
33
+
34
+ export function EmailCodeVerification({
35
+ destination,
36
+ expiresAt,
37
+ nextResendAvailableAt,
38
+ isResending = false,
39
+ isVerifying = false,
40
+ error,
41
+ title = 'Check your email',
42
+ description = `We sent an eight-digit verification code to ${destination}.`,
43
+ children,
44
+ headingProps,
45
+ headingRef,
46
+ onResend,
47
+ onUseAnotherEmail,
48
+ onVerify,
49
+ }: EmailCodeVerificationProps) {
50
+ const [code, setCode] = useState('');
51
+ const [now, setNow] = useState(() => Date.now());
52
+ const [codeError, setCodeError] = useState<string>();
53
+ const localHeadingRef = useRef<HTMLHeadingElement>(null);
54
+ const resendAvailableAt = timestamp(nextResendAvailableAt);
55
+ const expiration = timestamp(expiresAt);
56
+ const resendRemainingSeconds = resendAvailableAt
57
+ ? Math.max(0, Math.ceil((resendAvailableAt - now) / 1_000))
58
+ : 0;
59
+ const isResendCoolingDown = resendRemainingSeconds > 0;
60
+ const isExpired = expiration !== undefined && expiration <= now;
61
+ const isPending = isResending || isVerifying;
62
+
63
+ useEffect(() => {
64
+ const isPending = (tickNow: number) =>
65
+ (resendAvailableAt !== undefined && resendAvailableAt > tickNow) ||
66
+ (expiration !== undefined && expiration > tickNow);
67
+
68
+ const tickNow = Date.now();
69
+ setNow(tickNow);
70
+ if (!isPending(tickNow)) return;
71
+
72
+ const interval = window.setInterval(() => {
73
+ const nextTick = Date.now();
74
+ setNow(nextTick);
75
+ if (!isPending(nextTick)) {
76
+ window.clearInterval(interval);
77
+ }
78
+ }, 1_000);
79
+ return () => window.clearInterval(interval);
80
+ }, [expiration, resendAvailableAt]);
81
+
82
+ useEffect(() => {
83
+ localHeadingRef.current?.focus();
84
+ }, []);
85
+
86
+ const setHeadingRef = useCallback(
87
+ (heading: HTMLHeadingElement | null) => {
88
+ localHeadingRef.current = heading;
89
+ if (typeof headingRef === 'function') {
90
+ headingRef(heading);
91
+ } else if (headingRef) {
92
+ headingRef.current = heading;
93
+ }
94
+ },
95
+ [headingRef],
96
+ );
97
+
98
+ function submit(event: FormEvent<HTMLFormElement>) {
99
+ event.preventDefault();
100
+ if (isExpired || isVerifying) return;
101
+ if (!validateCode()) return;
102
+ void onVerify(code);
103
+ }
104
+
105
+ function validateCode() {
106
+ const error = EIGHT_DIGIT_CODE_RE.test(code)
107
+ ? undefined
108
+ : 'Enter the eight-digit verification code.';
109
+ setCodeError(error);
110
+ return error === undefined;
111
+ }
112
+
113
+ return (
114
+ <AuthShell
115
+ title={title}
116
+ description={description}
117
+ headingProps={headingProps}
118
+ headingRef={setHeadingRef}
119
+ >
120
+ {error ? (
121
+ <Callout role="alert" type="error">
122
+ {error}
123
+ </Callout>
124
+ ) : null}
125
+ {isExpired ? (
126
+ <Callout role="alert" type="error">
127
+ This verification code has expired. Use another email to try again.
128
+ </Callout>
129
+ ) : null}
130
+ <form className="flex flex-col gap-8" noValidate onSubmit={submit}>
131
+ <FormField label="Verification code" id="verification-code" error={codeError}>
132
+ <FormFieldInput
133
+ autoComplete="one-time-code"
134
+ inputMode="numeric"
135
+ maxLength={8}
136
+ pattern="[0-9]{8}"
137
+ value={code}
138
+ onChange={(event) => {
139
+ setCode(event.target.value.replace(/\D/gu, '').slice(0, 8));
140
+ setCodeError(undefined);
141
+ }}
142
+ onBlur={validateCode}
143
+ />
144
+ </FormField>
145
+ <Button className="w-full" type="submit" disabled={isExpired} isLoading={isVerifying}>
146
+ Verify email
147
+ </Button>
148
+ <Button
149
+ aria-disabled={isResendCoolingDown ? true : undefined}
150
+ className="w-full aria-disabled:cursor-not-allowed aria-disabled:opacity-70"
151
+ variant="secondary"
152
+ type="button"
153
+ isLoading={isResending}
154
+ onClick={() => {
155
+ if (!isResendCoolingDown && !isResending) void onResend();
156
+ }}
157
+ >
158
+ {isResending
159
+ ? 'Sending email...'
160
+ : isResendCoolingDown
161
+ ? `Resend in ${resendRemainingSeconds}s`
162
+ : 'Resend verification email'}
163
+ </Button>
164
+ </form>
165
+ <Button
166
+ className="w-full"
167
+ variant="transparent"
168
+ type="button"
169
+ disabled={isPending}
170
+ onClick={onUseAnotherEmail}
171
+ >
172
+ Use another email
173
+ </Button>
174
+ {children}
175
+ </AuthShell>
176
+ );
177
+ }
@@ -0,0 +1,51 @@
1
+ import {parseRedirectContext} from './redirect-context.js';
2
+
3
+ describe('parseRedirectContext', () => {
4
+ test('returns an ordinary safe return path', () => {
5
+ const context = parseRedirectContext('/workspaces/acme?tab=runs');
6
+
7
+ expect(context).toEqual({returnTo: '/workspaces/acme?tab=runs'});
8
+ });
9
+
10
+ test('separates an invitation token from generic redirect state', () => {
11
+ const context = parseRedirectContext('/invitations/accept?token=raw-invitation-token');
12
+
13
+ expect(context).toEqual({invitationToken: 'raw-invitation-token'});
14
+ expect(context.returnTo).toBeUndefined();
15
+ });
16
+
17
+ test('separates an invitation token after path normalization', () => {
18
+ const context = parseRedirectContext(
19
+ '/workspaces/../invitations/accept?token=raw-invitation-token',
20
+ );
21
+
22
+ expect(context).toEqual({invitationToken: 'raw-invitation-token'});
23
+ });
24
+
25
+ test.each([
26
+ 'https://attacker.example',
27
+ '//attacker.example',
28
+ '/auth/login',
29
+ '/%61uth/login',
30
+ '/%E0%80%80',
31
+ ])('rejects malformed or unsafe redirect %s', (redirect) => {
32
+ const context = parseRedirectContext(redirect);
33
+
34
+ expect(context).toEqual({});
35
+ });
36
+
37
+ test.each([
38
+ ['/invitations/accept', {}],
39
+ ['/invitations/accept?token=', {}],
40
+ [
41
+ '/invitations/other?token=raw-invitation-token',
42
+ {
43
+ returnTo: '/invitations/other?token=raw-invitation-token',
44
+ },
45
+ ],
46
+ ])('does not treat %s as an invitation context', (redirect, expected) => {
47
+ const context = parseRedirectContext(redirect);
48
+
49
+ expect(context).toEqual(expected);
50
+ });
51
+ });
@@ -0,0 +1,26 @@
1
+ import {sanitizeRedirectPath} from './redirect-target.js';
2
+
3
+ const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
+
5
+ export interface RedirectContext {
6
+ invitationToken?: string;
7
+ returnTo?: string;
8
+ }
9
+
10
+ /**
11
+ * Separates a safe post-authentication destination from an invitation token.
12
+ * The token never remains in `returnTo`, so callers can keep it in their
13
+ * short-lived invitation flow instead of forwarding it through generic redirects.
14
+ */
15
+ export function parseRedirectContext(value: unknown): RedirectContext {
16
+ const redirect = sanitizeRedirectPath(value);
17
+ if (!redirect) return {};
18
+
19
+ const decoded = decodeURIComponent(redirect);
20
+ const [path, queryString = ''] = decoded.split('?', 2);
21
+ if (path !== INVITATION_ACCEPT_PATH) return {returnTo: redirect};
22
+
23
+ const params = new URLSearchParams(queryString);
24
+ const invitationToken = params.get('token');
25
+ return invitationToken ? {invitationToken} : {};
26
+ }
@@ -23,6 +23,7 @@ describe('sanitizeRedirectPath', () => {
23
23
  ['no leading slash', 'foo'],
24
24
  ['protocol-relative URL', '//evil.com'],
25
25
  ['triple-slash URL', '///evil.com'],
26
+ ['backslash external URL', '/\\evil.com'],
26
27
  ['absolute https URL', 'https://evil.com'],
27
28
  ['javascript scheme', 'javascript:alert(1)'],
28
29
  ['plain /auth/login', '/auth/login'],
@@ -30,6 +31,7 @@ describe('sanitizeRedirectPath', () => {
30
31
  ['/auth/reset with token', '/auth/reset?token=x'],
31
32
  ['/auth with query bypass', '/auth?token=x'],
32
33
  ['/auth with fragment bypass', '/auth#foo'],
34
+ ['normalized auth path', '/workspaces/../auth/logout'],
33
35
  ])('rejects %s', (_label, input) => {
34
36
  test('returns undefined', () => {
35
37
  const result = sanitizeRedirectPath(input);
@@ -51,6 +53,12 @@ describe('sanitizeRedirectPath', () => {
51
53
  expect(result).toBeUndefined();
52
54
  });
53
55
 
56
+ test('rejects a percent-encoded normalized auth path', () => {
57
+ const result = sanitizeRedirectPath('/workspaces/%2e%2e/auth/logout');
58
+
59
+ expect(result).toBeUndefined();
60
+ });
61
+
54
62
  test('rejects malformed percent-encoded input', () => {
55
63
  const result = sanitizeRedirectPath('/%E0%80%80');
56
64
 
@@ -1,21 +1,22 @@
1
- const SEARCH_OR_HASH_RE = /[?#]/u;
1
+ const REDIRECT_ORIGIN = 'https://shipfox-redirect.invalid';
2
2
 
3
- // Returns the input string only if it is a same-origin internal path safe to
4
- // navigate to after authentication. We decode before checking so percent-encoded
5
- // variants like `/%61uth/login` cannot bypass the `/auth/*` rejection.
3
+ // Resolves and canonicalizes an internal path before returning it, so browser URL
4
+ // parsing cannot turn a seemingly safe path into an external or auth route.
6
5
  export function sanitizeRedirectPath(value: unknown): string | undefined {
7
- if (typeof value !== 'string') return undefined;
6
+ if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
8
7
  let decoded: string;
9
8
  try {
10
9
  decoded = decodeURIComponent(value);
11
10
  } catch {
12
11
  return undefined;
13
12
  }
14
- if (!decoded.startsWith('/')) return undefined;
15
- if (decoded.startsWith('//')) return undefined;
16
- // Strip search/hash before the /auth check so /auth?token=x and /auth#x
17
- // cannot bypass the prefix match.
18
- const pathOnly = decoded.split(SEARCH_OR_HASH_RE, 1)[0] ?? decoded;
19
- if (pathOnly === '/auth' || pathOnly.startsWith('/auth/')) return undefined;
20
- return value;
13
+ let target: URL;
14
+ try {
15
+ target = new URL(decoded, REDIRECT_ORIGIN);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ if (target.origin !== REDIRECT_ORIGIN) return undefined;
20
+ if (target.pathname === '/auth' || target.pathname.startsWith('/auth/')) return undefined;
21
+ return `${target.pathname}${target.search}${target.hash}`;
21
22
  }
@@ -0,0 +1,15 @@
1
+ import {
2
+ AuthActions,
3
+ AuthShell,
4
+ EmailCodeVerification,
5
+ parseRedirectContext,
6
+ } from '@shipfox/client-auth/continuation';
7
+
8
+ describe('client-auth continuation exports', () => {
9
+ test('publishes every continuation primitive through its public subpath', () => {
10
+ expect(AuthActions).toBeTypeOf('function');
11
+ expect(AuthShell).toBeTypeOf('function');
12
+ expect(EmailCodeVerification).toBeTypeOf('function');
13
+ expect(parseRedirectContext).toBeTypeOf('function');
14
+ });
15
+ });
@@ -0,0 +1,6 @@
1
+ export {AuthActions, AuthShell, type AuthShellProps} from './components/auth-shell.js';
2
+ export {
3
+ EmailCodeVerification,
4
+ type EmailCodeVerificationProps,
5
+ } from './components/email-code-verification.js';
6
+ export {parseRedirectContext, type RedirectContext} from './components/redirect-context.js';
@@ -1,41 +1,17 @@
1
1
  import type {LoginBodyDto, LoginResponseDto} from '@shipfox/api-auth-dto';
2
2
  import {apiRequest} from '@shipfox/client-api';
3
- import {useMutation, useQueryClient} from '@tanstack/react-query';
4
- import {useSetAtom} from 'jotai';
5
- import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
6
- import {authRefreshQueryKey} from './refresh-auth.js';
7
- import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
3
+ import {useMutation} from '@tanstack/react-query';
4
+ import {useAuthTransition} from '#state/auth.js';
8
5
 
9
6
  export async function loginAuth(body: LoginBodyDto) {
10
7
  return await apiRequest<LoginResponseDto>('/auth/login', {method: 'POST', body});
11
8
  }
12
9
 
13
10
  export function useLoginAuth() {
14
- const queryClient = useQueryClient();
15
- const setState = useSetAtom(authStateAtom);
11
+ const {enterAuthenticated} = useAuthTransition();
16
12
 
17
13
  return useMutation({
18
14
  mutationFn: loginAuth,
19
- onSuccess: async (result) => {
20
- queryClient.setQueryData(authRefreshQueryKey, result);
21
- // Resolve workspaces before flipping auth state to authenticated. A
22
- // single atomic setState avoids the intermediate window where the user
23
- // appears authenticated with zero workspaces, which sent users with
24
- // workspaces straight to `/setup/workspaces/new` after form login.
25
- let memberships: Awaited<ReturnType<typeof listUserWorkspaces>>['memberships'] = [];
26
- try {
27
- const workspaces = await queryClient.fetchQuery({
28
- queryKey: userWorkspacesQueryKey,
29
- queryFn: () => listUserWorkspaces(result.token),
30
- retry: false,
31
- staleTime: 0,
32
- });
33
- memberships = workspaces.memberships;
34
- queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
35
- } catch {
36
- // The user is authenticated even if workspace hydration fails.
37
- }
38
- setState(toAuthenticatedState(result, memberships));
39
- },
15
+ onSuccess: enterAuthenticated,
40
16
  });
41
17
  }
@@ -1,8 +1,6 @@
1
1
  import {apiRequest} from '@shipfox/client-api';
2
- import {useMutation, useQueryClient} from '@tanstack/react-query';
3
- import {useSetAtom} from 'jotai';
4
- import {authStateAtom} from '#state/auth.js';
5
- import {authRefreshQueryKey} from './refresh-auth.js';
2
+ import {useMutation} from '@tanstack/react-query';
3
+ import {useAuthTransition} from '#state/auth.js';
6
4
 
7
5
  async function logoutAuth() {
8
6
  try {
@@ -13,14 +11,10 @@ async function logoutAuth() {
13
11
  }
14
12
 
15
13
  export function useLogoutAuth() {
16
- const queryClient = useQueryClient();
17
- const setState = useSetAtom(authStateAtom);
14
+ const {enterGuest} = useAuthTransition();
18
15
 
19
16
  return useMutation({
20
17
  mutationFn: logoutAuth,
21
- onSettled: () => {
22
- setState({status: 'guest'});
23
- queryClient.removeQueries({queryKey: authRefreshQueryKey});
24
- },
18
+ onSettled: enterGuest,
25
19
  });
26
20
  }
@@ -4,11 +4,8 @@ import type {
4
4
  PasswordResetRequestBodyDto,
5
5
  } from '@shipfox/api-auth-dto';
6
6
  import {apiRequest} from '@shipfox/client-api';
7
- import {useMutation, useQueryClient} from '@tanstack/react-query';
8
- import {useSetAtom} from 'jotai';
9
- import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
10
- import {authRefreshQueryKey} from './refresh-auth.js';
11
- import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
7
+ import {useMutation} from '@tanstack/react-query';
8
+ import {useAuthTransition} from '#state/auth.js';
12
9
 
13
10
  export async function requestPasswordReset(body: PasswordResetRequestBodyDto) {
14
11
  await apiRequest<void>('/auth/password-reset', {method: 'POST', body});
@@ -26,26 +23,10 @@ export function useRequestPasswordResetAuth() {
26
23
  }
27
24
 
28
25
  export function useConfirmPasswordResetAuth() {
29
- const queryClient = useQueryClient();
30
- const setState = useSetAtom(authStateAtom);
26
+ const {enterAuthenticated} = useAuthTransition();
31
27
 
32
28
  return useMutation({
33
29
  mutationFn: confirmPasswordReset,
34
- onSuccess: async (result) => {
35
- setState(toAuthenticatedState(result));
36
- queryClient.setQueryData(authRefreshQueryKey, result);
37
- try {
38
- const workspaces = await queryClient.fetchQuery({
39
- queryKey: userWorkspacesQueryKey,
40
- queryFn: () => listUserWorkspaces(result.token),
41
- retry: false,
42
- staleTime: 0,
43
- });
44
- setState(toAuthenticatedState(result, workspaces.memberships));
45
- queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
46
- } catch {
47
- // The user is authenticated even if workspace hydration fails.
48
- }
49
- },
30
+ onSuccess: enterAuthenticated,
50
31
  });
51
32
  }
@@ -5,11 +5,8 @@ import type {
5
5
  VerifyEmailResendResponseDto,
6
6
  } from '@shipfox/api-auth-dto';
7
7
  import {apiRequest} from '@shipfox/client-api';
8
- import {useMutation, useQueryClient} from '@tanstack/react-query';
9
- import {useSetAtom} from 'jotai';
10
- import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
11
- import {authRefreshQueryKey} from './refresh-auth.js';
12
- import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
8
+ import {useMutation} from '@tanstack/react-query';
9
+ import {useAuthTransition} from '#state/auth.js';
13
10
 
14
11
  async function verifyEmailAuth(body: VerifyEmailConfirmBodyDto) {
15
12
  return await apiRequest<VerifyEmailConfirmResponseDto>('/auth/verify-email/confirm', {
@@ -30,26 +27,10 @@ export function useResendEmailVerificationAuth() {
30
27
  }
31
28
 
32
29
  export function useVerifyEmailAuth() {
33
- const queryClient = useQueryClient();
34
- const setState = useSetAtom(authStateAtom);
30
+ const {enterAuthenticated} = useAuthTransition();
35
31
 
36
32
  return useMutation({
37
33
  mutationFn: verifyEmailAuth,
38
- onSuccess: async (result) => {
39
- setState(toAuthenticatedState(result));
40
- queryClient.setQueryData(authRefreshQueryKey, result);
41
- try {
42
- const workspaces = await queryClient.fetchQuery({
43
- queryKey: userWorkspacesQueryKey,
44
- queryFn: () => listUserWorkspaces(result.token),
45
- retry: false,
46
- staleTime: 0,
47
- });
48
- setState(toAuthenticatedState(result, workspaces.memberships));
49
- queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
50
- } catch {
51
- // The user is authenticated even if workspace hydration fails.
52
- }
53
- },
34
+ onSuccess: enterAuthenticated,
54
35
  });
55
36
  }
@@ -42,7 +42,7 @@ describe('loginErrorToFormError', () => {
42
42
 
43
43
  expect(result).toEqual({
44
44
  kind: 'form',
45
- message: 'Sign-in protection is temporarily unavailable. Try again soon.',
45
+ message: 'Sign-in is temporarily unavailable. Try again soon.',
46
46
  });
47
47
  });
48
48
 
@@ -8,10 +8,7 @@ describe('authErrorMessage', () => {
8
8
  ['email-taken', 'An account already exists for this email.'],
9
9
  ['token-invalid', 'This link is invalid or expired.'],
10
10
  ['rate-limited', 'Too many attempts. Wait a bit and try again.'],
11
- [
12
- 'auth-rate-limit-unavailable',
13
- 'Sign-in protection is temporarily unavailable. Try again soon.',
14
- ],
11
+ ['auth-rate-limit-unavailable', 'Sign-in is temporarily unavailable. Try again soon.'],
15
12
  ['network-error', 'We could not reach the API. Check your connection and try again.'],
16
13
  ])('maps %s to client copy', (code, message) => {
17
14
  const error = new ApiError({code, message: 'Server copy', status: 400});
@@ -19,7 +19,7 @@ export function authErrorMessage(error: unknown): string {
19
19
  return 'Too many attempts. Wait a bit and try again.';
20
20
  }
21
21
  if (error.code === 'auth-rate-limit-unavailable') {
22
- return 'Sign-in protection is temporarily unavailable. Try again soon.';
22
+ return 'Sign-in is temporarily unavailable. Try again soon.';
23
23
  }
24
24
  if (error.code === 'network-error') {
25
25
  return 'We could not reach the API. Check your connection and try again.';
@@ -4,28 +4,10 @@ import type {
4
4
  } from '@shipfox/api-workspaces-dto';
5
5
  import {apiRequest} from '@shipfox/client-api';
6
6
  import {useQuery} from '@tanstack/react-query';
7
+ import {parseRedirectContext} from '#/components/redirect-context.js';
7
8
 
8
- const INVITATION_ACCEPT_PATH = '/invitations/accept';
9
-
10
- /**
11
- * Extract an invitation token from a `redirect=` URL if it points at the
12
- * canonical pre-auth invitation page. Returns undefined when the redirect is
13
- * absent, malformed, or unrelated to invitations.
14
- */
15
9
  export function extractInvitationToken(redirect: unknown): string | undefined {
16
- if (typeof redirect !== 'string') return undefined;
17
- let decoded: string;
18
- try {
19
- decoded = decodeURIComponent(redirect);
20
- } catch {
21
- return undefined;
22
- }
23
- if (!decoded.startsWith('/')) return undefined;
24
- const [path, queryString = ''] = decoded.split('?', 2);
25
- if (path !== INVITATION_ACCEPT_PATH) return undefined;
26
- const params = new URLSearchParams(queryString);
27
- const token = params.get('token');
28
- return token && token.length > 0 ? token : undefined;
10
+ return parseRedirectContext(redirect).invitationToken;
29
11
  }
30
12
 
31
13
  async function fetchPreview(token: string): Promise<PreviewInvitationResponseDto> {