@shipfox/client-auth 3.0.1 → 5.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 (116) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/dist/components/email-code-verification.d.ts +20 -0
  4. package/dist/components/email-code-verification.d.ts.map +1 -0
  5. package/dist/components/email-code-verification.js +136 -0
  6. package/dist/components/email-code-verification.js.map +1 -0
  7. package/dist/components/redirect-context.d.ts +11 -0
  8. package/dist/components/redirect-context.d.ts.map +1 -0
  9. package/dist/components/redirect-context.js +22 -0
  10. package/dist/components/redirect-context.js.map +1 -0
  11. package/dist/components/redirect-target.d.ts.map +1 -1
  12. package/dist/components/redirect-target.js +13 -12
  13. package/dist/components/redirect-target.js.map +1 -1
  14. package/dist/components/workspace-switcher.stories.d.ts +1 -1
  15. package/dist/continuation.d.ts +4 -0
  16. package/dist/continuation.d.ts.map +1 -0
  17. package/dist/continuation.js +5 -0
  18. package/dist/continuation.js.map +1 -0
  19. package/dist/hooks/api/auth-mapper.d.ts +21 -0
  20. package/dist/hooks/api/auth-mapper.d.ts.map +1 -0
  21. package/dist/hooks/api/auth-mapper.js +25 -0
  22. package/dist/hooks/api/auth-mapper.js.map +1 -0
  23. package/dist/hooks/api/login-auth.d.ts +3 -25
  24. package/dist/hooks/api/login-auth.d.ts.map +1 -1
  25. package/dist/hooks/api/login-auth.js +9 -30
  26. package/dist/hooks/api/login-auth.js.map +1 -1
  27. package/dist/hooks/api/logout-auth.d.ts.map +1 -1
  28. package/dist/hooks/api/logout-auth.js +4 -14
  29. package/dist/hooks/api/logout-auth.js.map +1 -1
  30. package/dist/hooks/api/password-reset-auth.d.ts +3 -25
  31. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
  32. package/dist/hooks/api/password-reset-auth.js +9 -25
  33. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  34. package/dist/hooks/api/signup-auth.d.ts +1 -25
  35. package/dist/hooks/api/signup-auth.d.ts.map +1 -1
  36. package/dist/hooks/api/signup-auth.js +5 -2
  37. package/dist/hooks/api/signup-auth.js.map +1 -1
  38. package/dist/hooks/api/verify-email-auth.d.ts +1 -12
  39. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
  40. package/dist/hooks/api/verify-email-auth.js +10 -26
  41. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  42. package/dist/hooks/api/workspace-auth.d.ts +3 -17
  43. package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
  44. package/dist/hooks/api/workspace-auth.js +5 -2
  45. package/dist/hooks/api/workspace-auth.js.map +1 -1
  46. package/dist/hooks/api/workspace-mapper.d.ts +8 -0
  47. package/dist/hooks/api/workspace-mapper.d.ts.map +1 -0
  48. package/dist/hooks/api/workspace-mapper.js +9 -0
  49. package/dist/hooks/api/workspace-mapper.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/pages/invitation-context.d.ts +9 -19
  55. package/dist/pages/invitation-context.d.ts.map +1 -1
  56. package/dist/pages/invitation-context.js +7 -46
  57. package/dist/pages/invitation-context.js.map +1 -1
  58. package/dist/pages/login-page.js +2 -2
  59. package/dist/pages/login-page.js.map +1 -1
  60. package/dist/pages/logout-page.js +1 -1
  61. package/dist/pages/logout-page.js.map +1 -1
  62. package/dist/pages/password-reset-page.d.ts.map +1 -1
  63. package/dist/pages/password-reset-page.js +1 -4
  64. package/dist/pages/password-reset-page.js.map +1 -1
  65. package/dist/pages/signup-page.d.ts.map +1 -1
  66. package/dist/pages/signup-page.js +58 -152
  67. package/dist/pages/signup-page.js.map +1 -1
  68. package/dist/state/auth.d.ts +1 -1
  69. package/dist/state/auth.d.ts.map +1 -1
  70. package/dist/state/auth.js +1 -1
  71. package/dist/state/auth.js.map +1 -1
  72. package/dist/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +10 -39
  74. package/src/components/auth-provider.test.tsx +173 -3
  75. package/src/components/email-code-verification.test.tsx +159 -0
  76. package/src/components/email-code-verification.tsx +177 -0
  77. package/src/components/redirect-context.test.ts +51 -0
  78. package/src/components/redirect-context.ts +26 -0
  79. package/src/components/redirect-target.test.ts +8 -0
  80. package/src/components/redirect-target.ts +13 -12
  81. package/src/continuation.test.ts +15 -0
  82. package/src/continuation.ts +6 -0
  83. package/src/hooks/api/auth-mapper.test.ts +77 -0
  84. package/src/hooks/api/auth-mapper.ts +39 -0
  85. package/src/hooks/api/login-auth.test.ts +1 -1
  86. package/src/hooks/api/login-auth.ts +12 -31
  87. package/src/hooks/api/logout-auth.ts +4 -10
  88. package/src/hooks/api/password-reset-auth.test.ts +1 -1
  89. package/src/hooks/api/password-reset-auth.ts +19 -32
  90. package/src/hooks/api/signup-auth.ts +8 -3
  91. package/src/hooks/api/verify-email-auth.ts +21 -34
  92. package/src/hooks/api/workspace-auth.ts +8 -3
  93. package/src/hooks/api/workspace-mapper.test.ts +21 -0
  94. package/src/hooks/api/workspace-mapper.ts +11 -0
  95. package/src/index.ts +0 -1
  96. package/src/pages/invitation-context.ts +10 -51
  97. package/src/pages/login-page.test.tsx +1 -1
  98. package/src/pages/login-page.tsx +2 -2
  99. package/src/pages/logout-page.tsx +1 -1
  100. package/src/pages/password-reset-page.tsx +1 -4
  101. package/src/pages/signup-page.test.tsx +1 -1
  102. package/src/pages/signup-page.tsx +41 -137
  103. package/src/state/auth.ts +1 -0
  104. package/src/state/last-workspace.test.ts +16 -0
  105. package/tsconfig.build.tsbuildinfo +1 -1
  106. package/dist/components/auth-shell.d.ts +0 -11
  107. package/dist/components/auth-shell.d.ts.map +0 -1
  108. package/dist/components/auth-shell.js +0 -63
  109. package/dist/components/auth-shell.js.map +0 -1
  110. package/dist/pages/email-verification-resend-model.d.ts +0 -6
  111. package/dist/pages/email-verification-resend-model.d.ts.map +0 -1
  112. package/dist/pages/email-verification-resend-model.js +0 -11
  113. package/dist/pages/email-verification-resend-model.js.map +0 -1
  114. package/src/components/auth-shell.tsx +0 -53
  115. package/src/pages/email-verification-resend-model.test.ts +0 -42
  116. package/src/pages/email-verification-resend-model.ts +0 -15
