@shipfox/client-auth 0.2.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/.storybook/main.ts +1 -0
- package/.storybook/preview.css +7 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +111 -0
- package/LICENSE +21 -0
- package/dist/components/auth-guard.d.ts +5 -0
- package/dist/components/auth-guard.d.ts.map +1 -0
- package/dist/components/auth-guard.js +61 -0
- package/dist/components/auth-guard.js.map +1 -0
- package/dist/components/auth-provider.d.ts +11 -0
- package/dist/components/auth-provider.d.ts.map +1 -0
- package/dist/components/auth-provider.js +21 -0
- package/dist/components/auth-provider.js.map +1 -0
- package/dist/components/auth-shell.d.ts +11 -0
- package/dist/components/auth-shell.d.ts.map +1 -0
- package/dist/components/auth-shell.js +63 -0
- package/dist/components/auth-shell.js.map +1 -0
- package/dist/components/redirect-target.d.ts +2 -0
- package/dist/components/redirect-target.d.ts.map +1 -0
- package/dist/components/redirect-target.js +22 -0
- package/dist/components/redirect-target.js.map +1 -0
- package/dist/components/workspace-crumb.d.ts +3 -0
- package/dist/components/workspace-crumb.d.ts.map +1 -0
- package/dist/components/workspace-crumb.js +3 -0
- package/dist/components/workspace-crumb.js.map +1 -0
- package/dist/components/workspace-switcher.d.ts +6 -0
- package/dist/components/workspace-switcher.d.ts.map +1 -0
- package/dist/components/workspace-switcher.js +91 -0
- package/dist/components/workspace-switcher.js.map +1 -0
- package/dist/components/workspace-switcher.stories.d.ts +26 -0
- package/dist/components/workspace-switcher.stories.d.ts.map +1 -0
- package/dist/components/workspace-switcher.stories.js +168 -0
- package/dist/components/workspace-switcher.stories.js.map +1 -0
- package/dist/feature.d.ts +33 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +43 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/login-auth.d.ts +29 -0
- package/dist/hooks/api/login-auth.d.ts.map +1 -0
- package/dist/hooks/api/login-auth.js +42 -0
- package/dist/hooks/api/login-auth.js.map +1 -0
- package/dist/hooks/api/logout-auth.d.ts +2 -0
- package/dist/hooks/api/logout-auth.d.ts.map +1 -0
- package/dist/hooks/api/logout-auth.js +32 -0
- package/dist/hooks/api/logout-auth.js.map +1 -0
- package/dist/hooks/api/password-reset-auth.d.ts +33 -0
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -0
- package/dist/hooks/api/password-reset-auth.js +48 -0
- package/dist/hooks/api/password-reset-auth.js.map +1 -0
- package/dist/hooks/api/refresh-auth.d.ts +2 -0
- package/dist/hooks/api/refresh-auth.d.ts.map +1 -0
- package/dist/hooks/api/refresh-auth.js +3 -0
- package/dist/hooks/api/refresh-auth.js.map +1 -0
- package/dist/hooks/api/signup-auth.d.ts +27 -0
- package/dist/hooks/api/signup-auth.d.ts.map +1 -0
- package/dist/hooks/api/signup-auth.js +15 -0
- package/dist/hooks/api/signup-auth.js.map +1 -0
- package/dist/hooks/api/verify-email-auth.d.ts +20 -0
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -0
- package/dist/hooks/api/verify-email-auth.js +48 -0
- package/dist/hooks/api/verify-email-auth.js.map +1 -0
- package/dist/hooks/api/workspace-auth.d.ts +22 -0
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -0
- package/dist/hooks/api/workspace-auth.js +25 -0
- package/dist/hooks/api/workspace-auth.js.map +1 -0
- package/dist/hooks/index.d.ts +11 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +11 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/use-active-workspace.d.ts +2 -0
- package/dist/hooks/use-active-workspace.d.ts.map +1 -0
- package/dist/hooks/use-active-workspace.js +3 -0
- package/dist/hooks/use-active-workspace.js.map +1 -0
- package/dist/hooks/use-auth-state.d.ts +2 -0
- package/dist/hooks/use-auth-state.d.ts.map +1 -0
- package/dist/hooks/use-auth-state.js +3 -0
- package/dist/hooks/use-auth-state.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/email-verification-resend-model.d.ts +7 -0
- package/dist/pages/email-verification-resend-model.d.ts.map +1 -0
- package/dist/pages/email-verification-resend-model.js +15 -0
- package/dist/pages/email-verification-resend-model.js.map +1 -0
- package/dist/pages/form-errors.d.ts +19 -0
- package/dist/pages/form-errors.d.ts.map +1 -0
- package/dist/pages/form-errors.js +51 -0
- package/dist/pages/form-errors.js.map +1 -0
- package/dist/pages/form-utils.d.ts +2 -0
- package/dist/pages/form-utils.d.ts.map +1 -0
- package/dist/pages/form-utils.js +28 -0
- package/dist/pages/form-utils.js.map +1 -0
- package/dist/pages/invitation-context.d.ts +31 -0
- package/dist/pages/invitation-context.d.ts.map +1 -0
- package/dist/pages/invitation-context.js +50 -0
- package/dist/pages/invitation-context.js.map +1 -0
- package/dist/pages/login-page.d.ts +2 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.js +214 -0
- package/dist/pages/login-page.js.map +1 -0
- package/dist/pages/logout-page.d.ts +2 -0
- package/dist/pages/logout-page.d.ts.map +1 -0
- package/dist/pages/logout-page.js +45 -0
- package/dist/pages/logout-page.js.map +1 -0
- package/dist/pages/password-reset-page.d.ts +2 -0
- package/dist/pages/password-reset-page.d.ts.map +1 -0
- package/dist/pages/password-reset-page.js +265 -0
- package/dist/pages/password-reset-page.js.map +1 -0
- package/dist/pages/signup-page.d.ts +2 -0
- package/dist/pages/signup-page.d.ts.map +1 -0
- package/dist/pages/signup-page.js +384 -0
- package/dist/pages/signup-page.js.map +1 -0
- package/dist/pages/verify-email-page.d.ts +2 -0
- package/dist/pages/verify-email-page.d.ts.map +1 -0
- package/dist/pages/verify-email-page.js +57 -0
- package/dist/pages/verify-email-page.js.map +1 -0
- package/dist/pages/workspace-onboarding-page.d.ts +2 -0
- package/dist/pages/workspace-onboarding-page.d.ts.map +1 -0
- package/dist/pages/workspace-onboarding-page.js +294 -0
- package/dist/pages/workspace-onboarding-page.js.map +1 -0
- package/dist/routes/index.d.ts +10 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +29 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/login.d.ts +5 -0
- package/dist/routes/login.d.ts.map +1 -0
- package/dist/routes/login.js +11 -0
- package/dist/routes/login.js.map +1 -0
- package/dist/routes/logout.d.ts +6 -0
- package/dist/routes/logout.d.ts.map +1 -0
- package/dist/routes/logout.js +7 -0
- package/dist/routes/logout.js.map +1 -0
- package/dist/routes/reset.d.ts +5 -0
- package/dist/routes/reset.d.ts.map +1 -0
- package/dist/routes/reset.js +11 -0
- package/dist/routes/reset.js.map +1 -0
- package/dist/routes/signup.d.ts +5 -0
- package/dist/routes/signup.d.ts.map +1 -0
- package/dist/routes/signup.js +11 -0
- package/dist/routes/signup.js.map +1 -0
- package/dist/routes/verify-email.d.ts +5 -0
- package/dist/routes/verify-email.d.ts.map +1 -0
- package/dist/routes/verify-email.js +11 -0
- package/dist/routes/verify-email.js.map +1 -0
- package/dist/routes/workspace-onboarding.d.ts +13 -0
- package/dist/routes/workspace-onboarding.d.ts.map +1 -0
- package/dist/routes/workspace-onboarding.js +18 -0
- package/dist/routes/workspace-onboarding.js.map +1 -0
- package/dist/state/auth.d.ts +10 -0
- package/dist/state/auth.d.ts.map +1 -0
- package/dist/state/auth.js +9 -0
- package/dist/state/auth.js.map +1 -0
- package/dist/state/last-workspace.d.ts +2 -0
- package/dist/state/last-workspace.d.ts.map +1 -0
- package/dist/state/last-workspace.js +3 -0
- package/dist/state/last-workspace.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +116 -0
- package/src/components/auth-guard.tsx +58 -0
- package/src/components/auth-provider.test.tsx +164 -0
- package/src/components/auth-provider.tsx +24 -0
- package/src/components/auth-shell.tsx +53 -0
- package/src/components/redirect-target.test.ts +60 -0
- package/src/components/redirect-target.ts +21 -0
- package/src/components/workspace-crumb.tsx +2 -0
- package/src/components/workspace-switcher.stories.tsx +169 -0
- package/src/components/workspace-switcher.tsx +76 -0
- package/src/feature.ts +18 -0
- package/src/hooks/api/login-auth.test.ts +45 -0
- package/src/hooks/api/login-auth.ts +41 -0
- package/src/hooks/api/logout-auth.ts +26 -0
- package/src/hooks/api/password-reset-auth.test.ts +68 -0
- package/src/hooks/api/password-reset-auth.ts +51 -0
- package/src/hooks/api/refresh-auth.test.ts +37 -0
- package/src/hooks/api/refresh-auth.ts +5 -0
- package/src/hooks/api/signup-auth.ts +11 -0
- package/src/hooks/api/verify-email-auth.ts +55 -0
- package/src/hooks/api/workspace-auth.test.ts +40 -0
- package/src/hooks/api/workspace-auth.ts +25 -0
- package/src/hooks/index.ts +13 -0
- package/src/hooks/use-active-workspace.ts +1 -0
- package/src/hooks/use-auth-state.ts +1 -0
- package/src/index.ts +18 -0
- package/src/pages/email-verification-resend-model.test.ts +52 -0
- package/src/pages/email-verification-resend-model.ts +21 -0
- package/src/pages/form-errors.test.ts +114 -0
- package/src/pages/form-errors.ts +45 -0
- package/src/pages/form-utils.test.ts +37 -0
- package/src/pages/form-utils.ts +29 -0
- package/src/pages/invitation-context.ts +57 -0
- package/src/pages/login-page.test.tsx +128 -0
- package/src/pages/login-page.tsx +184 -0
- package/src/pages/logout-page.tsx +33 -0
- package/src/pages/password-reset-page.test.tsx +109 -0
- package/src/pages/password-reset-page.tsx +222 -0
- package/src/pages/signup-page.test.tsx +289 -0
- package/src/pages/signup-page.tsx +354 -0
- package/src/pages/verify-email-page.test.tsx +82 -0
- package/src/pages/verify-email-page.tsx +48 -0
- package/src/pages/workspace-onboarding-page.test.tsx +161 -0
- package/src/pages/workspace-onboarding-page.tsx +213 -0
- package/src/routes/index.tsx +18 -0
- package/src/routes/login.tsx +11 -0
- package/src/routes/logout.tsx +4 -0
- package/src/routes/reset.tsx +11 -0
- package/src/routes/signup.tsx +11 -0
- package/src/routes/verify-email.tsx +11 -0
- package/src/routes/workspace-onboarding.tsx +14 -0
- package/src/state/auth.ts +18 -0
- package/src/state/last-workspace.test.ts +63 -0
- package/src/state/last-workspace.ts +5 -0
- package/test/factories/user.ts +17 -0
- package/test/pages.tsx +83 -0
- package/test/setup.ts +3 -0
- package/test/utils.ts +11 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +71 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {
|
|
3
|
+
loginErrorToFormError,
|
|
4
|
+
passwordResetConfirmErrorToFormError,
|
|
5
|
+
passwordResetRequestErrorToFormError,
|
|
6
|
+
signupErrorToFormError,
|
|
7
|
+
workspaceOnboardingErrorToFormError,
|
|
8
|
+
} from './form-errors.js';
|
|
9
|
+
|
|
10
|
+
function apiError(code: string, status = 400) {
|
|
11
|
+
return new ApiError({code, message: `${code} server message`, status});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('loginErrorToFormError', () => {
|
|
15
|
+
test('routes email-not-verified to the email field', () => {
|
|
16
|
+
const result = loginErrorToFormError(apiError('email-not-verified'));
|
|
17
|
+
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
kind: 'field',
|
|
20
|
+
field: 'email',
|
|
21
|
+
message: 'Verify your email before signing in.',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('routes invalid-credentials to a form-level alert', () => {
|
|
26
|
+
const result = loginErrorToFormError(apiError('invalid-credentials'));
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual({kind: 'form', message: 'Email or password is incorrect.'});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('routes rate-limited to a form-level alert', () => {
|
|
32
|
+
const result = loginErrorToFormError(apiError('rate-limited', 429));
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
kind: 'form',
|
|
36
|
+
message: 'Too many attempts. Wait a bit and try again.',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('routes limiter outages to a form-level alert', () => {
|
|
41
|
+
const result = loginErrorToFormError(apiError('auth-rate-limit-unavailable', 503));
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
kind: 'form',
|
|
45
|
+
message: 'Sign-in protection is temporarily unavailable. Try again soon.',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('routes unknown ApiError codes to a form-level alert', () => {
|
|
50
|
+
const result = loginErrorToFormError(apiError('unknown-code', 400));
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({kind: 'form', message: 'unknown-code server message'});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('routes non-ApiError to a generic form-level alert', () => {
|
|
56
|
+
const result = loginErrorToFormError(new Error('boom'));
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('signupErrorToFormError', () => {
|
|
63
|
+
test('routes email-taken to the email field', () => {
|
|
64
|
+
const result = signupErrorToFormError(apiError('email-taken'));
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
kind: 'field',
|
|
68
|
+
field: 'email',
|
|
69
|
+
message: 'An account already exists for this email.',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('routes other ApiError codes to a form-level alert', () => {
|
|
74
|
+
const result = signupErrorToFormError(apiError('network-error'));
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
kind: 'form',
|
|
78
|
+
message: 'We could not reach the API. Check your connection and try again.',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('passwordResetRequestErrorToFormError', () => {
|
|
84
|
+
test('always routes to a form-level alert', () => {
|
|
85
|
+
const result = passwordResetRequestErrorToFormError(apiError('rate-limited', 429));
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({
|
|
88
|
+
kind: 'form',
|
|
89
|
+
message: 'Too many attempts. Wait a bit and try again.',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('passwordResetConfirmErrorToFormError', () => {
|
|
95
|
+
test('routes token-invalid to a form-level alert', () => {
|
|
96
|
+
const result = passwordResetConfirmErrorToFormError(apiError('token-invalid'));
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({kind: 'form', message: 'This link is invalid or expired.'});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('routes unknown errors to a generic form-level alert', () => {
|
|
102
|
+
const result = passwordResetConfirmErrorToFormError(new Error('boom'));
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('workspaceOnboardingErrorToFormError', () => {
|
|
109
|
+
test('routes any error to a form-level alert', () => {
|
|
110
|
+
const result = workspaceOnboardingErrorToFormError(apiError('conflict', 409));
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({kind: 'form', message: 'conflict server message'});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {authErrorMessage} from './form-utils.js';
|
|
3
|
+
|
|
4
|
+
export type FormErrorMapping<TField extends string> =
|
|
5
|
+
| {kind: 'field'; field: TField; message: string}
|
|
6
|
+
| {kind: 'form'; message: string};
|
|
7
|
+
|
|
8
|
+
type LoginField = 'email' | 'password';
|
|
9
|
+
type SignupField = 'email' | 'password' | 'name';
|
|
10
|
+
type PasswordResetRequestField = 'email';
|
|
11
|
+
type PasswordResetConfirmField = 'new_password';
|
|
12
|
+
|
|
13
|
+
function apiCode(error: unknown): string | undefined {
|
|
14
|
+
return error instanceof ApiError ? error.code : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loginErrorToFormError(error: unknown): FormErrorMapping<LoginField> {
|
|
18
|
+
if (apiCode(error) === 'email-not-verified') {
|
|
19
|
+
return {kind: 'field', field: 'email', message: authErrorMessage(error)};
|
|
20
|
+
}
|
|
21
|
+
return {kind: 'form', message: authErrorMessage(error)};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function signupErrorToFormError(error: unknown): FormErrorMapping<SignupField> {
|
|
25
|
+
if (apiCode(error) === 'email-taken') {
|
|
26
|
+
return {kind: 'field', field: 'email', message: authErrorMessage(error)};
|
|
27
|
+
}
|
|
28
|
+
return {kind: 'form', message: authErrorMessage(error)};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function passwordResetRequestErrorToFormError(
|
|
32
|
+
error: unknown,
|
|
33
|
+
): FormErrorMapping<PasswordResetRequestField> {
|
|
34
|
+
return {kind: 'form', message: authErrorMessage(error)};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function passwordResetConfirmErrorToFormError(
|
|
38
|
+
error: unknown,
|
|
39
|
+
): FormErrorMapping<PasswordResetConfirmField> {
|
|
40
|
+
return {kind: 'form', message: authErrorMessage(error)};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function workspaceOnboardingErrorToFormError(error: unknown): FormErrorMapping<'name'> {
|
|
44
|
+
return {kind: 'form', message: authErrorMessage(error)};
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {authErrorMessage} from './form-utils.js';
|
|
3
|
+
|
|
4
|
+
describe('authErrorMessage', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
['invalid-credentials', 'Email or password is incorrect.'],
|
|
7
|
+
['email-not-verified', 'Verify your email before signing in.'],
|
|
8
|
+
['email-taken', 'An account already exists for this email.'],
|
|
9
|
+
['token-invalid', 'This link is invalid or expired.'],
|
|
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
|
+
],
|
|
15
|
+
['network-error', 'We could not reach the API. Check your connection and try again.'],
|
|
16
|
+
])('maps %s to client copy', (code, message) => {
|
|
17
|
+
const error = new ApiError({code, message: 'Server copy', status: 400});
|
|
18
|
+
|
|
19
|
+
const result = authErrorMessage(error);
|
|
20
|
+
|
|
21
|
+
expect(result).toBe(message);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('falls back to API error messages', () => {
|
|
25
|
+
const error = new ApiError({code: 'unknown-api-code', message: 'Try later', status: 429});
|
|
26
|
+
|
|
27
|
+
const result = authErrorMessage(error);
|
|
28
|
+
|
|
29
|
+
expect(result).toBe('Try later');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('uses generic copy for unknown errors', () => {
|
|
33
|
+
const result = authErrorMessage(new Error('boom'));
|
|
34
|
+
|
|
35
|
+
expect(result).toBe('Something went wrong. Try again.');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
|
|
3
|
+
export function authErrorMessage(error: unknown): string {
|
|
4
|
+
if (!(error instanceof ApiError)) return 'Something went wrong. Try again.';
|
|
5
|
+
|
|
6
|
+
if (error.code === 'invalid-credentials') {
|
|
7
|
+
return 'Email or password is incorrect.';
|
|
8
|
+
}
|
|
9
|
+
if (error.code === 'email-not-verified') {
|
|
10
|
+
return 'Verify your email before signing in.';
|
|
11
|
+
}
|
|
12
|
+
if (error.code === 'email-taken') {
|
|
13
|
+
return 'An account already exists for this email.';
|
|
14
|
+
}
|
|
15
|
+
if (error.code === 'token-invalid') {
|
|
16
|
+
return 'This link is invalid or expired.';
|
|
17
|
+
}
|
|
18
|
+
if (error.code === 'rate-limited') {
|
|
19
|
+
return 'Too many attempts. Wait a bit and try again.';
|
|
20
|
+
}
|
|
21
|
+
if (error.code === 'auth-rate-limit-unavailable') {
|
|
22
|
+
return 'Sign-in protection is temporarily unavailable. Try again soon.';
|
|
23
|
+
}
|
|
24
|
+
if (error.code === 'network-error') {
|
|
25
|
+
return 'We could not reach the API. Check your connection and try again.';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return error.message || 'Something went wrong. Try again.';
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PreviewInvitationPendingDto,
|
|
3
|
+
PreviewInvitationResponseDto,
|
|
4
|
+
} from '@shipfox/api-workspaces-dto';
|
|
5
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
6
|
+
import {useQuery} from '@tanstack/react-query';
|
|
7
|
+
|
|
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
|
+
export function extractInvitationToken(redirect: unknown): string | undefined {
|
|
16
|
+
if (typeof redirect !== 'string') return undefined;
|
|
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;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function fetchPreview(token: string): Promise<PreviewInvitationResponseDto> {
|
|
32
|
+
const params = new URLSearchParams({token});
|
|
33
|
+
return await apiRequest<PreviewInvitationResponseDto>(
|
|
34
|
+
`/invitations/preview?${params.toString()}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function pendingInvitation(
|
|
39
|
+
data: PreviewInvitationResponseDto | undefined,
|
|
40
|
+
): PreviewInvitationPendingDto | undefined {
|
|
41
|
+
return data?.status === 'pending' ? data : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mirrors the hook in `@shipfox/client-invitations` but lives in `client-auth`
|
|
46
|
+
* to avoid a circular dependency. Signup/login pages call this to lock the
|
|
47
|
+
* email field when arriving from an invitation link.
|
|
48
|
+
*/
|
|
49
|
+
export function useInvitationContext(token: string | undefined) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: ['invitations', 'preview', token ?? ''] as const,
|
|
52
|
+
queryFn: () => fetchPreview(token as string),
|
|
53
|
+
enabled: Boolean(token),
|
|
54
|
+
retry: false,
|
|
55
|
+
staleTime: 30_000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {act, fireEvent, screen} from '@testing-library/react';
|
|
3
|
+
import {GuestGuard} from '#components/auth-guard.js';
|
|
4
|
+
import {pageUserFactory} from '#test/factories/user.js';
|
|
5
|
+
import {renderAuthPage} from '#test/pages.js';
|
|
6
|
+
import {jsonResponse, requestUrl} from '#test/utils.js';
|
|
7
|
+
import {LoginPage} from './login-page.js';
|
|
8
|
+
|
|
9
|
+
describe('LoginPage', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('renders account recovery and signup links', async () => {
|
|
15
|
+
const fetchImpl = vi
|
|
16
|
+
.fn()
|
|
17
|
+
.mockResolvedValue(
|
|
18
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
19
|
+
);
|
|
20
|
+
configureApiClient({fetchImpl});
|
|
21
|
+
|
|
22
|
+
renderAuthPage('/auth/login', <LoginPage />);
|
|
23
|
+
|
|
24
|
+
expect(await screen.findByRole('heading', {name: 'Connect to Shipfox'})).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByRole('link', {name: 'Create an account'})).toHaveAttribute(
|
|
26
|
+
'href',
|
|
27
|
+
'/auth/signup',
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByRole('link', {name: 'Forgot password?'})).toHaveAttribute(
|
|
30
|
+
'href',
|
|
31
|
+
'/auth/reset',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('keeps credentials when switching to signup', async () => {
|
|
36
|
+
const fetchImpl = vi
|
|
37
|
+
.fn()
|
|
38
|
+
.mockResolvedValue(
|
|
39
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
40
|
+
);
|
|
41
|
+
configureApiClient({fetchImpl});
|
|
42
|
+
|
|
43
|
+
renderAuthPage('/auth/login', <LoginPage />);
|
|
44
|
+
fireEvent.change(await screen.findByLabelText('Email'), {
|
|
45
|
+
target: {value: 'new@example.com'},
|
|
46
|
+
});
|
|
47
|
+
fireEvent.change(screen.getByLabelText('Password'), {
|
|
48
|
+
target: {value: 'long secure password'},
|
|
49
|
+
});
|
|
50
|
+
fireEvent.click(screen.getByRole('link', {name: 'Create an account'}));
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
await screen.findByRole('heading', {name: 'Create your Shipfox account'}),
|
|
54
|
+
).toBeInTheDocument();
|
|
55
|
+
expect(screen.getByLabelText('Email')).toHaveValue('new@example.com');
|
|
56
|
+
expect(screen.getByLabelText('Password')).toHaveValue('long secure password');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('preserves invitation state when switching to signup', async () => {
|
|
60
|
+
const invitationToken = 'invite-token';
|
|
61
|
+
const redirect = `/invitations/accept?token=${encodeURIComponent(invitationToken)}`;
|
|
62
|
+
const fetchImpl = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
63
|
+
const url = requestUrl(input);
|
|
64
|
+
if (url === 'https://api.example.test/auth/me') {
|
|
65
|
+
return Promise.resolve(
|
|
66
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (url.includes('/invitations/preview?')) {
|
|
70
|
+
return Promise.resolve(
|
|
71
|
+
jsonResponse({
|
|
72
|
+
status: 'pending',
|
|
73
|
+
workspace_id: 'workspace-1',
|
|
74
|
+
workspace_name: 'Invite Workspace',
|
|
75
|
+
email: 'invitee@example.com',
|
|
76
|
+
invited_by_display: 'owner@example.com',
|
|
77
|
+
expires_at: '2026-05-18T00:00:00.000Z',
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
82
|
+
});
|
|
83
|
+
configureApiClient({fetchImpl});
|
|
84
|
+
|
|
85
|
+
renderAuthPage(`/auth/login?redirect=${encodeURIComponent(redirect)}`, <LoginPage />);
|
|
86
|
+
await screen.findByRole('heading', {name: 'Join Invite Workspace'});
|
|
87
|
+
|
|
88
|
+
expect(screen.getByRole('link', {name: 'Create an account'})).toHaveAttribute(
|
|
89
|
+
'href',
|
|
90
|
+
`/auth/signup?redirect=${encodeURIComponent(redirect)}`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('posts valid credentials and navigates home', async () => {
|
|
95
|
+
const user = pageUserFactory.build({email: 'login@example.com'});
|
|
96
|
+
const fetchImpl = vi
|
|
97
|
+
.fn()
|
|
98
|
+
.mockResolvedValueOnce(
|
|
99
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
100
|
+
)
|
|
101
|
+
.mockImplementationOnce(() => {
|
|
102
|
+
return jsonResponse({
|
|
103
|
+
token: 'access-token',
|
|
104
|
+
user,
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
.mockImplementationOnce(() => {
|
|
108
|
+
return jsonResponse({memberships: []});
|
|
109
|
+
});
|
|
110
|
+
configureApiClient({fetchImpl});
|
|
111
|
+
|
|
112
|
+
renderAuthPage(
|
|
113
|
+
'/auth/login',
|
|
114
|
+
<GuestGuard>
|
|
115
|
+
<LoginPage />
|
|
116
|
+
</GuestGuard>,
|
|
117
|
+
);
|
|
118
|
+
fireEvent.change(await screen.findByLabelText('Email'), {
|
|
119
|
+
target: {value: 'login@example.com'},
|
|
120
|
+
});
|
|
121
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'correct horse'}});
|
|
122
|
+
// Let TanStack Form publish field changes before submit validation reads them.
|
|
123
|
+
await act(() => Promise.resolve());
|
|
124
|
+
fireEvent.click(screen.getByRole('button', {name: 'Log in'}));
|
|
125
|
+
|
|
126
|
+
expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
3
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
5
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
6
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
7
|
+
import {useForm} from '@tanstack/react-form';
|
|
8
|
+
import {Link, useSearch} from '@tanstack/react-router';
|
|
9
|
+
import {useAtom} from 'jotai';
|
|
10
|
+
import {useEffect, useRef, useState} from 'react';
|
|
11
|
+
import {AuthShell} from '#/components/auth-shell.js';
|
|
12
|
+
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
13
|
+
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
14
|
+
import {loginErrorToFormError} from './form-errors.js';
|
|
15
|
+
import {
|
|
16
|
+
extractInvitationToken,
|
|
17
|
+
pendingInvitation,
|
|
18
|
+
useInvitationContext,
|
|
19
|
+
} from './invitation-context.js';
|
|
20
|
+
|
|
21
|
+
export function LoginPage() {
|
|
22
|
+
const login = useLoginAuth();
|
|
23
|
+
const search = useSearch({strict: false}) as {redirect?: unknown};
|
|
24
|
+
const invitationToken = extractInvitationToken(search.redirect);
|
|
25
|
+
const invitationPreview = useInvitationContext(invitationToken);
|
|
26
|
+
const invitationPending = pendingInvitation(invitationPreview.data);
|
|
27
|
+
const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
28
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
29
|
+
const draftRef = useRef(authFormDraft);
|
|
30
|
+
draftRef.current = authFormDraft;
|
|
31
|
+
// Set just before clearing the draft on success so the unmount cleanup
|
|
32
|
+
// below does not repersist the just-submitted credentials.
|
|
33
|
+
const skipDraftPersistRef = useRef(false);
|
|
34
|
+
|
|
35
|
+
const form = useForm({
|
|
36
|
+
defaultValues: {email: authFormDraft.email, password: authFormDraft.password},
|
|
37
|
+
onSubmit: async ({value}) => {
|
|
38
|
+
setFormError(undefined);
|
|
39
|
+
try {
|
|
40
|
+
await login.mutateAsync(value);
|
|
41
|
+
skipDraftPersistRef.current = true;
|
|
42
|
+
setAuthFormDraft(initialAuthFormDraft);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const mapped = loginErrorToFormError(error);
|
|
45
|
+
if (mapped.kind === 'field') {
|
|
46
|
+
form.setFieldMeta(mapped.field, (prev) => ({
|
|
47
|
+
...prev,
|
|
48
|
+
errorMap: {...prev.errorMap, onServer: mapped.message},
|
|
49
|
+
}));
|
|
50
|
+
} else {
|
|
51
|
+
setFormError(mapped.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Lock the email field when arriving from an invitation link so the user
|
|
58
|
+
// can't log in as a different account than the one the invitation targets.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (invitationPending && form.state.values.email !== invitationPending.email) {
|
|
61
|
+
form.setFieldValue('email', invitationPending.email);
|
|
62
|
+
setAuthFormDraft((current) => ({...current, email: invitationPending.email}));
|
|
63
|
+
}
|
|
64
|
+
}, [invitationPending, form, setAuthFormDraft]);
|
|
65
|
+
|
|
66
|
+
// Sync TanStack Form values back into the Jotai draft on unmount so a
|
|
67
|
+
// navigation to /signup or /reset preserves what the user typed. Skipped
|
|
68
|
+
// after a successful login because we just intentionally cleared the draft.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
return () => {
|
|
71
|
+
if (skipDraftPersistRef.current) return;
|
|
72
|
+
const {email, password} = form.state.values;
|
|
73
|
+
if (email !== draftRef.current.email || password !== draftRef.current.password) {
|
|
74
|
+
setAuthFormDraft({email, password});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [form, setAuthFormDraft]);
|
|
78
|
+
|
|
79
|
+
const isInvitationEmailLocked = Boolean(invitationPending);
|
|
80
|
+
const invitationRedirect = invitationToken
|
|
81
|
+
? `/invitations/accept?token=${encodeURIComponent(invitationToken)}`
|
|
82
|
+
: undefined;
|
|
83
|
+
const headerTitle = invitationPending
|
|
84
|
+
? `Join ${invitationPending.workspace_name}`
|
|
85
|
+
: 'Connect to Shipfox';
|
|
86
|
+
const headerDescription = invitationPending
|
|
87
|
+
? 'Log in to accept your invitation.'
|
|
88
|
+
: 'Log in to access Shipfox.';
|
|
89
|
+
|
|
90
|
+
function persistDraft() {
|
|
91
|
+
const {email, password} = form.state.values;
|
|
92
|
+
setAuthFormDraft({email, password});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<AuthShell title={headerTitle} description={headerDescription}>
|
|
97
|
+
<form
|
|
98
|
+
className="flex flex-col gap-18"
|
|
99
|
+
noValidate
|
|
100
|
+
onSubmit={(event) => {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
void form.handleSubmit();
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{formError ? (
|
|
107
|
+
<Callout role="alert" type="error">
|
|
108
|
+
{formError}
|
|
109
|
+
</Callout>
|
|
110
|
+
) : null}
|
|
111
|
+
<form.Field
|
|
112
|
+
name="email"
|
|
113
|
+
validators={{onBlur: loginBodySchema.shape.email, onSubmit: loginBodySchema.shape.email}}
|
|
114
|
+
>
|
|
115
|
+
{(field) => (
|
|
116
|
+
<FormField label="Email" id="email" error={fieldError(field)}>
|
|
117
|
+
<FormFieldInput
|
|
118
|
+
autoComplete="email"
|
|
119
|
+
name="email"
|
|
120
|
+
type="email"
|
|
121
|
+
value={field.state.value}
|
|
122
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
123
|
+
onBlur={() => {
|
|
124
|
+
field.handleBlur();
|
|
125
|
+
persistDraft();
|
|
126
|
+
}}
|
|
127
|
+
readOnly={isInvitationEmailLocked}
|
|
128
|
+
iconRight={
|
|
129
|
+
isInvitationEmailLocked ? (
|
|
130
|
+
<Icon
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
className="size-16 text-foreground-neutral-disabled"
|
|
133
|
+
name="lockLine"
|
|
134
|
+
/>
|
|
135
|
+
) : undefined
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
</FormField>
|
|
139
|
+
)}
|
|
140
|
+
</form.Field>
|
|
141
|
+
<form.Field
|
|
142
|
+
name="password"
|
|
143
|
+
validators={{
|
|
144
|
+
onBlur: loginBodySchema.shape.password,
|
|
145
|
+
onSubmit: loginBodySchema.shape.password,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
{(field) => (
|
|
149
|
+
<FormField label="Password" id="password" error={fieldError(field)}>
|
|
150
|
+
<FormFieldInput
|
|
151
|
+
autoComplete="current-password"
|
|
152
|
+
name="password"
|
|
153
|
+
type="password"
|
|
154
|
+
value={field.state.value}
|
|
155
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
156
|
+
onBlur={() => {
|
|
157
|
+
field.handleBlur();
|
|
158
|
+
persistDraft();
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
</FormField>
|
|
162
|
+
)}
|
|
163
|
+
</form.Field>
|
|
164
|
+
<ButtonLink asChild variant="subtle" className="-mt-8 self-end">
|
|
165
|
+
<Link to="/auth/reset">Forgot password?</Link>
|
|
166
|
+
</ButtonLink>
|
|
167
|
+
<Button className="w-full" isLoading={login.isPending} type="submit">
|
|
168
|
+
{login.isPending ? 'Logging in...' : 'Log in'}
|
|
169
|
+
</Button>
|
|
170
|
+
</form>
|
|
171
|
+
<Text size="sm" className="text-center text-foreground-neutral-subtle">
|
|
172
|
+
New to Shipfox?{' '}
|
|
173
|
+
<ButtonLink asChild variant="interactive" underline>
|
|
174
|
+
<Link
|
|
175
|
+
to="/auth/signup"
|
|
176
|
+
search={invitationRedirect ? {redirect: invitationRedirect} : undefined}
|
|
177
|
+
>
|
|
178
|
+
Create an account
|
|
179
|
+
</Link>
|
|
180
|
+
</ButtonLink>
|
|
181
|
+
</Text>
|
|
182
|
+
</AuthShell>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
2
|
+
import {useNavigate, useRouter, useSearch} from '@tanstack/react-router';
|
|
3
|
+
import {useEffect} from 'react';
|
|
4
|
+
import {AuthShell} from '#/components/auth-shell.js';
|
|
5
|
+
import {sanitizeRedirectPath} from '#/components/redirect-target.js';
|
|
6
|
+
import {useLogoutAuth} from '#hooks/api/logout-auth.js';
|
|
7
|
+
|
|
8
|
+
export function LogoutPage() {
|
|
9
|
+
const logout = useLogoutAuth();
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const search = useSearch({strict: false}) as {redirect?: unknown};
|
|
13
|
+
const target = sanitizeRedirectPath(search.redirect);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
logout.mutateAsync().finally(() => {
|
|
17
|
+
if (target !== undefined) {
|
|
18
|
+
// Same-origin runtime path; let history drive route resolution.
|
|
19
|
+
router.history.replace(target);
|
|
20
|
+
} else {
|
|
21
|
+
navigate({to: '/auth/login', replace: true});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}, [logout.mutateAsync, navigate, router, target]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<AuthShell title="Logging out" description="Ending your Shipfox session.">
|
|
28
|
+
<Text size="sm" className="text-center text-foreground-neutral-subtle">
|
|
29
|
+
You will be sent back to login in a moment.
|
|
30
|
+
</Text>
|
|
31
|
+
</AuthShell>
|
|
32
|
+
);
|
|
33
|
+
}
|