@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +7 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +111 -0
- package/LICENSE +21 -0
- package/dist/components/auth-guard.d.ts +5 -0
- package/dist/components/auth-guard.d.ts.map +1 -0
- package/dist/components/auth-guard.js +61 -0
- package/dist/components/auth-guard.js.map +1 -0
- package/dist/components/auth-provider.d.ts +11 -0
- package/dist/components/auth-provider.d.ts.map +1 -0
- package/dist/components/auth-provider.js +21 -0
- package/dist/components/auth-provider.js.map +1 -0
- package/dist/components/auth-shell.d.ts +11 -0
- package/dist/components/auth-shell.d.ts.map +1 -0
- package/dist/components/auth-shell.js +63 -0
- package/dist/components/auth-shell.js.map +1 -0
- package/dist/components/redirect-target.d.ts +2 -0
- package/dist/components/redirect-target.d.ts.map +1 -0
- package/dist/components/redirect-target.js +22 -0
- package/dist/components/redirect-target.js.map +1 -0
- package/dist/components/workspace-crumb.d.ts +3 -0
- package/dist/components/workspace-crumb.d.ts.map +1 -0
- package/dist/components/workspace-crumb.js +3 -0
- package/dist/components/workspace-crumb.js.map +1 -0
- package/dist/components/workspace-switcher.d.ts +6 -0
- package/dist/components/workspace-switcher.d.ts.map +1 -0
- package/dist/components/workspace-switcher.js +91 -0
- package/dist/components/workspace-switcher.js.map +1 -0
- package/dist/components/workspace-switcher.stories.d.ts +26 -0
- package/dist/components/workspace-switcher.stories.d.ts.map +1 -0
- package/dist/components/workspace-switcher.stories.js +168 -0
- package/dist/components/workspace-switcher.stories.js.map +1 -0
- package/dist/feature.d.ts +33 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +43 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/login-auth.d.ts +29 -0
- package/dist/hooks/api/login-auth.d.ts.map +1 -0
- package/dist/hooks/api/login-auth.js +42 -0
- package/dist/hooks/api/login-auth.js.map +1 -0
- package/dist/hooks/api/logout-auth.d.ts +2 -0
- package/dist/hooks/api/logout-auth.d.ts.map +1 -0
- package/dist/hooks/api/logout-auth.js +32 -0
- package/dist/hooks/api/logout-auth.js.map +1 -0
- package/dist/hooks/api/password-reset-auth.d.ts +33 -0
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -0
- package/dist/hooks/api/password-reset-auth.js +48 -0
- package/dist/hooks/api/password-reset-auth.js.map +1 -0
- package/dist/hooks/api/refresh-auth.d.ts +2 -0
- package/dist/hooks/api/refresh-auth.d.ts.map +1 -0
- package/dist/hooks/api/refresh-auth.js +3 -0
- package/dist/hooks/api/refresh-auth.js.map +1 -0
- package/dist/hooks/api/signup-auth.d.ts +27 -0
- package/dist/hooks/api/signup-auth.d.ts.map +1 -0
- package/dist/hooks/api/signup-auth.js +15 -0
- package/dist/hooks/api/signup-auth.js.map +1 -0
- package/dist/hooks/api/verify-email-auth.d.ts +20 -0
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -0
- package/dist/hooks/api/verify-email-auth.js +48 -0
- package/dist/hooks/api/verify-email-auth.js.map +1 -0
- package/dist/hooks/api/workspace-auth.d.ts +22 -0
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -0
- package/dist/hooks/api/workspace-auth.js +25 -0
- package/dist/hooks/api/workspace-auth.js.map +1 -0
- package/dist/hooks/index.d.ts +11 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +11 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-active-workspace.d.ts +2 -0
- package/dist/hooks/use-active-workspace.d.ts.map +1 -0
- package/dist/hooks/use-active-workspace.js +3 -0
- package/dist/hooks/use-active-workspace.js.map +1 -0
- package/dist/hooks/use-auth-state.d.ts +2 -0
- package/dist/hooks/use-auth-state.d.ts.map +1 -0
- package/dist/hooks/use-auth-state.js +3 -0
- package/dist/hooks/use-auth-state.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/email-verification-resend-model.d.ts +7 -0
- package/dist/pages/email-verification-resend-model.d.ts.map +1 -0
- package/dist/pages/email-verification-resend-model.js +15 -0
- package/dist/pages/email-verification-resend-model.js.map +1 -0
- package/dist/pages/form-errors.d.ts +19 -0
- package/dist/pages/form-errors.d.ts.map +1 -0
- package/dist/pages/form-errors.js +51 -0
- package/dist/pages/form-errors.js.map +1 -0
- package/dist/pages/form-utils.d.ts +2 -0
- package/dist/pages/form-utils.d.ts.map +1 -0
- package/dist/pages/form-utils.js +28 -0
- package/dist/pages/form-utils.js.map +1 -0
- package/dist/pages/invitation-context.d.ts +31 -0
- package/dist/pages/invitation-context.d.ts.map +1 -0
- package/dist/pages/invitation-context.js +50 -0
- package/dist/pages/invitation-context.js.map +1 -0
- package/dist/pages/login-page.d.ts +2 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.js +214 -0
- package/dist/pages/login-page.js.map +1 -0
- package/dist/pages/logout-page.d.ts +2 -0
- package/dist/pages/logout-page.d.ts.map +1 -0
- package/dist/pages/logout-page.js +45 -0
- package/dist/pages/logout-page.js.map +1 -0
- package/dist/pages/password-reset-page.d.ts +2 -0
- package/dist/pages/password-reset-page.d.ts.map +1 -0
- package/dist/pages/password-reset-page.js +265 -0
- package/dist/pages/password-reset-page.js.map +1 -0
- package/dist/pages/signup-page.d.ts +2 -0
- package/dist/pages/signup-page.d.ts.map +1 -0
- package/dist/pages/signup-page.js +384 -0
- package/dist/pages/signup-page.js.map +1 -0
- package/dist/pages/verify-email-page.d.ts +2 -0
- package/dist/pages/verify-email-page.d.ts.map +1 -0
- package/dist/pages/verify-email-page.js +57 -0
- package/dist/pages/verify-email-page.js.map +1 -0
- package/dist/pages/workspace-onboarding-page.d.ts +2 -0
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -0
- package/dist/pages/workspace-onboarding-page.js +294 -0
- package/dist/pages/workspace-onboarding-page.js.map +1 -0
- package/dist/routes/index.d.ts +10 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +29 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/login.d.ts +5 -0
- package/dist/routes/login.d.ts.map +1 -0
- package/dist/routes/login.js +11 -0
- package/dist/routes/login.js.map +1 -0
- package/dist/routes/logout.d.ts +6 -0
- package/dist/routes/logout.d.ts.map +1 -0
- package/dist/routes/logout.js +7 -0
- package/dist/routes/logout.js.map +1 -0
- package/dist/routes/reset.d.ts +5 -0
- package/dist/routes/reset.d.ts.map +1 -0
- package/dist/routes/reset.js +11 -0
- package/dist/routes/reset.js.map +1 -0
- package/dist/routes/signup.d.ts +5 -0
- package/dist/routes/signup.d.ts.map +1 -0
- package/dist/routes/signup.js +11 -0
- package/dist/routes/signup.js.map +1 -0
- package/dist/routes/verify-email.d.ts +5 -0
- package/dist/routes/verify-email.d.ts.map +1 -0
- package/dist/routes/verify-email.js +11 -0
- package/dist/routes/verify-email.js.map +1 -0
- package/dist/routes/workspace-onboarding.d.ts +13 -0
- package/dist/routes/workspace-onboarding.d.ts.map +1 -0
- package/dist/routes/workspace-onboarding.js +18 -0
- package/dist/routes/workspace-onboarding.js.map +1 -0
- package/dist/state/auth.d.ts +10 -0
- package/dist/state/auth.d.ts.map +1 -0
- package/dist/state/auth.js +9 -0
- package/dist/state/auth.js.map +1 -0
- package/dist/state/last-workspace.d.ts +2 -0
- package/dist/state/last-workspace.d.ts.map +1 -0
- package/dist/state/last-workspace.js +3 -0
- package/dist/state/last-workspace.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +116 -0
- package/src/components/auth-guard.tsx +58 -0
- package/src/components/auth-provider.test.tsx +164 -0
- package/src/components/auth-provider.tsx +24 -0
- package/src/components/auth-shell.tsx +53 -0
- package/src/components/redirect-target.test.ts +60 -0
- package/src/components/redirect-target.ts +21 -0
- package/src/components/workspace-crumb.tsx +2 -0
- package/src/components/workspace-switcher.stories.tsx +169 -0
- package/src/components/workspace-switcher.tsx +76 -0
- package/src/feature.ts +18 -0
- package/src/hooks/api/login-auth.test.ts +45 -0
- package/src/hooks/api/login-auth.ts +41 -0
- package/src/hooks/api/logout-auth.ts +26 -0
- package/src/hooks/api/password-reset-auth.test.ts +68 -0
- package/src/hooks/api/password-reset-auth.ts +51 -0
- package/src/hooks/api/refresh-auth.test.ts +37 -0
- package/src/hooks/api/refresh-auth.ts +5 -0
- package/src/hooks/api/signup-auth.ts +11 -0
- package/src/hooks/api/verify-email-auth.ts +55 -0
- package/src/hooks/api/workspace-auth.test.ts +40 -0
- package/src/hooks/api/workspace-auth.ts +25 -0
- package/src/hooks/index.ts +13 -0
- package/src/hooks/use-active-workspace.ts +1 -0
- package/src/hooks/use-auth-state.ts +1 -0
- package/src/index.ts +18 -0
- package/src/pages/email-verification-resend-model.test.ts +52 -0
- package/src/pages/email-verification-resend-model.ts +21 -0
- package/src/pages/form-errors.test.ts +114 -0
- package/src/pages/form-errors.ts +45 -0
- package/src/pages/form-utils.test.ts +37 -0
- package/src/pages/form-utils.ts +29 -0
- package/src/pages/invitation-context.ts +57 -0
- package/src/pages/login-page.test.tsx +128 -0
- package/src/pages/login-page.tsx +184 -0
- package/src/pages/logout-page.tsx +33 -0
- package/src/pages/password-reset-page.test.tsx +109 -0
- package/src/pages/password-reset-page.tsx +222 -0
- package/src/pages/signup-page.test.tsx +289 -0
- package/src/pages/signup-page.tsx +354 -0
- package/src/pages/verify-email-page.test.tsx +82 -0
- package/src/pages/verify-email-page.tsx +48 -0
- package/src/pages/workspace-onboarding-page.test.tsx +161 -0
- package/src/pages/workspace-onboarding-page.tsx +213 -0
- package/src/routes/index.tsx +18 -0
- package/src/routes/login.tsx +11 -0
- package/src/routes/logout.tsx +4 -0
- package/src/routes/reset.tsx +11 -0
- package/src/routes/signup.tsx +11 -0
- package/src/routes/verify-email.tsx +11 -0
- package/src/routes/workspace-onboarding.tsx +14 -0
- package/src/state/auth.ts +18 -0
- package/src/state/last-workspace.test.ts +63 -0
- package/src/state/last-workspace.ts +5 -0
- package/test/factories/user.ts +17 -0
- package/test/pages.tsx +83 -0
- package/test/setup.ts +3 -0
- package/test/utils.ts +11 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
}
|