@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,213 @@
|
|
|
1
|
+
import {createWorkspaceBodySchema} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
3
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
4
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
5
|
+
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@shipfox/react-ui/card';
|
|
6
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
7
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
8
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
9
|
+
import {Text} from '@shipfox/react-ui/typography';
|
|
10
|
+
import {useForm} from '@tanstack/react-form';
|
|
11
|
+
import {useNavigate} from '@tanstack/react-router';
|
|
12
|
+
import {useSetAtom} from 'jotai';
|
|
13
|
+
import {useState} from 'react';
|
|
14
|
+
import {useCreateWorkspaceAuth} from '#hooks/api/workspace-auth.js';
|
|
15
|
+
import {lastWorkspaceIdAtom} from '#state/last-workspace.js';
|
|
16
|
+
import {workspaceOnboardingErrorToFormError} from './form-errors.js';
|
|
17
|
+
|
|
18
|
+
const previewMetrics = [
|
|
19
|
+
{label: 'Runs', value: '--'},
|
|
20
|
+
{label: 'Passed', value: '--'},
|
|
21
|
+
{label: 'Failed', value: '--'},
|
|
22
|
+
{label: 'Duration', value: '--'},
|
|
23
|
+
];
|
|
24
|
+
const previewBars = [
|
|
25
|
+
{id: 'runs-start', height: 32},
|
|
26
|
+
{id: 'runs-mid-low', height: 48},
|
|
27
|
+
{id: 'runs-dip', height: 28},
|
|
28
|
+
{id: 'runs-mid-high', height: 66},
|
|
29
|
+
{id: 'runs-mid', height: 54},
|
|
30
|
+
{id: 'runs-peak', height: 82},
|
|
31
|
+
{id: 'runs-late-low', height: 44},
|
|
32
|
+
{id: 'runs-late-high', height: 74},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function WorkspaceOnboardingPage() {
|
|
36
|
+
const createWorkspace = useCreateWorkspaceAuth();
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
39
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
40
|
+
|
|
41
|
+
const form = useForm({
|
|
42
|
+
defaultValues: {name: ''},
|
|
43
|
+
onSubmit: async ({value}) => {
|
|
44
|
+
setFormError(undefined);
|
|
45
|
+
try {
|
|
46
|
+
const body = createWorkspaceBodySchema.parse({name: value.name});
|
|
47
|
+
const created = await createWorkspace.mutateAsync(body);
|
|
48
|
+
toast.success('Workspace created.');
|
|
49
|
+
// Pin the new workspace as the last-active one so a page refresh and
|
|
50
|
+
// future visits to `/` land on it.
|
|
51
|
+
try {
|
|
52
|
+
setLastWorkspaceId(created.id);
|
|
53
|
+
} catch {
|
|
54
|
+
// localStorage may throw in private browsing or quota-exceeded.
|
|
55
|
+
}
|
|
56
|
+
await navigate({to: '/workspaces/$wid', params: {wid: created.id}});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const mapped = workspaceOnboardingErrorToFormError(error);
|
|
59
|
+
setFormError(mapped.message);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<main className="min-h-screen bg-background-subtle-base px-24 py-32 max-[520px]:px-16">
|
|
66
|
+
<div className="mx-auto flex min-h-[calc(100vh-64px)] w-full max-w-[1120px] flex-col gap-24">
|
|
67
|
+
<header className="flex items-center justify-between">
|
|
68
|
+
<div className="flex items-center gap-10">
|
|
69
|
+
<div className="flex size-36 items-center justify-center rounded-8 border border-border-neutral-base bg-background-neutral-base shadow-button-neutral">
|
|
70
|
+
<Icon name="shipfox" className="size-24 text-background-highlight-interactive" />
|
|
71
|
+
</div>
|
|
72
|
+
<Text size="md" bold>
|
|
73
|
+
Shipfox
|
|
74
|
+
</Text>
|
|
75
|
+
</div>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<section className="grid flex-1 items-center gap-32 lg:grid-cols-[minmax(0,440px)_minmax(0,1fr)]">
|
|
79
|
+
<form
|
|
80
|
+
className="relative z-10 w-full"
|
|
81
|
+
noValidate
|
|
82
|
+
aria-labelledby="workspace-onboarding-title"
|
|
83
|
+
onSubmit={(event) => {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
void form.handleSubmit();
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Card className="gap-20 p-24 shadow-button-neutral">
|
|
90
|
+
<CardHeader className="gap-8">
|
|
91
|
+
<CardTitle id="workspace-onboarding-title" variant="h1">
|
|
92
|
+
Create your workspace
|
|
93
|
+
</CardTitle>
|
|
94
|
+
<CardDescription>Give your team a place to collaborate.</CardDescription>
|
|
95
|
+
</CardHeader>
|
|
96
|
+
|
|
97
|
+
{formError ? (
|
|
98
|
+
<Callout role="alert" type="error">
|
|
99
|
+
{formError}
|
|
100
|
+
</Callout>
|
|
101
|
+
) : null}
|
|
102
|
+
|
|
103
|
+
<CardContent className="flex flex-col gap-8">
|
|
104
|
+
<form.Field
|
|
105
|
+
name="name"
|
|
106
|
+
validators={{
|
|
107
|
+
onBlur: ({value}) =>
|
|
108
|
+
displayNameFieldError(
|
|
109
|
+
value,
|
|
110
|
+
'Workspace name',
|
|
111
|
+
createWorkspaceBodySchema.shape.name,
|
|
112
|
+
),
|
|
113
|
+
onSubmit: ({value}) =>
|
|
114
|
+
displayNameFieldError(
|
|
115
|
+
value,
|
|
116
|
+
'Workspace name',
|
|
117
|
+
createWorkspaceBodySchema.shape.name,
|
|
118
|
+
),
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{(field) => (
|
|
122
|
+
<FormField label="Workspace name" id="workspace-name" error={fieldError(field)}>
|
|
123
|
+
<FormFieldInput
|
|
124
|
+
autoComplete="organization"
|
|
125
|
+
name="name"
|
|
126
|
+
placeholder="Acme"
|
|
127
|
+
type="text"
|
|
128
|
+
value={field.state.value}
|
|
129
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
130
|
+
onBlur={field.handleBlur}
|
|
131
|
+
/>
|
|
132
|
+
</FormField>
|
|
133
|
+
)}
|
|
134
|
+
</form.Field>
|
|
135
|
+
</CardContent>
|
|
136
|
+
|
|
137
|
+
<Button
|
|
138
|
+
className="w-full"
|
|
139
|
+
iconRight="chevronRight"
|
|
140
|
+
isLoading={createWorkspace.isPending}
|
|
141
|
+
type="submit"
|
|
142
|
+
>
|
|
143
|
+
{createWorkspace.isPending ? 'Creating workspace...' : 'Create workspace'}
|
|
144
|
+
</Button>
|
|
145
|
+
</Card>
|
|
146
|
+
</form>
|
|
147
|
+
|
|
148
|
+
<div className="hidden flex-col gap-18 lg:flex" aria-hidden="true">
|
|
149
|
+
<div className="grid grid-cols-4 gap-12">
|
|
150
|
+
{previewMetrics.map((metric) => (
|
|
151
|
+
<div
|
|
152
|
+
className="rounded-8 border border-border-neutral-base bg-background-neutral-base p-14 shadow-button-neutral"
|
|
153
|
+
key={metric.label}
|
|
154
|
+
>
|
|
155
|
+
<Text size="xs" className="text-foreground-neutral-muted">
|
|
156
|
+
{metric.label}
|
|
157
|
+
</Text>
|
|
158
|
+
<Text size="xl" bold>
|
|
159
|
+
{metric.value}
|
|
160
|
+
</Text>
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="grid grid-cols-2 gap-18">
|
|
165
|
+
<PreviewPanel title="Performance over time" />
|
|
166
|
+
<PreviewPanel title="Duration distribution" bars />
|
|
167
|
+
</div>
|
|
168
|
+
<div className="rounded-8 border border-border-neutral-base bg-background-neutral-base p-16 shadow-button-neutral">
|
|
169
|
+
<Text size="sm" bold>
|
|
170
|
+
Jobs breakdown
|
|
171
|
+
</Text>
|
|
172
|
+
<div className="mt-14 flex flex-col gap-10">
|
|
173
|
+
{[0, 1, 2, 3].map((row) => (
|
|
174
|
+
<div
|
|
175
|
+
className="grid grid-cols-[1fr_80px_80px] gap-12 border-t border-border-neutral-base pt-10"
|
|
176
|
+
key={row}
|
|
177
|
+
>
|
|
178
|
+
<div className="h-12 rounded-full bg-background-neutral-disabled" />
|
|
179
|
+
<div className="h-12 rounded-full bg-background-neutral-disabled" />
|
|
180
|
+
<div className="h-12 rounded-full bg-background-neutral-disabled" />
|
|
181
|
+
</div>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</section>
|
|
187
|
+
</div>
|
|
188
|
+
</main>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function PreviewPanel({title, bars = false}: {title: string; bars?: boolean}) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="rounded-8 border border-border-neutral-base bg-background-neutral-base p-16 shadow-button-neutral">
|
|
195
|
+
<Text size="sm" bold>
|
|
196
|
+
{title}
|
|
197
|
+
</Text>
|
|
198
|
+
<div className="mt-16 flex h-[220px] items-end gap-8 border-b border-l border-border-neutral-base px-12 pb-10">
|
|
199
|
+
{previewBars.map((bar) => (
|
|
200
|
+
<div
|
|
201
|
+
className={
|
|
202
|
+
bars
|
|
203
|
+
? 'w-full rounded-t-4 bg-background-neutral-disabled'
|
|
204
|
+
: 'w-full rounded-full bg-background-neutral-disabled'
|
|
205
|
+
}
|
|
206
|
+
key={`${title}-${bar.id}`}
|
|
207
|
+
style={{height: `${bar.height}%`}}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {RouterContext} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {defineRoute, getLastWorkspaceId} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
4
|
+
import {redirect} from '@tanstack/react-router';
|
|
5
|
+
|
|
6
|
+
export default defineRoute({
|
|
7
|
+
beforeLoad: ({context}: {context: RouterContext}) => {
|
|
8
|
+
const auth = context.auth;
|
|
9
|
+
if (!auth || auth.isLoading) return;
|
|
10
|
+
if (!auth.isAuthenticated) throw redirect({to: '/auth/login'});
|
|
11
|
+
const [first, ...rest] = auth.workspaces;
|
|
12
|
+
if (!first) throw redirect({to: '/setup/workspaces/new'});
|
|
13
|
+
const target =
|
|
14
|
+
[first, ...rest].find((workspace) => workspace.id === getLastWorkspaceId()) ?? first;
|
|
15
|
+
throw redirect({to: '/workspaces/$wid', params: {wid: target.id}});
|
|
16
|
+
},
|
|
17
|
+
component: FullPageLoader,
|
|
18
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
|
+
import {LoginPage} from '#pages/login-page.js';
|
|
4
|
+
|
|
5
|
+
export default defineRoute({
|
|
6
|
+
component: () => (
|
|
7
|
+
<GuestGuard>
|
|
8
|
+
<LoginPage />
|
|
9
|
+
</GuestGuard>
|
|
10
|
+
),
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
|
+
import {PasswordResetPage} from '#pages/password-reset-page.js';
|
|
4
|
+
|
|
5
|
+
export default defineRoute({
|
|
6
|
+
component: () => (
|
|
7
|
+
<GuestGuard>
|
|
8
|
+
<PasswordResetPage />
|
|
9
|
+
</GuestGuard>
|
|
10
|
+
),
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
|
+
import {SignupPage} from '#pages/signup-page.js';
|
|
4
|
+
|
|
5
|
+
export default defineRoute({
|
|
6
|
+
component: () => (
|
|
7
|
+
<GuestGuard>
|
|
8
|
+
<SignupPage />
|
|
9
|
+
</GuestGuard>
|
|
10
|
+
),
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {GuestGuard} from '#components/auth-guard.js';
|
|
3
|
+
import {VerifyEmailPage} from '#pages/verify-email-page.js';
|
|
4
|
+
|
|
5
|
+
export default defineRoute({
|
|
6
|
+
component: () => (
|
|
7
|
+
<GuestGuard>
|
|
8
|
+
<VerifyEmailPage />
|
|
9
|
+
</GuestGuard>
|
|
10
|
+
),
|
|
11
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type {RouterContext} from '@shipfox/client-shell/runtime';
|
|
2
|
+
import {defineRoute} from '@shipfox/client-shell/runtime';
|
|
3
|
+
import {redirect} from '@tanstack/react-router';
|
|
4
|
+
import {WorkspaceOnboardingPage} from '#pages/workspace-onboarding-page.js';
|
|
5
|
+
|
|
6
|
+
export default defineRoute({
|
|
7
|
+
beforeLoad: ({context, location}: {context: RouterContext; location: {href: string}}) => {
|
|
8
|
+
const auth = context.auth;
|
|
9
|
+
if (!auth || auth.isLoading) return;
|
|
10
|
+
if (!auth.isAuthenticated)
|
|
11
|
+
throw redirect({to: '/auth/login', search: {redirect: location.href}});
|
|
12
|
+
},
|
|
13
|
+
component: WorkspaceOnboardingPage,
|
|
14
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {atom} from 'jotai';
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
type AuthState,
|
|
5
|
+
type AuthStatus,
|
|
6
|
+
authStateAtom,
|
|
7
|
+
initialAuthState,
|
|
8
|
+
toAuthenticatedState,
|
|
9
|
+
type Workspace,
|
|
10
|
+
} from '@shipfox/client-shell/runtime';
|
|
11
|
+
|
|
12
|
+
export interface AuthFormDraft {
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const initialAuthFormDraft: AuthFormDraft = {email: '', password: ''};
|
|
18
|
+
export const authFormDraftAtom = atom<AuthFormDraft>(initialAuthFormDraft);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {createStore} from 'jotai';
|
|
2
|
+
import {
|
|
3
|
+
getLastWorkspaceId,
|
|
4
|
+
lastWorkspaceIdAtom,
|
|
5
|
+
rememberLastWorkspaceId,
|
|
6
|
+
} from './last-workspace.js';
|
|
7
|
+
|
|
8
|
+
describe('lastWorkspaceIdAtom', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
window.localStorage.clear();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
window.localStorage.clear();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('initial value is undefined when storage is empty', () => {
|
|
18
|
+
const store = createStore();
|
|
19
|
+
|
|
20
|
+
expect(store.get(lastWorkspaceIdAtom)).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('write persists to localStorage and round-trips through the atom', () => {
|
|
24
|
+
const store = createStore();
|
|
25
|
+
|
|
26
|
+
store.set(lastWorkspaceIdAtom, 'workspace-1');
|
|
27
|
+
|
|
28
|
+
expect(JSON.parse(window.localStorage.getItem('shipfox.lastWorkspaceId') ?? 'null')).toBe(
|
|
29
|
+
'workspace-1',
|
|
30
|
+
);
|
|
31
|
+
expect(store.get(lastWorkspaceIdAtom)).toBe('workspace-1');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('subscribers receive updates when the atom is set', () => {
|
|
35
|
+
const store = createStore();
|
|
36
|
+
const seen: Array<string | undefined> = [];
|
|
37
|
+
const unsubscribe = store.sub(lastWorkspaceIdAtom, () => {
|
|
38
|
+
seen.push(store.get(lastWorkspaceIdAtom));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
store.set(lastWorkspaceIdAtom, 'workspace-1');
|
|
42
|
+
store.set(lastWorkspaceIdAtom, 'workspace-2');
|
|
43
|
+
|
|
44
|
+
expect(seen).toEqual(['workspace-1', 'workspace-2']);
|
|
45
|
+
unsubscribe();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('rememberLastWorkspaceId persists a root redirect target', () => {
|
|
49
|
+
rememberLastWorkspaceId('workspace-1');
|
|
50
|
+
|
|
51
|
+
const result = getLastWorkspaceId();
|
|
52
|
+
|
|
53
|
+
expect(result).toBe('workspace-1');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('getLastWorkspaceId ignores malformed storage', () => {
|
|
57
|
+
window.localStorage.setItem('shipfox.lastWorkspaceId', '{');
|
|
58
|
+
|
|
59
|
+
const result = getLastWorkspaceId();
|
|
60
|
+
|
|
61
|
+
expect(result).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {faker} from '@faker-js/faker';
|
|
2
|
+
import type {UserDto} from '@shipfox/api-auth-dto';
|
|
3
|
+
import {Factory} from 'fishery';
|
|
4
|
+
|
|
5
|
+
export const pageUserFactory = Factory.define<UserDto>(() => {
|
|
6
|
+
const createdAt = faker.date.recent();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
id: faker.string.uuid(),
|
|
10
|
+
email: faker.internet.email().toLowerCase(),
|
|
11
|
+
name: faker.person.fullName(),
|
|
12
|
+
email_verified_at: faker.helpers.maybe(() => faker.date.recent().toISOString()) ?? null,
|
|
13
|
+
status: 'active',
|
|
14
|
+
created_at: createdAt.toISOString(),
|
|
15
|
+
updated_at: faker.date.soon({refDate: createdAt}).toISOString(),
|
|
16
|
+
};
|
|
17
|
+
});
|
package/test/pages.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {Toaster} from '@shipfox/react-ui/toast';
|
|
2
|
+
import {
|
|
3
|
+
createMemoryHistory,
|
|
4
|
+
createRootRoute,
|
|
5
|
+
createRoute,
|
|
6
|
+
createRouter,
|
|
7
|
+
Outlet,
|
|
8
|
+
RouterProvider,
|
|
9
|
+
} from '@tanstack/react-router';
|
|
10
|
+
import {type RenderResult, render} from '@testing-library/react';
|
|
11
|
+
import {type ReactElement, StrictMode} from 'react';
|
|
12
|
+
import {AuthProvider} from '#components/auth-provider.js';
|
|
13
|
+
import {LoginPage} from '#pages/login-page.js';
|
|
14
|
+
import {PasswordResetPage} from '#pages/password-reset-page.js';
|
|
15
|
+
import {SignupPage} from '#pages/signup-page.js';
|
|
16
|
+
import {VerifyEmailPage} from '#pages/verify-email-page.js';
|
|
17
|
+
|
|
18
|
+
function createTestRouter(path: string, element: ReactElement) {
|
|
19
|
+
const rootRoute = createRootRoute({
|
|
20
|
+
component: Outlet,
|
|
21
|
+
});
|
|
22
|
+
const indexRoute = createRoute({
|
|
23
|
+
getParentRoute: () => rootRoute,
|
|
24
|
+
path: '/',
|
|
25
|
+
component: () => (path.split('?')[0] === '/' ? element : <h1>Authenticated home</h1>),
|
|
26
|
+
});
|
|
27
|
+
const workspaceRoute = createRoute({
|
|
28
|
+
getParentRoute: () => rootRoute,
|
|
29
|
+
path: 'workspaces/$wid',
|
|
30
|
+
component: () => <h1>Authenticated home</h1>,
|
|
31
|
+
});
|
|
32
|
+
const loginRoute = createRoute({
|
|
33
|
+
getParentRoute: () => rootRoute,
|
|
34
|
+
path: '/auth/login',
|
|
35
|
+
component: () => (path.split('?')[0] === '/auth/login' ? element : <LoginPage />),
|
|
36
|
+
});
|
|
37
|
+
const signupRoute = createRoute({
|
|
38
|
+
getParentRoute: () => rootRoute,
|
|
39
|
+
path: '/auth/signup',
|
|
40
|
+
component: () => (path.split('?')[0] === '/auth/signup' ? element : <SignupPage />),
|
|
41
|
+
});
|
|
42
|
+
const passwordResetRoute = createRoute({
|
|
43
|
+
getParentRoute: () => rootRoute,
|
|
44
|
+
path: '/auth/reset',
|
|
45
|
+
component: () => (path.split('?')[0] === '/auth/reset' ? element : <PasswordResetPage />),
|
|
46
|
+
});
|
|
47
|
+
const verifyEmailRoute = createRoute({
|
|
48
|
+
getParentRoute: () => rootRoute,
|
|
49
|
+
path: '/auth/verify-email',
|
|
50
|
+
component: () => (path.split('?')[0] === '/auth/verify-email' ? element : <VerifyEmailPage />),
|
|
51
|
+
});
|
|
52
|
+
const routeTree = rootRoute.addChildren([
|
|
53
|
+
indexRoute,
|
|
54
|
+
workspaceRoute,
|
|
55
|
+
loginRoute,
|
|
56
|
+
signupRoute,
|
|
57
|
+
passwordResetRoute,
|
|
58
|
+
verifyEmailRoute,
|
|
59
|
+
]);
|
|
60
|
+
return createRouter({
|
|
61
|
+
history: createMemoryHistory({initialEntries: [path]}),
|
|
62
|
+
routeTree,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderWithProviders(path: string, element: ReactElement) {
|
|
67
|
+
const router = createTestRouter(path, element);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<AuthProvider>
|
|
71
|
+
<RouterProvider router={router} />
|
|
72
|
+
<Toaster />
|
|
73
|
+
</AuthProvider>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderAuthPage(path: string, element: ReactElement): RenderResult {
|
|
78
|
+
return render(renderWithProviders(path, element));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderStrictAuthPage(path: string, element: ReactElement): RenderResult {
|
|
82
|
+
return render(<StrictMode>{renderWithProviders(path, element)}</StrictMode>);
|
|
83
|
+
}
|
package/test/setup.ts
ADDED
package/test/utils.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
2
|
+
return new Response(JSON.stringify(body), {
|
|
3
|
+
headers: {'content-type': 'application/json'},
|
|
4
|
+
status: 200,
|
|
5
|
+
...init,
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function requestUrl(input: RequestInfo | URL): string {
|
|
10
|
+
return input instanceof Request ? input.url : input.toString();
|
|
11
|
+
}
|