@shipfox/client-auth 0.2.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 (225) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +7 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +111 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/auth-guard.d.ts +5 -0
  10. package/dist/components/auth-guard.d.ts.map +1 -0
  11. package/dist/components/auth-guard.js +61 -0
  12. package/dist/components/auth-guard.js.map +1 -0
  13. package/dist/components/auth-provider.d.ts +11 -0
  14. package/dist/components/auth-provider.d.ts.map +1 -0
  15. package/dist/components/auth-provider.js +21 -0
  16. package/dist/components/auth-provider.js.map +1 -0
  17. package/dist/components/auth-shell.d.ts +11 -0
  18. package/dist/components/auth-shell.d.ts.map +1 -0
  19. package/dist/components/auth-shell.js +63 -0
  20. package/dist/components/auth-shell.js.map +1 -0
  21. package/dist/components/redirect-target.d.ts +2 -0
  22. package/dist/components/redirect-target.d.ts.map +1 -0
  23. package/dist/components/redirect-target.js +22 -0
  24. package/dist/components/redirect-target.js.map +1 -0
  25. package/dist/components/workspace-crumb.d.ts +3 -0
  26. package/dist/components/workspace-crumb.d.ts.map +1 -0
  27. package/dist/components/workspace-crumb.js +3 -0
  28. package/dist/components/workspace-crumb.js.map +1 -0
  29. package/dist/components/workspace-switcher.d.ts +6 -0
  30. package/dist/components/workspace-switcher.d.ts.map +1 -0
  31. package/dist/components/workspace-switcher.js +91 -0
  32. package/dist/components/workspace-switcher.js.map +1 -0
  33. package/dist/components/workspace-switcher.stories.d.ts +26 -0
  34. package/dist/components/workspace-switcher.stories.d.ts.map +1 -0
  35. package/dist/components/workspace-switcher.stories.js +168 -0
  36. package/dist/components/workspace-switcher.stories.js.map +1 -0
  37. package/dist/feature.d.ts +33 -0
  38. package/dist/feature.d.ts.map +1 -0
  39. package/dist/feature.js +43 -0
  40. package/dist/feature.js.map +1 -0
  41. package/dist/hooks/api/login-auth.d.ts +29 -0
  42. package/dist/hooks/api/login-auth.d.ts.map +1 -0
  43. package/dist/hooks/api/login-auth.js +42 -0
  44. package/dist/hooks/api/login-auth.js.map +1 -0
  45. package/dist/hooks/api/logout-auth.d.ts +2 -0
  46. package/dist/hooks/api/logout-auth.d.ts.map +1 -0
  47. package/dist/hooks/api/logout-auth.js +32 -0
  48. package/dist/hooks/api/logout-auth.js.map +1 -0
  49. package/dist/hooks/api/password-reset-auth.d.ts +33 -0
  50. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -0
  51. package/dist/hooks/api/password-reset-auth.js +48 -0
  52. package/dist/hooks/api/password-reset-auth.js.map +1 -0
  53. package/dist/hooks/api/refresh-auth.d.ts +2 -0
  54. package/dist/hooks/api/refresh-auth.d.ts.map +1 -0
  55. package/dist/hooks/api/refresh-auth.js +3 -0
  56. package/dist/hooks/api/refresh-auth.js.map +1 -0
  57. package/dist/hooks/api/signup-auth.d.ts +27 -0
  58. package/dist/hooks/api/signup-auth.d.ts.map +1 -0
  59. package/dist/hooks/api/signup-auth.js +15 -0
  60. package/dist/hooks/api/signup-auth.js.map +1 -0
  61. package/dist/hooks/api/verify-email-auth.d.ts +20 -0
  62. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -0
  63. package/dist/hooks/api/verify-email-auth.js +48 -0
  64. package/dist/hooks/api/verify-email-auth.js.map +1 -0
  65. package/dist/hooks/api/workspace-auth.d.ts +22 -0
  66. package/dist/hooks/api/workspace-auth.d.ts.map +1 -0
  67. package/dist/hooks/api/workspace-auth.js +25 -0
  68. package/dist/hooks/api/workspace-auth.js.map +1 -0
  69. package/dist/hooks/index.d.ts +11 -0
  70. package/dist/hooks/index.d.ts.map +1 -0
  71. package/dist/hooks/index.js +11 -0
  72. package/dist/hooks/index.js.map +1 -0
  73. package/dist/hooks/use-active-workspace.d.ts +2 -0
  74. package/dist/hooks/use-active-workspace.d.ts.map +1 -0
  75. package/dist/hooks/use-active-workspace.js +3 -0
  76. package/dist/hooks/use-active-workspace.js.map +1 -0
  77. package/dist/hooks/use-auth-state.d.ts +2 -0
  78. package/dist/hooks/use-auth-state.d.ts.map +1 -0
  79. package/dist/hooks/use-auth-state.js +3 -0
  80. package/dist/hooks/use-auth-state.js.map +1 -0
  81. package/dist/index.d.ts +15 -0
  82. package/dist/index.d.ts.map +1 -0
  83. package/dist/index.js +16 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/pages/email-verification-resend-model.d.ts +7 -0
  86. package/dist/pages/email-verification-resend-model.d.ts.map +1 -0
  87. package/dist/pages/email-verification-resend-model.js +15 -0
  88. package/dist/pages/email-verification-resend-model.js.map +1 -0
  89. package/dist/pages/form-errors.d.ts +19 -0
  90. package/dist/pages/form-errors.d.ts.map +1 -0
  91. package/dist/pages/form-errors.js +51 -0
  92. package/dist/pages/form-errors.js.map +1 -0
  93. package/dist/pages/form-utils.d.ts +2 -0
  94. package/dist/pages/form-utils.d.ts.map +1 -0
  95. package/dist/pages/form-utils.js +28 -0
  96. package/dist/pages/form-utils.js.map +1 -0
  97. package/dist/pages/invitation-context.d.ts +31 -0
  98. package/dist/pages/invitation-context.d.ts.map +1 -0
  99. package/dist/pages/invitation-context.js +50 -0
  100. package/dist/pages/invitation-context.js.map +1 -0
  101. package/dist/pages/login-page.d.ts +2 -0
  102. package/dist/pages/login-page.d.ts.map +1 -0
  103. package/dist/pages/login-page.js +214 -0
  104. package/dist/pages/login-page.js.map +1 -0
  105. package/dist/pages/logout-page.d.ts +2 -0
  106. package/dist/pages/logout-page.d.ts.map +1 -0
  107. package/dist/pages/logout-page.js +45 -0
  108. package/dist/pages/logout-page.js.map +1 -0
  109. package/dist/pages/password-reset-page.d.ts +2 -0
  110. package/dist/pages/password-reset-page.d.ts.map +1 -0
  111. package/dist/pages/password-reset-page.js +265 -0
  112. package/dist/pages/password-reset-page.js.map +1 -0
  113. package/dist/pages/signup-page.d.ts +2 -0
  114. package/dist/pages/signup-page.d.ts.map +1 -0
  115. package/dist/pages/signup-page.js +384 -0
  116. package/dist/pages/signup-page.js.map +1 -0
  117. package/dist/pages/verify-email-page.d.ts +2 -0
  118. package/dist/pages/verify-email-page.d.ts.map +1 -0
  119. package/dist/pages/verify-email-page.js +57 -0
  120. package/dist/pages/verify-email-page.js.map +1 -0
  121. package/dist/pages/workspace-onboarding-page.d.ts +2 -0
  122. package/dist/pages/workspace-onboarding-page.d.ts.map +1 -0
  123. package/dist/pages/workspace-onboarding-page.js +294 -0
  124. package/dist/pages/workspace-onboarding-page.js.map +1 -0
  125. package/dist/routes/index.d.ts +10 -0
  126. package/dist/routes/index.d.ts.map +1 -0
  127. package/dist/routes/index.js +29 -0
  128. package/dist/routes/index.js.map +1 -0
  129. package/dist/routes/login.d.ts +5 -0
  130. package/dist/routes/login.d.ts.map +1 -0
  131. package/dist/routes/login.js +11 -0
  132. package/dist/routes/login.js.map +1 -0
  133. package/dist/routes/logout.d.ts +6 -0
  134. package/dist/routes/logout.d.ts.map +1 -0
  135. package/dist/routes/logout.js +7 -0
  136. package/dist/routes/logout.js.map +1 -0
  137. package/dist/routes/reset.d.ts +5 -0
  138. package/dist/routes/reset.d.ts.map +1 -0
  139. package/dist/routes/reset.js +11 -0
  140. package/dist/routes/reset.js.map +1 -0
  141. package/dist/routes/signup.d.ts +5 -0
  142. package/dist/routes/signup.d.ts.map +1 -0
  143. package/dist/routes/signup.js +11 -0
  144. package/dist/routes/signup.js.map +1 -0
  145. package/dist/routes/verify-email.d.ts +5 -0
  146. package/dist/routes/verify-email.d.ts.map +1 -0
  147. package/dist/routes/verify-email.js +11 -0
  148. package/dist/routes/verify-email.js.map +1 -0
  149. package/dist/routes/workspace-onboarding.d.ts +13 -0
  150. package/dist/routes/workspace-onboarding.d.ts.map +1 -0
  151. package/dist/routes/workspace-onboarding.js +18 -0
  152. package/dist/routes/workspace-onboarding.js.map +1 -0
  153. package/dist/state/auth.d.ts +10 -0
  154. package/dist/state/auth.d.ts.map +1 -0
  155. package/dist/state/auth.js +9 -0
  156. package/dist/state/auth.js.map +1 -0
  157. package/dist/state/last-workspace.d.ts +2 -0
  158. package/dist/state/last-workspace.d.ts.map +1 -0
  159. package/dist/state/last-workspace.js +3 -0
  160. package/dist/state/last-workspace.js.map +1 -0
  161. package/dist/tsconfig.test.tsbuildinfo +1 -0
  162. package/package.json +116 -0
  163. package/src/components/auth-guard.tsx +58 -0
  164. package/src/components/auth-provider.test.tsx +164 -0
  165. package/src/components/auth-provider.tsx +24 -0
  166. package/src/components/auth-shell.tsx +53 -0
  167. package/src/components/redirect-target.test.ts +60 -0
  168. package/src/components/redirect-target.ts +21 -0
  169. package/src/components/workspace-crumb.tsx +2 -0
  170. package/src/components/workspace-switcher.stories.tsx +169 -0
  171. package/src/components/workspace-switcher.tsx +76 -0
  172. package/src/feature.ts +18 -0
  173. package/src/hooks/api/login-auth.test.ts +45 -0
  174. package/src/hooks/api/login-auth.ts +41 -0
  175. package/src/hooks/api/logout-auth.ts +26 -0
  176. package/src/hooks/api/password-reset-auth.test.ts +68 -0
  177. package/src/hooks/api/password-reset-auth.ts +51 -0
  178. package/src/hooks/api/refresh-auth.test.ts +37 -0
  179. package/src/hooks/api/refresh-auth.ts +5 -0
  180. package/src/hooks/api/signup-auth.ts +11 -0
  181. package/src/hooks/api/verify-email-auth.ts +55 -0
  182. package/src/hooks/api/workspace-auth.test.ts +40 -0
  183. package/src/hooks/api/workspace-auth.ts +25 -0
  184. package/src/hooks/index.ts +13 -0
  185. package/src/hooks/use-active-workspace.ts +1 -0
  186. package/src/hooks/use-auth-state.ts +1 -0
  187. package/src/index.ts +18 -0
  188. package/src/pages/email-verification-resend-model.test.ts +52 -0
  189. package/src/pages/email-verification-resend-model.ts +21 -0
  190. package/src/pages/form-errors.test.ts +114 -0
  191. package/src/pages/form-errors.ts +45 -0
  192. package/src/pages/form-utils.test.ts +37 -0
  193. package/src/pages/form-utils.ts +29 -0
  194. package/src/pages/invitation-context.ts +57 -0
  195. package/src/pages/login-page.test.tsx +128 -0
  196. package/src/pages/login-page.tsx +184 -0
  197. package/src/pages/logout-page.tsx +33 -0
  198. package/src/pages/password-reset-page.test.tsx +109 -0
  199. package/src/pages/password-reset-page.tsx +222 -0
  200. package/src/pages/signup-page.test.tsx +289 -0
  201. package/src/pages/signup-page.tsx +354 -0
  202. package/src/pages/verify-email-page.test.tsx +82 -0
  203. package/src/pages/verify-email-page.tsx +48 -0
  204. package/src/pages/workspace-onboarding-page.test.tsx +161 -0
  205. package/src/pages/workspace-onboarding-page.tsx +213 -0
  206. package/src/routes/index.tsx +18 -0
  207. package/src/routes/login.tsx +11 -0
  208. package/src/routes/logout.tsx +4 -0
  209. package/src/routes/reset.tsx +11 -0
  210. package/src/routes/signup.tsx +11 -0
  211. package/src/routes/verify-email.tsx +11 -0
  212. package/src/routes/workspace-onboarding.tsx +14 -0
  213. package/src/state/auth.ts +18 -0
  214. package/src/state/last-workspace.test.ts +63 -0
  215. package/src/state/last-workspace.ts +5 -0
  216. package/test/factories/user.ts +17 -0
  217. package/test/pages.tsx +83 -0
  218. package/test/setup.ts +3 -0
  219. package/test/utils.ts +11 -0
  220. package/tsconfig.build.json +10 -0
  221. package/tsconfig.build.tsbuildinfo +1 -0
  222. package/tsconfig.json +3 -0
  223. package/tsconfig.test.json +8 -0
  224. package/vercel.json +8 -0
  225. package/vitest.config.ts +71 -0
