@shipfox/client-auth 12.0.1 → 13.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +41 -0
- package/dist/components/auth-guard.js +1 -1
- package/dist/components/auth-guard.js.map +1 -1
- package/dist/components/workspace-switcher.d.ts.map +1 -1
- package/dist/components/workspace-switcher.js +4 -2
- package/dist/components/workspace-switcher.js.map +1 -1
- package/dist/components/workspace-switcher.stories.d.ts.map +1 -1
- package/dist/components/workspace-switcher.stories.js +3 -2
- package/dist/components/workspace-switcher.stories.js.map +1 -1
- package/dist/core/auth.d.ts +16 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +4 -1
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +45 -3
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.js +1 -0
- package/dist/hooks/api/workspace-mapper.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/pages/form-errors.d.ts +1 -1
- package/dist/pages/form-errors.d.ts.map +1 -1
- package/dist/pages/form-errors.js +7 -0
- package/dist/pages/form-errors.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +79 -12
- 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 +81 -27
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/src/components/auth-guard.tsx +1 -1
- package/src/components/redirect-target.test.ts +7 -11
- package/src/components/workspace-switcher.stories.tsx +3 -2
- package/src/components/workspace-switcher.tsx +3 -1
- package/src/core/auth.ts +12 -0
- package/src/hooks/api/workspace-auth.test.ts +66 -3
- package/src/hooks/api/workspace-auth.ts +51 -4
- package/src/hooks/api/workspace-mapper.test.ts +2 -1
- package/src/hooks/api/workspace-mapper.ts +1 -1
- package/src/hooks/index.ts +6 -1
- package/src/pages/form-errors.test.ts +11 -1
- package/src/pages/form-errors.ts +6 -1
- package/src/pages/logout-page.test.tsx +2 -2
- package/src/pages/signup-page.test.tsx +78 -0
- package/src/pages/signup-page.tsx +92 -12
- package/src/pages/workspace-onboarding-page.test.tsx +123 -1
- package/src/pages/workspace-onboarding-page.tsx +60 -7
- package/src/redirect-context.test.ts +5 -7
- package/src/routes/index.tsx +4 -1
- package/src/routes/inputs.test.ts +2 -2
- package/test/pages.tsx +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import {signupBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
-
import {
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
122
|
-
//
|
|
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: '/
|
|
65
|
+
await navigate({to: '/w/$workspaceSlug', params: {workspaceSlug: created.slug}});
|
|
60
66
|
} catch (error) {
|
|
61
67
|
const mapped = workspaceOnboardingErrorToFormError(error);
|
|
62
|
-
|
|
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) =>
|
|
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('/
|
|
5
|
+
const context: RedirectContext = parseRedirectContext('/w/acme');
|
|
6
6
|
|
|
7
|
-
expect(context).toEqual({returnTo: '/
|
|
7
|
+
expect(context).toEqual({returnTo: '/w/acme'});
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
test('returns an ordinary safe return path', () => {
|
|
11
|
-
const context = parseRedirectContext('/
|
|
11
|
+
const context = parseRedirectContext('/w/acme?tab=runs');
|
|
12
12
|
|
|
13
|
-
expect(context).toEqual({returnTo: '/
|
|
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
|
});
|
package/src/routes/index.tsx
CHANGED
|
@@ -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({
|
|
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: '/
|
|
6
|
-
redirect: '/
|
|
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: '
|
|
28
|
+
path: 'w/$workspaceSlug',
|
|
29
29
|
component: () => <h1>Authenticated home</h1>,
|
|
30
30
|
});
|
|
31
31
|
const loginRoute = createRoute({
|