@shipfox/client-auth 12.0.2 → 14.0.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.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +45 -0
  3. package/dist/components/auth-guard.js +1 -1
  4. package/dist/components/auth-guard.js.map +1 -1
  5. package/dist/components/workspace-switcher.d.ts.map +1 -1
  6. package/dist/components/workspace-switcher.js +4 -2
  7. package/dist/components/workspace-switcher.js.map +1 -1
  8. package/dist/components/workspace-switcher.stories.d.ts.map +1 -1
  9. package/dist/components/workspace-switcher.stories.js +3 -2
  10. package/dist/components/workspace-switcher.stories.js.map +1 -1
  11. package/dist/core/auth.d.ts +16 -0
  12. package/dist/core/auth.d.ts.map +1 -1
  13. package/dist/core/auth.js.map +1 -1
  14. package/dist/hooks/api/workspace-auth.d.ts +4 -1
  15. package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
  16. package/dist/hooks/api/workspace-auth.js +45 -3
  17. package/dist/hooks/api/workspace-auth.js.map +1 -1
  18. package/dist/hooks/api/workspace-mapper.js +1 -0
  19. package/dist/hooks/api/workspace-mapper.js.map +1 -1
  20. package/dist/hooks/index.d.ts +1 -1
  21. package/dist/hooks/index.d.ts.map +1 -1
  22. package/dist/hooks/index.js +1 -1
  23. package/dist/hooks/index.js.map +1 -1
  24. package/dist/pages/form-errors.d.ts +1 -1
  25. package/dist/pages/form-errors.d.ts.map +1 -1
  26. package/dist/pages/form-errors.js +7 -0
  27. package/dist/pages/form-errors.js.map +1 -1
  28. package/dist/pages/signup-page.d.ts.map +1 -1
  29. package/dist/pages/signup-page.js +79 -12
  30. package/dist/pages/signup-page.js.map +1 -1
  31. package/dist/pages/workspace-onboarding-page.d.ts.map +1 -1
  32. package/dist/pages/workspace-onboarding-page.js +81 -27
  33. package/dist/pages/workspace-onboarding-page.js.map +1 -1
  34. package/dist/routes/index.d.ts.map +1 -1
  35. package/dist/routes/index.js +2 -2
  36. package/dist/routes/index.js.map +1 -1
  37. package/dist/tsconfig.test.tsbuildinfo +1 -1
  38. package/package.json +8 -7
  39. package/src/components/auth-guard.tsx +1 -1
  40. package/src/components/redirect-target.test.ts +7 -11
  41. package/src/components/workspace-switcher.stories.tsx +3 -2
  42. package/src/components/workspace-switcher.tsx +3 -1
  43. package/src/core/auth.ts +12 -0
  44. package/src/hooks/api/workspace-auth.test.ts +66 -3
  45. package/src/hooks/api/workspace-auth.ts +51 -4
  46. package/src/hooks/api/workspace-mapper.test.ts +2 -1
  47. package/src/hooks/api/workspace-mapper.ts +1 -1
  48. package/src/hooks/index.ts +6 -1
  49. package/src/pages/form-errors.test.ts +11 -1
  50. package/src/pages/form-errors.ts +6 -1
  51. package/src/pages/logout-page.test.tsx +2 -2
  52. package/src/pages/signup-page.test.tsx +78 -0
  53. package/src/pages/signup-page.tsx +92 -12
  54. package/src/pages/workspace-onboarding-page.test.tsx +123 -1
  55. package/src/pages/workspace-onboarding-page.tsx +60 -7
  56. package/src/redirect-context.test.ts +5 -7
  57. package/src/routes/index.tsx +4 -1
  58. package/src/routes/inputs.test.ts +2 -2
  59. package/test/pages.tsx +1 -1
  60. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-auth",
3
3
  "license": "MIT",
