@shipfox/client-auth 5.0.0 → 6.0.2
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 +57 -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/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.d.ts.map +1 -1
- package/dist/components/workspace-switcher.js +3 -2
- package/dist/components/workspace-switcher.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 +2 -17
- package/dist/hooks/api/auth-mapper.d.ts.map +1 -1
- package/dist/hooks/api/auth-mapper.js.map +1 -1
- package/dist/hooks/api/login-auth.d.ts +3 -6
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +3 -3
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +3 -3
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts +5 -10
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +10 -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 -6
- package/dist/hooks/api/signup-auth.d.ts.map +1 -1
- package/dist/hooks/api/signup-auth.js +9 -2
- package/dist/hooks/api/signup-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts +4 -10
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +16 -6
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +5 -7
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +6 -4
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.d.ts +1 -5
- package/dist/hooks/api/workspace-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workspace-mapper.js.map +1 -1
- package/dist/pages/login-page.d.ts.map +1 -1
- package/dist/pages/login-page.js +4 -5
- 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 -7
- 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 +16 -10
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +6 -3
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -3
- package/dist/routes/index.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 -12
- package/src/components/auth-guard.tsx +4 -2
- package/src/components/auth-provider.test.tsx +132 -19
- package/src/components/redirect-target.test.ts +32 -1
- package/src/components/redirect-target.ts +41 -4
- package/src/components/workspace-switcher.tsx +3 -2
- package/src/core/auth.ts +55 -0
- package/src/hooks/api/auth-mapper.ts +2 -12
- package/src/hooks/api/login-auth.ts +5 -4
- package/src/hooks/api/logout-auth.ts +3 -3
- package/src/hooks/api/password-reset-auth.test.ts +2 -2
- package/src/hooks/api/password-reset-auth.ts +11 -11
- package/src/hooks/api/refresh-auth.ts +1 -0
- package/src/hooks/api/signup-auth.ts +9 -3
- package/src/hooks/api/verify-email-auth.ts +20 -10
- package/src/hooks/api/workspace-auth.ts +10 -5
- package/src/hooks/api/workspace-mapper.ts +1 -6
- package/src/pages/login-page.tsx +4 -3
- 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 -5
- package/src/pages/signup-page.tsx +14 -8
- package/src/pages/workspace-onboarding-page.tsx +6 -3
- package/src/routes/index.tsx +5 -3
- 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/src/state/last-workspace.test.ts +21 -28
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vercel.json +5 -0
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type VerifyEmailConfirmBodyDto,
|
|
3
|
-
type VerifyEmailResendBodyDto,
|
|
4
2
|
verifyEmailConfirmResponseSchema,
|
|
5
3
|
verifyEmailResendResponseSchema,
|
|
6
4
|
} from '@shipfox/api-auth-dto';
|
|
7
5
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
8
6
|
import {useMutation} from '@tanstack/react-query';
|
|
7
|
+
import type {
|
|
8
|
+
EmailVerificationResendResult,
|
|
9
|
+
ResendEmailVerificationCommand,
|
|
10
|
+
VerifyEmailCommand,
|
|
11
|
+
} from '#core/auth.js';
|
|
9
12
|
import {useAuthTransition} from '#state/auth.js';
|
|
10
13
|
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
11
14
|
|
|
12
|
-
async function verifyEmailAuth(
|
|
15
|
+
async function verifyEmailAuth(command: VerifyEmailCommand) {
|
|
13
16
|
const response = await checkedApiRequest(
|
|
14
17
|
verifyEmailConfirmResponseSchema,
|
|
15
18
|
'/auth/verify-email/confirm',
|
|
16
19
|
{
|
|
17
20
|
method: 'POST',
|
|
18
|
-
body,
|
|
21
|
+
body: {email: command.email, challenge_id: command.challengeId, code: command.code},
|
|
19
22
|
},
|
|
20
23
|
);
|
|
21
24
|
return toAuthenticatedSession(response);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
async function resendEmailVerificationAuth(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
async function resendEmailVerificationAuth(command: ResendEmailVerificationCommand) {
|
|
28
|
+
const response = await checkedApiRequest(
|
|
29
|
+
verifyEmailResendResponseSchema,
|
|
30
|
+
'/auth/verify-email/resend',
|
|
31
|
+
{
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: {email: command.email, challenge_id: command.challengeId},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
nextResendAvailableAt: response.next_resend_available_at,
|
|
38
|
+
} satisfies EmailVerificationResendResult;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export function useResendEmailVerificationAuth() {
|
|
@@ -37,6 +47,6 @@ export function useVerifyEmailAuth() {
|
|
|
37
47
|
|
|
38
48
|
return useMutation({
|
|
39
49
|
mutationFn: verifyEmailAuth,
|
|
40
|
-
onSuccess: enterAuthenticated,
|
|
50
|
+
onSuccess: (session) => enterAuthenticated(session),
|
|
41
51
|
});
|
|
42
52
|
}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
2
2
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
listUserWorkspaces,
|
|
5
|
+
userWorkspacesQueryKey,
|
|
6
|
+
userWorkspacesQueryOptions,
|
|
7
|
+
} from '@shipfox/client-shell/runtime';
|
|
4
8
|
import {useMutation} from '@tanstack/react-query';
|
|
9
|
+
import type {WorkspaceCreateCommand} from '#core/auth.js';
|
|
5
10
|
import {useRefreshAuth} from './refresh-auth.js';
|
|
6
11
|
import {toWorkspace} from './workspace-mapper.js';
|
|
7
12
|
|
|
8
|
-
export async function createWorkspace(
|
|
13
|
+
export async function createWorkspace(command: WorkspaceCreateCommand) {
|
|
9
14
|
const response = await checkedApiRequest(workspaceResponseSchema, '/workspaces', {
|
|
10
15
|
method: 'POST',
|
|
11
|
-
body,
|
|
16
|
+
body: {name: command.name},
|
|
12
17
|
});
|
|
13
18
|
return toWorkspace(response);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
export {listUserWorkspaces, userWorkspacesQueryKey};
|
|
21
|
+
export {listUserWorkspaces, userWorkspacesQueryKey, userWorkspacesQueryOptions};
|
|
17
22
|
|
|
18
23
|
export function useCreateWorkspaceAuth() {
|
|
19
24
|
const refreshAuth = useRefreshAuth();
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import type {WorkspaceResponseDto} from '@shipfox/api-workspaces-dto';
|
|
2
|
-
|
|
3
|
-
export interface Workspace {
|
|
4
|
-
id: string;
|
|
5
|
-
name: string;
|
|
6
|
-
status: 'active' | 'suspended' | 'deleted';
|
|
7
|
-
}
|
|
2
|
+
import type {Workspace} from '#core/auth.js';
|
|
8
3
|
|
|
9
4
|
export function toWorkspace(dto: WorkspaceResponseDto): Workspace {
|
|
10
5
|
return {id: dto.id, name: dto.name, status: dto.status};
|
package/src/pages/login-page.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
4
4
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
5
5
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
6
6
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
7
7
|
import {Text} from '@shipfox/react-ui/typography';
|
|
8
8
|
import {useForm} from '@tanstack/react-form';
|
|
9
|
-
import {Link
|
|
9
|
+
import {Link} from '@tanstack/react-router';
|
|
10
10
|
import {useAtom} from 'jotai';
|
|
11
11
|
import {useEffect, useRef, useState} from 'react';
|
|
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);
|
|
@@ -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} from '@shipfox/client-shell/runtime';
|
|
1
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {Text} from '@shipfox/react-ui/typography';
|
|
3
|
-
import {
|
|
3
|
+
import {useRouter} from '@tanstack/react-router';
|
|
4
4
|
import {useEffect} from 'react';
|
|
5
|
-
import {
|
|
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,14 +2,14 @@ import {
|
|
|
2
2
|
passwordResetConfirmBodySchema,
|
|
3
3
|
passwordResetRequestBodySchema,
|
|
4
4
|
} from '@shipfox/api-auth-dto';
|
|
5
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
5
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
6
6
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
7
7
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
8
8
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
9
9
|
import {toast} from '@shipfox/react-ui/toast';
|
|
10
10
|
import {Text} from '@shipfox/react-ui/typography';
|
|
11
11
|
import {useForm} from '@tanstack/react-form';
|
|
12
|
-
import {Link, useNavigate
|
|
12
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
13
13
|
import {useAtom} from 'jotai';
|
|
14
14
|
import {useEffect, useRef, useState} from 'react';
|
|
15
15
|
import {
|
|
@@ -17,14 +17,14 @@ import {
|
|
|
17
17
|
useRequestPasswordResetAuth,
|
|
18
18
|
} from '#hooks/api/password-reset-auth.js';
|
|
19
19
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
20
|
+
import {validatePasswordResetSearch} from '../routes/inputs.js';
|
|
20
21
|
import {
|
|
21
22
|
passwordResetConfirmErrorToFormError,
|
|
22
23
|
passwordResetRequestErrorToFormError,
|
|
23
24
|
} from './form-errors.js';
|
|
24
25
|
|
|
25
26
|
export function PasswordResetPage() {
|
|
26
|
-
const
|
|
27
|
-
const token = typeof search.token === 'string' ? search.token : undefined;
|
|
27
|
+
const {token} = useRouteSearch(validatePasswordResetSearch);
|
|
28
28
|
|
|
29
29
|
if (token) {
|
|
30
30
|
return <PasswordResetConfirm token={token} />;
|
|
@@ -160,7 +160,7 @@ function PasswordResetConfirm({token}: {token: string}) {
|
|
|
160
160
|
token,
|
|
161
161
|
new_password: value.new_password,
|
|
162
162
|
});
|
|
163
|
-
await confirmPasswordReset.mutateAsync(body);
|
|
163
|
+
await confirmPasswordReset.mutateAsync({token: body.token, newPassword: body.new_password});
|
|
164
164
|
setAuthFormDraft(initialAuthFormDraft);
|
|
165
165
|
toast.success('Your password has been changed. You are now logged in.');
|
|
166
166
|
await navigate({to: '/', replace: true});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {signupBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
4
4
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
5
5
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
@@ -8,7 +8,7 @@ import {Icon} from '@shipfox/react-ui/icon';
|
|
|
8
8
|
import {toast} from '@shipfox/react-ui/toast';
|
|
9
9
|
import {Text} from '@shipfox/react-ui/typography';
|
|
10
10
|
import {useForm} from '@tanstack/react-form';
|
|
11
|
-
import {Link, useNavigate
|
|
11
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
12
12
|
import {useAtom} from 'jotai';
|
|
13
13
|
import {useEffect, useRef, useState} from 'react';
|
|
14
14
|
import {EmailCodeVerification} from '#/components/email-code-verification.js';
|
|
@@ -16,6 +16,7 @@ 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
|
|
|
@@ -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();
|
|
@@ -12,7 +12,8 @@ import {useNavigate} from '@tanstack/react-router';
|
|
|
12
12
|
import {useSetAtom} from 'jotai';
|
|
13
13
|
import {useState} from 'react';
|
|
14
14
|
import {useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
|
|
15
|
-
import {
|
|
15
|
+
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
16
|
+
import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
|
|
16
17
|
import {workspaceOnboardingErrorToFormError} from './form-errors.js';
|
|
17
18
|
|
|
18
19
|
const previewMetrics = [
|
|
@@ -34,6 +35,7 @@ const previewBars = [
|
|
|
34
35
|
|
|
35
36
|
export function WorkspaceOnboardingPage() {
|
|
36
37
|
const createWorkspace = useCreateWorkspaceAuth();
|
|
38
|
+
const {user} = useAuthState();
|
|
37
39
|
const navigate = useNavigate();
|
|
38
40
|
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
39
41
|
const [formError, setFormError] = useState<string | undefined>();
|
|
@@ -43,13 +45,14 @@ export function WorkspaceOnboardingPage() {
|
|
|
43
45
|
onSubmit: async ({value}) => {
|
|
44
46
|
setFormError(undefined);
|
|
45
47
|
try {
|
|
46
|
-
const
|
|
47
|
-
const created = await createWorkspace.mutateAsync(
|
|
48
|
+
const command = createWorkspaceBodySchema.parse({name: value.name});
|
|
49
|
+
const created = await createWorkspace.mutateAsync(command);
|
|
48
50
|
toast.success('Workspace created.');
|
|
49
51
|
// Pin the new workspace as the last-active one so a page refresh and
|
|
50
52
|
// future visits to `/` land on it.
|
|
51
53
|
try {
|
|
52
54
|
setLastWorkspaceId(created.id);
|
|
55
|
+
if (user?.id) rememberLastWorkspaceId(user.id, created.id);
|
|
53
56
|
} catch {
|
|
54
57
|
// localStorage may throw in private browsing or quota-exceeded.
|
|
55
58
|
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -10,9 +10,11 @@ export default defineRoute({
|
|
|
10
10
|
if (!auth.isAuthenticated) throw redirect({to: '/auth/login'});
|
|
11
11
|
const [first, ...rest] = auth.workspaces;
|
|
12
12
|
if (!first) throw redirect({to: '/setup/workspaces/new'});
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const principalId = auth.user?.id;
|
|
14
|
+
const target = principalId
|
|
15
|
+
? [first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId(principalId))
|
|
16
|
+
: undefined;
|
|
17
|
+
throw redirect({to: '/workspaces/$wid', params: {wid: (target ?? first).id}});
|
|
16
18
|
},
|
|
17
19
|
component: FullPageLoader,
|
|
18
20
|
});
|
|
@@ -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 />
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
rememberLastWorkspaceId,
|
|
6
6
|
} from './last-workspace.js';
|
|
7
7
|
|
|
8
|
-
describe('
|
|
8
|
+
describe('last workspace state', () => {
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
window.localStorage.clear();
|
|
11
11
|
});
|
|
@@ -14,21 +14,13 @@ describe('lastWorkspaceIdAtom', () => {
|
|
|
14
14
|
window.localStorage.clear();
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test('
|
|
18
|
-
const store = createStore();
|
|
19
|
-
|
|
20
|
-
expect(store.get(lastWorkspaceIdAtom)).toBeUndefined();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('write persists to localStorage and round-trips through the atom', () => {
|
|
17
|
+
test('atom stores current UI selection without unscoped persistence', () => {
|
|
24
18
|
const store = createStore();
|
|
25
19
|
|
|
26
20
|
store.set(lastWorkspaceIdAtom, 'workspace-1');
|
|
27
21
|
|
|
28
|
-
expect(JSON.parse(window.localStorage.getItem('shipfox.lastWorkspaceId') ?? 'null')).toBe(
|
|
29
|
-
'workspace-1',
|
|
30
|
-
);
|
|
31
22
|
expect(store.get(lastWorkspaceIdAtom)).toBe('workspace-1');
|
|
23
|
+
expect(window.localStorage.getItem('shipfox.lastWorkspaceId')).toBeNull();
|
|
32
24
|
});
|
|
33
25
|
|
|
34
26
|
test('subscribers receive updates when the atom is set', () => {
|
|
@@ -45,34 +37,35 @@ describe('lastWorkspaceIdAtom', () => {
|
|
|
45
37
|
unsubscribe();
|
|
46
38
|
});
|
|
47
39
|
|
|
48
|
-
test('
|
|
49
|
-
|
|
50
|
-
const unsubscribe = store.sub(lastWorkspaceIdAtom, () => undefined);
|
|
51
|
-
window.localStorage.setItem('shipfox.lastWorkspaceId', JSON.stringify('workspace-2'));
|
|
52
|
-
|
|
53
|
-
window.dispatchEvent(
|
|
54
|
-
new StorageEvent('storage', {
|
|
55
|
-
key: 'shipfox.lastWorkspaceId',
|
|
56
|
-
storageArea: window.localStorage,
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
40
|
+
test('write persists to localStorage and can be read for the same principal', () => {
|
|
41
|
+
rememberLastWorkspaceId('principal-1', 'workspace-1');
|
|
59
42
|
|
|
60
|
-
expect(
|
|
61
|
-
|
|
43
|
+
expect(
|
|
44
|
+
JSON.parse(
|
|
45
|
+
window.localStorage.getItem('shipfox.lastWorkspaceId.principal.principal-1') ?? 'null',
|
|
46
|
+
),
|
|
47
|
+
).toBe('workspace-1');
|
|
48
|
+
expect(getLastWorkspaceId('principal-1')).toBe('workspace-1');
|
|
62
49
|
});
|
|
63
50
|
|
|
64
51
|
test('rememberLastWorkspaceId persists a root redirect target', () => {
|
|
65
|
-
rememberLastWorkspaceId('workspace-1');
|
|
52
|
+
rememberLastWorkspaceId('principal-1', 'workspace-1');
|
|
66
53
|
|
|
67
|
-
const result = getLastWorkspaceId();
|
|
54
|
+
const result = getLastWorkspaceId('principal-1');
|
|
68
55
|
|
|
69
56
|
expect(result).toBe('workspace-1');
|
|
70
57
|
});
|
|
71
58
|
|
|
59
|
+
test("does not reuse a different principal's root redirect target", () => {
|
|
60
|
+
rememberLastWorkspaceId('principal-a', 'workspace-a');
|
|
61
|
+
|
|
62
|
+
expect(getLastWorkspaceId('principal-b')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
72
65
|
test('getLastWorkspaceId ignores malformed storage', () => {
|
|
73
|
-
window.localStorage.setItem('shipfox.lastWorkspaceId', '{');
|
|
66
|
+
window.localStorage.setItem('shipfox.lastWorkspaceId.principal.principal-1', '{');
|
|
74
67
|
|
|
75
|
-
const result = getLastWorkspaceId();
|
|
68
|
+
const result = getLastWorkspaceId('principal-1');
|
|
76
69
|
|
|
77
70
|
expect(result).toBeUndefined();
|
|
78
71
|
});
|