@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.
Files changed (141) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +56 -0
  3. package/dist/components/auth-guard.d.ts.map +1 -1
  4. package/dist/components/auth-guard.js +4 -4
  5. package/dist/components/auth-guard.js.map +1 -1
  6. package/dist/components/email-code-verification.d.ts.map +1 -1
  7. package/dist/components/email-code-verification.js +1 -1
  8. package/dist/components/email-code-verification.js.map +1 -1
  9. package/dist/components/redirect-target.d.ts +7 -0
  10. package/dist/components/redirect-target.d.ts.map +1 -1
  11. package/dist/components/redirect-target.js +35 -4
  12. package/dist/components/redirect-target.js.map +1 -1
  13. package/dist/components/workspace-switcher.stories.d.ts +1 -1
  14. package/dist/continuation.d.ts +1 -1
  15. package/dist/continuation.d.ts.map +1 -1
  16. package/dist/continuation.js +1 -1
  17. package/dist/continuation.js.map +1 -1
  18. package/dist/core/auth.d.ts +57 -0
  19. package/dist/core/auth.d.ts.map +1 -0
  20. package/dist/core/auth.js +3 -0
  21. package/dist/core/auth.js.map +1 -0
  22. package/dist/hooks/api/auth-mapper.d.ts +6 -0
  23. package/dist/hooks/api/auth-mapper.d.ts.map +1 -0
  24. package/dist/hooks/api/auth-mapper.js +25 -0
  25. package/dist/hooks/api/auth-mapper.js.map +1 -0
  26. package/dist/hooks/api/login-auth.d.ts +3 -28
  27. package/dist/hooks/api/login-auth.d.ts.map +1 -1
  28. package/dist/hooks/api/login-auth.js +7 -4
  29. package/dist/hooks/api/login-auth.js.map +1 -1
  30. package/dist/hooks/api/logout-auth.js +2 -2
  31. package/dist/hooks/api/logout-auth.js.map +1 -1
  32. package/dist/hooks/api/password-reset-auth.d.ts +5 -32
  33. package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
  34. package/dist/hooks/api/password-reset-auth.js +13 -7
  35. package/dist/hooks/api/password-reset-auth.js.map +1 -1
  36. package/dist/hooks/api/refresh-auth.d.ts +1 -1
  37. package/dist/hooks/api/refresh-auth.d.ts.map +1 -1
  38. package/dist/hooks/api/refresh-auth.js +1 -1
  39. package/dist/hooks/api/refresh-auth.js.map +1 -1
  40. package/dist/hooks/api/signup-auth.d.ts +2 -30
  41. package/dist/hooks/api/signup-auth.d.ts.map +1 -1
  42. package/dist/hooks/api/signup-auth.js +14 -4
  43. package/dist/hooks/api/signup-auth.js.map +1 -1
  44. package/dist/hooks/api/verify-email-auth.d.ts +4 -21
  45. package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
  46. package/dist/hooks/api/verify-email-auth.js +20 -7
  47. package/dist/hooks/api/verify-email-auth.js.map +1 -1
  48. package/dist/hooks/api/workspace-auth.d.ts +5 -21
  49. package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
  50. package/dist/hooks/api/workspace-auth.js +11 -6
  51. package/dist/hooks/api/workspace-auth.js.map +1 -1
  52. package/dist/hooks/api/workspace-mapper.d.ts +4 -0
  53. package/dist/hooks/api/workspace-mapper.d.ts.map +1 -0
  54. package/dist/hooks/api/workspace-mapper.js +9 -0
  55. package/dist/hooks/api/workspace-mapper.js.map +1 -0
  56. package/dist/index.d.ts +0 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +0 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/pages/invitation-context.d.ts +9 -14
  61. package/dist/pages/invitation-context.d.ts.map +1 -1
  62. package/dist/pages/invitation-context.js +4 -27
  63. package/dist/pages/invitation-context.js.map +1 -1
  64. package/dist/pages/login-page.d.ts.map +1 -1
  65. package/dist/pages/login-page.js +5 -6
  66. package/dist/pages/login-page.js.map +1 -1
  67. package/dist/pages/logout-page.d.ts.map +1 -1
  68. package/dist/pages/logout-page.js +8 -18
  69. package/dist/pages/logout-page.js.map +1 -1
  70. package/dist/pages/password-reset-page.d.ts.map +1 -1
  71. package/dist/pages/password-reset-page.js +8 -10
  72. package/dist/pages/password-reset-page.js.map +1 -1
  73. package/dist/pages/signup-page.d.ts.map +1 -1
  74. package/dist/pages/signup-page.js +24 -18
  75. package/dist/pages/signup-page.js.map +1 -1
  76. package/dist/pages/workspace-onboarding-page.js +2 -2
  77. package/dist/pages/workspace-onboarding-page.js.map +1 -1
  78. package/dist/routes/inputs.d.ts +9 -0
  79. package/dist/routes/inputs.d.ts.map +1 -0
  80. package/dist/routes/inputs.js +17 -0
  81. package/dist/routes/inputs.js.map +1 -0
  82. package/dist/routes/login.d.ts +2 -0
  83. package/dist/routes/login.d.ts.map +1 -1
  84. package/dist/routes/login.js +2 -0
  85. package/dist/routes/login.js.map +1 -1
  86. package/dist/routes/logout.d.ts +2 -0
  87. package/dist/routes/logout.d.ts.map +1 -1
  88. package/dist/routes/logout.js +2 -0
  89. package/dist/routes/logout.js.map +1 -1
  90. package/dist/routes/reset.d.ts +2 -0
  91. package/dist/routes/reset.d.ts.map +1 -1
  92. package/dist/routes/reset.js +2 -0
  93. package/dist/routes/reset.js.map +1 -1
  94. package/dist/routes/signup.d.ts +2 -0
  95. package/dist/routes/signup.d.ts.map +1 -1
  96. package/dist/routes/signup.js +2 -0
  97. package/dist/routes/signup.js.map +1 -1
  98. package/dist/tsconfig.test.tsbuildinfo +1 -1
  99. package/package.json +11 -11
  100. package/src/components/auth-guard.tsx +4 -2
  101. package/src/components/email-code-verification.tsx +1 -1
  102. package/src/components/redirect-target.test.ts +32 -1
  103. package/src/components/redirect-target.ts +41 -4
  104. package/src/continuation.ts +1 -1
  105. package/src/core/auth.ts +55 -0
  106. package/src/hooks/api/auth-mapper.test.ts +77 -0
  107. package/src/hooks/api/auth-mapper.ts +29 -0
  108. package/src/hooks/api/login-auth.test.ts +1 -1
  109. package/src/hooks/api/login-auth.ts +10 -4
  110. package/src/hooks/api/logout-auth.ts +2 -2
  111. package/src/hooks/api/password-reset-auth.test.ts +3 -3
  112. package/src/hooks/api/password-reset-auth.ts +19 -13
  113. package/src/hooks/api/refresh-auth.ts +1 -0
  114. package/src/hooks/api/signup-auth.ts +15 -4
  115. package/src/hooks/api/verify-email-auth.ts +32 -16
  116. package/src/hooks/api/workspace-auth.ts +16 -6
  117. package/src/hooks/api/workspace-mapper.test.ts +21 -0
  118. package/src/hooks/api/workspace-mapper.ts +6 -0
  119. package/src/index.ts +0 -1
  120. package/src/pages/invitation-context.ts +8 -31
  121. package/src/pages/login-page.test.tsx +1 -1
  122. package/src/pages/login-page.tsx +5 -4
  123. package/src/pages/logout-page.test.tsx +75 -0
  124. package/src/pages/logout-page.tsx +9 -13
  125. package/src/pages/password-reset-page.tsx +5 -8
  126. package/src/pages/signup-page.test.tsx +1 -1
  127. package/src/pages/signup-page.tsx +22 -16
  128. package/src/pages/workspace-onboarding-page.tsx +2 -2
  129. package/src/routes/inputs.test.ts +17 -0
  130. package/src/routes/inputs.ts +21 -0
  131. package/src/routes/login.tsx +2 -0
  132. package/src/routes/logout.tsx +2 -1
  133. package/src/routes/reset.tsx +2 -0
  134. package/src/routes/signup.tsx +2 -0
  135. package/tsconfig.build.tsbuildinfo +1 -1
  136. package/vercel.json +5 -0
  137. package/dist/components/auth-shell.d.ts +0 -13
  138. package/dist/components/auth-shell.d.ts.map +0 -1
  139. package/dist/components/auth-shell.js +0 -68
  140. package/dist/components/auth-shell.js.map +0 -1
  141. package/src/components/auth-shell.tsx +0 -68
