@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,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
CommandEmpty,
|
|
4
|
+
CommandGroup,
|
|
5
|
+
CommandInput,
|
|
6
|
+
CommandItem,
|
|
7
|
+
CommandList,
|
|
8
|
+
CommandSeparator,
|
|
9
|
+
} from '@shipfox/react-ui/command';
|
|
10
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
11
|
+
import {useNavigate} from '@tanstack/react-router';
|
|
12
|
+
import {useSetAtom} from 'jotai';
|
|
13
|
+
import {useAuthState} from '#hooks/use-auth-state.js';
|
|
14
|
+
import {lastWorkspaceIdAtom} from '#state/last-workspace.js';
|
|
15
|
+
|
|
16
|
+
export interface WorkspaceSwitcherProps {
|
|
17
|
+
activeWorkspaceId: string | undefined;
|
|
18
|
+
onSelect?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function WorkspaceSwitcher({activeWorkspaceId, onSelect}: WorkspaceSwitcherProps) {
|
|
22
|
+
const {workspaces} = useAuthState();
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
25
|
+
|
|
26
|
+
const handleSelect = (workspaceId: string) => {
|
|
27
|
+
try {
|
|
28
|
+
setLastWorkspaceId(workspaceId);
|
|
29
|
+
} catch {
|
|
30
|
+
// localStorage may throw in private browsing or quota-exceeded; persistence is best-effort.
|
|
31
|
+
}
|
|
32
|
+
navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
|
|
33
|
+
onSelect?.();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleCreate = () => {
|
|
37
|
+
navigate({to: '/setup/workspaces/new'});
|
|
38
|
+
onSelect?.();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Command>
|
|
43
|
+
<CommandInput placeholder="Search workspaces..." />
|
|
44
|
+
<CommandList className="max-h-none overflow-visible overflow-y-visible p-0">
|
|
45
|
+
<div className="max-h-300 overflow-y-auto overflow-x-hidden p-4 scrollbar">
|
|
46
|
+
<CommandEmpty>No workspaces found.</CommandEmpty>
|
|
47
|
+
<CommandGroup heading="Workspaces">
|
|
48
|
+
{workspaces.map((workspace) => (
|
|
49
|
+
<CommandItem
|
|
50
|
+
key={workspace.id}
|
|
51
|
+
value={workspace.id}
|
|
52
|
+
keywords={[workspace.name]}
|
|
53
|
+
onSelect={() => handleSelect(workspace.id)}
|
|
54
|
+
>
|
|
55
|
+
<Icon
|
|
56
|
+
name="check"
|
|
57
|
+
className={`size-16 mr-8 ${
|
|
58
|
+
activeWorkspaceId === workspace.id ? 'opacity-100' : 'opacity-0'
|
|
59
|
+
}`}
|
|
60
|
+
/>
|
|
61
|
+
{workspace.name}
|
|
62
|
+
</CommandItem>
|
|
63
|
+
))}
|
|
64
|
+
</CommandGroup>
|
|
65
|
+
</div>
|
|
66
|
+
<CommandSeparator alwaysRender className="mx-0" />
|
|
67
|
+
<CommandGroup forceMount className="p-4">
|
|
68
|
+
<CommandItem value="__create" onSelect={handleCreate} forceMount>
|
|
69
|
+
<Icon name="addLine" className="size-16" />
|
|
70
|
+
Create workspace
|
|
71
|
+
</CommandItem>
|
|
72
|
+
</CommandGroup>
|
|
73
|
+
</CommandList>
|
|
74
|
+
</Command>
|
|
75
|
+
);
|
|
76
|
+
}
|
package/src/feature.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {defineClientFeature} from '@shipfox/client-shell';
|
|
2
|
+
|
|
3
|
+
export const authFeature = defineClientFeature({
|
|
4
|
+
id: 'shipfox.auth',
|
|
5
|
+
routes: [
|
|
6
|
+
{path: '/', parent: 'root', impl: '@shipfox/client-auth/routes/index'},
|
|
7
|
+
{path: '/auth/login', parent: 'root', impl: '@shipfox/client-auth/routes/login'},
|
|
8
|
+
{path: '/auth/logout', parent: 'root', impl: '@shipfox/client-auth/routes/logout'},
|
|
9
|
+
{path: '/auth/reset', parent: 'root', impl: '@shipfox/client-auth/routes/reset'},
|
|
10
|
+
{path: '/auth/signup', parent: 'root', impl: '@shipfox/client-auth/routes/signup'},
|
|
11
|
+
{path: '/auth/verify-email', parent: 'root', impl: '@shipfox/client-auth/routes/verify-email'},
|
|
12
|
+
{
|
|
13
|
+
path: '/setup/workspaces/new',
|
|
14
|
+
parent: 'root',
|
|
15
|
+
impl: '@shipfox/client-auth/routes/workspace-onboarding',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {loginAuth} from './login-auth.js';
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
headers: {'content-type': 'application/json'},
|
|
7
|
+
status: 200,
|
|
8
|
+
...init,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('loginAuth', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('posts login credentials', async () => {
|
|
18
|
+
let requestBody: unknown;
|
|
19
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
20
|
+
requestBody = await (input as Request).clone().json();
|
|
21
|
+
return jsonResponse({
|
|
22
|
+
token: 'access-token',
|
|
23
|
+
user: {
|
|
24
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
25
|
+
email: 'login@example.com',
|
|
26
|
+
name: null,
|
|
27
|
+
email_verified_at: '2026-04-27T00:00:00.000Z',
|
|
28
|
+
status: 'active',
|
|
29
|
+
created_at: '2026-04-27T00:00:00.000Z',
|
|
30
|
+
updated_at: '2026-04-27T00:00:00.000Z',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
configureApiClient({fetchImpl});
|
|
35
|
+
const body = {email: 'login@example.com', password: 'correct horse'};
|
|
36
|
+
|
|
37
|
+
const result = await loginAuth(body);
|
|
38
|
+
|
|
39
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
40
|
+
expect(result.token).toBe('access-token');
|
|
41
|
+
expect(request.url).toBe('https://api.example.test/auth/login');
|
|
42
|
+
expect(request.method).toBe('POST');
|
|
43
|
+
expect(requestBody).toEqual(body);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {LoginBodyDto, LoginResponseDto} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
|
4
|
+
import {useSetAtom} from 'jotai';
|
|
5
|
+
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
6
|
+
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
7
|
+
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
8
|
+
|
|
9
|
+
export async function loginAuth(body: LoginBodyDto) {
|
|
10
|
+
return await apiRequest<LoginResponseDto>('/auth/login', {method: 'POST', body});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useLoginAuth() {
|
|
14
|
+
const queryClient = useQueryClient();
|
|
15
|
+
const setState = useSetAtom(authStateAtom);
|
|
16
|
+
|
|
17
|
+
return useMutation({
|
|
18
|
+
mutationFn: loginAuth,
|
|
19
|
+
onSuccess: async (result) => {
|
|
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
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
2
|
+
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
|
3
|
+
import {useSetAtom} from 'jotai';
|
|
4
|
+
import {authStateAtom} from '#state/auth.js';
|
|
5
|
+
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
6
|
+
|
|
7
|
+
async function logoutAuth() {
|
|
8
|
+
try {
|
|
9
|
+
await apiRequest<void>('/auth/logout', {method: 'POST', body: {}});
|
|
10
|
+
} catch {
|
|
11
|
+
// Logout is best-effort: local session state must clear even if the API is offline.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useLogoutAuth() {
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
const setState = useSetAtom(authStateAtom);
|
|
18
|
+
|
|
19
|
+
return useMutation({
|
|
20
|
+
mutationFn: logoutAuth,
|
|
21
|
+
onSettled: () => {
|
|
22
|
+
setState({status: 'guest'});
|
|
23
|
+
queryClient.removeQueries({queryKey: authRefreshQueryKey});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {confirmPasswordReset, requestPasswordReset} from './password-reset-auth.js';
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
headers: {'content-type': 'application/json'},
|
|
7
|
+
status: 200,
|
|
8
|
+
...init,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('requestPasswordReset', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('posts reset request emails', async () => {
|
|
18
|
+
let requestBody: unknown;
|
|
19
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
20
|
+
requestBody = await (input as Request).clone().json();
|
|
21
|
+
return new Response(null, {status: 204});
|
|
22
|
+
});
|
|
23
|
+
configureApiClient({fetchImpl});
|
|
24
|
+
|
|
25
|
+
const result = await requestPasswordReset({email: 'reset@example.com'});
|
|
26
|
+
|
|
27
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
28
|
+
expect(result).toBeUndefined();
|
|
29
|
+
expect(request.url).toBe('https://api.example.test/auth/password-reset');
|
|
30
|
+
expect(request.method).toBe('POST');
|
|
31
|
+
expect(requestBody).toEqual({email: 'reset@example.com'});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('confirmPasswordReset', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('posts reset confirmation tokens and passwords', async () => {
|
|
41
|
+
let requestBody: unknown;
|
|
42
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
43
|
+
requestBody = await (input as Request).clone().json();
|
|
44
|
+
return jsonResponse({
|
|
45
|
+
token: 'reset-access-token',
|
|
46
|
+
user: {
|
|
47
|
+
id: '11111111-1111-4111-8111-111111111111',
|
|
48
|
+
email: 'reset@example.com',
|
|
49
|
+
name: null,
|
|
50
|
+
email_verified_at: '2026-04-27T00:00:00.000Z',
|
|
51
|
+
status: 'active',
|
|
52
|
+
created_at: '2026-04-27T00:00:00.000Z',
|
|
53
|
+
updated_at: '2026-04-27T00:00:00.000Z',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
configureApiClient({fetchImpl});
|
|
58
|
+
const body = {token: 'reset-token', new_password: 'new password is long'};
|
|
59
|
+
|
|
60
|
+
const result = await confirmPasswordReset(body);
|
|
61
|
+
|
|
62
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
63
|
+
expect(result.token).toBe('reset-access-token');
|
|
64
|
+
expect(request.url).toBe('https://api.example.test/auth/password-reset/confirm');
|
|
65
|
+
expect(request.method).toBe('POST');
|
|
66
|
+
expect(requestBody).toEqual(body);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PasswordResetConfirmBodyDto,
|
|
3
|
+
PasswordResetConfirmResponseDto,
|
|
4
|
+
PasswordResetRequestBodyDto,
|
|
5
|
+
} from '@shipfox/api-auth-dto';
|
|
6
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
7
|
+
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
|
8
|
+
import {useSetAtom} from 'jotai';
|
|
9
|
+
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
10
|
+
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
11
|
+
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
12
|
+
|
|
13
|
+
export async function requestPasswordReset(body: PasswordResetRequestBodyDto) {
|
|
14
|
+
await apiRequest<void>('/auth/password-reset', {method: 'POST', body});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function confirmPasswordReset(body: PasswordResetConfirmBodyDto) {
|
|
18
|
+
return await apiRequest<PasswordResetConfirmResponseDto>('/auth/password-reset/confirm', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useRequestPasswordResetAuth() {
|
|
25
|
+
return useMutation({mutationFn: requestPasswordReset});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useConfirmPasswordResetAuth() {
|
|
29
|
+
const queryClient = useQueryClient();
|
|
30
|
+
const setState = useSetAtom(authStateAtom);
|
|
31
|
+
|
|
32
|
+
return useMutation({
|
|
33
|
+
mutationFn: confirmPasswordReset,
|
|
34
|
+
onSuccess: async (result) => {
|
|
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
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {getAuthRefreshDelayMs} from './refresh-auth.js';
|
|
2
|
+
|
|
3
|
+
const BASE64_PADDING_RE = /=+$/;
|
|
4
|
+
|
|
5
|
+
function jwtWithPayload(payload: unknown): string {
|
|
6
|
+
const encodedPayload = btoa(JSON.stringify(payload))
|
|
7
|
+
.replaceAll('+', '-')
|
|
8
|
+
.replaceAll('/', '_')
|
|
9
|
+
.replace(BASE64_PADDING_RE, '');
|
|
10
|
+
return `header.${encodedPayload}.signature`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('getAuthRefreshDelayMs', () => {
|
|
14
|
+
test('returns the delay five minutes before the token expires', () => {
|
|
15
|
+
const nowMs = Date.UTC(2026, 3, 29, 12, 0, 0);
|
|
16
|
+
const token = jwtWithPayload({exp: nowMs / 1000 + 15 * 60});
|
|
17
|
+
|
|
18
|
+
const result = getAuthRefreshDelayMs(token, nowMs);
|
|
19
|
+
|
|
20
|
+
expect(result).toBe(10 * 60 * 1000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('returns zero or less for tokens inside the refresh window', () => {
|
|
24
|
+
const nowMs = Date.UTC(2026, 3, 29, 12, 0, 0);
|
|
25
|
+
const token = jwtWithPayload({exp: nowMs / 1000 + 4 * 60});
|
|
26
|
+
|
|
27
|
+
const result = getAuthRefreshDelayMs(token, nowMs);
|
|
28
|
+
|
|
29
|
+
expect(result).toBeLessThanOrEqual(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('ignores tokens without a readable expiration claim', () => {
|
|
33
|
+
const result = getAuthRefreshDelayMs('not-a-jwt', Date.UTC(2026, 3, 29, 12, 0, 0));
|
|
34
|
+
|
|
35
|
+
expect(result).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type {SignupBodyDto, SignupResponseDto} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {useMutation} from '@tanstack/react-query';
|
|
4
|
+
|
|
5
|
+
async function signupAuth(body: SignupBodyDto) {
|
|
6
|
+
return await apiRequest<SignupResponseDto>('/auth/signup', {method: 'POST', body});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useSignupAuth() {
|
|
10
|
+
return useMutation({mutationFn: signupAuth});
|
|
11
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
VerifyEmailConfirmBodyDto,
|
|
3
|
+
VerifyEmailConfirmResponseDto,
|
|
4
|
+
VerifyEmailResendBodyDto,
|
|
5
|
+
VerifyEmailResendResponseDto,
|
|
6
|
+
} from '@shipfox/api-auth-dto';
|
|
7
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
8
|
+
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
|
9
|
+
import {useSetAtom} from 'jotai';
|
|
10
|
+
import {authStateAtom, toAuthenticatedState} from '#state/auth.js';
|
|
11
|
+
import {authRefreshQueryKey} from './refresh-auth.js';
|
|
12
|
+
import {listUserWorkspaces, userWorkspacesQueryKey} from './workspace-auth.js';
|
|
13
|
+
|
|
14
|
+
async function verifyEmailAuth(body: VerifyEmailConfirmBodyDto) {
|
|
15
|
+
return await apiRequest<VerifyEmailConfirmResponseDto>('/auth/verify-email/confirm', {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
body,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resendEmailVerificationAuth(body: VerifyEmailResendBodyDto) {
|
|
22
|
+
return await apiRequest<VerifyEmailResendResponseDto>('/auth/verify-email/resend', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useResendEmailVerificationAuth() {
|
|
29
|
+
return useMutation({mutationFn: resendEmailVerificationAuth});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useVerifyEmailAuth() {
|
|
33
|
+
const queryClient = useQueryClient();
|
|
34
|
+
const setState = useSetAtom(authStateAtom);
|
|
35
|
+
|
|
36
|
+
return useMutation({
|
|
37
|
+
mutationFn: verifyEmailAuth,
|
|
38
|
+
onSuccess: async (result) => {
|
|
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
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {createWorkspace} from './workspace-auth.js';
|
|
3
|
+
|
|
4
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
headers: {'content-type': 'application/json'},
|
|
7
|
+
status: 200,
|
|
8
|
+
...init,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('createWorkspace', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: undefined});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('posts the workspace body', async () => {
|
|
18
|
+
let requestBody: unknown;
|
|
19
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
20
|
+
requestBody = await (input as Request).clone().json();
|
|
21
|
+
return jsonResponse({
|
|
22
|
+
id: '33333333-3333-4333-8333-333333333333',
|
|
23
|
+
name: 'Acme',
|
|
24
|
+
status: 'active',
|
|
25
|
+
settings: {},
|
|
26
|
+
created_at: '2026-04-27T00:00:00.000Z',
|
|
27
|
+
updated_at: '2026-04-27T00:00:00.000Z',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
configureApiClient({fetchImpl});
|
|
31
|
+
|
|
32
|
+
const result = await createWorkspace({name: 'Acme'});
|
|
33
|
+
|
|
34
|
+
const request = fetchImpl.mock.calls[0]?.[0] as Request;
|
|
35
|
+
expect(result.name).toBe('Acme');
|
|
36
|
+
expect(request.url).toBe('https://api.example.test/workspaces');
|
|
37
|
+
expect(request.method).toBe('POST');
|
|
38
|
+
expect(requestBody).toEqual({name: 'Acme'});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {CreateWorkspaceBodyDto, WorkspaceResponseDto} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {apiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {listUserWorkspaces, userWorkspacesQueryKey} from '@shipfox/client-shell/runtime';
|
|
4
|
+
import {useMutation} from '@tanstack/react-query';
|
|
5
|
+
import {useRefreshAuth} from './refresh-auth.js';
|
|
6
|
+
|
|
7
|
+
export async function createWorkspace(body: CreateWorkspaceBodyDto) {
|
|
8
|
+
return await apiRequest<WorkspaceResponseDto>('/workspaces', {method: 'POST', body});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export {listUserWorkspaces, userWorkspacesQueryKey};
|
|
12
|
+
|
|
13
|
+
export function useCreateWorkspaceAuth() {
|
|
14
|
+
const refreshAuth = useRefreshAuth();
|
|
15
|
+
|
|
16
|
+
return useMutation({
|
|
17
|
+
mutationFn: createWorkspace,
|
|
18
|
+
onSuccess: async () => {
|
|
19
|
+
// The new workspace introduces a membership the existing access token
|
|
20
|
+
// doesn't carry. Refresh so the next request includes it in the JWT
|
|
21
|
+
// claim and passes the in-memory canAccess() check on the server.
|
|
22
|
+
await refreshAuth();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {useLoginAuth} from './api/login-auth.js';
|
|
2
|
+
export {useLogoutAuth} from './api/logout-auth.js';
|
|
3
|
+
export {
|
|
4
|
+
useConfirmPasswordResetAuth,
|
|
5
|
+
useRequestPasswordResetAuth,
|
|
6
|
+
} from './api/password-reset-auth.js';
|
|
7
|
+
export {useRefreshAuth} from './api/refresh-auth.js';
|
|
8
|
+
export {useSignupAuth} from './api/signup-auth.js';
|
|
9
|
+
export {useResendEmailVerificationAuth, useVerifyEmailAuth} from './api/verify-email-auth.js';
|
|
10
|
+
export {useCreateWorkspaceAuth} from './api/workspace-auth.js';
|
|
11
|
+
export {useActiveWorkspace, useMaybeActiveWorkspace} from './use-active-workspace.js';
|
|
12
|
+
export type {AuthStateValue} from './use-auth-state.js';
|
|
13
|
+
export {useAuthState} from './use-auth-state.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {useActiveWorkspace, useMaybeActiveWorkspace} from '@shipfox/client-shell/runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {type AuthStateValue, useAuthState} from '@shipfox/client-shell/runtime';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './components/auth-guard.js';
|
|
2
|
+
export * from './components/auth-provider.js';
|
|
3
|
+
export * from './components/auth-shell.js';
|
|
4
|
+
export {WorkspaceCrumb, type WorkspaceCrumbProps} from './components/workspace-crumb.js';
|
|
5
|
+
export {WorkspaceSwitcher, type WorkspaceSwitcherProps} from './components/workspace-switcher.js';
|
|
6
|
+
export * from './hooks/index.js';
|
|
7
|
+
export * from './pages/login-page.js';
|
|
8
|
+
export * from './pages/logout-page.js';
|
|
9
|
+
export * from './pages/password-reset-page.js';
|
|
10
|
+
export * from './pages/signup-page.js';
|
|
11
|
+
export * from './pages/verify-email-page.js';
|
|
12
|
+
export * from './pages/workspace-onboarding-page.js';
|
|
13
|
+
export * from './state/auth.js';
|
|
14
|
+
export {
|
|
15
|
+
getLastWorkspaceId,
|
|
16
|
+
lastWorkspaceIdAtom,
|
|
17
|
+
rememberLastWorkspaceId,
|
|
18
|
+
} from './state/last-workspace.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {
|
|
3
|
+
getLocalResendAvailableAt,
|
|
4
|
+
getResendRemainingSeconds,
|
|
5
|
+
parseNextResendAvailableAt,
|
|
6
|
+
} from './email-verification-resend-model.js';
|
|
7
|
+
|
|
8
|
+
describe('email verification resend model', () => {
|
|
9
|
+
test('creates local cooldown targets from the shared cooldown', () => {
|
|
10
|
+
const now = Date.UTC(2026, 4, 11, 12, 0, 0);
|
|
11
|
+
|
|
12
|
+
const result = getLocalResendAvailableAt(now);
|
|
13
|
+
|
|
14
|
+
expect(result).toBe(now + EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS * 1000);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('rounds remaining cooldown seconds up for display', () => {
|
|
18
|
+
const now = Date.UTC(2026, 4, 11, 12, 0, 0);
|
|
19
|
+
|
|
20
|
+
const result = getResendRemainingSeconds({
|
|
21
|
+
nextResendAvailableAt: now + 1_001,
|
|
22
|
+
now,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(result).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('does not return negative remaining cooldown seconds', () => {
|
|
29
|
+
const now = Date.UTC(2026, 4, 11, 12, 0, 0);
|
|
30
|
+
|
|
31
|
+
const result = getResendRemainingSeconds({
|
|
32
|
+
nextResendAvailableAt: now - 1_000,
|
|
33
|
+
now,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('parses valid server cooldown timestamps', () => {
|
|
40
|
+
const timestamp = '2026-05-11T12:01:00.000Z';
|
|
41
|
+
|
|
42
|
+
const result = parseNextResendAvailableAt(timestamp);
|
|
43
|
+
|
|
44
|
+
expect(result).toBe(Date.parse(timestamp));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('ignores invalid server cooldown timestamps', () => {
|
|
48
|
+
const result = parseNextResendAvailableAt('not-a-date');
|
|
49
|
+
|
|
50
|
+
expect(result).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS} from '@shipfox/api-auth-dto';
|
|
2
|
+
|
|
3
|
+
export function getLocalResendAvailableAt(now: number): number {
|
|
4
|
+
return now + EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS * 1000;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getResendRemainingSeconds(params: {
|
|
8
|
+
nextResendAvailableAt: number | undefined;
|
|
9
|
+
now: number;
|
|
10
|
+
}): number {
|
|
11
|
+
if (!params.nextResendAvailableAt) return 0;
|
|
12
|
+
|
|
13
|
+
return Math.max(0, Math.ceil((params.nextResendAvailableAt - params.now) / 1000));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseNextResendAvailableAt(value: string): number | undefined {
|
|
17
|
+
const nextAvailableAt = Date.parse(value);
|
|
18
|
+
if (!Number.isFinite(nextAvailableAt)) return undefined;
|
|
19
|
+
|
|
20
|
+
return nextAvailableAt;
|
|
21
|
+
}
|