@@ -0,0 +1,354 @@
1
+ import {signupBodySchema} from '@shipfox/api-auth-dto';
2
+ import {displayNameFieldError} from '@shipfox/client-ui';
3
+ import {Button, ButtonLink} from '@shipfox/react-ui/button';
4
+ import {Callout} from '@shipfox/react-ui/callout';
5
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
6
+ import {Icon} from '@shipfox/react-ui/icon';
7
+ import {toast} from '@shipfox/react-ui/toast';
8
+ import {Text} from '@shipfox/react-ui/typography';
9
+ import {useForm} from '@tanstack/react-form';
10
+ import {Link, useNavigate, useSearch} from '@tanstack/react-router';
11
+ import {useAtom} from 'jotai';
12
+ import {useEffect, useRef, useState} from 'react';
13
+ import {AuthShell} from '#/components/auth-shell.js';
14
+ import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
15
+ import {useSignupAuth} from '#hooks/api/signup-auth.js';
16
+ import {useResendEmailVerificationAuth} from '#hooks/api/verify-email-auth.js';
17
+ import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
18
+ import {
19
+ getLocalResendAvailableAt,
20
+ getResendRemainingSeconds,
21
+ parseNextResendAvailableAt,
22
+ } from './email-verification-resend-model.js';
23
+ import {signupErrorToFormError} from './form-errors.js';
24
+ import {authErrorMessage} from './form-utils.js';
25
+ import {
26
+ extractInvitationToken,
27
+ pendingInvitation,
28
+ useInvitationContext,
29
+ } from './invitation-context.js';
30
+
31
+ export function SignupPage() {
32
+ const signup = useSignupAuth();
33
+ const resendEmailVerification = useResendEmailVerificationAuth();
34
+ const refreshAuth = useRefreshAuth();
35
+ const navigate = useNavigate();
36
+ const search = useSearch({strict: false}) as {redirect?: unknown};
37
+ const invitationToken = extractInvitationToken(search.redirect);
38
+ const invitationPreview = useInvitationContext(invitationToken);
39
+ const invitationPending = pendingInvitation(invitationPreview.data);
40
+ const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
41
+ const [submittedEmail, setSubmittedEmail] = useState<string | undefined>();
42
+ const [now, setNow] = useState(() => Date.now());
43
+ const [nextResendAvailableAt, setNextResendAvailableAt] = useState<number | undefined>();
44
+ const [formError, setFormError] = useState<string | undefined>();
45
+ const [resendError, setResendError] = useState<string | undefined>();
46
+ const draftRef = useRef(authFormDraft);
47
+ draftRef.current = authFormDraft;
48
+ // Set just before clearing the draft on success so the unmount cleanup
49
+ // below does not repersist the just-submitted credentials.
50
+ const skipDraftPersistRef = useRef(false);
51
+ const resendRemainingSeconds = getResendRemainingSeconds({nextResendAvailableAt, now});
52
+ const isResendCoolingDown = resendRemainingSeconds > 0;
53
+
54
+ const form = useForm({
55
+ defaultValues: {email: authFormDraft.email, password: authFormDraft.password, name: ''},
56
+ onSubmit: async ({value}) => {
57
+ setFormError(undefined);
58
+ try {
59
+ const body = signupBodySchema.parse({
60
+ email: value.email,
61
+ password: value.password,
62
+ name: value.name,
63
+ ...(invitationToken ? {invitation_token: invitationToken} : {}),
64
+ });
65
+ const result = await signup.mutateAsync(body);
66
+ skipDraftPersistRef.current = true;
67
+ setAuthFormDraft(initialAuthFormDraft);
68
+
69
+ if (invitationToken && result.membership && invitationPending) {
70
+ try {
71
+ await refreshAuth();
72
+ } catch {
73
+ // Refresh failures don't block the success message — the next API
74
+ // call's 401 handling will re-route the user.
75
+ }
76
+ toast.success(`You joined ${invitationPending.workspace_name}.`);
77
+ await navigate({
78
+ to: '/workspaces/$wid',
79
+ params: {wid: result.membership.workspace_id},
80
+ });
81
+ return;
82
+ }
83
+
84
+ if (invitationToken && result.accept_error) {
85
+ toast.error(result.accept_error.message);
86
+ await navigate({
87
+ to: '/invitations/accept',
88
+ search: {token: invitationToken},
89
+ });
90
+ return;
91
+ }
92
+
93
+ setSubmittedEmail(result.user.email);
94
+ setResendError(undefined);
95
+ restartLocalCooldown();
96
+ } catch (error) {
97
+ const mapped = signupErrorToFormError(error);
98
+ if (mapped.kind === 'field') {
99
+ form.setFieldMeta(mapped.field, (prev) => ({
100
+ ...prev,
101
+ errorMap: {...prev.errorMap, onServer: mapped.message},
102
+ }));
103
+ } else {
104
+ setFormError(mapped.message);
105
+ }
106
+ }
107
+ },
108
+ });
109
+
110
+ useEffect(() => {
111
+ if (!submittedEmail || !nextResendAvailableAt) {
112
+ return;
113
+ }
114
+
115
+ const current = Date.now();
116
+ setNow(current);
117
+ if (nextResendAvailableAt <= current) {
118
+ return;
119
+ }
120
+
121
+ const handle = window.setInterval(() => {
122
+ const tickNow = Date.now();
123
+ setNow(tickNow);
124
+ if (nextResendAvailableAt <= tickNow) {
125
+ window.clearInterval(handle);
126
+ }
127
+ }, 1000);
128
+ return () => window.clearInterval(handle);
129
+ }, [nextResendAvailableAt, submittedEmail]);
130
+
131
+ function restartLocalCooldown() {
132
+ const current = Date.now();
133
+ setNow(current);
134
+ setNextResendAvailableAt(getLocalResendAvailableAt(current));
135
+ }
136
+
137
+ // When arriving from an invitation link, prefill the email and lock it.
138
+ useEffect(() => {
139
+ if (invitationPending && form.state.values.email !== invitationPending.email) {
140
+ form.setFieldValue('email', invitationPending.email);
141
+ setAuthFormDraft((current) => ({...current, email: invitationPending.email}));
142
+ }
143
+ }, [invitationPending, form, setAuthFormDraft]);
144
+
145
+ // Sync form values back to the Jotai draft on unmount (only email + password
146
+ // — name is intentionally not persisted across navigation). Skipped after a
147
+ // successful signup because we just intentionally cleared the draft.
148
+ useEffect(() => {
149
+ return () => {
150
+ if (skipDraftPersistRef.current) return;
151
+ const {email, password} = form.state.values;
152
+ if (email !== draftRef.current.email || password !== draftRef.current.password) {
153
+ setAuthFormDraft({email, password});
154
+ }
155
+ };
156
+ }, [form, setAuthFormDraft]);
157
+
158
+ async function onResendVerificationEmail() {
159
+ if (!submittedEmail || isResendCoolingDown || resendEmailVerification.isPending) return;
160
+
161
+ setResendError(undefined);
162
+ try {
163
+ const result = await resendEmailVerification.mutateAsync({email: submittedEmail});
164
+ const nextAvailableAt = parseNextResendAvailableAt(result.next_resend_available_at);
165
+ setNow(Date.now());
166
+ if (nextAvailableAt !== undefined) {
167
+ setNextResendAvailableAt(nextAvailableAt);
168
+ } else {
169
+ restartLocalCooldown();
170
+ }
171
+ toast.success('If another verification email can be sent, it will arrive shortly.');
172
+ } catch (error) {
173
+ setResendError(authErrorMessage(error));
174
+ }
175
+ }
176
+
177
+ if (submittedEmail) {
178
+ return (
179
+ <AuthShell
180
+ title="Check your email"
181
+ description={`We sent a verification link to ${submittedEmail}.`}
182
+ >
183
+ {resendError ? (
184
+ <Callout role="alert" type="error">
185
+ {resendError}
186
+ </Callout>
187
+ ) : null}
188
+ <Callout role="alert" type="success">
189
+ Click the verification link to activate your account, then come back to log in.
190
+ </Callout>
191
+ <div className="flex flex-col gap-8">
192
+ <Button
193
+ aria-disabled={isResendCoolingDown ? true : undefined}
194
+ className="w-full aria-disabled:cursor-not-allowed aria-disabled:opacity-70"
195
+ variant="secondary"
196
+ type="button"
197
+ isLoading={resendEmailVerification.isPending}
198
+ onClick={onResendVerificationEmail}
199
+ >
200
+ {resendEmailVerification.isPending
201
+ ? 'Sending email...'
202
+ : isResendCoolingDown
203
+ ? `Resend in ${resendRemainingSeconds}s`
204
+ : 'Resend verification email'}
205
+ </Button>
206
+ <Button
207
+ className="w-full"
208
+ variant="transparent"
209
+ type="button"
210
+ onClick={() => {
211
+ setSubmittedEmail(undefined);
212
+ setResendError(undefined);
213
+ }}
214
+ >
215
+ Use another email
216
+ </Button>
217
+ </div>
218
+ <Text size="sm" className="text-center text-foreground-neutral-subtle">
219
+ Already verified?{' '}
220
+ <ButtonLink asChild variant="interactive" underline>
221
+ <Link to="/auth/login">Log in</Link>
222
+ </ButtonLink>
223
+ </Text>
224
+ </AuthShell>
225
+ );
226
+ }
227
+
228
+ const headerTitle = invitationPending
229
+ ? `Join ${invitationPending.workspace_name}`
230
+ : 'Create your Shipfox account';
231
+ const headerDescription = invitationPending
232
+ ? `Create an account to accept your invitation.`
233
+ : 'Start with your email and a password.';
234
+ const isInvitationEmailLocked = Boolean(invitationPending);
235
+ const invitationRedirect = invitationToken
236
+ ? `/invitations/accept?token=${encodeURIComponent(invitationToken)}`
237
+ : undefined;
238
+
239
+ function persistDraft() {
240
+ const {email, password} = form.state.values;
241
+ setAuthFormDraft({email, password});
242
+ }
243
+
244
+ return (
245
+ <AuthShell title={headerTitle} description={headerDescription}>
246
+ <form
247
+ className="flex flex-col gap-18"
248
+ noValidate
249
+ onSubmit={(event) => {
250
+ event.preventDefault();
251
+ event.stopPropagation();
252
+ void form.handleSubmit();
253
+ }}
254
+ >
255
+ {formError ? (
256
+ <Callout role="alert" type="error">
257
+ {formError}
258
+ </Callout>
259
+ ) : null}
260
+ <form.Field
261
+ name="name"
262
+ validators={{
263
+ onBlur: ({value}) => displayNameFieldError(value, 'Name', signupBodySchema.shape.name),
264
+ onSubmit: ({value}) =>
265
+ displayNameFieldError(value, 'Name', signupBodySchema.shape.name),
266
+ }}
267
+ >
268
+ {(field) => (
269
+ <FormField label="Name" id="name" error={fieldError(field)}>
270
+ <FormFieldInput
271
+ autoComplete="name"
272
+ name="name"
273
+ type="text"
274
+ value={field.state.value}
275
+ onChange={(event) => field.handleChange(event.target.value)}
276
+ onBlur={field.handleBlur}
277
+ />
278
+ </FormField>
279
+ )}
280
+ </form.Field>
281
+ <form.Field
282
+ name="email"
283
+ validators={{
284
+ onBlur: signupBodySchema.shape.email,
285
+ onSubmit: signupBodySchema.shape.email,
286
+ }}
287
+ >
288
+ {(field) => (
289
+ <FormField label="Email" id="email" error={fieldError(field)}>
290
+ <FormFieldInput
291
+ autoComplete="email"
292
+ name="email"
293
+ type="email"
294
+ value={field.state.value}
295
+ onChange={(event) => field.handleChange(event.target.value)}
296
+ onBlur={() => {
297
+ field.handleBlur();
298
+ persistDraft();
299
+ }}
300
+ readOnly={isInvitationEmailLocked}
301
+ iconRight={
302
+ isInvitationEmailLocked ? (
303
+ <Icon
304
+ aria-hidden="true"
305
+ className="size-16 text-foreground-neutral-disabled"
306
+ name="lockLine"
307
+ />
308
+ ) : undefined
309
+ }
310
+ />
311
+ </FormField>
312
+ )}
313
+ </form.Field>
314
+ <form.Field
315
+ name="password"
316
+ validators={{
317
+ onBlur: signupBodySchema.shape.password,
318
+ onSubmit: signupBodySchema.shape.password,
319
+ }}
320
+ >
321
+ {(field) => (
322
+ <FormField label="Password" id="password" error={fieldError(field)}>
323
+ <FormFieldInput
324
+ autoComplete="new-password"
325
+ name="password"
326
+ type="password"
327
+ value={field.state.value}
328
+ onChange={(event) => field.handleChange(event.target.value)}
329
+ onBlur={() => {
330
+ field.handleBlur();
331
+ persistDraft();
332
+ }}
333
+ />
334
+ </FormField>
335
+ )}
336
+ </form.Field>
337
+ <Button className="w-full" isLoading={signup.isPending} type="submit">
338
+ {signup.isPending ? 'Creating account...' : 'Create account'}
339
+ </Button>
340
+ </form>
341
+ <Text size="sm" className="text-center text-foreground-neutral-subtle">
342
+ Already have an account?{' '}
343
+ <ButtonLink asChild variant="interactive" underline>
344
+ <Link
345
+ to="/auth/login"
346
+ search={invitationRedirect ? {redirect: invitationRedirect} : undefined}
347
+ >
348
+ Log in
349
+ </Link>
350
+ </ButtonLink>
351
+ </Text>
352
+ </AuthShell>
353
+ );
354
+ }
@@ -0,0 +1,82 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {screen, waitFor} from '@testing-library/react';
3
+ import {pageUserFactory} from '#test/factories/user.js';
4
+ import {renderAuthPage, renderStrictAuthPage} from '#test/pages.js';
5
+ import {jsonResponse, requestUrl} from '#test/utils.js';
6
+ import {VerifyEmailPage} from './verify-email-page.js';
7
+
8
+ describe('VerifyEmailPage', () => {
9
+ beforeEach(() => {
10
+ configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
11
+ });
12
+
13
+ test('only confirms once under StrictMode', async () => {
14
+ const user = pageUserFactory.build();
15
+ let didVerify = false;
16
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
17
+ const url = requestUrl(input);
18
+ if (url.endsWith('/auth/verify-email/confirm')) {
19
+ didVerify = true;
20
+ return Promise.resolve(jsonResponse({token: 'verified-access-token', user}));
21
+ }
22
+ if (url.endsWith('/auth/refresh')) {
23
+ return Promise.resolve(
24
+ didVerify
25
+ ? jsonResponse({token: 'refreshed-access-token', user})
26
+ : jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
27
+ );
28
+ }
29
+ return Promise.resolve(
30
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
31
+ );
32
+ });
33
+ configureApiClient({fetchImpl});
34
+
35
+ renderStrictAuthPage('/auth/verify-email?token=strict-token', <VerifyEmailPage />);
36
+
37
+ expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
38
+ expect(
39
+ await screen.findByText('Your email is verified. You are now logged in.'),
40
+ ).toBeInTheDocument();
41
+ const verifyCalls = fetchImpl.mock.calls.filter(([input]) =>
42
+ requestUrl(input).endsWith('/auth/verify-email/confirm'),
43
+ );
44
+ expect(verifyCalls).toHaveLength(1);
45
+ });
46
+
47
+ test('reports an invalid token', async () => {
48
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
49
+ const url = requestUrl(input);
50
+ if (url.endsWith('/auth/verify-email/confirm')) {
51
+ return Promise.resolve(
52
+ jsonResponse({code: 'invalid-token', message: 'Invalid token'}, {status: 400}),
53
+ );
54
+ }
55
+ return Promise.resolve(
56
+ jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
57
+ );
58
+ });
59
+ configureApiClient({fetchImpl});
60
+
61
+ renderAuthPage('/auth/verify-email?token=bad-token', <VerifyEmailPage />);
62
+
63
+ expect(await screen.findByText('Invalid token')).toBeInTheDocument();
64
+ });
65
+
66
+ test('handles missing token locally', async () => {
67
+ const fetchImpl = vi
68
+ .fn()
69
+ .mockResolvedValue(
70
+ jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
71
+ );
72
+ configureApiClient({fetchImpl});
73
+
74
+ renderAuthPage('/auth/verify-email', <VerifyEmailPage />);
75
+
76
+ expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
77
+ expect(
78
+ await screen.findByText('This verification link is missing a token.'),
79
+ ).toBeInTheDocument();
80
+ await waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
81
+ });
82
+ });
@@ -0,0 +1,48 @@
1
+ import {toast} from '@shipfox/react-ui/toast';
2
+ import {useNavigate, useSearch} from '@tanstack/react-router';
3
+ import {useEffect} from 'react';
4
+ import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
5
+ import {useVerifyEmailAuth} from '#hooks/api/verify-email-auth.js';
6
+ import {authErrorMessage} from './form-utils.js';
7
+
8
+ const verificationRequests = new Map<string, Promise<void>>();
9
+ const toastedVerificationTokens = new Set<string>();
10
+
11
+ export function VerifyEmailPage() {
12
+ const verifyEmail = useVerifyEmailAuth();
13
+ const refreshAuth = useRefreshAuth();
14
+ const navigate = useNavigate();
15
+ const search = useSearch({strict: false});
16
+ const token = typeof search.token === 'string' ? search.token : undefined;
17
+
18
+ useEffect(() => {
19
+ if (!token) {
20
+ toast.error('This verification link is missing a token.');
21
+ navigate({to: '/', replace: true});
22
+ return;
23
+ }
24
+
25
+ let request = verificationRequests.get(token);
26
+ if (!request) {
27
+ request = verifyEmail.mutateAsync({token}).then(async () => {
28
+ await refreshAuth();
29
+ });
30
+ verificationRequests.set(token, request);
31
+ }
32
+
33
+ request
34
+ .then(() => {
35
+ if (toastedVerificationTokens.has(token)) return;
36
+ toastedVerificationTokens.add(token);
37
+ toast.success('Your email is verified. You are now logged in.');
38
+ navigate({to: '/', replace: true});
39
+ })
40
+ .catch((error) => {
41
+ if (toastedVerificationTokens.has(token)) return;
42
+ toastedVerificationTokens.add(token);
43
+ toast.error(authErrorMessage(error));
44
+ });
45
+ }, [navigate, refreshAuth, token, verifyEmail.mutateAsync]);
46
+
47
+ return null;
48
+ }
@@ -0,0 +1,161 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {fireEvent, screen, waitFor} from '@testing-library/react';
3
+ import {AuthGuard, WorkspaceGuard} from '#components/auth-guard.js';
4
+ import {pageUserFactory} from '#test/factories/user.js';
5
+ import {renderAuthPage} from '#test/pages.js';
6
+ import {jsonResponse, requestUrl} from '#test/utils.js';
7
+
8
+ describe('WorkspaceOnboardingPage', () => {
9
+ beforeEach(() => {
10
+ window.localStorage.clear();
11
+ configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
12
+ });
13
+
14
+ afterEach(() => {
15
+ window.localStorage.clear();
16
+ });
17
+
18
+ test('creates a workspace before showing the signed-in app', async () => {
19
+ const user = pageUserFactory.build({email: 'workspace@example.com'});
20
+ let didCreateWorkspace = false;
21
+ let createWorkspaceBody: unknown;
22
+ const fetchImpl = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
23
+ const url = requestUrl(input);
24
+ const method = input instanceof Request ? input.method : 'GET';
25
+ if (url.endsWith('/auth/refresh')) {
26
+ return jsonResponse({
27
+ token: didCreateWorkspace ? 'workspace-access-token' : 'access-token',
28
+ user,
29
+ });
30
+ }
31
+ if (url.endsWith('/workspaces') && method === 'GET') {
32
+ return jsonResponse({
33
+ memberships: didCreateWorkspace
34
+ ? [
35
+ {
36
+ id: '22222222-2222-4222-8222-222222222222',
37
+ user_id: user.id,
38
+ workspace_id: '33333333-3333-4333-8333-333333333333',
39
+ workspace_name: 'Acme',
40
+ created_at: '2026-04-27T00:00:00.000Z',
41
+ updated_at: '2026-04-27T00:00:00.000Z',
42
+ },
43
+ ]
44
+ : [],
45
+ });
46
+ }
47
+ if (url.endsWith('/workspaces') && method === 'POST') {
48
+ didCreateWorkspace = true;
49
+ createWorkspaceBody = input instanceof Request ? await input.json() : undefined;
50
+ return jsonResponse(
51
+ {
52
+ id: '33333333-3333-4333-8333-333333333333',
53
+ name: 'Acme',
54
+ status: 'active',
55
+ settings: {},
56
+ created_at: '2026-04-27T00:00:00.000Z',
57
+ updated_at: '2026-04-27T00:00:00.000Z',
58
+ },
59
+ {status: 201},
60
+ );
61
+ }
62
+
63
+ return jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404});
64
+ });
65
+ configureApiClient({fetchImpl});
66
+
67
+ renderAuthPage(
68
+ '/',
69
+ <AuthGuard>
70
+ <WorkspaceGuard>
71
+ <h1>Authenticated home</h1>
72
+ </WorkspaceGuard>
73
+ </AuthGuard>,
74
+ );
75
+ fireEvent.change(await screen.findByLabelText('Workspace name'), {
76
+ target: {value: ' Acme '},
77
+ });
78
+ fireEvent.click(screen.getByRole('button', {name: 'Create workspace'}));
79
+
80
+ await waitFor(() => expect(didCreateWorkspace).toBe(true));
81
+ expect(createWorkspaceBody).toEqual({name: 'Acme'});
82
+ });
83
+
84
+ test('requires the workspace name locally', async () => {
85
+ const user = pageUserFactory.build({email: 'workspace@example.com'});
86
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
87
+ const url = requestUrl(input);
88
+ if (url.endsWith('/auth/refresh')) {
89
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
90
+ }
91
+ if (url.endsWith('/workspaces')) {
92
+ return Promise.resolve(jsonResponse({memberships: []}));
93
+ }
94
+
95
+ return Promise.resolve(
96
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
97
+ );
98
+ });
99
+ configureApiClient({fetchImpl});
100
+
101
+ renderAuthPage(
102
+ '/',
103
+ <AuthGuard>
104
+ <WorkspaceGuard>
105
+ <h1>Authenticated home</h1>
106
+ </WorkspaceGuard>
107
+ </AuthGuard>,
108
+ );
109
+ fireEvent.click(await screen.findByRole('button', {name: 'Create workspace'}));
110
+
111
+ expect(await screen.findByText('Workspace name is required.')).toBeInTheDocument();
112
+ await waitFor(() => expect(screen.getByLabelText('Workspace name')).toBeInvalid());
113
+ expect(workspacePostCount(fetchImpl)).toBe(0);
114
+ });
115
+
116
+ test('rejects control characters in the workspace name locally', async () => {
117
+ const user = pageUserFactory.build({email: 'workspace@example.com'});
118
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
119
+ const url = requestUrl(input);
120
+ if (url.endsWith('/auth/refresh')) {
121
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
122
+ }
123
+ if (url.endsWith('/workspaces')) {
124
+ return Promise.resolve(jsonResponse({memberships: []}));
125
+ }
126
+
127
+ return Promise.resolve(
128
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
129
+ );
130
+ });
131
+ configureApiClient({fetchImpl});
132
+
133
+ renderAuthPage(
134
+ '/',
135
+ <AuthGuard>
136
+ <WorkspaceGuard>
137
+ <h1>Authenticated home</h1>
138
+ </WorkspaceGuard>
139
+ </AuthGuard>,
140
+ );
141
+ fireEvent.change(await screen.findByLabelText('Workspace name'), {
142
+ target: {value: 'Acme\u202eLabs'},
143
+ });
144
+ fireEvent.click(screen.getByRole('button', {name: 'Create workspace'}));
145
+
146
+ expect(
147
+ await screen.findByText(
148
+ 'Workspace name cannot include line breaks, tabs, or hidden formatting characters.',
149
+ ),
150
+ ).toBeInTheDocument();
151
+ expect(workspacePostCount(fetchImpl)).toBe(0);
152
+ });
153
+ });
154
+
155
+ function workspacePostCount(fetchImpl: ReturnType<typeof vi.fn>): number {
156
+ return fetchImpl.mock.calls.filter(([input]) => {
157
+ const url = requestUrl(input as RequestInfo | URL);
158
+ const method = input instanceof Request ? input.method : 'GET';
159
+ return url.endsWith('/workspaces') && method === 'POST';
160
+ }).length;
161
+ }