@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,109 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {fireEvent, screen} from '@testing-library/react';
|
|
3
|
+
import {pageUserFactory} from '#test/factories/user.js';
|
|
4
|
+
import {renderAuthPage} from '#test/pages.js';
|
|
5
|
+
import {jsonResponse, requestUrl} from '#test/utils.js';
|
|
6
|
+
import {PasswordResetPage} from './password-reset-page.js';
|
|
7
|
+
|
|
8
|
+
describe('PasswordResetPage', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('renders the reset request form and links back to login', async () => {
|
|
14
|
+
const fetchImpl = vi
|
|
15
|
+
.fn()
|
|
16
|
+
.mockResolvedValue(
|
|
17
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
18
|
+
);
|
|
19
|
+
configureApiClient({fetchImpl});
|
|
20
|
+
|
|
21
|
+
renderAuthPage('/auth/reset', <PasswordResetPage />);
|
|
22
|
+
|
|
23
|
+
expect(await screen.findByRole('heading', {name: 'Reset your password'})).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByRole('link', {name: 'Log in'})).toHaveAttribute('href', '/auth/login');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('requests a reset link for a valid email', async () => {
|
|
28
|
+
const fetchImpl = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
29
|
+
const url = requestUrl(input);
|
|
30
|
+
if (url.endsWith('/auth/password-reset')) {
|
|
31
|
+
return new Response(null, {status: 204});
|
|
32
|
+
}
|
|
33
|
+
if (url.endsWith('/auth/refresh')) {
|
|
34
|
+
return jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404});
|
|
38
|
+
});
|
|
39
|
+
configureApiClient({fetchImpl});
|
|
40
|
+
|
|
41
|
+
renderAuthPage('/auth/reset', <PasswordResetPage />);
|
|
42
|
+
fireEvent.change(await screen.findByLabelText('Email'), {
|
|
43
|
+
target: {value: 'Reset@Example.com'},
|
|
44
|
+
});
|
|
45
|
+
fireEvent.click(screen.getByRole('button', {name: 'Send reset link'}));
|
|
46
|
+
|
|
47
|
+
expect(await screen.findByRole('heading', {name: 'Check your email'})).toBeInTheDocument();
|
|
48
|
+
expect(
|
|
49
|
+
screen.getByText(
|
|
50
|
+
'If a Shipfox account exists for that email, the reset link will arrive shortly.',
|
|
51
|
+
),
|
|
52
|
+
).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('confirms a password reset token', async () => {
|
|
56
|
+
const user = pageUserFactory.build({email: 'reset@example.com'});
|
|
57
|
+
let didConfirm = false;
|
|
58
|
+
const fetchImpl = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
59
|
+
const url = requestUrl(input);
|
|
60
|
+
if (url.endsWith('/auth/password-reset/confirm')) {
|
|
61
|
+
didConfirm = true;
|
|
62
|
+
return jsonResponse({token: 'reset-access-token', user});
|
|
63
|
+
}
|
|
64
|
+
if (url.endsWith('/auth/refresh')) {
|
|
65
|
+
return didConfirm
|
|
66
|
+
? jsonResponse({token: 'refreshed-access-token', user})
|
|
67
|
+
: jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404});
|
|
71
|
+
});
|
|
72
|
+
configureApiClient({fetchImpl});
|
|
73
|
+
|
|
74
|
+
renderAuthPage('/auth/reset?token=reset-token', <PasswordResetPage />);
|
|
75
|
+
fireEvent.change(await screen.findByLabelText('New password'), {
|
|
76
|
+
target: {value: 'new password is long'},
|
|
77
|
+
});
|
|
78
|
+
fireEvent.click(screen.getByRole('button', {name: 'Update password'}));
|
|
79
|
+
|
|
80
|
+
expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
|
|
81
|
+
expect(
|
|
82
|
+
await screen.findByText('Your password has been changed. You are now logged in.'),
|
|
83
|
+
).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('reports invalid reset tokens', async () => {
|
|
87
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
88
|
+
const url = requestUrl(input);
|
|
89
|
+
if (url.endsWith('/auth/password-reset/confirm')) {
|
|
90
|
+
return Promise.resolve(
|
|
91
|
+
jsonResponse({code: 'token-invalid', message: 'Reset token expired'}, {status: 410}),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return Promise.resolve(
|
|
96
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
configureApiClient({fetchImpl});
|
|
100
|
+
|
|
101
|
+
renderAuthPage('/auth/reset?token=bad-token', <PasswordResetPage />);
|
|
102
|
+
fireEvent.change(await screen.findByLabelText('New password'), {
|
|
103
|
+
target: {value: 'new password is long'},
|
|
104
|
+
});
|
|
105
|
+
fireEvent.click(screen.getByRole('button', {name: 'Update password'}));
|
|
106
|
+
|
|
107
|
+
expect(await screen.findByText('This link is invalid or expired.')).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
passwordResetConfirmBodySchema,
|
|
3
|
+
passwordResetRequestBodySchema,
|
|
4
|
+
} from '@shipfox/api-auth-dto';
|
|
5
|
+
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
6
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
7
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
8
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
9
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
10
|
+
import {useForm} from '@tanstack/react-form';
|
|
11
|
+
import {Link, useNavigate, useSearch} from '@tanstack/react-router';
|
|
12
|
+
import {useAtom} from 'jotai';
|
|
13
|
+
import {useEffect, useRef, useState} from 'react';
|
|
14
|
+
import {AuthShell} from '#/components/auth-shell.js';
|
|
15
|
+
import {
|
|
16
|
+
useConfirmPasswordResetAuth,
|
|
17
|
+
useRequestPasswordResetAuth,
|
|
18
|
+
} from '#hooks/api/password-reset-auth.js';
|
|
19
|
+
import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
|
|
20
|
+
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
21
|
+
import {
|
|
22
|
+
passwordResetConfirmErrorToFormError,
|
|
23
|
+
passwordResetRequestErrorToFormError,
|
|
24
|
+
} from './form-errors.js';
|
|
25
|
+
|
|
26
|
+
export function PasswordResetPage() {
|
|
27
|
+
const search = useSearch({strict: false});
|
|
28
|
+
const token = typeof search.token === 'string' ? search.token : undefined;
|
|
29
|
+
|
|
30
|
+
if (token) {
|
|
31
|
+
return <PasswordResetConfirm token={token} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return <PasswordResetRequest />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function PasswordResetRequest() {
|
|
38
|
+
const requestPasswordReset = useRequestPasswordResetAuth();
|
|
39
|
+
const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
40
|
+
const [submittedEmail, setSubmittedEmail] = useState<string | undefined>();
|
|
41
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
42
|
+
const draftRef = useRef(authFormDraft);
|
|
43
|
+
draftRef.current = authFormDraft;
|
|
44
|
+
|
|
45
|
+
const form = useForm({
|
|
46
|
+
defaultValues: {email: authFormDraft.email},
|
|
47
|
+
onSubmit: async ({value}) => {
|
|
48
|
+
setFormError(undefined);
|
|
49
|
+
try {
|
|
50
|
+
const parsed = passwordResetRequestBodySchema.parse(value);
|
|
51
|
+
await requestPasswordReset.mutateAsync(parsed);
|
|
52
|
+
setSubmittedEmail(parsed.email);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const mapped = passwordResetRequestErrorToFormError(error);
|
|
55
|
+
setFormError(mapped.message);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return () => {
|
|
62
|
+
const {email} = form.state.values;
|
|
63
|
+
if (email !== draftRef.current.email) {
|
|
64
|
+
setAuthFormDraft((current) => ({...current, email}));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}, [form, setAuthFormDraft]);
|
|
68
|
+
|
|
69
|
+
if (submittedEmail) {
|
|
70
|
+
return (
|
|
71
|
+
<AuthShell
|
|
72
|
+
title="Check your email"
|
|
73
|
+
description={`We sent reset instructions to ${submittedEmail}.`}
|
|
74
|
+
>
|
|
75
|
+
<Callout role="alert" type="success">
|
|
76
|
+
If a Shipfox account exists for that email, the reset link will arrive shortly.
|
|
77
|
+
</Callout>
|
|
78
|
+
<Button
|
|
79
|
+
className="w-full"
|
|
80
|
+
variant="secondary"
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => setSubmittedEmail(undefined)}
|
|
83
|
+
>
|
|
84
|
+
Use another email
|
|
85
|
+
</Button>
|
|
86
|
+
<Text size="sm" className="text-center text-foreground-neutral-subtle">
|
|
87
|
+
Remembered it?{' '}
|
|
88
|
+
<ButtonLink asChild variant="interactive" underline>
|
|
89
|
+
<Link to="/auth/login">Log in</Link>
|
|
90
|
+
</ButtonLink>
|
|
91
|
+
</Text>
|
|
92
|
+
</AuthShell>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<AuthShell title="Reset your password" description="Enter your email to get a reset link.">
|
|
98
|
+
<form
|
|
99
|
+
className="flex flex-col gap-18"
|
|
100
|
+
noValidate
|
|
101
|
+
onSubmit={(event) => {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
event.stopPropagation();
|
|
104
|
+
void form.handleSubmit();
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{formError ? (
|
|
108
|
+
<Callout role="alert" type="error">
|
|
109
|
+
{formError}
|
|
110
|
+
</Callout>
|
|
111
|
+
) : null}
|
|
112
|
+
<form.Field
|
|
113
|
+
name="email"
|
|
114
|
+
validators={{
|
|
115
|
+
onBlur: passwordResetRequestBodySchema.shape.email,
|
|
116
|
+
onSubmit: passwordResetRequestBodySchema.shape.email,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{(field) => (
|
|
120
|
+
<FormField label="Email" id="email" error={fieldError(field)}>
|
|
121
|
+
<FormFieldInput
|
|
122
|
+
autoComplete="email"
|
|
123
|
+
name="email"
|
|
124
|
+
type="email"
|
|
125
|
+
value={field.state.value}
|
|
126
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
127
|
+
onBlur={() => {
|
|
128
|
+
field.handleBlur();
|
|
129
|
+
setAuthFormDraft((current) => ({...current, email: field.state.value}));
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
</FormField>
|
|
133
|
+
)}
|
|
134
|
+
</form.Field>
|
|
135
|
+
<Button className="w-full" isLoading={requestPasswordReset.isPending} type="submit">
|
|
136
|
+
{requestPasswordReset.isPending ? 'Sending link...' : 'Send reset link'}
|
|
137
|
+
</Button>
|
|
138
|
+
</form>
|
|
139
|
+
<Text size="sm" className="text-center text-foreground-neutral-subtle">
|
|
140
|
+
Remembered it?{' '}
|
|
141
|
+
<ButtonLink asChild variant="interactive" underline>
|
|
142
|
+
<Link to="/auth/login">Log in</Link>
|
|
143
|
+
</ButtonLink>
|
|
144
|
+
</Text>
|
|
145
|
+
</AuthShell>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function PasswordResetConfirm({token}: {token: string}) {
|
|
150
|
+
const confirmPasswordReset = useConfirmPasswordResetAuth();
|
|
151
|
+
const refreshAuth = useRefreshAuth();
|
|
152
|
+
const navigate = useNavigate();
|
|
153
|
+
const [, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
154
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
155
|
+
|
|
156
|
+
const form = useForm({
|
|
157
|
+
defaultValues: {new_password: ''},
|
|
158
|
+
onSubmit: async ({value}) => {
|
|
159
|
+
setFormError(undefined);
|
|
160
|
+
try {
|
|
161
|
+
const body = passwordResetConfirmBodySchema.parse({
|
|
162
|
+
token,
|
|
163
|
+
new_password: value.new_password,
|
|
164
|
+
});
|
|
165
|
+
await confirmPasswordReset.mutateAsync(body);
|
|
166
|
+
await refreshAuth();
|
|
167
|
+
setAuthFormDraft(initialAuthFormDraft);
|
|
168
|
+
toast.success('Your password has been changed. You are now logged in.');
|
|
169
|
+
await navigate({to: '/', replace: true});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const mapped = passwordResetConfirmErrorToFormError(error);
|
|
172
|
+
setFormError(mapped.message);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<AuthShell title="Set a new password" description="Choose a password for your Shipfox account.">
|
|
179
|
+
<form
|
|
180
|
+
className="flex flex-col gap-18"
|
|
181
|
+
noValidate
|
|
182
|
+
onSubmit={(event) => {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopPropagation();
|
|
185
|
+
void form.handleSubmit();
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{formError ? (
|
|
189
|
+
<Callout role="alert" type="error">
|
|
190
|
+
{formError}
|
|
191
|
+
</Callout>
|
|
192
|
+
) : null}
|
|
193
|
+
<form.Field
|
|
194
|
+
name="new_password"
|
|
195
|
+
validators={{
|
|
196
|
+
onBlur: passwordResetConfirmBodySchema.shape.new_password,
|
|
197
|
+
onSubmit: passwordResetConfirmBodySchema.shape.new_password,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{(field) => (
|
|
201
|
+
<FormField label="New password" id="new-password" error={fieldError(field)}>
|
|
202
|
+
<FormFieldInput
|
|
203
|
+
autoComplete="new-password"
|
|
204
|
+
name="new_password"
|
|
205
|
+
type="password"
|
|
206
|
+
value={field.state.value}
|
|
207
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
208
|
+
onBlur={field.handleBlur}
|
|
209
|
+
/>
|
|
210
|
+
</FormField>
|
|
211
|
+
)}
|
|
212
|
+
</form.Field>
|
|
213
|
+
<Button className="w-full" isLoading={confirmPasswordReset.isPending} type="submit">
|
|
214
|
+
{confirmPasswordReset.isPending ? 'Updating password...' : 'Update password'}
|
|
215
|
+
</Button>
|
|
216
|
+
</form>
|
|
217
|
+
<ButtonLink asChild variant="subtle" className="self-center">
|
|
218
|
+
<Link to="/auth/login">Back to login</Link>
|
|
219
|
+
</ButtonLink>
|
|
220
|
+
</AuthShell>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {act, fireEvent, screen} from '@testing-library/react';
|
|
3
|
+
import {pageUserFactory} from '#test/factories/user.js';
|
|
4
|
+
import {renderAuthPage} from '#test/pages.js';
|
|
5
|
+
import {jsonResponse, requestUrl} from '#test/utils.js';
|
|
6
|
+
import {SignupPage} from './signup-page.js';
|
|
7
|
+
|
|
8
|
+
const SUBMITTED_EMAIL_RE = /new@example.com/;
|
|
9
|
+
const RESEND_COUNTDOWN_RE = /^Resend in \d+s$/;
|
|
10
|
+
|
|
11
|
+
describe('SignupPage', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('shows check-email state after success', async () => {
|
|
18
|
+
const user = pageUserFactory.build({email: 'new@example.com', name: 'New User'});
|
|
19
|
+
let signupBody: unknown;
|
|
20
|
+
const fetchImpl = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
|
21
|
+
const url = requestUrl(input);
|
|
22
|
+
if (url === 'https://api.example.test/auth/me') {
|
|
23
|
+
return jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401});
|
|
24
|
+
}
|
|
25
|
+
if (url === 'https://api.example.test/auth/signup' && input instanceof Request) {
|
|
26
|
+
signupBody = await input.json();
|
|
27
|
+
return jsonResponse({user}, {status: 201});
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
30
|
+
});
|
|
31
|
+
configureApiClient({fetchImpl});
|
|
32
|
+
|
|
33
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
34
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: ' New User '}});
|
|
35
|
+
fireEvent.change(screen.getByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
36
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
37
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
38
|
+
|
|
39
|
+
expect(await screen.findByRole('heading', {name: 'Check your email'})).toBeInTheDocument();
|
|
40
|
+
expect(signupBody).toEqual({
|
|
41
|
+
email: 'new@example.com',
|
|
42
|
+
password: 'long secure password',
|
|
43
|
+
name: 'New User',
|
|
44
|
+
});
|
|
45
|
+
expect(screen.getByText(SUBMITTED_EMAIL_RE)).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByRole('button', {name: RESEND_COUNTDOWN_RE})).toHaveAttribute(
|
|
47
|
+
'aria-disabled',
|
|
48
|
+
'true',
|
|
49
|
+
);
|
|
50
|
+
expect(screen.getByRole('button', {name: 'Use another email'})).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByRole('link', {name: 'Log in'})).toHaveAttribute('href', '/auth/login');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('keeps credentials when switching to login', async () => {
|
|
55
|
+
const fetchImpl = vi
|
|
56
|
+
.fn()
|
|
57
|
+
.mockResolvedValue(
|
|
58
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
59
|
+
);
|
|
60
|
+
configureApiClient({fetchImpl});
|
|
61
|
+
|
|
62
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
63
|
+
fireEvent.change(await screen.findByLabelText('Email'), {
|
|
64
|
+
target: {value: 'existing@example.com'},
|
|
65
|
+
});
|
|
66
|
+
fireEvent.change(screen.getByLabelText('Password'), {
|
|
67
|
+
target: {value: 'long secure password'},
|
|
68
|
+
});
|
|
69
|
+
fireEvent.click(screen.getByRole('link', {name: 'Log in'}));
|
|
70
|
+
|
|
71
|
+
expect(await screen.findByRole('heading', {name: 'Connect to Shipfox'})).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByLabelText('Email')).toHaveValue('existing@example.com');
|
|
73
|
+
expect(screen.getByLabelText('Password')).toHaveValue('long secure password');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('preserves invitation state when switching to login', async () => {
|
|
77
|
+
const invitationToken = 'invite-token';
|
|
78
|
+
const redirect = `/invitations/accept?token=${encodeURIComponent(invitationToken)}`;
|
|
79
|
+
const fetchImpl = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
80
|
+
const url = requestUrl(input);
|
|
81
|
+
if (url === 'https://api.example.test/auth/me') {
|
|
82
|
+
return Promise.resolve(
|
|
83
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (url.includes('/invitations/preview?')) {
|
|
87
|
+
return Promise.resolve(
|
|
88
|
+
jsonResponse({
|
|
89
|
+
status: 'pending',
|
|
90
|
+
workspace_id: 'workspace-1',
|
|
91
|
+
workspace_name: 'Invite Workspace',
|
|
92
|
+
email: 'invitee@example.com',
|
|
93
|
+
invited_by_display: 'owner@example.com',
|
|
94
|
+
expires_at: '2026-05-18T00:00:00.000Z',
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
99
|
+
});
|
|
100
|
+
configureApiClient({fetchImpl});
|
|
101
|
+
|
|
102
|
+
renderAuthPage(`/auth/signup?redirect=${encodeURIComponent(redirect)}`, <SignupPage />);
|
|
103
|
+
await screen.findByRole('heading', {name: 'Join Invite Workspace'});
|
|
104
|
+
|
|
105
|
+
expect(screen.getByRole('link', {name: 'Log in'})).toHaveAttribute(
|
|
106
|
+
'href',
|
|
107
|
+
`/auth/login?redirect=${encodeURIComponent(redirect)}`,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('surfaces duplicate-email errors', async () => {
|
|
112
|
+
const fetchImpl = vi
|
|
113
|
+
.fn()
|
|
114
|
+
.mockResolvedValueOnce(
|
|
115
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
116
|
+
)
|
|
117
|
+
.mockResolvedValueOnce(
|
|
118
|
+
jsonResponse(
|
|
119
|
+
{code: 'email-already-exists', message: 'Email already exists'},
|
|
120
|
+
{status: 409},
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
configureApiClient({fetchImpl});
|
|
124
|
+
|
|
125
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
126
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
127
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
128
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
129
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
130
|
+
|
|
131
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Email already exists');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('validates the name locally', async () => {
|
|
135
|
+
const fetchImpl = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValue(
|
|
138
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
139
|
+
);
|
|
140
|
+
configureApiClient({fetchImpl});
|
|
141
|
+
|
|
142
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
143
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New\u202eUser'}});
|
|
144
|
+
fireEvent.change(screen.getByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
145
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
146
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
147
|
+
|
|
148
|
+
expect(
|
|
149
|
+
await screen.findByText(
|
|
150
|
+
'Name cannot include line breaks, tabs, or hidden formatting characters.',
|
|
151
|
+
),
|
|
152
|
+
).toBeInTheDocument();
|
|
153
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('requires the name locally', async () => {
|
|
157
|
+
const fetchImpl = vi
|
|
158
|
+
.fn()
|
|
159
|
+
.mockResolvedValue(
|
|
160
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
161
|
+
);
|
|
162
|
+
configureApiClient({fetchImpl});
|
|
163
|
+
|
|
164
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
165
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
166
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
167
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
168
|
+
|
|
169
|
+
expect(await screen.findByText('Name is required.')).toBeInTheDocument();
|
|
170
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('enables resend after the signup cooldown expires', async () => {
|
|
174
|
+
vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']});
|
|
175
|
+
const clearIntervalSpy = vi.spyOn(window, 'clearInterval');
|
|
176
|
+
const user = pageUserFactory.build({email: 'new@example.com', name: 'New User'});
|
|
177
|
+
const fetchImpl = vi
|
|
178
|
+
.fn()
|
|
179
|
+
.mockResolvedValueOnce(
|
|
180
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
181
|
+
)
|
|
182
|
+
.mockResolvedValueOnce(jsonResponse({user}, {status: 201}));
|
|
183
|
+
configureApiClient({fetchImpl});
|
|
184
|
+
|
|
185
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
186
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
187
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
188
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
189
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
190
|
+
expect(await screen.findByRole('button', {name: RESEND_COUNTDOWN_RE})).toHaveAttribute(
|
|
191
|
+
'aria-disabled',
|
|
192
|
+
'true',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await act(async () => {
|
|
196
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(screen.getByRole('button', {name: 'Resend verification email'})).not.toHaveAttribute(
|
|
200
|
+
'aria-disabled',
|
|
201
|
+
);
|
|
202
|
+
expect(clearIntervalSpy).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('resends verification email and restarts cooldown from the server response', async () => {
|
|
206
|
+
vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']});
|
|
207
|
+
const user = pageUserFactory.build({email: 'new@example.com', name: 'New User'});
|
|
208
|
+
const nextResendAvailableAt = new Date(Date.now() + 120_000).toISOString();
|
|
209
|
+
const fetchImpl = vi
|
|
210
|
+
.fn()
|
|
211
|
+
.mockResolvedValueOnce(
|
|
212
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
213
|
+
)
|
|
214
|
+
.mockResolvedValueOnce(jsonResponse({user}, {status: 201}))
|
|
215
|
+
.mockResolvedValueOnce(
|
|
216
|
+
jsonResponse({next_resend_available_at: nextResendAvailableAt}, {status: 200}),
|
|
217
|
+
);
|
|
218
|
+
configureApiClient({fetchImpl});
|
|
219
|
+
|
|
220
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
221
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
222
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
223
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
224
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
225
|
+
await screen.findByRole('heading', {name: 'Check your email'});
|
|
226
|
+
await act(async () => {
|
|
227
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
228
|
+
});
|
|
229
|
+
fireEvent.click(screen.getByRole('button', {name: 'Resend verification email'}));
|
|
230
|
+
|
|
231
|
+
expect(
|
|
232
|
+
await screen.findByText('If another verification email can be sent, it will arrive shortly.'),
|
|
233
|
+
).toBeInTheDocument();
|
|
234
|
+
expect(screen.getByRole('button', {name: RESEND_COUNTDOWN_RE})).toHaveAttribute(
|
|
235
|
+
'aria-disabled',
|
|
236
|
+
'true',
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('surfaces resend failures without leaving the check-email state', async () => {
|
|
241
|
+
vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']});
|
|
242
|
+
const user = pageUserFactory.build({email: 'new@example.com', name: 'New User'});
|
|
243
|
+
const fetchImpl = vi
|
|
244
|
+
.fn()
|
|
245
|
+
.mockResolvedValueOnce(
|
|
246
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
247
|
+
)
|
|
248
|
+
.mockResolvedValueOnce(jsonResponse({user}, {status: 201}))
|
|
249
|
+
.mockResolvedValueOnce(
|
|
250
|
+
jsonResponse({code: 'server-error', message: 'Server error'}, {status: 500}),
|
|
251
|
+
);
|
|
252
|
+
configureApiClient({fetchImpl});
|
|
253
|
+
|
|
254
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
255
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
256
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
257
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
258
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
259
|
+
await screen.findByRole('heading', {name: 'Check your email'});
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
262
|
+
});
|
|
263
|
+
fireEvent.click(screen.getByRole('button', {name: 'Resend verification email'}));
|
|
264
|
+
|
|
265
|
+
expect(await screen.findByText('Server error')).toBeInTheDocument();
|
|
266
|
+
expect(screen.getByRole('heading', {name: 'Check your email'})).toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('returns to the signup form when choosing another email', async () => {
|
|
270
|
+
const user = pageUserFactory.build({email: 'new@example.com', name: 'New User'});
|
|
271
|
+
const fetchImpl = vi
|
|
272
|
+
.fn()
|
|
273
|
+
.mockResolvedValueOnce(
|
|
274
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
275
|
+
)
|
|
276
|
+
.mockResolvedValueOnce(jsonResponse({user}, {status: 201}));
|
|
277
|
+
configureApiClient({fetchImpl});
|
|
278
|
+
|
|
279
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
280
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
281
|
+
fireEvent.change(await screen.findByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
282
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
283
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
284
|
+
await screen.findByRole('heading', {name: 'Check your email'});
|
|
285
|
+
fireEvent.click(screen.getByRole('button', {name: 'Use another email'}));
|
|
286
|
+
|
|
287
|
+
expect(screen.getByRole('heading', {name: 'Create your Shipfox account'})).toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
});
|