4
- "version": "12.0.2",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -35,13 +35,14 @@
35
35
  "dependencies": {
36
36
  "@swc/helpers": "^0.5.17",
37
37
  "@tanstack/react-form": "^1.32.0",
38
- "@shipfox/api-auth-dto": "10.2.0",
39
- "@shipfox/api-workspaces-dto": "11.0.0",
38
+ "@shipfox/api-common-dto": "12.0.0",
39
+ "@shipfox/api-auth-dto": "12.0.0",
40
+ "@shipfox/api-workspaces-dto": "12.0.0",
40
41
  "@shipfox/client-api": "6.0.1",
41
- "@shipfox/client-invitations": "12.0.2",
42
- "@shipfox/client-shell": "12.0.2",
43
- "@shipfox/client-ui": "6.0.2",
44
- "@shipfox/react-ui": "0.3.7"
42
+ "@shipfox/client-invitations": "14.0.0",
43
+ "@shipfox/client-shell": "14.0.0",
44
+ "@shipfox/client-ui": "14.0.0",
45
+ "@shipfox/react-ui": "0.5.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@ export function GuestGuard({children}: PropsWithChildren) {
29
29
 
30
30
  useEffect(() => {
31
31
  if (auth.isAuthenticated && target !== undefined) {
32
- // Bypass the typed route matcher `target` is an arbitrary same-origin
32
+ // Bypass the typed route matcher: `target` is an arbitrary same-origin
33
33
  // path resolved at runtime, so we let the URL change drive route resolution.
34
34
  router.history.replace(target);
35
35
  }
@@ -11,10 +11,10 @@ function encodeRepeatedly(value: string, times: number): string {
11
11
  describe('sanitizeRedirectPath', () => {
12
12
  describe.each([
13
13
  ['simple absolute path', '/foo'],
14
- ['nested workspace path', '/workspaces/abc/projects/xyz'],
14
+ ['nested workspace path', '/w/abc/p/xyz'],
15
15
  ['path with search', '/foo?bar=1'],
16
16
  ['path with hash', '/foo#hash'],
17
- ['path with search and hash', '/workspaces/abc?tab=runs#header'],
17
+ ['path with search and hash', '/w/abc?tab=runs#header'],
18
18
  ])('accepts %s', (_label, input) => {
19
19
  test('returns the original string', () => {
20
20
  const result = sanitizeRedirectPath(input);
@@ -39,7 +39,7 @@ describe('sanitizeRedirectPath', () => {
39
39
  ['/auth/reset with token', '/auth/reset?token=x'],
40
40
  ['/auth with query bypass', '/auth?token=x'],
41
41
  ['/auth with fragment bypass', '/auth#foo'],
42
- ['normalized auth path', '/workspaces/../auth/logout'],
42
+ ['normalized auth path', '/w/../auth/logout'],
43
43
  ])('rejects %s', (_label, input) => {
44
44
  test('returns undefined', () => {
45
45
  const result = sanitizeRedirectPath(input);
@@ -66,7 +66,7 @@ describe('sanitizeRedirectPath', () => {
66
66
  });
67
67
 
68
68
  test('rejects a percent-encoded normalized auth path', () => {
69
- const result = sanitizeRedirectPath('/workspaces/%2e%2e/auth/logout');
69
+ const result = sanitizeRedirectPath('/w/%2e%2e/auth/logout');
70
70
 
71
71
  expect(result).toBeUndefined();
72
72
  });
@@ -106,12 +106,8 @@ describe('sanitizeRedirectPath', () => {
106
106
  describe('sanitizeLogoutRedirectPath', () => {
107
107
  describe.each([
108
108
  ['explicit login fallback', '/auth/login', '/auth/login'],
109
- ['same-origin workspace path', '/workspaces/abc', '/workspaces/abc'],
110
- [
111
- 'same-origin path with search and hash',
112
- '/workspaces/abc?tab=runs#header',
113
- '/workspaces/abc?tab=runs#header',
114
- ],
109
+ ['same-origin workspace path', '/w/abc', '/w/abc'],
110
+ ['same-origin path with search and hash', '/w/abc?tab=runs#header', '/w/abc?tab=runs#header'],
115
111
  ])('accepts %s', (_label, input, expected) => {
116
112
  test('returns the safe destination', () => {
117
113
  expect(sanitizeLogoutRedirectPath(input)).toBe(expected);
@@ -123,7 +119,7 @@ describe('sanitizeLogoutRedirectPath', () => {
123
119
  ['external URL', 'https://attacker.example'],
124
120
  ['protocol-relative URL', '//attacker.example'],
125
121
  ['auth route other than login', '/auth/reset'],
126
- ['login route with a query', '/auth/login?redirect=/workspaces/abc'],
122
+ ['login route with a query', '/auth/login?redirect=/w/abc'],
127
123
  ['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
128
124
  ['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
129
125
  ['double-encoded auth route', '/%2561uth/login'],
@@ -39,6 +39,7 @@ function workspace(id: string, name: string, index: number): Workspace {
39
39
  return {
40
40
  id,
41
41
  name,
42
+ slug: name.toLowerCase().replaceAll(' ', '-'),
42
43
  membershipId: `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
43
44
  };
44
45
  }
@@ -53,7 +54,7 @@ function WorkspaceSwitcherStory({activeWorkspaceId, workspaces}: WorkspaceSwitch
53
54
  const rootRoute = createRootRoute({component: Outlet});
54
55
  const workspaceRoute = createRoute({
55
56
  getParentRoute: () => rootRoute,
56
- path: '/workspaces/$wid',
57
+ path: '/w/$workspaceSlug',
57
58
  component: () => (
58
59
  <JotaiProvider store={store}>
59
60
  <div className="min-h-screen bg-background-neutral-base p-24">
@@ -72,7 +73,7 @@ function WorkspaceSwitcherStory({activeWorkspaceId, workspaces}: WorkspaceSwitch
72
73
 
73
74
  return createRouter({
74
75
  routeTree: rootRoute.addChildren([workspaceRoute, setupRoute]),
75
- history: createMemoryHistory({initialEntries: [`/workspaces/${activeWorkspaceId}`]}),
76
+ history: createMemoryHistory({initialEntries: ['/w/acme']}),
76
77
  });
77
78
  }, [activeWorkspaceId, store]);
78
79
 
@@ -24,13 +24,15 @@ export function WorkspaceSwitcher({activeWorkspaceId, onSelect}: WorkspaceSwitch
24
24
  const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
25
25
 
26
26
  const handleSelect = (workspaceId: string) => {
27
+ const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
28
+ if (!workspace) return;
27
29
  try {
28
30
  setLastWorkspaceId(workspaceId);
29
31
  if (user?.id) rememberLastWorkspaceId(user.id, workspaceId);
30
32
  } catch {
31
33
  // localStorage may throw in private browsing or quota-exceeded; persistence is best-effort.
32
34
  }
33
- navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
35
+ navigate({to: '/w/$workspaceSlug', params: {workspaceSlug: workspace.slug}});
34
36
  onSelect?.();
35
37
  };
36
38
 
package/src/core/auth.ts CHANGED
@@ -30,11 +30,23 @@ export interface ResendEmailVerificationCommand {
30
30
 
31
31
  export interface WorkspaceCreateCommand {
32
32
  name: string;
33
+ slug: string;
33
34
  }
34
35
 
36
+ type WorkspaceUpdateBody =
37
+ | {name: string; slug?: never}
38
+ | {name?: never; slug: string}
39
+ | {
40
+ name: string;
41
+ slug: string;
42
+ };
43
+
44
+ export type WorkspaceUpdateCommand = {workspaceId: string} & WorkspaceUpdateBody;
45
+
35
46
  export interface Workspace {
36
47
  id: string;
37
48
  name: string;
49
+ slug: string;
38
50
  status: 'active' | 'suspended' | 'deleted';
39
51
  }
40
52
 
@@ -1,5 +1,9 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
- import {createWorkspace} from './workspace-auth.js';
2
+ import {
3
+ checkWorkspaceSlugAvailability,
4
+ createWorkspace,
5
+ updateWorkspace,
6
+ } from './workspace-auth.js';
3
7
 
4
8
  function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
5
9
  return new Response(JSON.stringify(body), {
@@ -21,6 +25,7 @@ describe('createWorkspace', () => {
21
25
  return jsonResponse({
22
26
  id: '33333333-3333-4333-8333-333333333333',
23
27
  name: 'Acme',
28
+ slug: 'acme',
24
29
  status: 'active',
25
30
  settings: {},
26
31
  created_at: '2026-04-27T00:00:00.000Z',
@@ -29,12 +34,70 @@ describe('createWorkspace', () => {
29
34
  });
30
35
  configureApiClient({fetchImpl});
31
36
 
32
- const result = await createWorkspace({name: 'Acme'});
37
+ const result = await createWorkspace({name: 'Acme', slug: 'acme'});
33
38
 
34
39
  const request = fetchImpl.mock.calls[0]?.[0] as Request;
35
40
  expect(result.name).toBe('Acme');
41
+ expect(result.slug).toBe('acme');
36
42
  expect(request.url).toBe('https://api.example.test/workspaces');
37
43
  expect(request.method).toBe('POST');
38
- expect(requestBody).toEqual({name: 'Acme'});
44
+ expect(requestBody).toEqual({name: 'Acme', slug: 'acme'});
45
+ });
46
+ });
47
+
48
+ describe('updateWorkspace', () => {
49
+ beforeEach(() => {
50
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
51
+ });
52
+
53
+ test('patches the workspace body', async () => {
54
+ let requestBody: unknown;
55
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
56
+ requestBody = await (input as Request).clone().json();
57
+ return jsonResponse({
58
+ id: '33333333-3333-4333-8333-333333333333',
59
+ name: 'Acme Labs',
60
+ slug: 'acme-labs',
61
+ status: 'active',
62
+ settings: {},
63
+ created_at: '2026-04-27T00:00:00.000Z',
64
+ updated_at: '2026-04-27T00:00:00.000Z',
65
+ });
66
+ });
67
+ configureApiClient({fetchImpl});
68
+
69
+ const result = await updateWorkspace({
70
+ workspaceId: '33333333-3333-4333-8333-333333333333',
71
+ name: 'Acme Labs',
72
+ slug: 'acme-labs',
73
+ });
74
+
75
+ const request = fetchImpl.mock.calls[0]?.[0] as Request;
76
+ expect(result.slug).toBe('acme-labs');
77
+ expect(request.url).toBe(
78
+ 'https://api.example.test/workspaces/33333333-3333-4333-8333-333333333333',
79
+ );
80
+ expect(request.method).toBe('PATCH');
81
+ expect(requestBody).toEqual({name: 'Acme Labs', slug: 'acme-labs'});
82
+ });
83
+ });
84
+
85
+ describe('checkWorkspaceSlugAvailability', () => {
86
+ beforeEach(() => {
87
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
88
+ });
89
+
90
+ test('encodes the slug and returns the server result', async () => {
91
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({available: true}));
92
+ configureApiClient({fetchImpl});
93
+
94
+ const slug = 'acme/labs?region=eu';
95
+ await expect(checkWorkspaceSlugAvailability(slug)).resolves.toBe(true);
96
+
97
+ const request = fetchImpl.mock.calls[0]?.[0] as Request;
98
+ const url = new URL(request.url);
99
+ expect(url.pathname).toBe('/workspaces/slug-availability');
100
+ expect(url.search).toBe(`?slug=${encodeURIComponent(slug)}`);
101
+ expect(request.method).toBe('GET');
39
102
  });
40
103
  });
@@ -1,23 +1,47 @@
1
- import {workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
1
+ import {
2
+ updateWorkspaceBodySchema,
3
+ workspaceResponseSchema,
4
+ workspaceSlugAvailabilityResponseSchema,
5
+ } from '@shipfox/api-workspaces-dto';
2
6
  import {checkedApiRequest} from '@shipfox/client-api';
3
7
  import {
4
8
  listUserWorkspaces,
5
9
  userWorkspacesQueryKey,
6
10
  userWorkspacesQueryOptions,
7
11
  } from '@shipfox/client-shell/runtime';
8
- import {useMutation} from '@tanstack/react-query';
9
- import type {WorkspaceCreateCommand} from '#core/auth.js';
12
+ import {useMutation, useQueryClient} from '@tanstack/react-query';
13
+ import {useSetAtom} from 'jotai';
14
+ import type {WorkspaceCreateCommand, WorkspaceUpdateCommand} from '#core/auth.js';
15
+ import {authStateAtom} from '#state/auth.js';
10
16
  import {useRefreshAuth} from './refresh-auth.js';
11
17
  import {toWorkspace} from './workspace-mapper.js';
12
18
 
13
19
  export async function createWorkspace(command: WorkspaceCreateCommand) {
14
20
  const response = await checkedApiRequest(workspaceResponseSchema, '/workspaces', {
15
21
  method: 'POST',
16
- body: {name: command.name},
22
+ body: {name: command.name, slug: command.slug},
17
23
  });
18
24
  return toWorkspace(response);
19
25
  }
20
26
 
27
+ export async function updateWorkspace(command: WorkspaceUpdateCommand) {
28
+ const body = updateWorkspaceBodySchema.parse({name: command.name, slug: command.slug});
29
+ const response = await checkedApiRequest(
30
+ workspaceResponseSchema,
31
+ `/workspaces/${command.workspaceId}`,
32
+ {method: 'PATCH', body},
33
+ );
34
+ return toWorkspace(response);
35
+ }
36
+
37
+ export async function checkWorkspaceSlugAvailability(slug: string): Promise<boolean> {
38
+ const response = await checkedApiRequest(
39
+ workspaceSlugAvailabilityResponseSchema,
40
+ `/workspaces/slug-availability?slug=${encodeURIComponent(slug)}`,
41
+ );
42
+ return response.available;
43
+ }
44
+
21
45
  export {listUserWorkspaces, userWorkspacesQueryKey, userWorkspacesQueryOptions};
22
46
 
23
47
  export function useCreateWorkspaceAuth() {
@@ -33,3 +57,26 @@ export function useCreateWorkspaceAuth() {
33
57
  },
34
58
  });
35
59
  }
60
+
61
+ export function useUpdateWorkspaceMutation() {
62
+ const queryClient = useQueryClient();
63
+ const setAuthState = useSetAtom(authStateAtom);
64
+
65
+ return useMutation({
66
+ mutationFn: updateWorkspace,
67
+ onSuccess: async (workspace) => {
68
+ setAuthState((previous) => {
69
+ if (previous.status !== 'authenticated') return previous;
70
+ return {
71
+ ...previous,
72
+ workspaces: (previous.workspaces ?? []).map((candidate) =>
73
+ candidate.id === workspace.id
74
+ ? {...candidate, name: workspace.name, slug: workspace.slug, status: workspace.status}
75
+ : candidate,
76
+ ),
77
+ };
78
+ });
79
+ await queryClient.invalidateQueries({queryKey: userWorkspacesQueryKey});
80
+ },
81
+ });
82
+ }
@@ -10,12 +10,13 @@ describe('toWorkspace', () => {
10
10
  const dto: WorkspaceResponseDto = {
11
11
  id: '11111111-1111-4111-8111-111111111111',
12
12
  name: 'Acme',
13
+ slug: 'acme',
13
14
  status,
14
15
  settings: {},
15
16
  created_at: '2026-04-27T00:00:00.000Z',
16
17
  updated_at: '2026-04-27T00:00:00.000Z',
17
18
  };
18
19
 
19
- expect(toWorkspace(dto)).toEqual({id: dto.id, name: dto.name, status});
20
+ expect(toWorkspace(dto)).toEqual({id: dto.id, name: dto.name, slug: dto.slug, status});
20
21
  });
21
22
  });
@@ -2,5 +2,5 @@ import type {WorkspaceResponseDto} from '@shipfox/api-workspaces-dto';
2
2
  import type {Workspace} from '#core/auth.js';
3
3
 
4
4
  export function toWorkspace(dto: WorkspaceResponseDto): Workspace {
5
- return {id: dto.id, name: dto.name, status: dto.status};
5
+ return {id: dto.id, name: dto.name, slug: dto.slug, status: dto.status};
6
6
  }
@@ -7,7 +7,12 @@ export {
7
7
  export {useRefreshAuth} from './api/refresh-auth.js';
8
8
  export {useSignupAuth} from './api/signup-auth.js';
9
9
  export {useResendEmailVerificationAuth, useVerifyEmailAuth} from './api/verify-email-auth.js';
10
- export {useCreateWorkspaceAuth} from './api/workspace-auth.js';
10
+ export {
11
+ checkWorkspaceSlugAvailability,
12
+ updateWorkspace,
13
+ useCreateWorkspaceAuth,
14
+ useUpdateWorkspaceMutation,
15
+ } from './api/workspace-auth.js';
11
16
  export {useActiveWorkspace, useMaybeActiveWorkspace} from './use-active-workspace.js';
12
17
  export type {AuthStateValue} from './use-auth-state.js';
13
18
  export {useAuthState} from './use-auth-state.js';
@@ -106,7 +106,17 @@ describe('passwordResetConfirmErrorToFormError', () => {
106
106
  });
107
107
 
108
108
  describe('workspaceOnboardingErrorToFormError', () => {
109
- test('routes any error to a form-level alert', () => {
109
+ test('routes slug conflicts to the slug field', () => {
110
+ const result = workspaceOnboardingErrorToFormError(apiError('slug-conflict', 409));
111
+
112
+ expect(result).toEqual({
113
+ kind: 'field',
114
+ field: 'slug',
115
+ message: 'That workspace slug is already taken.',
116
+ });
117
+ });
118
+
119
+ test('routes other errors to a form-level alert', () => {
110
120
  const result = workspaceOnboardingErrorToFormError(apiError('conflict', 409));
111
121
 
112
122
  expect(result).toEqual({kind: 'form', message: 'conflict server message'});
@@ -40,6 +40,11 @@ export function passwordResetConfirmErrorToFormError(
40
40
  return {kind: 'form', message: authErrorMessage(error)};
41
41
  }
42
42
 
43
- export function workspaceOnboardingErrorToFormError(error: unknown): FormErrorMapping<'name'> {
43
+ export function workspaceOnboardingErrorToFormError(
44
+ error: unknown,
45
+ ): FormErrorMapping<'name' | 'slug'> {
46
+ if (apiCode(error) === 'slug-conflict') {
47
+ return {kind: 'field', field: 'slug', message: 'That workspace slug is already taken.'};
48
+ }
44
49
  return {kind: 'form', message: authErrorMessage(error)};
45
50
  }
@@ -27,7 +27,7 @@ function renderLogoutPage(path: string) {
27
27
  });
28
28
  const workspaceRoute = createRoute({
29
29
  getParentRoute: () => rootRoute,
30
- path: '/workspaces/$wid',
30
+ path: '/w/$workspaceSlug',
31
31
  component: () => <h1>Workspace destination</h1>,
32
32
  });
33
33
  const router = createRouter({
@@ -56,7 +56,7 @@ describe('LogoutPage', () => {
56
56
  const fetchImpl = vi.fn().mockResolvedValue(new Response(null, {status: 204}));
57
57
  configureApiClient({fetchImpl});
58
58
 
59
- renderLogoutPage('/auth/logout?redirect=%2Fworkspaces%2Facme');
59
+ renderLogoutPage('/auth/logout?redirect=%2Fw%2Facme');
60
60
 
61
61
  expect(await screen.findByRole('heading', {name: 'Workspace destination'})).toBeVisible();
62
62
  const request = fetchImpl.mock.calls[0]?.[0] as Request;
@@ -158,6 +158,84 @@ describe('SignupPage', () => {
158
158
  );
159
159
  });
160
160
 
161
+ test('keeps invitation signup recoverable when the post-signup refresh fails', async () => {
162
+ const invitationToken = 'invite-token';
163
+ const redirect = `/invitations/accept?token=${encodeURIComponent(invitationToken)}`;
164
+ const user = pageUserFactory.build({email: 'invitee@example.com', name: 'Invitee'});
165
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
166
+ const session = {token: 'access-token', user};
167
+ let signupCompleted = false;
168
+ let postSignupRefreshFailures = 0;
169
+ const fetchImpl = vi.fn().mockImplementation((input: RequestInfo | URL) => {
170
+ const url = requestUrl(input);
171
+ if (url === 'https://api.example.test/auth/me') {
172
+ return jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401});
173
+ }
174
+ if (url.includes('/invitations/preview?')) {
175
+ return jsonResponse({
176
+ status: 'pending',
177
+ workspace_id: workspaceId,
178
+ workspace_name: 'Invite Workspace',
179
+ email: user.email,
180
+ invited_by_display: 'owner@example.com',
181
+ expires_at: '2026-05-18T00:00:00.000Z',
182
+ });
183
+ }
184
+ if (url === 'https://api.example.test/auth/signup') {
185
+ signupCompleted = true;
186
+ return jsonResponse(
187
+ {
188
+ user,
189
+ membership: {
190
+ id: '22222222-2222-4222-8222-222222222222',
191
+ user_id: user.id,
192
+ workspace_id: workspaceId,
193
+ },
194
+ },
195
+ {status: 201},
196
+ );
197
+ }
198
+ if (url === 'https://api.example.test/auth/refresh') {
199
+ if (signupCompleted && postSignupRefreshFailures < 2) {
200
+ postSignupRefreshFailures += 1;
201
+ return jsonResponse({code: 'server-error', message: 'Refresh failed'}, {status: 500});
202
+ }
203
+ return jsonResponse(session);
204
+ }
205
+ if (url === 'https://api.example.test/workspaces') {
206
+ return jsonResponse({
207
+ memberships: [
208
+ {
209
+ id: '33333333-3333-4333-8333-333333333333',
210
+ user_id: user.id,
211
+ workspace_id: workspaceId,
212
+ created_at: '2026-05-01T00:00:00.000Z',
213
+ updated_at: '2026-05-01T00:00:00.000Z',
214
+ workspace_name: 'Invite Workspace',
215
+ workspace_slug: 'invite-workspace',
216
+ workspace_status: 'active',
217
+ },
218
+ ],
219
+ });
220
+ }
221
+ throw new Error(`Unexpected request: ${url}`);
222
+ });
223
+ configureApiClient({fetchImpl});
224
+
225
+ renderAuthPage(`/auth/signup?redirect=${encodeURIComponent(redirect)}`, <SignupPage />);
226
+ await screen.findByRole('heading', {name: 'Join Invite Workspace'});
227
+ fireEvent.change(screen.getByLabelText('Name'), {target: {value: user.name}});
228
+ fireEvent.change(screen.getByLabelText('Email'), {target: {value: user.email}});
229
+ fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
230
+ fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
231
+
232
+ expect(await screen.findByRole('button', {name: 'Retry sign-in'})).toBeInTheDocument();
233
+ expect(screen.getByRole('heading', {name: 'Join Invite Workspace'})).toBeInTheDocument();
234
+
235
+ fireEvent.click(screen.getByRole('button', {name: 'Retry sign-in'}));
236
+ expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
237
+ });
238
+
161
239
  test('surfaces duplicate-email errors', async () => {
162
240
  const fetchImpl = vi
163
241
  .fn()