@@ -1,39 +1,16 @@
1
- import type {
2
- PreviewInvitationPendingDto,
3
- PreviewInvitationResponseDto,
4
- } from '@shipfox/api-workspaces-dto';
5
- import {apiRequest} from '@shipfox/client-api';
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 useQuery({
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: 'workspace-1',
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',
@@ -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, useSearch} from '@tanstack/react-router';
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 = useSearch({strict: false}) as {redirect?: unknown};
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.workspace_name}`
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 {useNavigate, useRouter, useSearch} from '@tanstack/react-router';
3
+ import {useRouter} from '@tanstack/react-router';
3
4
  import {useEffect} from 'react';
4
- import {AuthShell} from '#/components/auth-shell.js';
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 = useSearch({strict: false}) as {redirect?: unknown};
13
- const target = sanitizeRedirectPath(search.redirect);
12
+ const search = useRouteSearch(validateRedirectSearch);
13
+ const target = sanitizeLogoutRedirectPath(search.redirect);
14
14
 
15
15
  useEffect(() => {
16
16
  logout.mutateAsync().finally(() => {
17
- if (target !== undefined) {
18
- // Same-origin runtime path; let history drive route resolution.
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, navigate, router, target]);
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, useSearch} from '@tanstack/react-router';
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 search = useSearch({strict: false});
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: 'workspace-1',
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, useSearch} from '@tanstack/react-router';
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 = useSearch({strict: false}) as {redirect?: unknown};
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 body = signupBodySchema.parse({
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(body);
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.workspace_name}.`);
76
+ toast.success(`You joined ${invitationPending.workspaceName}.`);
71
77
  await navigate({
72
78
  to: '/workspaces/$wid',
73
- params: {wid: result.membership.workspace_id},
79
+ params: {wid: result.membership.workspaceId},
74
80
  });
75
81
  return;
76
82
  }
77
83
 
78
- if (invitationToken && result.accept_error) {
79
- toast.error(result.accept_error.message);
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.email_challenge) {
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.email_challenge.id});
96
+ setEmailChallenge({email: result.user.email, id: result.emailChallenge.id});
91
97
  setResendError(undefined);
92
- setNextResendAvailableAt(result.email_challenge.next_resend_available_at);
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
- challenge_id: emailChallenge.id,
141
+ challengeId: emailChallenge.id,
136
142
  });
137
- setNextResendAvailableAt(result.next_resend_available_at);
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
- challenge_id: emailChallenge.id,
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.workspace_name}`
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 body = createWorkspaceBodySchema.parse({name: value.name});
47
- const created = await createWorkspace.mutateAsync(body);
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
+ }
@@ -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 />
@@ -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});
@@ -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 />
@@ -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 />