@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
@@ -1,4 +1,4 @@
1
- import {signupBodySchema, verifyEmailConfirmBodySchema} from '@shipfox/api-auth-dto';
1
+ import {signupBodySchema} from '@shipfox/api-auth-dto';
2
2
  import {displayNameFieldError} from '@shipfox/client-ui';
3
3
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
4
4
  import {Callout} from '@shipfox/react-ui/callout';
@@ -11,14 +11,11 @@ import {Link, useNavigate, useSearch} from '@tanstack/react-router';
11
11
  import {useAtom} from 'jotai';
12
12
  import {useEffect, useRef, useState} from 'react';
13
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: ''},
@@ -94,12 +88,8 @@ export function SignupPage() {
94
88
  throw new Error('Signup did not return an email verification challenge');
95
89
  }
96
90
  setEmailChallenge({email: result.user.email, id: result.email_challenge.id});
97
- verificationForm.reset();
98
91
  setResendError(undefined);
99
- setNow(Date.now());
100
- setNextResendAvailableAt(
101
- parseNextResendAvailableAt(result.email_challenge.next_resend_available_at),
102
- );
92
+ setNextResendAvailableAt(result.email_challenge.next_resend_available_at);
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,80 +143,40 @@ 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
 
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