@shipfox/client-auth 4.0.0 → 6.0.1
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +56 -0
- package/dist/components/auth-guard.d.ts.map +1 -1
- package/dist/components/auth-guard.js +4 -4
- package/dist/components/auth-guard.js.map +1 -1
- package/dist/components/email-code-verification.d.ts.map +1 -1
- package/dist/components/email-code-verification.js +1 -1
- package/dist/components/email-code-verification.js.map +1 -1
- package/dist/components/redirect-target.d.ts +7 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +35 -4
- package/dist/components/redirect-target.js.map +1 -1
- package/dist/components/workspace-switcher.stories.d.ts +1 -1
- package/dist/continuation.d.ts +1 -1
- package/dist/continuation.d.ts.map +1 -1
- package/dist/continuation.js +1 -1
- package/dist/continuation.js.map +1 -1
- package/dist/core/auth.d.ts +57 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +3 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/hooks/api/auth-mapper.d.ts +6 -0
- package/dist/hooks/api/auth-mapper.d.ts.map +1 -0
- package/dist/hooks/api/auth-mapper.js +25 -0
- package/dist/hooks/api/auth-mapper.js.map +1 -0
- package/dist/hooks/api/login-auth.d.ts +3 -28
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +7 -4
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +2 -2
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts +5 -32
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +13 -7
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/refresh-auth.d.ts +1 -1
- package/dist/hooks/api/refresh-auth.d.ts.map +1 -1
- package/dist/hooks/api/refresh-auth.js +1 -1
- package/dist/hooks/api/refresh-auth.js.map +1 -1
- package/dist/hooks/api/signup-auth.d.ts +2 -30
- package/dist/hooks/api/signup-auth.d.ts.map +1 -1
- package/dist/hooks/api/signup-auth.js +14 -4
- package/dist/hooks/api/signup-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts +4 -21
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +20 -7
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +5 -21
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +11 -6
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.d.ts +4 -0
- package/dist/hooks/api/workspace-mapper.d.ts.map +1 -0
- package/dist/hooks/api/workspace-mapper.js +9 -0
- package/dist/hooks/api/workspace-mapper.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/invitation-context.d.ts +9 -14
- package/dist/pages/invitation-context.d.ts.map +1 -1
- package/dist/pages/invitation-context.js +4 -27
- package/dist/pages/invitation-context.js.map +1 -1
- package/dist/pages/login-page.d.ts.map +1 -1
- package/dist/pages/login-page.js +5 -6
- package/dist/pages/login-page.js.map +1 -1
- package/dist/pages/logout-page.d.ts.map +1 -1
- package/dist/pages/logout-page.js +8 -18
- package/dist/pages/logout-page.js.map +1 -1
- package/dist/pages/password-reset-page.d.ts.map +1 -1
- package/dist/pages/password-reset-page.js +8 -10
- package/dist/pages/password-reset-page.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +24 -18
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +2 -2
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/inputs.d.ts +9 -0
- package/dist/routes/inputs.d.ts.map +1 -0
- package/dist/routes/inputs.js +17 -0
- package/dist/routes/inputs.js.map +1 -0
- package/dist/routes/login.d.ts +2 -0
- package/dist/routes/login.d.ts.map +1 -1
- package/dist/routes/login.js +2 -0
- package/dist/routes/login.js.map +1 -1
- package/dist/routes/logout.d.ts +2 -0
- package/dist/routes/logout.d.ts.map +1 -1
- package/dist/routes/logout.js +2 -0
- package/dist/routes/logout.js.map +1 -1
- package/dist/routes/reset.d.ts +2 -0
- package/dist/routes/reset.d.ts.map +1 -1
- package/dist/routes/reset.js +2 -0
- package/dist/routes/reset.js.map +1 -1
- package/dist/routes/signup.d.ts +2 -0
- package/dist/routes/signup.d.ts.map +1 -1
- package/dist/routes/signup.js +2 -0
- package/dist/routes/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -11
- package/src/components/auth-guard.tsx +4 -2
- package/src/components/email-code-verification.tsx +1 -1
- package/src/components/redirect-target.test.ts +32 -1
- package/src/components/redirect-target.ts +41 -4
- package/src/continuation.ts +1 -1
- package/src/core/auth.ts +55 -0
- package/src/hooks/api/auth-mapper.test.ts +77 -0
- package/src/hooks/api/auth-mapper.ts +29 -0
- package/src/hooks/api/login-auth.test.ts +1 -1
- package/src/hooks/api/login-auth.ts +10 -4
- package/src/hooks/api/logout-auth.ts +2 -2
- package/src/hooks/api/password-reset-auth.test.ts +3 -3
- package/src/hooks/api/password-reset-auth.ts +19 -13
- package/src/hooks/api/refresh-auth.ts +1 -0
- package/src/hooks/api/signup-auth.ts +15 -4
- package/src/hooks/api/verify-email-auth.ts +32 -16
- package/src/hooks/api/workspace-auth.ts +16 -6
- package/src/hooks/api/workspace-mapper.test.ts +21 -0
- package/src/hooks/api/workspace-mapper.ts +6 -0
- package/src/index.ts +0 -1
- package/src/pages/invitation-context.ts +8 -31
- package/src/pages/login-page.test.tsx +1 -1
- package/src/pages/login-page.tsx +5 -4
- package/src/pages/logout-page.test.tsx +75 -0
- package/src/pages/logout-page.tsx +9 -13
- package/src/pages/password-reset-page.tsx +5 -8
- package/src/pages/signup-page.test.tsx +1 -1
- package/src/pages/signup-page.tsx +22 -16
- package/src/pages/workspace-onboarding-page.tsx +2 -2
- package/src/routes/inputs.test.ts +17 -0
- package/src/routes/inputs.ts +21 -0
- package/src/routes/login.tsx +2 -0
- package/src/routes/logout.tsx +2 -1
- package/src/routes/reset.tsx +2 -0
- package/src/routes/signup.tsx +2 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vercel.json +5 -0
- package/dist/components/auth-shell.d.ts +0 -13
- package/dist/components/auth-shell.d.ts.map +0 -1
- package/dist/components/auth-shell.js +0 -68
- package/dist/components/auth-shell.js.map +0 -1
- package/src/components/auth-shell.tsx +0 -68
|
@@ -1,39 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {useQuery} from '@tanstack/react-query';
|
|
1
|
+
import {
|
|
2
|
+
type InvitationPreview,
|
|
3
|
+
pendingInvitation,
|
|
4
|
+
usePreviewInvitation,
|
|
5
|
+
} from '@shipfox/client-invitations';
|
|
7
6
|
import {parseRedirectContext} from '#/components/redirect-context.js';
|
|
8
7
|
|
|
9
8
|
export function extractInvitationToken(redirect: unknown): string | undefined {
|
|
10
9
|
return parseRedirectContext(redirect).invitationToken;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
async function fetchPreview(token: string): Promise<PreviewInvitationResponseDto> {
|
|
14
|
-
const params = new URLSearchParams({token});
|
|
15
|
-
return await apiRequest<PreviewInvitationResponseDto>(
|
|
16
|
-
`/invitations/preview?${params.toString()}`,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function pendingInvitation(
|
|
21
|
-
data: PreviewInvitationResponseDto | undefined,
|
|
22
|
-
): PreviewInvitationPendingDto | undefined {
|
|
23
|
-
return data?.status === 'pending' ? data : undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Mirrors the hook in `@shipfox/client-invitations` but lives in `client-auth`
|
|
28
|
-
* to avoid a circular dependency. Signup/login pages call this to lock the
|
|
29
|
-
* email field when arriving from an invitation link.
|
|
30
|
-
*/
|
|
31
12
|
export function useInvitationContext(token: string | undefined) {
|
|
32
|
-
return
|
|
33
|
-
queryKey: ['invitations', 'preview', token ?? ''] as const,
|
|
34
|
-
queryFn: () => fetchPreview(token as string),
|
|
35
|
-
enabled: Boolean(token),
|
|
36
|
-
retry: false,
|
|
37
|
-
staleTime: 30_000,
|
|
38
|
-
});
|
|
13
|
+
return usePreviewInvitation(token);
|
|
39
14
|
}
|
|
15
|
+
|
|
16
|
+
export {type InvitationPreview, pendingInvitation};
|
|
@@ -70,7 +70,7 @@ describe('LoginPage', () => {
|
|
|
70
70
|
return Promise.resolve(
|
|
71
71
|
jsonResponse({
|
|
72
72
|
status: 'pending',
|
|
73
|
-
workspace_id: '
|
|
73
|
+
workspace_id: '11111111-1111-4111-8111-111111111111',
|
|
74
74
|
workspace_name: 'Invite Workspace',
|
|
75
75
|
email: 'invitee@example.com',
|
|
76
76
|
invited_by_display: 'owner@example.com',
|
package/src/pages/login-page.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
2
3
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
3
4
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
5
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
5
6
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
6
7
|
import {Text} from '@shipfox/react-ui/typography';
|
|
7
8
|
import {useForm} from '@tanstack/react-form';
|
|
8
|
-
import {Link
|
|
9
|
+
import {Link} from '@tanstack/react-router';
|
|
9
10
|
import {useAtom} from 'jotai';
|
|
10
11
|
import {useEffect, useRef, useState} from 'react';
|
|
11
|
-
import {AuthShell} from '#/components/auth-shell.js';
|
|
12
12
|
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
13
13
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
14
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
14
15
|
import {loginErrorToFormError} from './form-errors.js';
|
|
15
16
|
import {
|
|
16
17
|
extractInvitationToken,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
|
|
21
22
|
export function LoginPage() {
|
|
22
23
|
const login = useLoginAuth();
|
|
23
|
-
const search =
|
|
24
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
24
25
|
const invitationToken = extractInvitationToken(search.redirect);
|
|
25
26
|
const invitationPreview = useInvitationContext(invitationToken);
|
|
26
27
|
const invitationPending = pendingInvitation(invitationPreview.data);
|
|
@@ -81,7 +82,7 @@ export function LoginPage() {
|
|
|
81
82
|
? `/invitations/accept?token=${encodeURIComponent(invitationToken)}`
|
|
82
83
|
: undefined;
|
|
83
84
|
const headerTitle = invitationPending
|
|
84
|
-
? `Join ${invitationPending.
|
|
85
|
+
? `Join ${invitationPending.workspaceName}`
|
|
85
86
|
: 'Connect to Shipfox';
|
|
86
87
|
const headerDescription = invitationPending
|
|
87
88
|
? 'Log in to accept your invitation.'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {AuthRuntime} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
createMemoryHistory,
|
|
6
|
+
createRootRoute,
|
|
7
|
+
createRoute,
|
|
8
|
+
createRouter,
|
|
9
|
+
Outlet,
|
|
10
|
+
RouterProvider,
|
|
11
|
+
} from '@tanstack/react-router';
|
|
12
|
+
import {render, screen} from '@testing-library/react';
|
|
13
|
+
import {Provider as JotaiProvider} from 'jotai';
|
|
14
|
+
import {LogoutPage} from './logout-page.js';
|
|
15
|
+
|
|
16
|
+
function renderLogoutPage(path: string) {
|
|
17
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
18
|
+
const logoutRoute = createRoute({
|
|
19
|
+
getParentRoute: () => rootRoute,
|
|
20
|
+
path: '/auth/logout',
|
|
21
|
+
component: LogoutPage,
|
|
22
|
+
});
|
|
23
|
+
const loginRoute = createRoute({
|
|
24
|
+
getParentRoute: () => rootRoute,
|
|
25
|
+
path: '/auth/login',
|
|
26
|
+
component: () => <h1>Login destination</h1>,
|
|
27
|
+
});
|
|
28
|
+
const workspaceRoute = createRoute({
|
|
29
|
+
getParentRoute: () => rootRoute,
|
|
30
|
+
path: '/workspaces/$wid',
|
|
31
|
+
component: () => <h1>Workspace destination</h1>,
|
|
32
|
+
});
|
|
33
|
+
const router = createRouter({
|
|
34
|
+
history: createMemoryHistory({initialEntries: [path]}),
|
|
35
|
+
routeTree: rootRoute.addChildren([logoutRoute, loginRoute, workspaceRoute]),
|
|
36
|
+
});
|
|
37
|
+
const queryClient = new QueryClient();
|
|
38
|
+
|
|
39
|
+
render(
|
|
40
|
+
<QueryClientProvider client={queryClient}>
|
|
41
|
+
<JotaiProvider>
|
|
42
|
+
<AuthRuntime effects={false}>
|
|
43
|
+
<RouterProvider router={router} />
|
|
44
|
+
</AuthRuntime>
|
|
45
|
+
</JotaiProvider>
|
|
46
|
+
</QueryClientProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('LogoutPage', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('logs out before following a safe same-origin redirect', async () => {
|
|
56
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, {status: 204}));
|
|
57
|
+
configureApiClient({fetchImpl});
|
|
58
|
+
|
|
59
|
+
renderLogoutPage('/auth/logout?redirect=%2Fworkspaces%2Facme');
|
|
60
|
+
|
|
61
|
+
expect(await screen.findByRole('heading', {name: 'Workspace destination'})).toBeVisible();
|
|
62
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
63
|
+
expect(request.url).toBe('https://api.example.test/auth/logout');
|
|
64
|
+
expect(request.method).toBe('POST');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('falls back to login without forwarding an invitation token', async () => {
|
|
68
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, {status: 204}));
|
|
69
|
+
configureApiClient({fetchImpl});
|
|
70
|
+
|
|
71
|
+
renderLogoutPage('/auth/logout?redirect=%2Finvitations%2Faccept%3Ftoken%3Dsf_i_raw-token');
|
|
72
|
+
|
|
73
|
+
expect(await screen.findByRole('heading', {name: 'Login destination'})).toBeVisible();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -1,27 +1,23 @@
|
|
|
1
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
1
2
|
import {Text} from '@shipfox/react-ui/typography';
|
|
2
|
-
import {
|
|
3
|
+
import {useRouter} from '@tanstack/react-router';
|
|
3
4
|
import {useEffect} from 'react';
|
|
4
|
-
import {
|
|
5
|
-
import {sanitizeRedirectPath} from '#/components/redirect-target.js';
|
|
5
|
+
import {sanitizeLogoutRedirectPath} from '#/components/redirect-target.js';
|
|
6
6
|
import {useLogoutAuth} from '#hooks/api/logout-auth.js';
|
|
7
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
7
8
|
|
|
8
9
|
export function LogoutPage() {
|
|
9
10
|
const logout = useLogoutAuth();
|
|
10
|
-
const navigate = useNavigate();
|
|
11
11
|
const router = useRouter();
|
|
12
|
-
const search =
|
|
13
|
-
const target =
|
|
12
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
13
|
+
const target = sanitizeLogoutRedirectPath(search.redirect);
|
|
14
14
|
|
|
15
15
|
useEffect(() => {
|
|
16
16
|
logout.mutateAsync().finally(() => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
router.history.replace(target);
|
|
20
|
-
} else {
|
|
21
|
-
navigate({to: '/auth/login', replace: true});
|
|
22
|
-
}
|
|
17
|
+
// Same-origin runtime path; let history drive route resolution.
|
|
18
|
+
router.history.replace(target);
|
|
23
19
|
});
|
|
24
|
-
}, [logout.mutateAsync,
|
|
20
|
+
}, [logout.mutateAsync, router, target]);
|
|
25
21
|
|
|
26
22
|
return (
|
|
27
23
|
<AuthShell title="Logging out" description="Ending your Shipfox session.">
|
|
@@ -2,30 +2,29 @@ import {
|
|
|
2
2
|
passwordResetConfirmBodySchema,
|
|
3
3
|
passwordResetRequestBodySchema,
|
|
4
4
|
} from '@shipfox/api-auth-dto';
|
|
5
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
5
6
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
6
7
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
7
8
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
8
9
|
import {toast} from '@shipfox/react-ui/toast';
|
|
9
10
|
import {Text} from '@shipfox/react-ui/typography';
|
|
10
11
|
import {useForm} from '@tanstack/react-form';
|
|
11
|
-
import {Link, useNavigate
|
|
12
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
12
13
|
import {useAtom} from 'jotai';
|
|
13
14
|
import {useEffect, useRef, useState} from 'react';
|
|
14
|
-
import {AuthShell} from '#/components/auth-shell.js';
|
|
15
15
|
import {
|
|
16
16
|
useConfirmPasswordResetAuth,
|
|
17
17
|
useRequestPasswordResetAuth,
|
|
18
18
|
} from '#hooks/api/password-reset-auth.js';
|
|
19
|
-
import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
|
|
20
19
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
20
|
+
import {validatePasswordResetSearch} from '../routes/inputs.js';
|
|
21
21
|
import {
|
|
22
22
|
passwordResetConfirmErrorToFormError,
|
|
23
23
|
passwordResetRequestErrorToFormError,
|
|
24
24
|
} from './form-errors.js';
|
|
25
25
|
|
|
26
26
|
export function PasswordResetPage() {
|
|
27
|
-
const
|
|
28
|
-
const token = typeof search.token === 'string' ? search.token : undefined;
|
|
27
|
+
const {token} = useRouteSearch(validatePasswordResetSearch);
|
|
29
28
|
|
|
30
29
|
if (token) {
|
|
31
30
|
return <PasswordResetConfirm token={token} />;
|
|
@@ -148,7 +147,6 @@ function PasswordResetRequest() {
|
|
|
148
147
|
|
|
149
148
|
function PasswordResetConfirm({token}: {token: string}) {
|
|
150
149
|
const confirmPasswordReset = useConfirmPasswordResetAuth();
|
|
151
|
-
const refreshAuth = useRefreshAuth();
|
|
152
150
|
const navigate = useNavigate();
|
|
153
151
|
const [, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
154
152
|
const [formError, setFormError] = useState<string | undefined>();
|
|
@@ -162,8 +160,7 @@ function PasswordResetConfirm({token}: {token: string}) {
|
|
|
162
160
|
token,
|
|
163
161
|
new_password: value.new_password,
|
|
164
162
|
});
|
|
165
|
-
await confirmPasswordReset.mutateAsync(body);
|
|
166
|
-
await refreshAuth();
|
|
163
|
+
await confirmPasswordReset.mutateAsync({token: body.token, newPassword: body.new_password});
|
|
167
164
|
setAuthFormDraft(initialAuthFormDraft);
|
|
168
165
|
toast.success('Your password has been changed. You are now logged in.');
|
|
169
166
|
await navigate({to: '/', replace: true});
|
|
@@ -137,7 +137,7 @@ describe('SignupPage', () => {
|
|
|
137
137
|
return Promise.resolve(
|
|
138
138
|
jsonResponse({
|
|
139
139
|
status: 'pending',
|
|
140
|
-
workspace_id: '
|
|
140
|
+
workspace_id: '11111111-1111-4111-8111-111111111111',
|
|
141
141
|
workspace_name: 'Invite Workspace',
|
|
142
142
|
email: 'invitee@example.com',
|
|
143
143
|
invited_by_display: 'owner@example.com',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {signupBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
2
3
|
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
3
4
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
4
5
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
@@ -7,15 +8,15 @@ import {Icon} from '@shipfox/react-ui/icon';
|
|
|
7
8
|
import {toast} from '@shipfox/react-ui/toast';
|
|
8
9
|
import {Text} from '@shipfox/react-ui/typography';
|
|
9
10
|
import {useForm} from '@tanstack/react-form';
|
|
10
|
-
import {Link, useNavigate
|
|
11
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
11
12
|
import {useAtom} from 'jotai';
|
|
12
13
|
import {useEffect, useRef, useState} from 'react';
|
|
13
|
-
import {AuthShell} from '#/components/auth-shell.js';
|
|
14
14
|
import {EmailCodeVerification} from '#/components/email-code-verification.js';
|
|
15
15
|
import {useRefreshAuth} from '#hooks/api/refresh-auth.js';
|
|
16
16
|
import {useSignupAuth} from '#hooks/api/signup-auth.js';
|
|
17
17
|
import {useResendEmailVerificationAuth, useVerifyEmailAuth} from '#hooks/api/verify-email-auth.js';
|
|
18
18
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
19
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
19
20
|
import {signupErrorToFormError} from './form-errors.js';
|
|
20
21
|
import {authErrorMessage} from './form-utils.js';
|
|
21
22
|
import {
|
|
@@ -30,7 +31,7 @@ export function SignupPage() {
|
|
|
30
31
|
const resendEmailVerification = useResendEmailVerificationAuth();
|
|
31
32
|
const refreshAuth = useRefreshAuth();
|
|
32
33
|
const navigate = useNavigate();
|
|
33
|
-
const search =
|
|
34
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
34
35
|
const invitationToken = extractInvitationToken(search.redirect);
|
|
35
36
|
const invitationPreview = useInvitationContext(invitationToken);
|
|
36
37
|
const invitationPending = pendingInvitation(invitationPreview.data);
|
|
@@ -50,13 +51,18 @@ export function SignupPage() {
|
|
|
50
51
|
onSubmit: async ({value}) => {
|
|
51
52
|
setFormError(undefined);
|
|
52
53
|
try {
|
|
53
|
-
const
|
|
54
|
+
const parsed = signupBodySchema.parse({
|
|
54
55
|
email: value.email,
|
|
55
56
|
password: value.password,
|
|
56
57
|
name: value.name,
|
|
57
58
|
...(invitationToken ? {invitation_token: invitationToken} : {}),
|
|
58
59
|
});
|
|
59
|
-
const result = await signup.mutateAsync(
|
|
60
|
+
const result = await signup.mutateAsync({
|
|
61
|
+
email: parsed.email,
|
|
62
|
+
password: parsed.password,
|
|
63
|
+
name: parsed.name,
|
|
64
|
+
...(parsed.invitation_token ? {invitationToken: parsed.invitation_token} : {}),
|
|
65
|
+
});
|
|
60
66
|
skipDraftPersistRef.current = true;
|
|
61
67
|
setAuthFormDraft(initialAuthFormDraft);
|
|
62
68
|
|
|
@@ -67,16 +73,16 @@ export function SignupPage() {
|
|
|
67
73
|
// Refresh failures don't block the success message — the next API
|
|
68
74
|
// call's 401 handling will re-route the user.
|
|
69
75
|
}
|
|
70
|
-
toast.success(`You joined ${invitationPending.
|
|
76
|
+
toast.success(`You joined ${invitationPending.workspaceName}.`);
|
|
71
77
|
await navigate({
|
|
72
78
|
to: '/workspaces/$wid',
|
|
73
|
-
params: {wid: result.membership.
|
|
79
|
+
params: {wid: result.membership.workspaceId},
|
|
74
80
|
});
|
|
75
81
|
return;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
if (invitationToken && result.
|
|
79
|
-
toast.error(result.
|
|
84
|
+
if (invitationToken && result.acceptError) {
|
|
85
|
+
toast.error(result.acceptError.message);
|
|
80
86
|
await navigate({
|
|
81
87
|
to: '/invitations/accept',
|
|
82
88
|
search: {token: invitationToken},
|
|
@@ -84,12 +90,12 @@ export function SignupPage() {
|
|
|
84
90
|
return;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
if (!result.
|
|
93
|
+
if (!result.emailChallenge) {
|
|
88
94
|
throw new Error('Signup did not return an email verification challenge');
|
|
89
95
|
}
|
|
90
|
-
setEmailChallenge({email: result.user.email, id: result.
|
|
96
|
+
setEmailChallenge({email: result.user.email, id: result.emailChallenge.id});
|
|
91
97
|
setResendError(undefined);
|
|
92
|
-
setNextResendAvailableAt(result.
|
|
98
|
+
setNextResendAvailableAt(result.emailChallenge.nextResendAvailableAt);
|
|
93
99
|
} catch (error) {
|
|
94
100
|
const mapped = signupErrorToFormError(error);
|
|
95
101
|
if (mapped.kind === 'field') {
|
|
@@ -132,9 +138,9 @@ export function SignupPage() {
|
|
|
132
138
|
try {
|
|
133
139
|
const result = await resendEmailVerification.mutateAsync({
|
|
134
140
|
email: emailChallenge.email,
|
|
135
|
-
|
|
141
|
+
challengeId: emailChallenge.id,
|
|
136
142
|
});
|
|
137
|
-
setNextResendAvailableAt(result.
|
|
143
|
+
setNextResendAvailableAt(result.nextResendAvailableAt);
|
|
138
144
|
toast.success('If another verification email can be sent, it will arrive shortly.');
|
|
139
145
|
} catch (error) {
|
|
140
146
|
setResendError(authErrorMessage(error));
|
|
@@ -154,7 +160,7 @@ export function SignupPage() {
|
|
|
154
160
|
try {
|
|
155
161
|
await verifyEmail.mutateAsync({
|
|
156
162
|
email: emailChallenge.email,
|
|
157
|
-
|
|
163
|
+
challengeId: emailChallenge.id,
|
|
158
164
|
code,
|
|
159
165
|
});
|
|
160
166
|
await refreshAuth();
|
|
@@ -181,7 +187,7 @@ export function SignupPage() {
|
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
const headerTitle = invitationPending
|
|
184
|
-
? `Join ${invitationPending.
|
|
190
|
+
? `Join ${invitationPending.workspaceName}`
|
|
185
191
|
: 'Create your Shipfox account';
|
|
186
192
|
const headerDescription = invitationPending
|
|
187
193
|
? `Create an account to accept your invitation.`
|
|
@@ -43,8 +43,8 @@ export function WorkspaceOnboardingPage() {
|
|
|
43
43
|
onSubmit: async ({value}) => {
|
|
44
44
|
setFormError(undefined);
|
|
45
45
|
try {
|
|
46
|
-
const
|
|
47
|
-
const created = await createWorkspace.mutateAsync(
|
|
46
|
+
const command = createWorkspaceBodySchema.parse({name: value.name});
|
|
47
|
+
const created = await createWorkspace.mutateAsync(command);
|
|
48
48
|
toast.success('Workspace created.');
|
|
49
49
|
// Pin the new workspace as the last-active one so a page refresh and
|
|
50
50
|
// future visits to `/` land on it.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {validatePasswordResetSearch, validateRedirectSearch} from './inputs.js';
|
|
2
|
+
|
|
3
|
+
describe('auth route inputs', () => {
|
|
4
|
+
it('keeps only a non-empty redirect', () => {
|
|
5
|
+
expect(validateRedirectSearch({redirect: '/workspaces/w-1'})).toEqual({
|
|
6
|
+
redirect: '/workspaces/w-1',
|
|
7
|
+
});
|
|
8
|
+
expect(validateRedirectSearch({redirect: ''})).toEqual({});
|
|
9
|
+
expect(validateRedirectSearch({redirect: ['unexpected']})).toEqual({});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('keeps only a non-empty password reset token', () => {
|
|
13
|
+
expect(validatePasswordResetSearch({token: 'reset-token'})).toEqual({token: 'reset-token'});
|
|
14
|
+
expect(validatePasswordResetSearch({token: ''})).toEqual({});
|
|
15
|
+
expect(validatePasswordResetSearch({token: {value: 'unexpected'}})).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface AuthRedirectSearch {
|
|
2
|
+
redirect?: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface PasswordResetSearch {
|
|
6
|
+
token?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateRedirectSearch(input: Record<string, unknown>): AuthRedirectSearch {
|
|
10
|
+
const redirect = nonEmptyString(input.redirect);
|
|
11
|
+
return redirect ? {redirect} : {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validatePasswordResetSearch(input: Record<string, unknown>): PasswordResetSearch {
|
|
15
|
+
const token = nonEmptyString(input.token);
|
|
16
|
+
return token ? {token} : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
20
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
21
|
+
}
|
package/src/routes/login.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
3
|
import {LoginPage} from '#pages/login-page.js';
|
|
4
|
+
import {validateRedirectSearch} from './inputs.js';
|
|
4
5
|
|
|
5
6
|
export default defineRoute({
|
|
7
|
+
validateSearch: validateRedirectSearch,
|
|
6
8
|
component: () => (
|
|
7
9
|
<GuestGuard>
|
|
8
10
|
<LoginPage />
|
package/src/routes/logout.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {LogoutPage} from '#pages/logout-page.js';
|
|
3
|
+
import {validateRedirectSearch} from './inputs.js';
|
|
3
4
|
|
|
4
|
-
export default defineRoute({component: LogoutPage});
|
|
5
|
+
export default defineRoute({validateSearch: validateRedirectSearch, component: LogoutPage});
|
package/src/routes/reset.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
3
|
import {PasswordResetPage} from '#pages/password-reset-page.js';
|
|
4
|
+
import {validatePasswordResetSearch} from './inputs.js';
|
|
4
5
|
|
|
5
6
|
export default defineRoute({
|
|
7
|
+
validateSearch: validatePasswordResetSearch,
|
|
6
8
|
component: () => (
|
|
7
9
|
<GuestGuard>
|
|
8
10
|
<PasswordResetPage />
|
package/src/routes/signup.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
3
|
import {SignupPage} from '#pages/signup-page.js';
|
|
4
|
+
import {validateRedirectSearch} from './inputs.js';
|
|
4
5
|
|
|
5
6
|
export default defineRoute({
|
|
7
|
+
validateSearch: validateRedirectSearch,
|
|
6
8
|
component: () => (
|
|
7
9
|
<GuestGuard>
|
|
8
10
|
<SignupPage />
|