@@ -1,57 +1,16 @@
1
- import type {
2
- PreviewInvitationPendingDto,
3
- PreviewInvitationResponseDto,
4
- } from '@shipfox/api-workspaces-dto';
5
- import {apiRequest} from '@shipfox/client-api';
6
- import {useQuery} from '@tanstack/react-query';
1
+ import {
2
+ type InvitationPreview,
3
+ pendingInvitation,
4
+ usePreviewInvitation,
5
+ } from '@shipfox/client-invitations';
6
+ import {parseRedirectContext} from '#/components/redirect-context.js';
7
7
 
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
8
  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;
29
- }
30
-
31
- async function fetchPreview(token: string): Promise<PreviewInvitationResponseDto> {
32
- const params = new URLSearchParams({token});
33
- return await apiRequest<PreviewInvitationResponseDto>(
34
- `/invitations/preview?${params.toString()}`,
35
- );
9
+ return parseRedirectContext(redirect).invitationToken;
36
10
  }
37
11
 
38
- export function pendingInvitation(
39
- data: PreviewInvitationResponseDto | undefined,
40
- ): PreviewInvitationPendingDto | undefined {
41
- return data?.status === 'pending' ? data : undefined;
42
- }
43
-
44
- /**
45
- * Mirrors the hook in `@shipfox/client-invitations` but lives in `client-auth`
46
- * to avoid a circular dependency. Signup/login pages call this to lock the
47
- * email field when arriving from an invitation link.
48
- */
49
12
  export function useInvitationContext(token: string | undefined) {
50
- return useQuery({
51
- queryKey: ['invitations', 'preview', token ?? ''] as const,
52
- queryFn: () => fetchPreview(token as string),
53
- enabled: Boolean(token),
54
- retry: false,
55
- staleTime: 30_000,
56
- });
13
+ return usePreviewInvitation(token);
57
14
  }
