@shipfox/client-auth 2.0.0 → 4.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 +40 -0
- package/dist/components/auth-shell.d.ts +6 -4
- package/dist/components/auth-shell.d.ts.map +1 -1
- package/dist/components/auth-shell.js +6 -1
- package/dist/components/auth-shell.js.map +1 -1
- 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/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/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +4 -28
- 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.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +4 -23
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +4 -23
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/pages/form-utils.js +1 -1
- package/dist/pages/form-utils.js.map +1 -1
- package/dist/pages/invitation-context.d.ts +0 -5
- package/dist/pages/invitation-context.d.ts.map +1 -1
- package/dist/pages/invitation-context.js +3 -19
- package/dist/pages/invitation-context.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +50 -144
- 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 -40
- package/src/components/auth-provider.test.tsx +173 -3
- package/src/components/auth-shell.tsx +20 -5
- 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/login-auth.ts +4 -28
- package/src/hooks/api/logout-auth.ts +4 -10
- package/src/hooks/api/password-reset-auth.ts +4 -23
- package/src/hooks/api/verify-email-auth.ts +4 -23
- package/src/pages/form-errors.test.ts +1 -1
- package/src/pages/form-utils.test.ts +1 -4
- package/src/pages/form-utils.ts +1 -1
- package/src/pages/invitation-context.ts +2 -20
- package/src/pages/signup-page.tsx +33 -129
- package/src/state/auth.ts +1 -0
- package/src/state/last-workspace.test.ts +16 -0
- package/tsconfig.build.tsbuildinfo +1 -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/pages/email-verification-resend-model.test.ts +0 -42
- package/src/pages/email-verification-resend-model.ts +0 -15
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
2
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
3
|
+
import {FormField, FormFieldInput} from '@shipfox/react-ui/form-field';
|
|
4
|
+
import type {HeaderProps} from '@shipfox/react-ui/typography';
|
|
5
|
+
import type {FormEvent, ReactNode, Ref} from 'react';
|
|
6
|
+
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
7
|
+
import {AuthShell} from './auth-shell.js';
|
|
8
|
+
|
|
9
|
+
const EIGHT_DIGIT_CODE_RE = /^\d{8}$/u;
|
|
10
|
+
|
|
11
|
+
export interface EmailCodeVerificationProps {
|
|
12
|
+
destination: string;
|
|
13
|
+
expiresAt?: string | undefined;
|
|
14
|
+
nextResendAvailableAt?: string | undefined;
|
|
15
|
+
isResending?: boolean;
|
|
16
|
+
isVerifying?: boolean;
|
|
17
|
+
error?: string | undefined;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
headingProps?: Omit<HeaderProps, 'children' | 'id'> | undefined;
|
|
22
|
+
headingRef?: Ref<HTMLHeadingElement> | undefined;
|
|
23
|
+
onResend: () => void | Promise<void>;
|
|
24
|
+
onUseAnotherEmail: () => void;
|
|
25
|
+
onVerify: (code: string) => void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timestamp(value: string | undefined): number | undefined {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
const parsed = Date.parse(value);
|
|
31
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function EmailCodeVerification({
|
|
35
|
+
destination,
|
|
36
|
+
expiresAt,
|
|
37
|
+
nextResendAvailableAt,
|
|
38
|
+
isResending = false,
|
|
39
|
+
isVerifying = false,
|
|
40
|
+
error,
|
|
41
|
+
title = 'Check your email',
|
|
42
|
+
description = `We sent an eight-digit verification code to ${destination}.`,
|
|
43
|
+
children,
|
|
44
|
+
headingProps,
|
|
45
|
+
headingRef,
|
|
46
|
+
onResend,
|
|
47
|
+
onUseAnotherEmail,
|
|
48
|
+
onVerify,
|
|
49
|
+
}: EmailCodeVerificationProps) {
|
|
50
|
+
const [code, setCode] = useState('');
|
|
51
|
+
const [now, setNow] = useState(() => Date.now());
|
|
52
|
+
const [codeError, setCodeError] = useState<string>();
|
|
53
|
+
const localHeadingRef = useRef<HTMLHeadingElement>(null);
|
|
54
|
+
const resendAvailableAt = timestamp(nextResendAvailableAt);
|
|
55
|
+
const expiration = timestamp(expiresAt);
|
|
56
|
+
const resendRemainingSeconds = resendAvailableAt
|
|
57
|
+
? Math.max(0, Math.ceil((resendAvailableAt - now) / 1_000))
|
|
58
|
+
: 0;
|
|
59
|
+
const isResendCoolingDown = resendRemainingSeconds > 0;
|
|
60
|
+
const isExpired = expiration !== undefined && expiration <= now;
|
|
61
|
+
const isPending = isResending || isVerifying;
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const isPending = (tickNow: number) =>
|
|
65
|
+
(resendAvailableAt !== undefined && resendAvailableAt > tickNow) ||
|
|
66
|
+
(expiration !== undefined && expiration > tickNow);
|
|
67
|
+
|
|
68
|
+
const tickNow = Date.now();
|
|
69
|
+
setNow(tickNow);
|
|
70
|
+
if (!isPending(tickNow)) return;
|
|
71
|
+
|
|
72
|
+
const interval = window.setInterval(() => {
|
|
73
|
+
const nextTick = Date.now();
|
|
74
|
+
setNow(nextTick);
|
|
75
|
+
if (!isPending(nextTick)) {
|
|
76
|
+
window.clearInterval(interval);
|
|
77
|
+
}
|
|
78
|
+
}, 1_000);
|
|
79
|
+
return () => window.clearInterval(interval);
|
|
80
|
+
}, [expiration, resendAvailableAt]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
localHeadingRef.current?.focus();
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const setHeadingRef = useCallback(
|
|
87
|
+
(heading: HTMLHeadingElement | null) => {
|
|
88
|
+
localHeadingRef.current = heading;
|
|
89
|
+
if (typeof headingRef === 'function') {
|
|
90
|
+
headingRef(heading);
|
|
91
|
+
} else if (headingRef) {
|
|
92
|
+
headingRef.current = heading;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[headingRef],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
function submit(event: FormEvent<HTMLFormElement>) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
if (isExpired || isVerifying) return;
|
|
101
|
+
if (!validateCode()) return;
|
|
102
|
+
void onVerify(code);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateCode() {
|
|
106
|
+
const error = EIGHT_DIGIT_CODE_RE.test(code)
|
|
107
|
+
? undefined
|
|
108
|
+
: 'Enter the eight-digit verification code.';
|
|
109
|
+
setCodeError(error);
|
|
110
|
+
return error === undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<AuthShell
|
|
115
|
+
title={title}
|
|
116
|
+
description={description}
|
|
117
|
+
headingProps={headingProps}
|
|
118
|
+
headingRef={setHeadingRef}
|
|
119
|
+
>
|
|
120
|
+
{error ? (
|
|
121
|
+
<Callout role="alert" type="error">
|
|
122
|
+
{error}
|
|
123
|
+
</Callout>
|
|
124
|
+
) : null}
|
|
125
|
+
{isExpired ? (
|
|
126
|
+
<Callout role="alert" type="error">
|
|
127
|
+
This verification code has expired. Use another email to try again.
|
|
128
|
+
</Callout>
|
|
129
|
+
) : null}
|
|
130
|
+
<form className="flex flex-col gap-8" noValidate onSubmit={submit}>
|
|
131
|
+
<FormField label="Verification code" id="verification-code" error={codeError}>
|
|
132
|
+
<FormFieldInput
|
|
133
|
+
autoComplete="one-time-code"
|
|
134
|
+
inputMode="numeric"
|
|
135
|
+
maxLength={8}
|
|
136
|
+
pattern="[0-9]{8}"
|
|
137
|
+
value={code}
|
|
138
|
+
onChange={(event) => {
|
|
139
|
+
setCode(event.target.value.replace(/\D/gu, '').slice(0, 8));
|
|
140
|
+
setCodeError(undefined);
|
|
141
|
+
}}
|
|
142
|
+
onBlur={validateCode}
|
|
143
|
+
/>
|
|
144
|
+
</FormField>
|
|
145
|
+
<Button className="w-full" type="submit" disabled={isExpired} isLoading={isVerifying}>
|
|
146
|
+
Verify email
|
|
147
|
+
</Button>
|
|
148
|
+
<Button
|
|
149
|
+
aria-disabled={isResendCoolingDown ? true : undefined}
|
|
150
|
+
className="w-full aria-disabled:cursor-not-allowed aria-disabled:opacity-70"
|
|
151
|
+
variant="secondary"
|
|
152
|
+
type="button"
|
|
153
|
+
isLoading={isResending}
|
|
154
|
+
onClick={() => {
|
|
155
|
+
if (!isResendCoolingDown && !isResending) void onResend();
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{isResending
|
|
159
|
+
? 'Sending email...'
|
|
160
|
+
: isResendCoolingDown
|
|
161
|
+
? `Resend in ${resendRemainingSeconds}s`
|
|
162
|
+
: 'Resend verification email'}
|
|
163
|
+
</Button>
|
|
164
|
+
</form>
|
|
165
|
+
<Button
|
|
166
|
+
className="w-full"
|
|
167
|
+
variant="transparent"
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={isPending}
|
|
170
|
+
onClick={onUseAnotherEmail}
|
|
171
|
+
>
|
|
172
|
+
Use another email
|
|
173
|
+
</Button>
|
|
174
|
+
{children}
|
|
175
|
+
</AuthShell>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -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 './components/auth-shell.js';
|
|
2
|
+
export {
|
|
3
|
+
EmailCodeVerification,
|
|
4
|
+
type EmailCodeVerificationProps,
|
|
5
|
+
} from './components/email-code-verification.js';
|
|
6
|
+
export {parseRedirectContext, type RedirectContext} from './components/redirect-context.js';
|
|
@@ -1,41 +1,17 @@
|
|
|
1
1
|
import type {LoginBodyDto, LoginResponseDto} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {apiRequest} from '@shipfox/client-api';
|
|
3
|
-
import {useMutation
|
|
4
|
-
import {
|
|
5
|
-
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
6
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
7
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
3
|
+
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
8
5
|
|
|
9
6
|
export async function loginAuth(body: LoginBodyDto) {
|
|
10
7
|
return await apiRequest<LoginResponseDto>('/auth/login', {method: 'POST', body});
|
|
11
8
|
}
|
|
12
9
|
|
|
13
10
|
export function useLoginAuth() {
|
|
14
|
-
const
|
|
15
|
-
const setState = useSetAtom(authStateAtom);
|
|
11
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
16
12
|
|
|
17
13
|
return useMutation({
|
|
18
14
|
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
|
-
},
|
|
15
|
+
onSuccess: enterAuthenticated,
|
|
40
16
|
});
|
|
41
17
|
}
|
|
@@ -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
|
}
|
|
@@ -4,11 +4,8 @@ import type {
|
|
|
4
4
|
PasswordResetRequestBodyDto,
|
|
5
5
|
} from '@shipfox/api-auth-dto';
|
|
6
6
|
import {apiRequest} from '@shipfox/client-api';
|
|
7
|
-
import {useMutation
|
|
8
|
-
import {
|
|
9
|
-
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
10
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
11
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
7
|
+
import {useMutation} from '@tanstack/react-query';
|
|
8
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
12
9
|
|
|
13
10
|
export async function requestPasswordReset(body: PasswordResetRequestBodyDto) {
|
|
14
11
|
await apiRequest<void>('/auth/password-reset', {method: 'POST', body});
|
|
@@ -26,26 +23,10 @@ export function useRequestPasswordResetAuth() {
|
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
export function useConfirmPasswordResetAuth() {
|
|
29
|
-
const
|
|
30
|
-
const setState = useSetAtom(authStateAtom);
|
|
26
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
31
27
|
|
|
32
28
|
return useMutation({
|
|
33
29
|
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
|
-
},
|
|
30
|
+
onSuccess: enterAuthenticated,
|
|
50
31
|
});
|
|
51
32
|
}
|
|
@@ -5,11 +5,8 @@ import type {
|
|
|
5
5
|
VerifyEmailResendResponseDto,
|
|
6
6
|
} from '@shipfox/api-auth-dto';
|
|
7
7
|
import {apiRequest} from '@shipfox/client-api';
|
|
8
|
-
import {useMutation
|
|
9
|
-
import {
|
|
10
|
-
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
11
|
-
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
12
|
-
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
8
|
+
import {useMutation} from '@tanstack/react-query';
|
|
9
|
+
import {useAuthTransition} from '#state/auth.js';
|
|
13
10
|
|
|
14
11
|
async function verifyEmailAuth(body: VerifyEmailConfirmBodyDto) {
|
|
15
12
|
return await apiRequest<VerifyEmailConfirmResponseDto>('/auth/verify-email/confirm', {
|
|
@@ -30,26 +27,10 @@ export function useResendEmailVerificationAuth() {
|
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
export function useVerifyEmailAuth() {
|
|
33
|
-
const
|
|
34
|
-
const setState = useSetAtom(authStateAtom);
|
|
30
|
+
const {enterAuthenticated} = useAuthTransition();
|
|
35
31
|
|
|
36
32
|
return useMutation({
|
|
37
33
|
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
|
-
},
|
|
34
|
+
onSuccess: enterAuthenticated,
|
|
54
35
|
});
|
|
55
36
|
}
|
|
@@ -8,10 +8,7 @@ describe('authErrorMessage', () => {
|
|
|
8
8
|
['email-taken', 'An account already exists for this email.'],
|
|
9
9
|
['token-invalid', 'This link is invalid or expired.'],
|
|
10
10
|
['rate-limited', 'Too many attempts. Wait a bit and try again.'],
|
|
11
|
-
[
|
|
12
|
-
'auth-rate-limit-unavailable',
|
|
13
|
-
'Sign-in protection is temporarily unavailable. Try again soon.',
|
|
14
|
-
],
|
|
11
|
+
['auth-rate-limit-unavailable', 'Sign-in is temporarily unavailable. Try again soon.'],
|
|
15
12
|
['network-error', 'We could not reach the API. Check your connection and try again.'],
|
|
16
13
|
])('maps %s to client copy', (code, message) => {
|
|
17
14
|
const error = new ApiError({code, message: 'Server copy', status: 400});
|
package/src/pages/form-utils.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function authErrorMessage(error: unknown): string {
|
|
|
19
19
|
return 'Too many attempts. Wait a bit and try again.';
|
|
20
20
|
}
|
|
21
21
|
if (error.code === 'auth-rate-limit-unavailable') {
|
|
22
|
-
return 'Sign-in
|
|
22
|
+
return 'Sign-in is temporarily unavailable. Try again soon.';
|
|
23
23
|
}
|
|
24
24
|
if (error.code === 'network-error') {
|
|
25
25
|
return 'We could not reach the API. Check your connection and try again.';
|
|
@@ -4,28 +4,10 @@ import type {
|
|
|
4
4
|
} from '@shipfox/api-workspaces-dto';
|
|
5
5
|
import {apiRequest} from '@shipfox/client-api';
|
|
6
6
|
import {useQuery} from '@tanstack/react-query';
|
|
7
|
+
import {parseRedirectContext} from '#/components/redirect-context.js';
|
|
7
8
|
|
|
8
|
-
const INVITATION_ACCEPT_PATH = '/invitations/accept';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Extract an invitation token from a `redirect=` URL if it points at the
|
|
12
|
-
* canonical pre-auth invitation page. Returns undefined when the redirect is
|
|
13
|
-
* absent, malformed, or unrelated to invitations.
|
|
14
|
-
*/
|
|
15
9
|
export function extractInvitationToken(redirect: unknown): string | undefined {
|
|
16
|
-
|
|
17
|
-
let decoded: string;
|
|
18
|
-
try {
|
|
19
|
-
decoded = decodeURIComponent(redirect);
|
|
20
|
-
} catch {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
if (!decoded.startsWith('/')) return undefined;
|
|
24
|
-
const [path, queryString = ''] = decoded.split('?', 2);
|
|
25
|
-
if (path !== INVITATION_ACCEPT_PATH) return undefined;
|
|
26
|
-
const params = new URLSearchParams(queryString);
|
|
27
|
-
const token = params.get('token');
|
|
28
|
-
return token && token.length > 0 ? token : undefined;
|
|
10
|
+
return parseRedirectContext(redirect).invitationToken;
|
|
29
11
|
}
|
|
30
12
|
|
|
31
13
|
async function fetchPreview(token: string): Promise<PreviewInvitationResponseDto> {
|