@shipfox/client-auth 3.0.1 → 5.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/email-code-verification.d.ts +20 -0
- package/dist/components/email-code-verification.d.ts.map +1 -0
- package/dist/components/email-code-verification.js +136 -0
- package/dist/components/email-code-verification.js.map +1 -0
- package/dist/components/redirect-context.d.ts +11 -0
- package/dist/components/redirect-context.d.ts.map +1 -0
- package/dist/components/redirect-context.js +22 -0
- package/dist/components/redirect-context.js.map +1 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +13 -12
- package/dist/components/redirect-target.js.map +1 -1
- package/dist/components/workspace-switcher.stories.d.ts +1 -1
- package/dist/continuation.d.ts +4 -0
- package/dist/continuation.d.ts.map +1 -0
- package/dist/continuation.js +5 -0
- package/dist/continuation.js.map +1 -0
- package/dist/hooks/api/auth-mapper.d.ts +21 -0
- package/dist/hooks/api/auth-mapper.d.ts.map +1 -0
- package/dist/hooks/api/auth-mapper.js +25 -0
- package/dist/hooks/api/auth-mapper.js.map +1 -0
- package/dist/hooks/api/login-auth.d.ts +3 -25
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +9 -30
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.d.ts.map +1 -1
- package/dist/hooks/api/logout-auth.js +4 -14
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts +3 -25
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +9 -25
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/signup-auth.d.ts +1 -25
- package/dist/hooks/api/signup-auth.d.ts.map +1 -1
- package/dist/hooks/api/signup-auth.js +5 -2
- package/dist/hooks/api/signup-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts +1 -12
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +10 -26
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +3 -17
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +5 -2
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.d.ts +8 -0
- package/dist/hooks/api/workspace-mapper.d.ts.map +1 -0
- package/dist/hooks/api/workspace-mapper.js +9 -0
- package/dist/hooks/api/workspace-mapper.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/pages/invitation-context.d.ts +9 -19
- package/dist/pages/invitation-context.d.ts.map +1 -1
- package/dist/pages/invitation-context.js +7 -46
- package/dist/pages/invitation-context.js.map +1 -1
- package/dist/pages/login-page.js +2 -2
- package/dist/pages/login-page.js.map +1 -1
- package/dist/pages/logout-page.js +1 -1
- package/dist/pages/logout-page.js.map +1 -1
- package/dist/pages/password-reset-page.d.ts.map +1 -1
- package/dist/pages/password-reset-page.js +1 -4
- package/dist/pages/password-reset-page.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +58 -152
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/state/auth.d.ts +1 -1
- package/dist/state/auth.d.ts.map +1 -1
- package/dist/state/auth.js +1 -1
- package/dist/state/auth.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -39
- package/src/components/auth-provider.test.tsx +173 -3
- package/src/components/email-code-verification.test.tsx +159 -0
- package/src/components/email-code-verification.tsx +177 -0
- package/src/components/redirect-context.test.ts +51 -0
- package/src/components/redirect-context.ts +26 -0
- package/src/components/redirect-target.test.ts +8 -0
- package/src/components/redirect-target.ts +13 -12
- package/src/continuation.test.ts +15 -0
- package/src/continuation.ts +6 -0
- package/src/hooks/api/auth-mapper.test.ts +77 -0
- package/src/hooks/api/auth-mapper.ts +39 -0
- package/src/hooks/api/login-auth.test.ts +1 -1
- package/src/hooks/api/login-auth.ts +12 -31
- package/src/hooks/api/logout-auth.ts +4 -10
- package/src/hooks/api/password-reset-auth.test.ts +1 -1
- package/src/hooks/api/password-reset-auth.ts +19 -32
- package/src/hooks/api/signup-auth.ts +8 -3
- package/src/hooks/api/verify-email-auth.ts +21 -34
- package/src/hooks/api/workspace-auth.ts +8 -3
- package/src/hooks/api/workspace-mapper.test.ts +21 -0
- package/src/hooks/api/workspace-mapper.ts +11 -0
- package/src/index.ts +0 -1
- package/src/pages/invitation-context.ts +10 -51
- package/src/pages/login-page.test.tsx +1 -1
- package/src/pages/login-page.tsx +2 -2
- package/src/pages/logout-page.tsx +1 -1
- package/src/pages/password-reset-page.tsx +1 -4
- package/src/pages/signup-page.test.tsx +1 -1
- package/src/pages/signup-page.tsx +41 -137
- package/src/state/auth.ts +1 -0
- package/src/state/last-workspace.test.ts +16 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/components/auth-shell.d.ts +0 -11
- package/dist/components/auth-shell.d.ts.map +0 -1
- package/dist/components/auth-shell.js +0 -63
- package/dist/components/auth-shell.js.map +0 -1
- package/dist/pages/email-verification-resend-model.d.ts +0 -6
- package/dist/pages/email-verification-resend-model.d.ts.map +0 -1
- package/dist/pages/email-verification-resend-model.js +0 -11
- package/dist/pages/email-verification-resend-model.js.map +0 -1
- package/src/components/auth-shell.tsx +0 -53
- package/src/pages/email-verification-resend-model.test.ts +0 -42
- package/src/pages/email-verification-resend-model.ts +0 -15
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {parseRedirectContext} from './redirect-context.js';
|
|
2
|
+
|
|
3
|
+
describe('parseRedirectContext', () => {
|
|
4
|
+
test('returns an ordinary safe return path', () => {
|
|
5
|
+
const context = parseRedirectContext('/workspaces/acme?tab=runs');
|
|
6
|
+
|
|
7
|
+
expect(context).toEqual({returnTo: '/workspaces/acme?tab=runs'});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('separates an invitation token from generic redirect state', () => {
|
|
11
|
+
const context = parseRedirectContext('/invitations/accept?token=raw-invitation-token');
|
|
12
|
+
|
|
13
|
+
expect(context).toEqual({invitationToken: 'raw-invitation-token'});
|
|
14
|
+
expect(context.returnTo).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('separates an invitation token after path normalization', () => {
|
|
18
|
+
const context = parseRedirectContext(
|
|
19
|
+
'/workspaces/../invitations/accept?token=raw-invitation-token',
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(context).toEqual({invitationToken: 'raw-invitation-token'});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.each([
|
|
26
|
+
'https://attacker.example',
|
|
27
|
+
'//attacker.example',
|
|
28
|
+
'/auth/login',
|
|
29
|
+
'/%61uth/login',
|
|
30
|
+
'/%E0%80%80',
|
|
31
|
+
])('rejects malformed or unsafe redirect %s', (redirect) => {
|
|
32
|
+
const context = parseRedirectContext(redirect);
|
|
33
|
+
|
|
34
|
+
expect(context).toEqual({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test.each([
|
|
38
|
+
['/invitations/accept', {}],
|
|
39
|
+
['/invitations/accept?token=', {}],
|
|
40
|
+
[
|
|
41
|
+
'/invitations/other?token=raw-invitation-token',
|
|
42
|
+
{
|
|
43
|
+
returnTo: '/invitations/other?token=raw-invitation-token',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
])('does not treat %s as an invitation context', (redirect, expected) => {
|
|
47
|
+
const context = parseRedirectContext(redirect);
|
|
48
|
+
|
|
49
|
+
expect(context).toEqual(expected);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {sanitizeRedirectPath} from './redirect-target.js';
|
|
2
|
+
|
|
3
|
+
const INVITATION_ACCEPT_PATH = '/invitations/accept';
|
|
4
|
+
|
|
5
|
+
export interface RedirectContext {
|
|
6
|
+
invitationToken?: string;
|
|
7
|
+
returnTo?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Separates a safe post-authentication destination from an invitation token.
|
|
12
|
+
* The token never remains in `returnTo`, so callers can keep it in their
|
|
13
|
+
* short-lived invitation flow instead of forwarding it through generic redirects.
|
|
14
|
+
*/
|
|
15
|
+
export function parseRedirectContext(value: unknown): RedirectContext {
|
|
16
|
+
const redirect = sanitizeRedirectPath(value);
|
|
17
|
+
if (!redirect) return {};
|
|
18
|
+
|
|
19
|
+
const decoded = decodeURIComponent(redirect);
|
|
20
|
+
const [path, queryString = ''] = decoded.split('?', 2);
|
|
21
|
+
if (path !== INVITATION_ACCEPT_PATH) return {returnTo: redirect};
|
|
22
|
+
|
|
23
|
+
const params = new URLSearchParams(queryString);
|
|
24
|
+
const invitationToken = params.get('token');
|
|
25
|
+
return invitationToken ? {invitationToken} : {};
|
|
26
|
+
}
|
|
@@ -23,6 +23,7 @@ describe('sanitizeRedirectPath', () => {
|
|
|
23
23
|
['no leading slash', 'foo'],
|
|
24
24
|
['protocol-relative URL', '//evil.com'],
|
|
25
25
|
['triple-slash URL', '///evil.com'],
|
|
26
|
+
['backslash external URL', '/\\evil.com'],
|
|
26
27
|
['absolute https URL', 'https://evil.com'],
|
|
27
28
|
['javascript scheme', 'javascript:alert(1)'],
|
|
28
29
|
['plain /auth/login', '/auth/login'],
|
|
@@ -30,6 +31,7 @@ describe('sanitizeRedirectPath', () => {
|
|
|
30
31
|
['/auth/reset with token', '/auth/reset?token=x'],
|
|
31
32
|
['/auth with query bypass', '/auth?token=x'],
|
|
32
33
|
['/auth with fragment bypass', '/auth#foo'],
|
|
34
|
+
['normalized auth path', '/workspaces/../auth/logout'],
|
|
33
35
|
])('rejects %s', (_label, input) => {
|
|
34
36
|
test('returns undefined', () => {
|
|
35
37
|
const result = sanitizeRedirectPath(input);
|
|
@@ -51,6 +53,12 @@ describe('sanitizeRedirectPath', () => {
|
|
|
51
53
|
expect(result).toBeUndefined();
|
|
52
54
|
});
|
|
53
55
|
|
|
56
|
+
test('rejects a percent-encoded normalized auth path', () => {
|
|
57
|
+
const result = sanitizeRedirectPath('/workspaces/%2e%2e/auth/logout');
|
|
58
|
+
|
|
59
|
+
expect(result).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
54
62
|
test('rejects malformed percent-encoded input', () => {
|
|
55
63
|
const result = sanitizeRedirectPath('/%E0%80%80');
|
|
56
64
|
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
const
|
|
1
|
+
const REDIRECT_ORIGIN = 'https://shipfox-redirect.invalid';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// variants like `/%61uth/login` cannot bypass the `/auth/*` rejection.
|
|
3
|
+
// Resolves and canonicalizes an internal path before returning it, so browser URL
|
|
4
|
+
// parsing cannot turn a seemingly safe path into an external or auth route.
|
|
6
5
|
export function sanitizeRedirectPath(value: unknown): string | undefined {
|
|
7
|
-
if (typeof value !== 'string') return undefined;
|
|
6
|
+
if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
|
|
8
7
|
let decoded: string;
|
|
9
8
|
try {
|
|
10
9
|
decoded = decodeURIComponent(value);
|
|
11
10
|
} catch {
|
|
12
11
|
return undefined;
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return
|
|
13
|
+
let target: URL;
|
|
14
|
+
try {
|
|
15
|
+
target = new URL(decoded, REDIRECT_ORIGIN);
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (target.origin !== REDIRECT_ORIGIN) return undefined;
|
|
20
|
+
if (target.pathname === '/auth' || target.pathname.startsWith('/auth/')) return undefined;
|
|
21
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
21
22
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthActions,
|
|
3
|
+
AuthShell,
|
|
4
|
+
EmailCodeVerification,
|
|
5
|
+
parseRedirectContext,
|
|
6
|
+
} from '@shipfox/client-auth/continuation';
|
|
7
|
+
|
|
8
|
+
describe('client-auth continuation exports', () => {
|
|
9
|
+
test('publishes every continuation primitive through its public subpath', () => {
|
|
10
|
+
expect(AuthActions).toBeTypeOf('function');
|
|
11
|
+
expect(AuthShell).toBeTypeOf('function');
|
|
12
|
+
expect(EmailCodeVerification).toBeTypeOf('function');
|
|
13
|
+
expect(parseRedirectContext).toBeTypeOf('function');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export {AuthActions, AuthShell, type AuthShellProps} from '@shipfox/client-shell/runtime';
|
|
2
|
+
export {
|
|
3
|
+
EmailCodeVerification,
|
|
4
|
+
type EmailCodeVerificationProps,
|
|
5
|
+
} from './components/email-code-verification.js';
|
|
6
|
+
export {parseRedirectContext, type RedirectContext} from './components/redirect-context.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {SignupResponseDto} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {toSignupResult} from './auth-mapper.js';
|
|
3
|
+
|
|
4
|
+
const baseUser: SignupResponseDto['user'] = {
|
|
5
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
6
|
+
email: 'signup@example.com',
|
|
7
|
+
name: null,
|
|
8
|
+
email_verified_at: null,
|
|
9
|
+
status: 'active',
|
|
10
|
+
created_at: '2026-04-27T00:00:00.000Z',
|
|
11
|
+
updated_at: '2026-04-27T00:00:00.000Z',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('toSignupResult', () => {
|
|
15
|
+
test('maps only the user when no challenge, membership, or accept error is present', () => {
|
|
16
|
+
const result = toSignupResult({user: baseUser});
|
|
17
|
+
|
|
18
|
+
expect(result).toEqual({user: {id: baseUser.id, email: baseUser.email}});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('maps the user name and email verification when present', () => {
|
|
22
|
+
const result = toSignupResult({
|
|
23
|
+
user: {...baseUser, name: 'Ada', email_verified_at: '2026-04-27T00:00:00.000Z'},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(result.user).toEqual({
|
|
27
|
+
id: baseUser.id,
|
|
28
|
+
email: baseUser.email,
|
|
29
|
+
name: 'Ada',
|
|
30
|
+
emailVerifiedAt: '2026-04-27T00:00:00.000Z',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('maps the email challenge into camelCase fields', () => {
|
|
35
|
+
const result = toSignupResult({
|
|
36
|
+
user: baseUser,
|
|
37
|
+
email_challenge: {
|
|
38
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
39
|
+
next_resend_available_at: '2026-04-27T00:01:00.000Z',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.emailChallenge).toEqual({
|
|
44
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
45
|
+
nextResendAvailableAt: '2026-04-27T00:01:00.000Z',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('maps the invitation membership into camelCase fields', () => {
|
|
50
|
+
const result = toSignupResult({
|
|
51
|
+
user: baseUser,
|
|
52
|
+
membership: {
|
|
53
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
54
|
+
user_id: baseUser.id,
|
|
55
|
+
workspace_id: '44444444-4444-4444-8444-444444444444',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.membership).toEqual({
|
|
60
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
61
|
+
userId: baseUser.id,
|
|
62
|
+
workspaceId: '44444444-4444-4444-8444-444444444444',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('maps the invitation accept error as-is', () => {
|
|
67
|
+
const result = toSignupResult({
|
|
68
|
+
user: baseUser,
|
|
69
|
+
accept_error: {code: 'invitation-expired', message: 'This invitation has expired.'},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.acceptError).toEqual({
|
|
73
|
+
code: 'invitation-expired',
|
|
74
|
+
message: 'This invitation has expired.',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {SignupResponseDto} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {
|
|
3
|
+
toAuthenticatedSession,
|
|
4
|
+
toUserIdentity,
|
|
5
|
+
type UserIdentity,
|
|
6
|
+
} from '@shipfox/client-shell/runtime';
|
|
7
|
+
|
|
8
|
+
export {toAuthenticatedSession};
|
|
9
|
+
|
|
10
|
+
export interface SignupResult {
|
|
11
|
+
user: UserIdentity;
|
|
12
|
+
emailChallenge?: {id: string; nextResendAvailableAt: string};
|
|
13
|
+
membership?: {id: string; userId: string; workspaceId: string};
|
|
14
|
+
acceptError?: {code: string; message: string};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toSignupResult(dto: SignupResponseDto): SignupResult {
|
|
18
|
+
return {
|
|
19
|
+
user: toUserIdentity(dto.user),
|
|
20
|
+
...(dto.email_challenge
|
|
21
|
+
? {
|
|
22
|
+
emailChallenge: {
|
|
23
|
+
id: dto.email_challenge.id,
|
|
24
|
+
nextResendAvailableAt: dto.email_challenge.next_resend_available_at,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
: {}),
|
|
28
|
+
...(dto.membership
|
|
29
|
+
? {
|
|
30
|
+
membership: {
|
|
31
|
+
id: dto.membership.id,
|
|
32
|
+
userId: dto.membership.user_id,
|
|
33
|
+
workspaceId: dto.membership.workspace_id,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
...(dto.accept_error ? {acceptError: dto.accept_error} : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -37,7 +37,7 @@ describe('loginAuth', () => {
|
|
|
37
37
|
const result = await loginAuth(body);
|
|
38
38
|
|
|
39
39
|
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
40
|
-
expect(result.
|
|
40
|
+
expect(result.accessToken).toBe('access-token');
|
|
41
41
|
expect(request.url).toBe('https://api.example.test/auth/login');
|
|
42
42
|
expect(request.method).toBe('POST');
|
|
43
43
|
expect(requestBody).toEqual(body);
|
|
@@ -1,41 +1,22 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import {useMutation
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
7
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
1
|
+
import {type LoginBodyDto, loginResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
5
|
+
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
8
6
|
|
|
9
7
|
export async function loginAuth(body: LoginBodyDto) {
|
|
10
|
-
|
|
8
|
+
const response = await checkedApiRequest(loginResponseSchema, '/auth/login', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
body,
|
|
11
|
+
});
|
|
12
|
+
return toAuthenticatedSession(response);
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function useLoginAuth() {
|
|
14
|
-
const
|
|
15
|
-
const setState = useSetAtom(authStateAtom);
|
|
16
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
16
17
|
|
|
17
18
|
return useMutation({
|
|
18
19
|
mutationFn: loginAuth,
|
|
19
|
-
onSuccess:
|
|
20
|
-
queryClient.setQueryData(authRefreshQueryKey, result);
|
|
21
|
-
// Resolve workspaces before flipping auth state to authenticated. A
|
|
22
|
-
// single atomic setState avoids the intermediate window where the user
|
|
23
|
-
// appears authenticated with zero workspaces, which sent users with
|
|
24
|
-
// workspaces straight to `/setup/workspaces/new` after form login.
|
|
25
|
-
let memberships: Awaited<ReturnType<typeof listUserWorkspaces>>['memberships'] = [];
|
|
26
|
-
try {
|
|
27
|
-
const workspaces = await queryClient.fetchQuery({
|
|
28
|
-
queryKey: userWorkspacesQueryKey,
|
|
29
|
-
queryFn: () => listUserWorkspaces(result.token),
|
|
30
|
-
retry: false,
|
|
31
|
-
staleTime: 0,
|
|
32
|
-
});
|
|
33
|
-
memberships = workspaces.memberships;
|
|
34
|
-
queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
|
|
35
|
-
} catch {
|
|
36
|
-
// The user is authenticated even if workspace hydration fails.
|
|
37
|
-
}
|
|
38
|
-
setState(toAuthenticatedState(result, memberships));
|
|
39
|
-
},
|
|
20
|
+
onSuccess: enterAuthenticated,
|
|
40
21
|
});
|
|
41
22
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {apiRequest} from '@shipfox/client-api';
|
|
2
|
-
import {useMutation
|
|
3
|
-
import {
|
|
4
|
-
import {authStateAtom} from '#state/auth.js';
|
|
5
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
2
|
+
import {useMutation} from '@tanstack/react-query';
|
|
3
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
6
4
|
|
|
7
5
|
async function logoutAuth() {
|
|
8
6
|
try {
|
|
@@ -13,14 +11,10 @@ async function logoutAuth() {
|
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
export function useLogoutAuth() {
|
|
16
|
-
const
|
|
17
|
-
const setState = useSetAtom(authStateAtom);
|
|
14
|
+
const {enterGuest} = useAuthTransition();
|
|
18
15
|
|
|
19
16
|
return useMutation({
|
|
20
17
|
mutationFn: logoutAuth,
|
|
21
|
-
onSettled:
|
|
22
|
-
setState({status: 'guest'});
|
|
23
|
-
queryClient.removeQueries({queryKey: authRefreshQueryKey});
|
|
24
|
-
},
|
|
18
|
+
onSettled: enterGuest,
|
|
25
19
|
});
|
|
26
20
|
}
|
|
@@ -60,7 +60,7 @@ describe('confirmPasswordReset', () => {
|
|
|
60
60
|
const result = await confirmPasswordReset(body);
|
|
61
61
|
|
|
62
62
|
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
63
|
-
expect(result.
|
|
63
|
+
expect(result.accessToken).toBe('reset-access-token');
|
|
64
64
|
expect(request.url).toBe('https://api.example.test/auth/password-reset/confirm');
|
|
65
65
|
expect(request.method).toBe('POST');
|
|
66
66
|
expect(requestBody).toEqual(body);
|
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import
|
|
2
|
-
PasswordResetConfirmBodyDto,
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
type PasswordResetConfirmBodyDto,
|
|
3
|
+
type PasswordResetRequestBodyDto,
|
|
4
|
+
passwordResetConfirmResponseSchema,
|
|
5
5
|
} from '@shipfox/api-auth-dto';
|
|
6
|
-
import {apiRequest} from '@shipfox/client-api';
|
|
7
|
-
import {useMutation
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
11
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
6
|
+
import {apiRequest, checkedApiRequest} from '@shipfox/client-api';
|
|
7
|
+
import {useMutation} from '@tanstack/react-query';
|
|
8
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
9
|
+
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
12
10
|
|
|
13
11
|
export async function requestPasswordReset(body: PasswordResetRequestBodyDto) {
|
|
14
12
|
await apiRequest<void>('/auth/password-reset', {method: 'POST', body});
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export async function confirmPasswordReset(body: PasswordResetConfirmBodyDto) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
const response = await checkedApiRequest(
|
|
17
|
+
passwordResetConfirmResponseSchema,
|
|
18
|
+
'/auth/password-reset/confirm',
|
|
19
|
+
{
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body,
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
return toAuthenticatedSession(response);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export function useRequestPasswordResetAuth() {
|
|
@@ -26,26 +29,10 @@ export function useRequestPasswordResetAuth() {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export function useConfirmPasswordResetAuth() {
|
|
29
|
-
const
|
|
30
|
-
const setState = useSetAtom(authStateAtom);
|
|
32
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
31
33
|
|
|
32
34
|
return useMutation({
|
|
33
35
|
mutationFn: confirmPasswordReset,
|
|
34
|
-
onSuccess:
|
|
35
|
-
setState(toAuthenticatedState(result));
|
|
36
|
-
queryClient.setQueryData(authRefreshQueryKey, result);
|
|
37
|
-
try {
|
|
38
|
-
const workspaces = await queryClient.fetchQuery({
|
|
39
|
-
queryKey: userWorkspacesQueryKey,
|
|
40
|
-
queryFn: () => listUserWorkspaces(result.token),
|
|
41
|
-
retry: false,
|
|
42
|
-
staleTime: 0,
|
|
43
|
-
});
|
|
44
|
-
setState(toAuthenticatedState(result, workspaces.memberships));
|
|
45
|
-
queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
|
|
46
|
-
} catch {
|
|
47
|
-
// The user is authenticated even if workspace hydration fails.
|
|
48
|
-
}
|
|
49
|
-
},
|
|
36
|
+
onSuccess: enterAuthenticated,
|
|
50
37
|
});
|
|
51
38
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import {type SignupBodyDto, signupResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
3
|
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import {toSignupResult} from './auth-mapper.js';
|
|
4
5
|
|
|
5
6
|
async function signupAuth(body: SignupBodyDto) {
|
|
6
|
-
|
|
7
|
+
const response = await checkedApiRequest(signupResponseSchema, '/auth/signup', {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
body,
|
|
10
|
+
});
|
|
11
|
+
return toSignupResult(response);
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function useSignupAuth() {
|
|
@@ -1,25 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
VerifyEmailConfirmBodyDto,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
type VerifyEmailConfirmBodyDto,
|
|
3
|
+
type VerifyEmailResendBodyDto,
|
|
4
|
+
verifyEmailConfirmResponseSchema,
|
|
5
|
+
verifyEmailResendResponseSchema,
|
|
6
6
|
} from '@shipfox/api-auth-dto';
|
|
7
|
-
import {
|
|
8
|
-
import {useMutation
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
12
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
7
|
+
import {checkedApiRequest} from '@shipfox/client-api';
|
|
8
|
+
import {useMutation} from '@tanstack/react-query';
|
|
9
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
10
|
+
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
13
11
|
|
|
14
12
|
async function verifyEmailAuth(body: VerifyEmailConfirmBodyDto) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
const response = await checkedApiRequest(
|
|
14
|
+
verifyEmailConfirmResponseSchema,
|
|
15
|
+
'/auth/verify-email/confirm',
|
|
16
|
+
{
|
|
17
|
+
method: 'POST',
|
|
18
|
+
body,
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
return toAuthenticatedSession(response);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
async function resendEmailVerificationAuth(body: VerifyEmailResendBodyDto) {
|
|
22
|
-
return await
|
|
25
|
+
return await checkedApiRequest(verifyEmailResendResponseSchema, '/auth/verify-email/resend', {
|
|
23
26
|
method: 'POST',
|
|
24
27
|
body,
|
|
25
28
|
});
|
|
@@ -30,26 +33,10 @@ export function useResendEmailVerificationAuth() {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function useVerifyEmailAuth() {
|
|
33
|
-
const
|
|
34
|
-
const setState = useSetAtom(authStateAtom);
|
|
36
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
35
37
|
|
|
36
38
|
return useMutation({
|
|
37
39
|
mutationFn: verifyEmailAuth,
|
|
38
|
-
onSuccess:
|
|
39
|
-
setState(toAuthenticatedState(result));
|
|
40
|
-
queryClient.setQueryData(authRefreshQueryKey, result);
|
|
41
|
-
try {
|
|
42
|
-
const workspaces = await queryClient.fetchQuery({
|
|
43
|
-
queryKey: userWorkspacesQueryKey,
|
|
44
|
-
queryFn: () => listUserWorkspaces(result.token),
|
|
45
|
-
retry: false,
|
|
46
|
-
staleTime: 0,
|
|
47
|
-
});
|
|
48
|
-
setState(toAuthenticatedState(result, workspaces.memberships));
|
|
49
|
-
queryClient.setQueryData(userWorkspacesQueryKey, workspaces);
|
|
50
|
-
} catch {
|
|
51
|
-
// The user is authenticated even if workspace hydration fails.
|
|
52
|
-
}
|
|
53
|
-
},
|
|
40
|
+
onSuccess: enterAuthenticated,
|
|
54
41
|
});
|
|
55
42
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import {type CreateWorkspaceBodyDto, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
3
|
import {listUserWorkspaces, userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
|
|
4
4
|
import {useMutation} from '@tanstack/react-query';
|
|
5
5
|
import {useRefreshAuth} from './refresh-auth.js';
|
|
6
|
+
import {toWorkspace} from './workspace-mapper.js';
|
|
6
7
|
|
|
7
8
|
export async function createWorkspace(body: CreateWorkspaceBodyDto) {
|
|
8
|
-
|
|
9
|
+
const response = await checkedApiRequest(workspaceResponseSchema, '/workspaces', {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
body,
|
|
12
|
+
});
|
|
13
|
+
return toWorkspace(response);
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export {listUserWorkspaces, userWorkspacesQueryKey};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {WorkspaceResponseDto} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {toWorkspace} from './workspace-mapper.js';
|
|
3
|
+
|
|
4
|
+
describe('toWorkspace', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
'active',
|
|
7
|
+
'suspended',
|
|
8
|
+
'deleted',
|
|
9
|
+
] as const)('maps a %s workspace to its domain shape', (status) => {
|
|
10
|
+
const dto: WorkspaceResponseDto = {
|
|
11
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
12
|
+
name: 'Acme',
|
|
13
|
+
status,
|
|
14
|
+
settings: {},
|
|
15
|
+
created_at: '2026-04-27T00:00:00.000Z',
|
|
16
|
+
updated_at: '2026-04-27T00:00:00.000Z',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(toWorkspace(dto)).toEqual({id: dto.id, name: dto.name, status});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type {WorkspaceResponseDto} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
|
|
3
|
+
export interface Workspace {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
status: 'active' | 'suspended' | 'deleted';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toWorkspace(dto: WorkspaceResponseDto): Workspace {
|
|
10
|
+
return {id: dto.id, name: dto.name, status: dto.status};
|
|
11
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
export * from './components/auth-guard.js';
|
|
2
2
|
export * from './components/auth-provider.js';
|
|
3
|
-
export * from './components/auth-shell.js';
|
|
4
3
|
export {WorkspaceCrumb, type WorkspaceCrumbProps} from './components/workspace-crumb.js';
|
|
5
4
|
export {WorkspaceSwitcher, type WorkspaceSwitcherProps} from './components/workspace-switcher.js';
|
|
6
5
|
export * from './hooks/index.js';
|