@shipfox/client-auth 5.0.0 → 6.0.1
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 +43 -0
- package/dist/components/auth-guard.d.ts.map +1 -1
- package/dist/components/auth-guard.js +4 -4
- package/dist/components/auth-guard.js.map +1 -1
- package/dist/components/redirect-target.d.ts +7 -0
- package/dist/components/redirect-target.d.ts.map +1 -1
- package/dist/components/redirect-target.js +35 -4
- package/dist/components/redirect-target.js.map +1 -1
- package/dist/core/auth.d.ts +57 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +3 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/hooks/api/auth-mapper.d.ts +2 -17
- package/dist/hooks/api/auth-mapper.d.ts.map +1 -1
- package/dist/hooks/api/auth-mapper.js.map +1 -1
- package/dist/hooks/api/login-auth.d.ts +3 -6
- package/dist/hooks/api/login-auth.d.ts.map +1 -1
- package/dist/hooks/api/login-auth.js +2 -2
- package/dist/hooks/api/login-auth.js.map +1 -1
- package/dist/hooks/api/logout-auth.js +2 -2
- package/dist/hooks/api/logout-auth.js.map +1 -1
- package/dist/hooks/api/password-reset-auth.d.ts +5 -10
- package/dist/hooks/api/password-reset-auth.d.ts.map +1 -1
- package/dist/hooks/api/password-reset-auth.js +9 -6
- package/dist/hooks/api/password-reset-auth.js.map +1 -1
- package/dist/hooks/api/refresh-auth.d.ts +1 -1
- package/dist/hooks/api/refresh-auth.d.ts.map +1 -1
- package/dist/hooks/api/refresh-auth.js +1 -1
- package/dist/hooks/api/refresh-auth.js.map +1 -1
- package/dist/hooks/api/signup-auth.d.ts +2 -6
- package/dist/hooks/api/signup-auth.d.ts.map +1 -1
- package/dist/hooks/api/signup-auth.js +9 -2
- package/dist/hooks/api/signup-auth.js.map +1 -1
- package/dist/hooks/api/verify-email-auth.d.ts +4 -10
- package/dist/hooks/api/verify-email-auth.d.ts.map +1 -1
- package/dist/hooks/api/verify-email-auth.js +15 -5
- package/dist/hooks/api/verify-email-auth.js.map +1 -1
- package/dist/hooks/api/workspace-auth.d.ts +5 -7
- package/dist/hooks/api/workspace-auth.d.ts.map +1 -1
- package/dist/hooks/api/workspace-auth.js +6 -4
- package/dist/hooks/api/workspace-auth.js.map +1 -1
- package/dist/hooks/api/workspace-mapper.d.ts +1 -5
- package/dist/hooks/api/workspace-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workspace-mapper.js.map +1 -1
- package/dist/pages/login-page.d.ts.map +1 -1
- package/dist/pages/login-page.js +4 -5
- package/dist/pages/login-page.js.map +1 -1
- package/dist/pages/logout-page.d.ts.map +1 -1
- package/dist/pages/logout-page.js +8 -18
- 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 +8 -7
- 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 +16 -10
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/pages/workspace-onboarding-page.js +2 -2
- package/dist/pages/workspace-onboarding-page.js.map +1 -1
- package/dist/routes/inputs.d.ts +9 -0
- package/dist/routes/inputs.d.ts.map +1 -0
- package/dist/routes/inputs.js +17 -0
- package/dist/routes/inputs.js.map +1 -0
- package/dist/routes/login.d.ts +2 -0
- package/dist/routes/login.d.ts.map +1 -1
- package/dist/routes/login.js +2 -0
- package/dist/routes/login.js.map +1 -1
- package/dist/routes/logout.d.ts +2 -0
- package/dist/routes/logout.d.ts.map +1 -1
- package/dist/routes/logout.js +2 -0
- package/dist/routes/logout.js.map +1 -1
- package/dist/routes/reset.d.ts +2 -0
- package/dist/routes/reset.d.ts.map +1 -1
- package/dist/routes/reset.js +2 -0
- package/dist/routes/reset.js.map +1 -1
- package/dist/routes/signup.d.ts +2 -0
- package/dist/routes/signup.d.ts.map +1 -1
- package/dist/routes/signup.js +2 -0
- package/dist/routes/signup.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +11 -12
- package/src/components/auth-guard.tsx +4 -2
- package/src/components/redirect-target.test.ts +32 -1
- package/src/components/redirect-target.ts +41 -4
- package/src/core/auth.ts +55 -0
- package/src/hooks/api/auth-mapper.ts +2 -12
- package/src/hooks/api/login-auth.ts +4 -3
- package/src/hooks/api/logout-auth.ts +2 -2
- package/src/hooks/api/password-reset-auth.test.ts +2 -2
- package/src/hooks/api/password-reset-auth.ts +10 -10
- package/src/hooks/api/refresh-auth.ts +1 -0
- package/src/hooks/api/signup-auth.ts +9 -3
- package/src/hooks/api/verify-email-auth.ts +19 -9
- package/src/hooks/api/workspace-auth.ts +10 -5
- package/src/hooks/api/workspace-mapper.ts +1 -6
- package/src/pages/login-page.tsx +4 -3
- package/src/pages/logout-page.test.tsx +75 -0
- package/src/pages/logout-page.tsx +9 -13
- package/src/pages/password-reset-page.tsx +5 -5
- package/src/pages/signup-page.tsx +14 -8
- package/src/pages/workspace-onboarding-page.tsx +2 -2
- package/src/routes/inputs.test.ts +17 -0
- package/src/routes/inputs.ts +21 -0
- package/src/routes/login.tsx +2 -0
- package/src/routes/logout.tsx +2 -1
- package/src/routes/reset.tsx +2 -0
- package/src/routes/signup.tsx +2 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/vercel.json +5 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "dist/index.js",
|
|
12
12
|
"types": "dist/index.d.ts",
|
|
13
|
-
"imports": {
|
|
14
|
-
"#*": "./dist/*",
|
|
15
|
-
"#test/*": "./test/*"
|
|
16
|
-
},
|
|
17
13
|
"exports": {
|
|
18
14
|
".": {
|
|
19
15
|
"types": "./dist/index.d.ts",
|
|
@@ -35,13 +31,13 @@
|
|
|
35
31
|
"dependencies": {
|
|
36
32
|
"@swc/helpers": "^0.5.17",
|
|
37
33
|
"@tanstack/react-form": "^1.32.0",
|
|
38
|
-
"@shipfox/api-auth-dto": "
|
|
39
|
-
"@shipfox/
|
|
40
|
-
"@shipfox/api
|
|
41
|
-
"@shipfox/client-invitations": "
|
|
42
|
-
"@shipfox/client-shell": "
|
|
43
|
-
"@shipfox/client-ui": "
|
|
44
|
-
"@shipfox/react-ui": "0.3.
|
|
34
|
+
"@shipfox/api-auth-dto": "9.0.1",
|
|
35
|
+
"@shipfox/api-workspaces-dto": "9.0.1",
|
|
36
|
+
"@shipfox/client-api": "6.0.1",
|
|
37
|
+
"@shipfox/client-invitations": "6.0.1",
|
|
38
|
+
"@shipfox/client-shell": "6.0.1",
|
|
39
|
+
"@shipfox/client-ui": "6.0.1",
|
|
40
|
+
"@shipfox/react-ui": "0.3.6"
|
|
45
41
|
},
|
|
46
42
|
"peerDependencies": {
|
|
47
43
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -50,6 +46,9 @@
|
|
|
50
46
|
"react": "^19.0.0",
|
|
51
47
|
"react-dom": "^19.0.0"
|
|
52
48
|
},
|
|
49
|
+
"imports": {
|
|
50
|
+
"#*": "./dist/*"
|
|
51
|
+
},
|
|
53
52
|
"scripts": {
|
|
54
53
|
"build": "shipfox-swc",
|
|
55
54
|
"check": "shipfox-biome-check",
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import {useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
1
2
|
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
2
|
-
import {Navigate, useRouter
|
|
3
|
+
import {Navigate, useRouter} from '@tanstack/react-router';
|
|
3
4
|
import {type PropsWithChildren, useEffect} from 'react';
|
|
4
5
|
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
5
6
|
import {WorkspaceOnboardingPage} from '#pages/workspace-onboarding-page.js';
|
|
7
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
6
8
|
import {sanitizeRedirectPath} from './redirect-target.js';
|
|
7
9
|
|
|
8
10
|
export function AuthGuard({children}: PropsWithChildren) {
|
|
@@ -21,7 +23,7 @@ export function AuthGuard({children}: PropsWithChildren) {
|
|
|
21
23
|
|
|
22
24
|
export function GuestGuard({children}: PropsWithChildren) {
|
|
23
25
|
const auth = useAuthState();
|
|
24
|
-
const search =
|
|
26
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
25
27
|
const router = useRouter();
|
|
26
28
|
const target = sanitizeRedirectPath(search.redirect);
|
|
27
29
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {sanitizeRedirectPath} from './redirect-target.js';
|
|
1
|
+
import {sanitizeLogoutRedirectPath, sanitizeRedirectPath} from './redirect-target.js';
|
|
2
2
|
|
|
3
3
|
describe('sanitizeRedirectPath', () => {
|
|
4
4
|
describe.each([
|
|
@@ -66,3 +66,34 @@ describe('sanitizeRedirectPath', () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
describe('sanitizeLogoutRedirectPath', () => {
|
|
71
|
+
describe.each([
|
|
72
|
+
['explicit login fallback', '/auth/login', '/auth/login'],
|
|
73
|
+
['same-origin workspace path', '/workspaces/abc', '/workspaces/abc'],
|
|
74
|
+
[
|
|
75
|
+
'same-origin path with search and hash',
|
|
76
|
+
'/workspaces/abc?tab=runs#header',
|
|
77
|
+
'/workspaces/abc?tab=runs#header',
|
|
78
|
+
],
|
|
79
|
+
])('accepts %s', (_label, input, expected) => {
|
|
80
|
+
test('returns the safe destination', () => {
|
|
81
|
+
expect(sanitizeLogoutRedirectPath(input)).toBe(expected);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe.each([
|
|
86
|
+
['missing redirect', undefined],
|
|
87
|
+
['external URL', 'https://attacker.example'],
|
|
88
|
+
['protocol-relative URL', '//attacker.example'],
|
|
89
|
+
['auth route other than login', '/auth/reset'],
|
|
90
|
+
['login route with a query', '/auth/login?redirect=/workspaces/abc'],
|
|
91
|
+
['raw invitation token', '/invitations/accept?token=sf_i_raw-token'],
|
|
92
|
+
['raw invitation token with trailing slash', '/invitations/accept/?token=sf_i_raw-token'],
|
|
93
|
+
['malformed percent encoding', '/%E0%80%80'],
|
|
94
|
+
])('falls back for %s', (_label, input) => {
|
|
95
|
+
test('returns login without forwarding unsafe state', () => {
|
|
96
|
+
expect(sanitizeLogoutRedirectPath(input)).toBe('/auth/login');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const REDIRECT_ORIGIN = 'https://shipfox-redirect.invalid';
|
|
2
|
+
const LOGIN_PATH = '/auth/login';
|
|
3
|
+
const INVITATION_ACCEPT_PATH = '/invitations/accept';
|
|
4
|
+
const DEFAULT_LOGOUT_REDIRECT = LOGIN_PATH;
|
|
5
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
// parsing cannot turn a seemingly safe path into an external or auth route.
|
|
5
|
-
export function sanitizeRedirectPath(value: unknown): string | undefined {
|
|
7
|
+
function resolveRedirectPath(value: unknown): URL | undefined {
|
|
6
8
|
if (typeof value !== 'string' || !value.startsWith('/')) return undefined;
|
|
7
9
|
let decoded: string;
|
|
8
10
|
try {
|
|
@@ -17,6 +19,41 @@ export function sanitizeRedirectPath(value: unknown): string | undefined {
|
|
|
17
19
|
return undefined;
|
|
18
20
|
}
|
|
19
21
|
if (target.origin !== REDIRECT_ORIGIN) return undefined;
|
|
20
|
-
|
|
22
|
+
return target;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatRedirectPath(target: URL): string {
|
|
21
26
|
return `${target.pathname}${target.search}${target.hash}`;
|
|
22
27
|
}
|
|
28
|
+
|
|
29
|
+
function isAuthPath(pathname: string): boolean {
|
|
30
|
+
return pathname === '/auth' || pathname.startsWith('/auth/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolves and canonicalizes an internal path before returning it, so browser URL
|
|
34
|
+
// parsing cannot turn a seemingly safe path into an external or auth route.
|
|
35
|
+
export function sanitizeRedirectPath(value: unknown): string | undefined {
|
|
36
|
+
const target = resolveRedirectPath(value);
|
|
37
|
+
if (!target || isAuthPath(target.pathname)) return undefined;
|
|
38
|
+
return formatRedirectPath(target);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function containsInvitationToken(target: URL): boolean {
|
|
42
|
+
const normalizedPathname = target.pathname.replace(TRAILING_SLASHES, '') || '/';
|
|
43
|
+
return normalizedPathname === INVITATION_ACCEPT_PATH && target.searchParams.has('token');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the destination used by the shared logout route.
|
|
48
|
+
*
|
|
49
|
+
* Login is the only auth destination allowed, and invitation tokens never
|
|
50
|
+
* survive this boundary. Invalid values fail closed to login.
|
|
51
|
+
*/
|
|
52
|
+
export function sanitizeLogoutRedirectPath(value: unknown): string {
|
|
53
|
+
const target = resolveRedirectPath(value);
|
|
54
|
+
if (!target) return DEFAULT_LOGOUT_REDIRECT;
|
|
55
|
+
if (isAuthPath(target.pathname) || containsInvitationToken(target)) {
|
|
56
|
+
return DEFAULT_LOGOUT_REDIRECT;
|
|
57
|
+
}
|
|
58
|
+
return formatRedirectPath(target);
|
|
59
|
+
}
|
package/src/core/auth.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface LoginCommand {
|
|
2
|
+
email: string;
|
|
3
|
+
password: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SignupCommand extends LoginCommand {
|
|
7
|
+
name: string;
|
|
8
|
+
invitationToken?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PasswordResetRequestCommand {
|
|
12
|
+
email: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PasswordResetConfirmCommand {
|
|
16
|
+
token: string;
|
|
17
|
+
newPassword: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface VerifyEmailCommand {
|
|
21
|
+
email: string;
|
|
22
|
+
challengeId: string;
|
|
23
|
+
code: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ResendEmailVerificationCommand {
|
|
27
|
+
email: string;
|
|
28
|
+
challengeId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WorkspaceCreateCommand {
|
|
32
|
+
name: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Workspace {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
status: 'active' | 'suspended' | 'deleted';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SignupResult {
|
|
42
|
+
user: {
|
|
43
|
+
id: string;
|
|
44
|
+
email: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
emailVerifiedAt?: string;
|
|
47
|
+
};
|
|
48
|
+
emailChallenge?: {id: string; nextResendAvailableAt: string};
|
|
49
|
+
membership?: {id: string; userId: string; workspaceId: string};
|
|
50
|
+
acceptError?: {code: string; message: string};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EmailVerificationResendResult {
|
|
54
|
+
nextResendAvailableAt: string;
|
|
55
|
+
}
|
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
import type {SignupResponseDto} from '@shipfox/api-auth-dto';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
toUserIdentity,
|
|
5
|
-
type UserIdentity,
|
|
6
|
-
} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {toAuthenticatedSession, toUserIdentity} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import type {SignupResult} from '#core/auth.js';
|
|
7
4
|
|
|
8
5
|
export {toAuthenticatedSession};
|
|
9
6
|
|
|
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
7
|
export function toSignupResult(dto: SignupResponseDto): SignupResult {
|
|
18
8
|
return {
|
|
19
9
|
user: toUserIdentity(dto.user),
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {loginResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
3
|
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import type {LoginCommand} from '#core/auth.js';
|
|
4
5
|
import {useAuthTransition} from '#state/auth.js';
|
|
5
6
|
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
6
7
|
|
|
7
|
-
export async function loginAuth(
|
|
8
|
+
export async function loginAuth(command: LoginCommand) {
|
|
8
9
|
const response = await checkedApiRequest(loginResponseSchema, '/auth/login', {
|
|
9
10
|
method: 'POST',
|
|
10
|
-
body,
|
|
11
|
+
body: command,
|
|
11
12
|
});
|
|
12
13
|
return toAuthenticatedSession(response);
|
|
13
14
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
|
|
2
2
|
import {useMutation} from '@tanstack/react-query';
|
|
3
3
|
import {useAuthTransition} from '#state/auth.js';
|
|
4
4
|
|
|
5
5
|
async function logoutAuth() {
|
|
6
6
|
try {
|
|
7
|
-
await
|
|
7
|
+
await checkedApiRequest(emptyResponseSchema, '/auth/logout', {method: 'POST', body: {}});
|
|
8
8
|
} catch {
|
|
9
9
|
// Logout is best-effort: local session state must clear even if the API is offline.
|
|
10
10
|
}
|
|
@@ -55,7 +55,7 @@ describe('confirmPasswordReset', () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
configureApiClient({fetchImpl});
|
|
58
|
-
const body = {token: 'reset-token',
|
|
58
|
+
const body = {token: 'reset-token', newPassword: 'new password is long'};
|
|
59
59
|
|
|
60
60
|
const result = await confirmPasswordReset(body);
|
|
61
61
|
|
|
@@ -63,6 +63,6 @@ describe('confirmPasswordReset', () => {
|
|
|
63
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
|
-
expect(requestBody).toEqual(body);
|
|
66
|
+
expect(requestBody).toEqual({token: body.token, new_password: body.newPassword});
|
|
67
67
|
});
|
|
68
68
|
});
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type PasswordResetRequestBodyDto,
|
|
4
|
-
passwordResetConfirmResponseSchema,
|
|
5
|
-
} from '@shipfox/api-auth-dto';
|
|
6
|
-
import {apiRequest, checkedApiRequest} from '@shipfox/client-api';
|
|
1
|
+
import {passwordResetConfirmResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {checkedApiRequest, emptyResponseSchema} from '@shipfox/client-api';
|
|
7
3
|
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import type {PasswordResetConfirmCommand, PasswordResetRequestCommand} from '#core/auth.js';
|
|
8
5
|
import {useAuthTransition} from '#state/auth.js';
|
|
9
6
|
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
10
7
|
|
|
11
|
-
export async function requestPasswordReset(
|
|
12
|
-
await
|
|
8
|
+
export async function requestPasswordReset(command: PasswordResetRequestCommand) {
|
|
9
|
+
await checkedApiRequest(emptyResponseSchema, '/auth/password-reset', {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
body: command,
|
|
12
|
+
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export async function confirmPasswordReset(
|
|
15
|
+
export async function confirmPasswordReset(command: PasswordResetConfirmCommand) {
|
|
16
16
|
const response = await checkedApiRequest(
|
|
17
17
|
passwordResetConfirmResponseSchema,
|
|
18
18
|
'/auth/password-reset/confirm',
|
|
19
19
|
{
|
|
20
20
|
method: 'POST',
|
|
21
|
-
body,
|
|
21
|
+
body: {token: command.token, new_password: command.newPassword},
|
|
22
22
|
},
|
|
23
23
|
);
|
|
24
24
|
return toAuthenticatedSession(response);
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {signupResponseSchema} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
3
|
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
import type {SignupCommand} from '#core/auth.js';
|
|
4
5
|
import {toSignupResult} from './auth-mapper.js';
|
|
5
6
|
|
|
6
|
-
async function signupAuth(
|
|
7
|
+
async function signupAuth(command: SignupCommand) {
|
|
7
8
|
const response = await checkedApiRequest(signupResponseSchema, '/auth/signup', {
|
|
8
9
|
method: 'POST',
|
|
9
|
-
body
|
|
10
|
+
body: {
|
|
11
|
+
email: command.email,
|
|
12
|
+
password: command.password,
|
|
13
|
+
name: command.name,
|
|
14
|
+
...(command.invitationToken ? {invitation_token: command.invitationToken} : {}),
|
|
15
|
+
},
|
|
10
16
|
});
|
|
11
17
|
return toSignupResult(response);
|
|
12
18
|
}
|
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type VerifyEmailConfirmBodyDto,
|
|
3
|
-
type VerifyEmailResendBodyDto,
|
|
4
2
|
verifyEmailConfirmResponseSchema,
|
|
5
3
|
verifyEmailResendResponseSchema,
|
|
6
4
|
} from '@shipfox/api-auth-dto';
|
|
7
5
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
8
6
|
import {useMutation} from '@tanstack/react-query';
|
|
7
|
+
import type {
|
|
8
|
+
EmailVerificationResendResult,
|
|
9
|
+
ResendEmailVerificationCommand,
|
|
10
|
+
VerifyEmailCommand,
|
|
11
|
+
} from '#core/auth.js';
|
|
9
12
|
import {useAuthTransition} from '#state/auth.js';
|
|
10
13
|
import {toAuthenticatedSession} from './auth-mapper.js';
|
|
11
14
|
|
|
12
|
-
async function verifyEmailAuth(
|
|
15
|
+
async function verifyEmailAuth(command: VerifyEmailCommand) {
|
|
13
16
|
const response = await checkedApiRequest(
|
|
14
17
|
verifyEmailConfirmResponseSchema,
|
|
15
18
|
'/auth/verify-email/confirm',
|
|
16
19
|
{
|
|
17
20
|
method: 'POST',
|
|
18
|
-
body,
|
|
21
|
+
body: {email: command.email, challenge_id: command.challengeId, code: command.code},
|
|
19
22
|
},
|
|
20
23
|
);
|
|
21
24
|
return toAuthenticatedSession(response);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
async function resendEmailVerificationAuth(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
async function resendEmailVerificationAuth(command: ResendEmailVerificationCommand) {
|
|
28
|
+
const response = await checkedApiRequest(
|
|
29
|
+
verifyEmailResendResponseSchema,
|
|
30
|
+
'/auth/verify-email/resend',
|
|
31
|
+
{
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: {email: command.email, challenge_id: command.challengeId},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
nextResendAvailableAt: response.next_resend_available_at,
|
|
38
|
+
} satisfies EmailVerificationResendResult;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export function useResendEmailVerificationAuth() {
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
2
2
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
listUserWorkspaces,
|
|
5
|
+
userWorkspacesQueryKey,
|
|
6
|
+
userWorkspacesQueryOptions,
|
|
7
|
+
} from '@shipfox/client-shell/runtime';
|
|
4
8
|
import {useMutation} from '@tanstack/react-query';
|
|
9
|
+
import type {WorkspaceCreateCommand} from '#core/auth.js';
|
|
5
10
|
import {useRefreshAuth} from './refresh-auth.js';
|
|
6
11
|
import {toWorkspace} from './workspace-mapper.js';
|
|
7
12
|
|
|
8
|
-
export async function createWorkspace(
|
|
13
|
+
export async function createWorkspace(command: WorkspaceCreateCommand) {
|
|
9
14
|
const response = await checkedApiRequest(workspaceResponseSchema, '/workspaces', {
|
|
10
15
|
method: 'POST',
|
|
11
|
-
body,
|
|
16
|
+
body: {name: command.name},
|
|
12
17
|
});
|
|
13
18
|
return toWorkspace(response);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
export {listUserWorkspaces, userWorkspacesQueryKey};
|
|
21
|
+
export {listUserWorkspaces, userWorkspacesQueryKey, userWorkspacesQueryOptions};
|
|
17
22
|
|
|
18
23
|
export function useCreateWorkspaceAuth() {
|
|
19
24
|
const refreshAuth = useRefreshAuth();
|
|
@@ -1,10 +1,5 @@
|
|
|
1
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
|
-
}
|
|
2
|
+
import type {Workspace} from '#core/auth.js';
|
|
8
3
|
|
|
9
4
|
export function toWorkspace(dto: WorkspaceResponseDto): Workspace {
|
|
10
5
|
return {id: dto.id, name: dto.name, status: dto.status};
|
package/src/pages/login-page.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
3
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
4
4
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
5
5
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
6
6
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
7
7
|
import {Text} from '@shipfox/react-ui/typography';
|
|
8
8
|
import {useForm} from '@tanstack/react-form';
|
|
9
|
-
import {Link
|
|
9
|
+
import {Link} from '@tanstack/react-router';
|
|
10
10
|
import {useAtom} from 'jotai';
|
|
11
11
|
import {useEffect, useRef, useState} from 'react';
|
|
12
12
|
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
13
13
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
14
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
14
15
|
import {loginErrorToFormError} from './form-errors.js';
|
|
15
16
|
import {
|
|
16
17
|
extractInvitationToken,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
|
|
21
22
|
export function LoginPage() {
|
|
22
23
|
const login = useLoginAuth();
|
|
23
|
-
const search =
|
|
24
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
24
25
|
const invitationToken = extractInvitationToken(search.redirect);
|
|
25
26
|
const invitationPreview = useInvitationContext(invitationToken);
|
|
26
27
|
const invitationPending = pendingInvitation(invitationPreview.data);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {AuthRuntime} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
createMemoryHistory,
|
|
6
|
+
createRootRoute,
|
|
7
|
+
createRoute,
|
|
8
|
+
createRouter,
|
|
9
|
+
Outlet,
|
|
10
|
+
RouterProvider,
|
|
11
|
+
} from '@tanstack/react-router';
|
|
12
|
+
import {render, screen} from '@testing-library/react';
|
|
13
|
+
import {Provider as JotaiProvider} from 'jotai';
|
|
14
|
+
import {LogoutPage} from './logout-page.js';
|
|
15
|
+
|
|
16
|
+
function renderLogoutPage(path: string) {
|
|
17
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
18
|
+
const logoutRoute = createRoute({
|
|
19
|
+
getParentRoute: () => rootRoute,
|
|
20
|
+
path: '/auth/logout',
|
|
21
|
+
component: LogoutPage,
|
|
22
|
+
});
|
|
23
|
+
const loginRoute = createRoute({
|
|
24
|
+
getParentRoute: () => rootRoute,
|
|
25
|
+
path: '/auth/login',
|
|
26
|
+
component: () => <h1>Login destination</h1>,
|
|
27
|
+
});
|
|
28
|
+
const workspaceRoute = createRoute({
|
|
29
|
+
getParentRoute: () => rootRoute,
|
|
30
|
+
path: '/workspaces/$wid',
|
|
31
|
+
component: () => <h1>Workspace destination</h1>,
|
|
32
|
+
});
|
|
33
|
+
const router = createRouter({
|
|
34
|
+
history: createMemoryHistory({initialEntries: [path]}),
|
|
35
|
+
routeTree: rootRoute.addChildren([logoutRoute, loginRoute, workspaceRoute]),
|
|
36
|
+
});
|
|
37
|
+
const queryClient = new QueryClient();
|
|
38
|
+
|
|
39
|
+
render(
|
|
40
|
+
<QueryClientProvider client={queryClient}>
|
|
41
|
+
<JotaiProvider>
|
|
42
|
+
<AuthRuntime effects={false}>
|
|
43
|
+
<RouterProvider router={router} />
|
|
44
|
+
</AuthRuntime>
|
|
45
|
+
</JotaiProvider>
|
|
46
|
+
</QueryClientProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('LogoutPage', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
configureApiClient({baseUrl: 'https://api.example.test', getAccessToken: undefined});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('logs out before following a safe same-origin redirect', async () => {
|
|
56
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, {status: 204}));
|
|
57
|
+
configureApiClient({fetchImpl});
|
|
58
|
+
|
|
59
|
+
renderLogoutPage('/auth/logout?redirect=%2Fworkspaces%2Facme');
|
|
60
|
+
|
|
61
|
+
expect(await screen.findByRole('heading', {name: 'Workspace destination'})).toBeVisible();
|
|
62
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
63
|
+
expect(request.url).toBe('https://api.example.test/auth/logout');
|
|
64
|
+
expect(request.method).toBe('POST');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('falls back to login without forwarding an invitation token', async () => {
|
|
68
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response(null, {status: 204}));
|
|
69
|
+
configureApiClient({fetchImpl});
|
|
70
|
+
|
|
71
|
+
renderLogoutPage('/auth/logout?redirect=%2Finvitations%2Faccept%3Ftoken%3Dsf_i_raw-token');
|
|
72
|
+
|
|
73
|
+
expect(await screen.findByRole('heading', {name: 'Login destination'})).toBeVisible();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -1,27 +1,23 @@
|
|
|
1
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
1
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
2
2
|
import {Text} from '@shipfox/react-ui/typography';
|
|
3
|
-
import {
|
|
3
|
+
import {useRouter} from '@tanstack/react-router';
|
|
4
4
|
import {useEffect} from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import {sanitizeLogoutRedirectPath} from '#/components/redirect-target.js';
|
|
6
6
|
import {useLogoutAuth} from '#hooks/api/logout-auth.js';
|
|
7
|
+
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
7
8
|
|
|
8
9
|
export function LogoutPage() {
|
|
9
10
|
const logout = useLogoutAuth();
|
|
10
|
-
const navigate = useNavigate();
|
|
11
11
|
const router = useRouter();
|
|
12
|
-
const search =
|
|
13
|
-
const target =
|
|
12
|
+
const search = useRouteSearch(validateRedirectSearch);
|
|
13
|
+
const target = sanitizeLogoutRedirectPath(search.redirect);
|
|
14
14
|
|
|
15
15
|
useEffect(() => {
|
|
16
16
|
logout.mutateAsync().finally(() => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
router.history.replace(target);
|
|
20
|
-
} else {
|
|
21
|
-
navigate({to: '/auth/login', replace: true});
|
|
22
|
-
}
|
|
17
|
+
// Same-origin runtime path; let history drive route resolution.
|
|
18
|
+
router.history.replace(target);
|
|
23
19
|
});
|
|
24
|
-
}, [logout.mutateAsync,
|
|
20
|
+
}, [logout.mutateAsync, router, target]);
|
|
25
21
|
|
|
26
22
|
return (
|
|
27
23
|
<AuthShell title="Logging out" description="Ending your Shipfox session.">
|
|
@@ -2,14 +2,14 @@ import {
|
|
|
2
2
|
passwordResetConfirmBodySchema,
|
|
3
3
|
passwordResetRequestBodySchema,
|
|
4
4
|
} from '@shipfox/api-auth-dto';
|
|
5
|
-
import {AuthShell} from '@shipfox/client-shell/runtime';
|
|
5
|
+
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
6
6
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
7
7
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
8
8
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
9
9
|
import {toast} from '@shipfox/react-ui/toast';
|
|
10
10
|
import {Text} from '@shipfox/react-ui/typography';
|
|
11
11
|
import {useForm} from '@tanstack/react-form';
|
|
12
|
-
import {Link, useNavigate
|
|
12
|
+
import {Link, useNavigate} from '@tanstack/react-router';
|
|
13
13
|
import {useAtom} from 'jotai';
|
|
14
14
|
import {useEffect, useRef, useState} from 'react';
|
|
15
15
|
import {
|
|
@@ -17,14 +17,14 @@ import {
|
|
|
17
17
|
useRequestPasswordResetAuth,
|
|
18
18
|
} from '#hooks/api/password-reset-auth.js';
|
|
19
19
|
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
20
|
+
import {validatePasswordResetSearch} from '../routes/inputs.js';
|
|
20
21
|
import {
|
|
21
22
|
passwordResetConfirmErrorToFormError,
|
|
22
23
|
passwordResetRequestErrorToFormError,
|
|
23
24
|
} from './form-errors.js';
|
|
24
25
|
|
|
25
26
|
export function PasswordResetPage() {
|
|
26
|
-
const
|
|
27
|
-
const token = typeof search.token === 'string' ? search.token : undefined;
|
|
27
|
+
const {token} = useRouteSearch(validatePasswordResetSearch);
|
|
28
28
|
|
|
29
29
|
if (token) {
|
|
30
30
|
return <PasswordResetConfirm token={token} />;
|
|
@@ -160,7 +160,7 @@ function PasswordResetConfirm({token}: {token: string}) {
|
|
|
160
160
|
token,
|
|
161
161
|
new_password: value.new_password,
|
|
162
162
|
});
|
|
163
|
-
await confirmPasswordReset.mutateAsync(body);
|
|
163
|
+
await confirmPasswordReset.mutateAsync({token: body.token, newPassword: body.new_password});
|
|
164
164
|
setAuthFormDraft(initialAuthFormDraft);
|
|
165
165
|
toast.success('Your password has been changed. You are now logged in.');
|
|
166
166
|
await navigate({to: '/', replace: true});
|