15
+
16
+ export {type InvitationPreview, pendingInvitation};
@@ -70,7 +70,7 @@ describe('LoginPage', () => {
70
70
  return Promise.resolve(
71
71
  jsonResponse({
72
72
  status: 'pending',
73
- workspace_id: 'workspace-1',
73
+ workspace_id: '11111111-1111-4111-8111-111111111111',
74
74
  workspace_name: 'Invite Workspace',
75
75
  email: 'invitee@example.com',
76
76
  invited_by_display: 'owner@example.com',
@@ -1,4 +1,5 @@
1
1
  import {loginBodySchema} from '@shipfox/api-auth-dto';
2
+ import {AuthShell} from '@shipfox/client-shell/runtime';
2
3
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
3
4
  import {Callout} from '@shipfox/react-ui/callout';
4
5
  import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
@@ -8,7 +9,6 @@ import {useForm} from '@tanstack/react-form';
8
9
  import {Link, useSearch} from '@tanstack/react-router';
9
10
  import {useAtom} from 'jotai';
10
11
  import {useEffect, useRef, useState} from 'react';
11
- import {AuthShell} from '#/components/auth-shell.js';
12
12
  import {useLoginAuth} from '#hooks/api/login-auth.js';
13
13
  import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
14
14
  import {loginErrorToFormError} from './form-errors.js';
@@ -81,7 +81,7 @@ export function LoginPage() {
81
81
  ? `/invitations/accept?token=${encodeURIComponent(invitationToken)}`
82
82
  : undefined;
83
83
  const headerTitle = invitationPending
84
- ? `Join ${invitationPending.workspace_name}`
84
+ ? `Join ${invitationPending.workspaceName}`
85
85
  : 'Connect to Shipfox';
86
86
  const headerDescription = invitationPending
87
87
  ? 'Log in to accept your invitation.'
@@ -1,7 +1,7 @@
1
+ import {AuthShell} from '@shipfox/client-shell/runtime';
1
2
  import {Text} from '@shipfox/react-ui/typography';
2
3
  import {useNavigate, useRouter, useSearch} from '@tanstack/react-router';
3
4
  import {useEffect} from 'react';
4
- import {AuthShell} from '#/components/auth-shell.js';
5
5
  import {sanitizeRedirectPath} from '#/components/redirect-target.js';
6
6
  import {useLogoutAuth} from '#hooks/api/logout-auth.js';
7
7
 
@@ -2,6 +2,7 @@ import {
2
2
  passwordResetConfirmBodySchema,
3
3
  passwordResetRequestBodySchema,
4
4
  } from '@shipfox/api-auth-dto';
5
+ import {AuthShell} from '@shipfox/client-shell/runtime';
5
6
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
6
7
  import {Callout} from '@shipfox/react-ui/callout';
7
8
  import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
@@ -11,12 +12,10 @@ import {useForm} from '@tanstack/react-form';
11
12
  import {Link, useNavigate, useSearch} from '@tanstack/react-router';
12
13
  import {useAtom} from 'jotai';
13
14
  import {useEffect, useRef, useState} from 'react';
14
- import {AuthShell} from '#/components/auth-shell.js';
15
15
  import {
16
16
  useConfirmPasswordResetAuth,
17
17
  useRequestPasswordResetAuth,
18
18
  } from '#hooks/api/password-reset-auth.js';
19
- import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
20
19
  import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
21
20
  import {
22
21
  passwordResetConfirmErrorToFormError,
@@ -148,7 +147,6 @@ function PasswordResetRequest() {
148
147
 
149
148
  function PasswordResetConfirm({token}: {token: string}) {
150
149
  const confirmPasswordReset = useConfirmPasswordResetAuth();
151
- const refreshAuth = useRefreshAuth();
152
150
  const navigate = useNavigate();
153
151
  const [, setAuthFormDraft] = useAtom(authFormDraftAtom);
154
152
  const [formError, setFormError] = useState<string | undefined>();
@@ -163,7 +161,6 @@ function PasswordResetConfirm({token}: {token: string}) {
163
161
  new_password: value.new_password,
164
162
  });
165
163
  await confirmPasswordReset.mutateAsync(body);
166
- await refreshAuth();
167
164
  setAuthFormDraft(initialAuthFormDraft);
168
165
  toast.success('Your password has been changed. You are now logged in.');
169
166
  await navigate({to: '/', replace: true});
@@ -137,7 +137,7 @@ describe('SignupPage', () => {
137
137
  return Promise.resolve(
138
138
  jsonResponse({
139
139
  status: 'pending',
140
- workspace_id: 'workspace-1',
140
+ workspace_id: '11111111-1111-4111-8111-111111111111',
141
141
  workspace_name: 'Invite Workspace',
142
142
  email: 'invitee@example.com',
143
143
  invited_by_display: 'owner@example.com',
@@ -1,4 +1,5 @@
1
- import {signupBodySchema, verifyEmailConfirmBodySchema} from '@shipfox/api-auth-dto';
1
+ import {signupBodySchema} from '@shipfox/api-auth-dto';
2
+ import {AuthShell} from '@shipfox/client-shell/runtime';
2
3
  import {displayNameFieldError} from '@shipfox/client-ui';
3
4
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
4
5
  import {Callout} from '@shipfox/react-ui/callout';
@@ -10,15 +11,11 @@ import {useForm} from '@tanstack/react-form';
10
11
  import {Link, useNavigate, useSearch} from '@tanstack/react-router';
11
12
  import {useAtom} from 'jotai';
12
13
  import {useEffect, useRef, useState} from 'react';
13
- import {AuthShell} from '#/components/auth-shell.js';
14
+ import {EmailCodeVerification} from '#/components/email-code-verification.js';
14
15
  import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
15
16
  import {useSignupAuth} from '#hooks/api/signup-auth.js';
16
17
  import {useResendEmailVerificationAuth, useVerifyEmailAuth} from '#hooks/api/verify-email-auth.js';
17
18
  import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
18
- import {
19
- getResendRemainingSeconds,
20
- parseNextResendAvailableAt,
21
- } from './email-verification-resend-model.js';
22
19
  import {signupErrorToFormError} from './form-errors.js';
23
20
  import {authErrorMessage} from './form-utils.js';
24
21
  import {
@@ -39,8 +36,7 @@ export function SignupPage() {
39
36
  const invitationPending = pendingInvitation(invitationPreview.data);
40
37
  const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
41
38
  const [emailChallenge, setEmailChallenge] = useState<{email: string; id: string} | undefined>();
42
- const [now, setNow] = useState(() => Date.now());
43
- const [nextResendAvailableAt, setNextResendAvailableAt] = useState<number | undefined>();
39
+ const [nextResendAvailableAt, setNextResendAvailableAt] = useState<string | undefined>();
44
40
  const [formError, setFormError] = useState<string | undefined>();
45
41
  const [resendError, setResendError] = useState<string | undefined>();
46
42
  const draftRef = useRef(authFormDraft);
@@ -48,8 +44,6 @@ export function SignupPage() {
48
44
  // Set just before clearing the draft on success so the unmount cleanup
49
45
  // below does not repersist the just-submitted credentials.
50
46
  const skipDraftPersistRef = useRef(false);
51
- const resendRemainingSeconds = getResendRemainingSeconds({nextResendAvailableAt, now});
52
- const isResendCoolingDown = resendRemainingSeconds > 0;
53
47
 
54
48
  const form = useForm({
55
49
  defaultValues: {email: authFormDraft.email, password: authFormDraft.password, name: ''},
@@ -73,16 +67,16 @@ export function SignupPage() {
73
67
  // Refresh failures don't block the success message — the next API
74
68
  // call's 401 handling will re-route the user.
75
69
  }
76
- toast.success(`You joined ${invitationPending.workspace_name}.`);
70
+ toast.success(`You joined ${invitationPending.workspaceName}.`);
77
71
  await navigate({
78
72
  to: '/workspaces/$wid',
79
- params: {wid: result.membership.workspace_id},
73
+ params: {wid: result.membership.workspaceId},
80
74
  });
81
75
  return;
82
76
  }
83
77
 
84
- if (invitationToken && result.accept_error) {
85
- toast.error(result.accept_error.message);
78
+ if (invitationToken && result.acceptError) {
79
+ toast.error(result.acceptError.message);
86
80
  await navigate({
87
81
  to: '/invitations/accept',
88
82
  search: {token: invitationToken},
@@ -90,16 +84,12 @@ export function SignupPage() {
90
84
  return;
91
85
  }
92
86
 
93
- if (!result.email_challenge) {
87
+ if (!result.emailChallenge) {
94
88
  throw new Error('Signup did not return an email verification challenge');
95
89
  }
96
- setEmailChallenge({email: result.user.email, id: result.email_challenge.id});
97
- verificationForm.reset();
90
+ setEmailChallenge({email: result.user.email, id: result.emailChallenge.id});
98
91
  setResendError(undefined);
99
- setNow(Date.now());
100
- setNextResendAvailableAt(
101
- parseNextResendAvailableAt(result.email_challenge.next_resend_available_at),
102
- );
92
+ setNextResendAvailableAt(result.emailChallenge.nextResendAvailableAt);
103
93
  } catch (error) {
104
94
  const mapped = signupErrorToFormError(error);
105
95
  if (mapped.kind === 'field') {
@@ -114,48 +104,6 @@ export function SignupPage() {
114
104
  },
115
105
  });
116
106
 
117
- const verificationForm = useForm({
118
- defaultValues: {code: ''},
119
- onSubmit: async ({value}) => {
120
- if (!emailChallenge || verifyEmail.isPending) return;
121
-
122
- setResendError(undefined);
123
- try {
124
- await verifyEmail.mutateAsync({
125
- email: emailChallenge.email,
126
- challenge_id: emailChallenge.id,
127
- code: value.code,
128
- });
129
- await refreshAuth();
130
- toast.success('Your email is verified. You are now logged in.');
131
- await navigate({to: '/', replace: true});
132
- } catch (error) {
133
- setResendError(authErrorMessage(error));
134
- }
135
- },
136
- });
137
-
138
- useEffect(() => {
139
- if (!emailChallenge || !nextResendAvailableAt) {
140
- return;
141
- }
142
-
143
- const current = Date.now();
144
- setNow(current);
145
- if (nextResendAvailableAt <= current) {
146
- return;
147
- }
148
-
149
- const handle = window.setInterval(() => {
150
- const tickNow = Date.now();
151
- setNow(tickNow);
152
- if (nextResendAvailableAt <= tickNow) {
153
- window.clearInterval(handle);
154
- }
155
- }, 1000);
156
- return () => window.clearInterval(handle);
157
- }, [emailChallenge, nextResendAvailableAt]);
158
-
159
107
  // When arriving from an invitation link, prefill the email and lock it.
160
108
  useEffect(() => {
161
109
  if (invitationPending && form.state.values.email !== invitationPending.email) {
@@ -178,7 +126,7 @@ export function SignupPage() {
178
126
  }, [form, setAuthFormDraft]);
179
127
 
180
128
  async function onResendVerificationEmail() {
181
- if (!emailChallenge || isResendCoolingDown || resendEmailVerification.isPending) return;
129
+ if (!emailChallenge || resendEmailVerification.isPending) return;
182
130
 
183
131
  setResendError(undefined);
184
132
  try {
@@ -186,11 +134,7 @@ export function SignupPage() {
186
134
  email: emailChallenge.email,
187
135
  challenge_id: emailChallenge.id,
188
136
  });
189
- const nextAvailableAt = parseNextResendAvailableAt(result.next_resend_available_at);
190
- setNow(Date.now());
191
- if (nextAvailableAt !== undefined) {
192
- setNextResendAvailableAt(nextAvailableAt);
193
- }
137
+ setNextResendAvailableAt(result.next_resend_available_at);
194
138
  toast.success('If another verification email can be sent, it will arrive shortly.');
195
139
  } catch (error) {
196
140
  setResendError(authErrorMessage(error));
@@ -199,85 +143,45 @@ export function SignupPage() {
199
143
 
200
144
  if (emailChallenge) {
201
145
  return (
202
- <AuthShell
203
- title="Check your email"
204
- description={`We sent an eight-digit verification code to ${emailChallenge.email}.`}
146
+ <EmailCodeVerification
147
+ destination={emailChallenge.email}
148
+ nextResendAvailableAt={nextResendAvailableAt}
149
+ isResending={resendEmailVerification.isPending}
150
+ isVerifying={verifyEmail.isPending}
151
+ error={resendError}
152
+ onVerify={async (code) => {
153
+ setResendError(undefined);
154
+ try {
155
+ await verifyEmail.mutateAsync({
156
+ email: emailChallenge.email,
157
+ challenge_id: emailChallenge.id,
158
+ code,
159
+ });
160
+ await refreshAuth();
161
+ toast.success('Your email is verified. You are now logged in.');
162
+ await navigate({to: '/', replace: true});
163
+ } catch (error) {
164
+ setResendError(authErrorMessage(error));
165
+ }
166
+ }}
167
+ onResend={onResendVerificationEmail}
168
+ onUseAnotherEmail={() => {
169
+ setEmailChallenge(undefined);
170
+ setResendError(undefined);
171
+ }}
205
172
  >
206
- {resendError ? (
207
- <Callout role="alert" type="error">
208
- {resendError}
209
- </Callout>
210
- ) : null}
211
- <form
212
- className="flex flex-col gap-8"
213
- noValidate
214
- onSubmit={(event) => {
215
- event.preventDefault();
216
- void verificationForm.handleSubmit();
217
- }}
218
- >
219
- <verificationForm.Field
220
- name="code"
221
- validators={{
222
- onBlur: verifyEmailConfirmBodySchema.shape.code,
223
- onSubmit: verifyEmailConfirmBodySchema.shape.code,
224
- }}
225
- >
226
- {(field) => (
227
- <FormField label="Verification code" id="verification-code" error={fieldError(field)}>
228
- <FormFieldInput
229
- autoComplete="one-time-code"
230
- inputMode="numeric"
231
- maxLength={8}
232
- pattern="[0-9]{8}"
233
- value={field.state.value}
234
- onChange={(event) => field.handleChange(event.target.value.replace(/\D/gu, ''))}
235
- onBlur={field.handleBlur}
236
- />
237
- </FormField>
238
- )}
239
- </verificationForm.Field>
240
- <Button className="w-full" type="submit" isLoading={verifyEmail.isPending}>
241
- Verify email
242
- </Button>
243
- <Button
244
- aria-disabled={isResendCoolingDown ? true : undefined}
245
- className="w-full aria-disabled:cursor-not-allowed aria-disabled:opacity-70"
246
- variant="secondary"
247
- type="button"
248
- isLoading={resendEmailVerification.isPending}
249
- onClick={onResendVerificationEmail}
250
- >
251
- {resendEmailVerification.isPending
252
- ? 'Sending email...'
253
- : isResendCoolingDown
254
- ? `Resend in ${resendRemainingSeconds}s`
255
- : 'Resend verification email'}
256
- </Button>
257
- </form>
258
- <Button
259
- className="w-full"
260
- variant="transparent"
261
- type="button"
262
- onClick={() => {
263
- setEmailChallenge(undefined);
264
- setResendError(undefined);
265
- }}
266
- >
267
- Use another email
268
- </Button>
269
173
  <Text size="sm" className="text-center text-foreground-neutral-subtle">
270
174
  Already verified?{' '}
271
175
  <ButtonLink asChild variant="interactive" underline>
272
176
  <Link to="/auth/login">Log in</Link>
273
177
  </ButtonLink>
274
178
  </Text>
275
- </AuthShell>
179
+ </EmailCodeVerification>
276
180
  );
277
181
  }
278
182
 
279
183
  const headerTitle = invitationPending
280
- ? `Join ${invitationPending.workspace_name}`
184
+ ? `Join ${invitationPending.workspaceName}`
281
185
  : 'Create your Shipfox account';
282
186
  const headerDescription = invitationPending
283
187
  ? `Create an account to accept your invitation.`
package/src/state/auth.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  authStateAtom,
7
7
  initialAuthState,
8
8
  toAuthenticatedState,
9
+ useAuthTransition,
9
10
  type Workspace,
10
11
  } from '@shipfox/client-shell/runtime';
11
12
 
@@ -45,6 +45,22 @@ describe('lastWorkspaceIdAtom', () => {
45
45
  unsubscribe();
46
46
  });
47
47
 
48
+ test('syncs the atom when another tab updates last workspace storage', () => {
49
+ const store = createStore();
50
+ const unsubscribe = store.sub(lastWorkspaceIdAtom, () => undefined);
51
+ window.localStorage.setItem('shipfox.lastWorkspaceId', JSON.stringify('workspace-2'));
52
+
53
+ window.dispatchEvent(
54
+ new StorageEvent('storage', {
55
+ key: 'shipfox.lastWorkspaceId',
56
+ storageArea: window.localStorage,
57
+ }),
58
+ );
59
+
60
+ expect(store.get(lastWorkspaceIdAtom)).toBe('workspace-2');
61
+ unsubscribe();
62
+ });
63
+
48
64
  test('rememberLastWorkspaceId persists a root redirect target', () => {
49
65
  rememberLastWorkspaceId('workspace-1');
50
66