@shipfox/client-auth 12.0.2 → 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 +32 -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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "13.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-
|
|
39
|
-
"@shipfox/api-
|
|
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": "
|
|
42
|
-
"@shipfox/client-shell": "
|
|
43
|
-
"@shipfox/client-ui": "
|
|
44
|
-
"@shipfox/react-ui": "0.
|
|
42
|
+
"@shipfox/client-invitations": "13.0.0",
|
|
43
|
+
"@shipfox/client-shell": "13.0.0",
|
|
44
|
+
"@shipfox/client-ui": "13.0.0",
|
|
45
|
+
"@shipfox/react-ui": "0.4.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
|
|
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', '/
|
|
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', '/
|
|
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', '/
|
|
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('/
|
|
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', '/
|
|
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=/
|
|
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: '/
|
|
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: [
|
|
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: '/
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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'});
|
package/src/pages/form-errors.ts
CHANGED
|
@@ -40,6 +40,11 @@ export function passwordResetConfirmErrorToFormError(
|
|
|
40
40
|
return {kind: 'form', message: authErrorMessage(error)};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function workspaceOnboardingErrorToFormError(
|
|
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: '/
|
|
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=%
|
|
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()
|