@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
@@ -1,5 +1,10 @@
1
1
  import {signupBodySchema} from '@shipfox/api-auth-dto';
2
- import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
2
+ import {
3
+ AuthShell,
4
+ rememberLastWorkspaceId,
5
+ useRouteSearch,
6
+ userWorkspacesQueryKey,
7
+ } from '@shipfox/client-shell/runtime';
3
8
  import {displayNameFieldError} from '@shipfox/client-ui';
4
9
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
5
10
  import {Callout} from '@shipfox/react-ui/callout';
@@ -8,6 +13,7 @@ import {Icon} from '@shipfox/react-ui/icon';
8
13
  import {toast} from '@shipfox/react-ui/toast';
9
14
  import {Text} from '@shipfox/react-ui/typography';
10
15
  import {useForm} from '@tanstack/react-form';
16
+ import {useQueryClient} from '@tanstack/react-query';
11
17
  import {Link, useNavigate} from '@tanstack/react-router';
12
18
  import {useAtom} from 'jotai';
13
19
  import {useEffect, useRef, useState} from 'react';
@@ -30,6 +36,7 @@ export function SignupPage() {
30
36
  const verifyEmail = useVerifyEmailAuth();
31
37
  const resendEmailVerification = useResendEmailVerificationAuth();
32
38
  const refreshAuth = useRefreshAuth();
39
+ const queryClient = useQueryClient();
33
40
  const navigate = useNavigate();
34
41
  const search = useRouteSearch(validateRedirectSearch);
35
42
  const invitationToken = extractInvitationToken(search.redirect);
@@ -40,12 +47,36 @@ export function SignupPage() {
40
47
  const [nextResendAvailableAt, setNextResendAvailableAt] = useState<string | undefined>();
41
48
  const [formError, setFormError] = useState<string | undefined>();
42
49
  const [resendError, setResendError] = useState<string | undefined>();
50
+ const [invitationRefreshFailure, setInvitationRefreshFailure] = useState<{
51
+ workspaceId: string;
52
+ workspaceName: string;
53
+ userId?: string | undefined;
54
+ }>();
55
+ const [isRetryingInvitationRefresh, setIsRetryingInvitationRefresh] = useState(false);
43
56
  const draftRef = useRef(authFormDraft);
44
57
  draftRef.current = authFormDraft;
45
58
  // Set just before clearing the draft on success so the unmount cleanup
46
59
  // below does not repersist the just-submitted credentials.
47
60
  const skipDraftPersistRef = useRef(false);
48
61
 
62
+ async function refreshInvitationWorkspace(workspaceId: string) {
63
+ for (let attempt = 0; attempt < 2; attempt += 1) {
64
+ try {
65
+ await refreshAuth();
66
+ const workspaces = queryClient.getQueryData<{
67
+ memberships: Array<{id: string}>;
68
+ }>(userWorkspacesQueryKey);
69
+ const memberships = workspaces?.memberships ?? [];
70
+ if (memberships.some(({id}) => id === workspaceId)) return true;
71
+ } catch {
72
+ // A mount-time refresh can race the post-signup refresh. Retry once so
73
+ // an in-flight request that predates membership creation does not turn
74
+ // a successful invitation into a manual recovery flow.
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
49
80
  const form = useForm({
50
81
  defaultValues: {email: authFormDraft.email, password: authFormDraft.password, name: ''},
51
82
  onSubmit: async ({value}) => {
@@ -67,17 +98,20 @@ export function SignupPage() {
67
98
  setAuthFormDraft(initialAuthFormDraft);
68
99
 
69
100
  if (invitationToken && result.membership && invitationPending) {
70
- try {
71
- await refreshAuth();
72
- } catch {
73
- // Refresh failures don't block the success message — the next API
74
- // call's 401 handling will re-route the user.
101
+ const joinedWorkspace = await refreshInvitationWorkspace(result.membership.workspaceId);
102
+ if (!joinedWorkspace) {
103
+ setInvitationRefreshFailure({
104
+ workspaceId: result.membership.workspaceId,
105
+ workspaceName: invitationPending.workspaceName,
106
+ userId: result.user?.id,
107
+ });
108
+ return;
75
109
  }
76
110
  toast.success(`You joined ${invitationPending.workspaceName}.`);
77
- await navigate({
78
- to: '/workspaces/$wid',
79
- params: {wid: result.membership.workspaceId},
80
- });
111
+ if (result.user?.id) {
112
+ rememberLastWorkspaceId(result.user.id, result.membership.workspaceId);
113
+ }
114
+ await navigate({to: '/'});
81
115
  return;
82
116
  }
83
117
 
@@ -110,6 +144,23 @@ export function SignupPage() {
110
144
  },
111
145
  });
112
146
 
147
+ async function retryInvitationAuthRefresh() {
148
+ if (!invitationRefreshFailure || isRetryingInvitationRefresh) return;
149
+ setIsRetryingInvitationRefresh(true);
150
+ const joinedWorkspace = await refreshInvitationWorkspace(invitationRefreshFailure.workspaceId);
151
+ setIsRetryingInvitationRefresh(false);
152
+ if (!joinedWorkspace) return;
153
+ if (invitationRefreshFailure.userId) {
154
+ rememberLastWorkspaceId(
155
+ invitationRefreshFailure.userId,
156
+ invitationRefreshFailure.workspaceId,
157
+ );
158
+ }
159
+ toast.success(`You joined ${invitationRefreshFailure.workspaceName}.`);
160
+ setInvitationRefreshFailure(undefined);
161
+ await navigate({to: '/'});
162
+ }
163
+
113
164
  // When arriving from an invitation link, prefill the email and lock it.
114
165
  useEffect(() => {
115
166
  if (invitationPending && form.state.values.email !== invitationPending.email) {
@@ -118,8 +169,9 @@ export function SignupPage() {
118
169
  }
119
170
  }, [invitationPending, form, setAuthFormDraft]);
120
171
 
121
- // Sync form values back to the Jotai draft on unmount (only email + password
122
- // name is intentionally not persisted across navigation). Skipped after a
172
+ // Sync form values back to the Jotai draft on unmount. The draft stores only
173
+ // email and password; name is intentionally not persisted across navigation.
174
+ // Skipped after a
123
175
  // successful signup because we just intentionally cleared the draft.
124
176
  useEffect(() => {
125
177
  return () => {
@@ -147,6 +199,34 @@ export function SignupPage() {
147
199
  }
148
200
  }
149
201
 
202
+ if (invitationRefreshFailure) {
203
+ return (
204
+ <AuthShell
205
+ title={`Join ${invitationRefreshFailure.workspaceName}`}
206
+ description="Your account was created, but we could not finish signing you in."
207
+ >
208
+ <Callout role="alert" type="error">
209
+ <div className="flex flex-col gap-8">
210
+ <Text size="sm">
211
+ Try again to finish joining your workspace. You can safely retry this step.
212
+ </Text>
213
+ <Button
214
+ type="button"
215
+ variant="secondary"
216
+ className="w-fit"
217
+ isLoading={isRetryingInvitationRefresh}
218
+ onClick={() => {
219
+ void retryInvitationAuthRefresh();
220
+ }}
221
+ >
222
+ Retry sign-in
223
+ </Button>
224
+ </div>
225
+ </Callout>
226
+ </AuthShell>
227
+ );
228
+ }
229
+
150
230
  if (emailChallenge) {
151
231
  return (
152
232
  <EmailCodeVerification
@@ -37,6 +37,7 @@ describe('WorkspaceOnboardingPage', () => {
37
37
  user_id: user.id,
38
38
  workspace_id: '33333333-3333-4333-8333-333333333333',
39
39
  workspace_name: 'Acme',
40
+ workspace_slug: 'acme',
40
41
  created_at: '2026-04-27T00:00:00.000Z',
41
42
  updated_at: '2026-04-27T00:00:00.000Z',
42
43
  },
@@ -51,6 +52,7 @@ describe('WorkspaceOnboardingPage', () => {
51
52
  {
52
53
  id: '33333333-3333-4333-8333-333333333333',
53
54
  name: 'Acme',
55
+ slug: 'acme',
54
56
  status: 'active',
55
57
  settings: {},
56
58
  created_at: '2026-04-27T00:00:00.000Z',
@@ -78,7 +80,127 @@ describe('WorkspaceOnboardingPage', () => {
78
80
  fireEvent.click(screen.getByRole('button', {name: 'Create workspace'}));
79
81
 
80
82
  await waitFor(() => expect(didCreateWorkspace).toBe(true));
81
- expect(createWorkspaceBody).toEqual({name: 'Acme'});
83
+ expect(createWorkspaceBody).toEqual({name: 'Acme', slug: 'acme'});
84
+ });
85
+
86
+ test('prefills the slug from the name until the slug is edited', async () => {
87
+ const user = pageUserFactory.build({email: 'workspace-slug@example.com'});
88
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
89
+ const url = requestUrl(input);
90
+ if (url.endsWith('/auth/refresh')) {
91
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
92
+ }
93
+ if (url.endsWith('/workspaces')) {
94
+ return Promise.resolve(jsonResponse({memberships: []}));
95
+ }
96
+ return Promise.resolve(
97
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
98
+ );
99
+ });
100
+ configureApiClient({fetchImpl});
101
+
102
+ renderAuthPage(
103
+ '/',
104
+ <AuthGuard>
105
+ <WorkspaceGuard>
106
+ <h1>Authenticated home</h1>
107
+ </WorkspaceGuard>
108
+ </AuthGuard>,
109
+ );
110
+ const name = await screen.findByLabelText('Workspace name');
111
+ const slug = screen.getByLabelText('Workspace slug');
112
+ expect(screen.getByText(`${window.location.origin}/w/acme`)).toBeInTheDocument();
113
+ fireEvent.change(name, {target: {value: 'Acme Labs'}});
114
+ expect(slug).toHaveValue('acme-labs');
115
+ expect(screen.getByText(`${window.location.origin}/w/acme-labs`)).toBeInTheDocument();
116
+
117
+ fireEvent.change(slug, {target: {value: 'custom-workspace'}});
118
+ fireEvent.change(name, {target: {value: 'Renamed Labs'}});
119
+ expect(slug).toHaveValue('custom-workspace');
120
+ expect(screen.getByText(`${window.location.origin}/w/custom-workspace`)).toBeInTheDocument();
121
+ });
122
+
123
+ test('checks a manually edited workspace slug for availability', async () => {
124
+ const user = pageUserFactory.build({email: 'workspace-availability@example.com'});
125
+ const availabilityRequests: string[] = [];
126
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
127
+ const request = input as Request;
128
+ const url = request.url;
129
+ if (url.includes('/auth/refresh')) {
130
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
131
+ }
132
+ if (url.includes('/workspaces/slug-availability')) {
133
+ availabilityRequests.push(url);
134
+ return Promise.resolve(jsonResponse({available: true}));
135
+ }
136
+ if (url.endsWith('/workspaces')) {
137
+ return Promise.resolve(jsonResponse({memberships: []}));
138
+ }
139
+ return Promise.resolve(
140
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
141
+ );
142
+ });
143
+ configureApiClient({fetchImpl});
144
+
145
+ renderAuthPage(
146
+ '/',
147
+ <AuthGuard>
148
+ <WorkspaceGuard>
149
+ <h1>Authenticated home</h1>
150
+ </WorkspaceGuard>
151
+ </AuthGuard>,
152
+ );
153
+ fireEvent.change(await screen.findByLabelText('Workspace slug'), {
154
+ target: {value: 'custom-workspace'},
155
+ });
156
+
157
+ await waitFor(() => expect(availabilityRequests).toHaveLength(1));
158
+ expect(new URL(availabilityRequests[0] ?? '').searchParams.get('slug')).toBe(
159
+ 'custom-workspace',
160
+ );
161
+ expect(await screen.findByText('Slug is available.')).toBeInTheDocument();
162
+ });
163
+
164
+ test('shows a duplicate slug error on the slug field', async () => {
165
+ const user = pageUserFactory.build({email: 'workspace-conflict@example.com'});
166
+ const fetchImpl = vi.fn((input: RequestInfo | URL) => {
167
+ const url = requestUrl(input);
168
+ const method = input instanceof Request ? input.method : 'GET';
169
+ if (url.endsWith('/auth/refresh')) {
170
+ return Promise.resolve(jsonResponse({token: 'access-token', user}));
171
+ }
172
+ if (url.endsWith('/workspaces') && method === 'GET') {
173
+ return Promise.resolve(jsonResponse({memberships: []}));
174
+ }
175
+ if (url.endsWith('/workspaces') && method === 'POST') {
176
+ return Promise.resolve(
177
+ jsonResponse(
178
+ {code: 'slug-conflict', message: 'Workspace slug is already taken'},
179
+ {status: 409},
180
+ ),
181
+ );
182
+ }
183
+ return Promise.resolve(
184
+ jsonResponse({code: 'not-found', message: 'Not found'}, {status: 404}),
185
+ );
186
+ });
187
+ configureApiClient({fetchImpl});
188
+
189
+ renderAuthPage(
190
+ '/',
191
+ <AuthGuard>
192
+ <WorkspaceGuard>
193
+ <h1>Authenticated home</h1>
194
+ </WorkspaceGuard>
195
+ </AuthGuard>,
196
+ );
197
+ fireEvent.change(await screen.findByLabelText('Workspace name'), {
198
+ target: {value: 'Acme'},
199
+ });
200
+ fireEvent.click(screen.getByRole('button', {name: 'Create workspace'}));
201
+
202
+ expect(await screen.findByText('That workspace slug is already taken.')).toBeInTheDocument();
203
+ expect(screen.getByLabelText('Workspace slug')).toBeInvalid();
82
204
  });
83
205
 
84
206
  test('requires the workspace name locally', async () => {
@@ -1,5 +1,6 @@
1
+ import {slugifyName, slugSchema} from '@shipfox/api-common-dto';
1
2
  import {createWorkspaceBodySchema} from '@shipfox/api-workspaces-dto';
2
- import {displayNameFieldError} from '@shipfox/client-ui';
3
+ import {displayNameFieldError, SlugField} from '@shipfox/client-ui';
3
4
  import {Button} from '@shipfox/react-ui/button';
4
5
  import {Callout} from '@shipfox/react-ui/callout';
5
6
  import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@shipfox/react-ui/card';
@@ -11,7 +12,7 @@ import {useForm} from '@tanstack/react-form';
11
12
  import {useNavigate} from '@tanstack/react-router';
12
13
  import {useSetAtom} from 'jotai';
13
14
  import {useState} from 'react';
14
- import {useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
15
+ import {checkWorkspaceSlugAvailability, useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
15
16
  import {useAuthState} from '#hooks/use-auth-state.js';
16
17
  import {lastWorkspaceIdAtom, rememberLastWorkspaceId} from '#state/last-workspace.js';
17
18
  import {workspaceOnboardingErrorToFormError} from './form-errors.js';
@@ -33,19 +34,24 @@ const previewBars = [
33
34
  {id: 'runs-late-high', height: 74},
34
35
  ];
35
36
 
37
+ function isSlugValid(value: string): boolean {
38
+ return slugSchema.safeParse(value).success;
39
+ }
40
+
36
41
  export function WorkspaceOnboardingPage() {
37
42
  const createWorkspace = useCreateWorkspaceAuth();
38
43
  const {user} = useAuthState();
39
44
  const navigate = useNavigate();
40
45
  const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
41
46
  const [formError, setFormError] = useState<string | undefined>();
47
+ const [slugTouched, setSlugTouched] = useState(false);
42
48
 
43
49
  const form = useForm({
44
- defaultValues: {name: ''},
50
+ defaultValues: {name: '', slug: ''},
45
51
  onSubmit: async ({value}) => {
46
52
  setFormError(undefined);
47
53
  try {
48
- const command = createWorkspaceBodySchema.parse({name: value.name});
54
+ const command = createWorkspaceBodySchema.parse({name: value.name, slug: value.slug});
49
55
  const created = await createWorkspace.mutateAsync(command);
50
56
  toast.success('Workspace created.');
51
57
  // Pin the new workspace as the last-active one so a page refresh and
@@ -56,10 +62,17 @@ export function WorkspaceOnboardingPage() {
56
62
  } catch {
57
63
  // localStorage may throw in private browsing or quota-exceeded.
58
64
  }
59
- await navigate({to: '/workspaces/$wid', params: {wid: created.id}});
65
+ await navigate({to: '/w/$workspaceSlug', params: {workspaceSlug: created.slug}});
60
66
  } catch (error) {
61
67
  const mapped = workspaceOnboardingErrorToFormError(error);
62
- setFormError(mapped.message);
68
+ if (mapped.kind === 'field') {
69
+ form.setFieldMeta(mapped.field, (previous) => ({
70
+ ...previous,
71
+ errorMap: {...previous.errorMap, onServer: mapped.message},
72
+ }));
73
+ } else {
74
+ setFormError(mapped.message);
75
+ }
63
76
  }
64
77
  },
65
78
  });
@@ -129,12 +142,52 @@ export function WorkspaceOnboardingPage() {
129
142
  placeholder="Acme"
130
143
  type="text"
131
144
  value={field.state.value}
132
- onChange={(event) => field.handleChange(event.target.value)}
145
+ onChange={(event) => {
146
+ const name = event.target.value;
147
+ field.handleChange(name);
148
+ if (!slugTouched) {
149
+ form.setFieldValue(
150
+ 'slug',
151
+ name ? slugifyName(name, {fallback: 'workspace'}) : '',
152
+ );
153
+ }
154
+ }}
133
155
  onBlur={field.handleBlur}
134
156
  />
135
157
  </FormField>
136
158
  )}
137
159
  </form.Field>
160
+ <form.Field
161
+ name="slug"
162
+ validators={{
163
+ onBlur: createWorkspaceBodySchema.shape.slug,
164
+ onSubmit: createWorkspaceBodySchema.shape.slug,
165
+ }}
166
+ >
167
+ {(field) => (
168
+ <SlugField
169
+ id="workspace-slug"
170
+ label="Workspace slug"
171
+ name="slug"
172
+ value={field.state.value}
173
+ onChange={(value) => {
174
+ setSlugTouched(true);
175
+ field.handleChange(value);
176
+ }}
177
+ onBlur={field.handleBlur}
178
+ error={fieldError(field)}
179
+ description={
180
+ <span className="break-all font-code">
181
+ {`${window.location.origin}/w/${field.state.value || 'acme'}`}
182
+ </span>
183
+ }
184
+ placeholder="acme"
185
+ checkEnabled={slugTouched}
186
+ isValid={isSlugValid}
187
+ checkAvailability={checkWorkspaceSlugAvailability}
188
+ />
189
+ )}
190
+ </form.Field>
138
191
  </CardContent>
139
192
 
140
193
  <Button
@@ -2,15 +2,15 @@ import {parseRedirectContext, type RedirectContext} from '@shipfox/client-auth/r
2
2
 
3
3
  describe('@shipfox/client-auth/redirect-context', () => {
4
4
  test('imports the parser and its type through the Node-safe public subpath', () => {
5
- const context: RedirectContext = parseRedirectContext('/workspaces/acme');
5
+ const context: RedirectContext = parseRedirectContext('/w/acme');
6
6
 
7
- expect(context).toEqual({returnTo: '/workspaces/acme'});
7
+ expect(context).toEqual({returnTo: '/w/acme'});
8
8
  });
9
9
 
10
10
  test('returns an ordinary safe return path', () => {
11
- const context = parseRedirectContext('/workspaces/acme?tab=runs');
11
+ const context = parseRedirectContext('/w/acme?tab=runs');
12
12
 
13
- expect(context).toEqual({returnTo: '/workspaces/acme?tab=runs'});
13
+ expect(context).toEqual({returnTo: '/w/acme?tab=runs'});
14
14
  });
15
15
 
16
16
  test('separates an invitation token from generic redirect state', () => {
@@ -21,9 +21,7 @@ describe('@shipfox/client-auth/redirect-context', () => {
21
21
  });
22
22
 
23
23
  test('separates an invitation token after path normalization', () => {
24
- const context = parseRedirectContext(
25
- '/workspaces/../invitations/accept?token=raw-invitation-token',
26
- );
24
+ const context = parseRedirectContext('/w/../invitations/accept?token=raw-invitation-token');
27
25
 
28
26
  expect(context).toEqual({invitationToken: 'raw-invitation-token'});
29
27
  });
@@ -14,7 +14,10 @@ export default defineRoute({
14
14
  const target = principalId
15
15
  ? [first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId(principalId))
16
16
  : undefined;
17
- throw redirect({to: '/workspaces/$wid', params: {wid: (target ?? first).id}});
17
+ throw redirect({
18
+ to: '/w/$workspaceSlug',
19
+ params: {workspaceSlug: (target ?? first).slug},
20
+ });
18
21
  },
19
22
  component: FullPageLoader,
20
23
  });
@@ -2,8 +2,8 @@ import {validatePasswordResetSearch, validateRedirectSearch} from './inputs.js';
2
2
 
3
3
  describe('auth route inputs', () => {
4
4
  it('keeps only a non-empty redirect', () => {
5
- expect(validateRedirectSearch({redirect: '/workspaces/w-1'})).toEqual({
6
- redirect: '/workspaces/w-1',
5
+ expect(validateRedirectSearch({redirect: '/w/acme'})).toEqual({
6
+ redirect: '/w/acme',
7
7
  });
8
8
  expect(validateRedirectSearch({redirect: ''})).toEqual({});
9
9
  expect(validateRedirectSearch({redirect: ['unexpected']})).toEqual({});
package/test/pages.tsx CHANGED
@@ -25,7 +25,7 @@ function createTestRouter(path: string, element: ReactElement) {
25
25
  });
26
26
  const workspaceRoute = createRoute({
27
27
  getParentRoute: () => rootRoute,
28
- path: 'workspaces/$wid',
28
+ path: 'w/$workspaceSlug',
29
29
  component: () => <h1>Authenticated home</h1>,
30
30
  });
31
31
  const loginRoute = createRoute({