@shipfox/client-auth 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/components/password-login-form.d.ts +7 -0
- package/dist/components/password-login-form.d.ts.map +1 -0
- package/dist/components/password-login-form.js +164 -0
- package/dist/components/password-login-form.js.map +1 -0
- package/dist/continuation.d.ts +1 -0
- package/dist/continuation.d.ts.map +1 -1
- package/dist/continuation.js +1 -0
- package/dist/continuation.js.map +1 -1
- package/dist/pages/login-page.d.ts.map +1 -1
- package/dist/pages/login-page.js +12 -168
- package/dist/pages/login-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/components/password-login-form.test.tsx +34 -0
- package/src/components/password-login-form.tsx +149 -0
- package/src/continuation.test.ts +2 -0
- package/src/continuation.ts +4 -0
- package/src/pages/login-page.tsx +4 -141
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "8.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"@shipfox/api-auth-dto": "9.0.2",
|
|
39
39
|
"@shipfox/api-workspaces-dto": "9.0.2",
|
|
40
40
|
"@shipfox/client-api": "6.0.1",
|
|
41
|
-
"@shipfox/client-invitations": "
|
|
42
|
-
"@shipfox/client-shell": "
|
|
41
|
+
"@shipfox/client-invitations": "8.0.0",
|
|
42
|
+
"@shipfox/client-shell": "8.0.0",
|
|
43
43
|
"@shipfox/client-ui": "6.0.2",
|
|
44
44
|
"@shipfox/react-ui": "0.3.7"
|
|
45
45
|
},
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {render, screen} from '@testing-library/react';
|
|
3
|
+
import {jsonResponse} from '#test/utils.js';
|
|
4
|
+
import {AuthProvider} from './auth-provider.js';
|
|
5
|
+
import {PasswordLoginForm} from './password-login-form.js';
|
|
6
|
+
|
|
7
|
+
describe('PasswordLoginForm', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const fetchImpl = vi
|
|
10
|
+
.fn()
|
|
11
|
+
.mockResolvedValue(
|
|
12
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
13
|
+
);
|
|
14
|
+
configureApiClient({
|
|
15
|
+
baseUrl: 'https://api.example.test',
|
|
16
|
+
fetchImpl,
|
|
17
|
+
getAccessToken: undefined,
|
|
18
|
+
refreshAccessToken: undefined,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('renders without a router context', async () => {
|
|
23
|
+
render(
|
|
24
|
+
<AuthProvider>
|
|
25
|
+
<PasswordLoginForm invitationEmail="invitee@example.com" />
|
|
26
|
+
</AuthProvider>,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(await screen.findByLabelText('Email')).toHaveValue('invitee@example.com');
|
|
30
|
+
expect(screen.getByLabelText('Email')).toHaveAttribute('readonly');
|
|
31
|
+
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByRole('button', {name: 'Log in'})).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
3
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
|
+
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
5
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
6
|
+
import {useForm} from '@tanstack/react-form';
|
|
7
|
+
import {useAtom} from 'jotai';
|
|
8
|
+
import {type ReactNode, useEffect, useRef, useState} from 'react';
|
|
9
|
+
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
10
|
+
import {loginErrorToFormError} from '#pages/form-errors.js';
|
|
11
|
+
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
12
|
+
|
|
13
|
+
export interface PasswordLoginFormProps {
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
invitationEmail?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PasswordLoginForm({children, invitationEmail}: PasswordLoginFormProps = {}) {
|
|
19
|
+
const login = useLoginAuth();
|
|
20
|
+
const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
21
|
+
const [formError, setFormError] = useState<string | undefined>();
|
|
22
|
+
const draftRef = useRef(authFormDraft);
|
|
23
|
+
draftRef.current = authFormDraft;
|
|
24
|
+
// Set just before clearing the draft on success so the unmount cleanup
|
|
25
|
+
// below does not repersist the just-submitted credentials.
|
|
26
|
+
const skipDraftPersistRef = useRef(false);
|
|
27
|
+
|
|
28
|
+
const form = useForm({
|
|
29
|
+
defaultValues: {email: authFormDraft.email, password: authFormDraft.password},
|
|
30
|
+
onSubmit: async ({value}) => {
|
|
31
|
+
setFormError(undefined);
|
|
32
|
+
try {
|
|
33
|
+
await login.mutateAsync(value);
|
|
34
|
+
skipDraftPersistRef.current = true;
|
|
35
|
+
setAuthFormDraft(initialAuthFormDraft);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const mapped = loginErrorToFormError(error);
|
|
38
|
+
if (mapped.kind === 'field') {
|
|
39
|
+
form.setFieldMeta(mapped.field, (prev) => ({
|
|
40
|
+
...prev,
|
|
41
|
+
errorMap: {...prev.errorMap, onServer: mapped.message},
|
|
42
|
+
}));
|
|
43
|
+
} else {
|
|
44
|
+
setFormError(mapped.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (invitationEmail && form.state.values.email !== invitationEmail) {
|
|
52
|
+
form.setFieldValue('email', invitationEmail);
|
|
53
|
+
setAuthFormDraft((current) => ({...current, email: invitationEmail}));
|
|
54
|
+
}
|
|
55
|
+
}, [form, invitationEmail, setAuthFormDraft]);
|
|
56
|
+
|
|
57
|
+
// Sync TanStack Form values back into the Jotai draft on unmount so a
|
|
58
|
+
// navigation to /signup or /reset preserves what the user typed. Skipped
|
|
59
|
+
// after a successful login because we just intentionally cleared the draft.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return () => {
|
|
62
|
+
if (skipDraftPersistRef.current) return;
|
|
63
|
+
const {email, password} = form.state.values;
|
|
64
|
+
if (email !== draftRef.current.email || password !== draftRef.current.password) {
|
|
65
|
+
setAuthFormDraft({email, password});
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}, [form, setAuthFormDraft]);
|
|
69
|
+
|
|
70
|
+
function persistDraft() {
|
|
71
|
+
const {email, password} = form.state.values;
|
|
72
|
+
setAuthFormDraft({email, password});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<form
|
|
77
|
+
className="flex flex-col gap-18"
|
|
78
|
+
noValidate
|
|
79
|
+
onSubmit={(event) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
event.stopPropagation();
|
|
82
|
+
void form.handleSubmit();
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{formError ? (
|
|
86
|
+
<Callout role="alert" type="error">
|
|
87
|
+
{formError}
|
|
88
|
+
</Callout>
|
|
89
|
+
) : null}
|
|
90
|
+
<form.Field
|
|
91
|
+
name="email"
|
|
92
|
+
validators={{onBlur: loginBodySchema.shape.email, onSubmit: loginBodySchema.shape.email}}
|
|
93
|
+
>
|
|
94
|
+
{(field) => (
|
|
95
|
+
<FormField label="Email" id="email" error={fieldError(field)}>
|
|
96
|
+
<FormFieldInput
|
|
97
|
+
autoComplete="email"
|
|
98
|
+
name="email"
|
|
99
|
+
type="email"
|
|
100
|
+
value={field.state.value}
|
|
101
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
102
|
+
onBlur={() => {
|
|
103
|
+
field.handleBlur();
|
|
104
|
+
persistDraft();
|
|
105
|
+
}}
|
|
106
|
+
readOnly={Boolean(invitationEmail)}
|
|
107
|
+
iconRight={
|
|
108
|
+
invitationEmail ? (
|
|
109
|
+
<Icon
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
className="size-16 text-foreground-neutral-disabled"
|
|
112
|
+
name="lockLine"
|
|
113
|
+
/>
|
|
114
|
+
) : undefined
|
|
115
|
+
}
|
|
116
|
+
/>
|
|
117
|
+
</FormField>
|
|
118
|
+
)}
|
|
119
|
+
</form.Field>
|
|
120
|
+
<form.Field
|
|
121
|
+
name="password"
|
|
122
|
+
validators={{
|
|
123
|
+
onBlur: loginBodySchema.shape.password,
|
|
124
|
+
onSubmit: loginBodySchema.shape.password,
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{(field) => (
|
|
128
|
+
<FormField label="Password" id="password" error={fieldError(field)}>
|
|
129
|
+
<FormFieldInput
|
|
130
|
+
autoComplete="current-password"
|
|
131
|
+
name="password"
|
|
132
|
+
type="password"
|
|
133
|
+
value={field.state.value}
|
|
134
|
+
onChange={(event) => field.handleChange(event.target.value)}
|
|
135
|
+
onBlur={() => {
|
|
136
|
+
field.handleBlur();
|
|
137
|
+
persistDraft();
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</FormField>
|
|
141
|
+
)}
|
|
142
|
+
</form.Field>
|
|
143
|
+
{children}
|
|
144
|
+
<Button className="w-full" isLoading={login.isPending} type="submit">
|
|
145
|
+
{login.isPending ? 'Logging in...' : 'Log in'}
|
|
146
|
+
</Button>
|
|
147
|
+
</form>
|
|
148
|
+
);
|
|
149
|
+
}
|
package/src/continuation.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AuthActions,
|
|
3
3
|
AuthShell,
|
|
4
4
|
EmailCodeVerification,
|
|
5
|
+
PasswordLoginForm,
|
|
5
6
|
parseRedirectContext,
|
|
6
7
|
} from '@shipfox/client-auth/continuation';
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ describe('client-auth continuation exports', () => {
|
|
|
10
11
|
expect(AuthActions).toBeTypeOf('function');
|
|
11
12
|
expect(AuthShell).toBeTypeOf('function');
|
|
12
13
|
expect(EmailCodeVerification).toBeTypeOf('function');
|
|
14
|
+
expect(PasswordLoginForm).toBeTypeOf('function');
|
|
13
15
|
expect(parseRedirectContext).toBeTypeOf('function');
|
|
14
16
|
});
|
|
15
17
|
});
|
package/src/continuation.ts
CHANGED
|
@@ -3,4 +3,8 @@ export {
|
|
|
3
3
|
EmailCodeVerification,
|
|
4
4
|
type EmailCodeVerificationProps,
|
|
5
5
|
} from './components/email-code-verification.js';
|
|
6
|
+
export {
|
|
7
|
+
PasswordLoginForm,
|
|
8
|
+
type PasswordLoginFormProps,
|
|
9
|
+
} from './components/password-login-form.js';
|
|
6
10
|
export {parseRedirectContext, type RedirectContext} from './redirect-context.js';
|
package/src/pages/login-page.tsx
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
|
-
import {loginBodySchema} from '@shipfox/api-auth-dto';
|
|
2
1
|
import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
|
|
3
|
-
import {
|
|
4
|
-
import {Callout} from '@shipfox/react-ui/callout';
|
|
5
|
-
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
6
|
-
import {Icon} from '@shipfox/react-ui/icon';
|
|
2
|
+
import {ButtonLink} from '@shipfox/react-ui/button';
|
|
7
3
|
import {Text} from '@shipfox/react-ui/typography';
|
|
8
|
-
import {useForm} from '@tanstack/react-form';
|
|
9
4
|
import {Link} from '@tanstack/react-router';
|
|
10
|
-
import {
|
|
11
|
-
import {useEffect, useRef, useState} from 'react';
|
|
12
|
-
import {useLoginAuth} from '#hooks/api/login-auth.js';
|
|
13
|
-
import {authFormDraftAtom, initialAuthFormDraft} from '#state/auth.js';
|
|
5
|
+
import {PasswordLoginForm} from '#components/password-login-form.js';
|
|
14
6
|
import {validateRedirectSearch} from '../routes/inputs.js';
|
|
15
|
-
import {loginErrorToFormError} from './form-errors.js';
|
|
16
7
|
import {
|
|
17
8
|
extractInvitationToken,
|
|
18
9
|
pendingInvitation,
|
|
@@ -20,64 +11,10 @@ import {
|
|
|
20
11
|
} from './invitation-context.js';
|
|
21
12
|
|
|
22
13
|
export function LoginPage() {
|
|
23
|
-
const login = useLoginAuth();
|
|
24
14
|
const search = useRouteSearch(validateRedirectSearch);
|
|
25
15
|
const invitationToken = extractInvitationToken(search.redirect);
|
|
26
16
|
const invitationPreview = useInvitationContext(invitationToken);
|
|
27
17
|
const invitationPending = pendingInvitation(invitationPreview.data);
|
|
28
|
-
const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
29
|
-
const [formError, setFormError] = useState<string | undefined>();
|
|
30
|
-
const draftRef = useRef(authFormDraft);
|
|
31
|
-
draftRef.current = authFormDraft;
|
|
32
|
-
// Set just before clearing the draft on success so the unmount cleanup
|
|
33
|
-
// below does not repersist the just-submitted credentials.
|
|
34
|
-
const skipDraftPersistRef = useRef(false);
|
|
35
|
-
|
|
36
|
-
const form = useForm({
|
|
37
|
-
defaultValues: {email: authFormDraft.email, password: authFormDraft.password},
|
|
38
|
-
onSubmit: async ({value}) => {
|
|
39
|
-
setFormError(undefined);
|
|
40
|
-
try {
|
|
41
|
-
await login.mutateAsync(value);
|
|
42
|
-
skipDraftPersistRef.current = true;
|
|
43
|
-
setAuthFormDraft(initialAuthFormDraft);
|
|
44
|
-
} catch (error) {
|
|
45
|
-
const mapped = loginErrorToFormError(error);
|
|
46
|
-
if (mapped.kind === 'field') {
|
|
47
|
-
form.setFieldMeta(mapped.field, (prev) => ({
|
|
48
|
-
...prev,
|
|
49
|
-
errorMap: {...prev.errorMap, onServer: mapped.message},
|
|
50
|
-
}));
|
|
51
|
-
} else {
|
|
52
|
-
setFormError(mapped.message);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// Lock the email field when arriving from an invitation link so the user
|
|
59
|
-
// can't log in as a different account than the one the invitation targets.
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
if (invitationPending && form.state.values.email !== invitationPending.email) {
|
|
62
|
-
form.setFieldValue('email', invitationPending.email);
|
|
63
|
-
setAuthFormDraft((current) => ({...current, email: invitationPending.email}));
|
|
64
|
-
}
|
|
65
|
-
}, [invitationPending, form, setAuthFormDraft]);
|
|
66
|
-
|
|
67
|
-
// Sync TanStack Form values back into the Jotai draft on unmount so a
|
|
68
|
-
// navigation to /signup or /reset preserves what the user typed. Skipped
|
|
69
|
-
// after a successful login because we just intentionally cleared the draft.
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
return () => {
|
|
72
|
-
if (skipDraftPersistRef.current) return;
|
|
73
|
-
const {email, password} = form.state.values;
|
|
74
|
-
if (email !== draftRef.current.email || password !== draftRef.current.password) {
|
|
75
|
-
setAuthFormDraft({email, password});
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
}, [form, setAuthFormDraft]);
|
|
79
|
-
|
|
80
|
-
const isInvitationEmailLocked = Boolean(invitationPending);
|
|
81
18
|
const invitationRedirect = invitationToken
|
|
82
19
|
? `/invitations/accept?token=${encodeURIComponent(invitationToken)}`
|
|
83
20
|
: undefined;
|
|
@@ -88,87 +25,13 @@ export function LoginPage() {
|
|
|
88
25
|
? 'Log in to accept your invitation.'
|
|
89
26
|
: 'Log in to access Shipfox.';
|
|
90
27
|
|
|
91
|
-
function persistDraft() {
|
|
92
|
-
const {email, password} = form.state.values;
|
|
93
|
-
setAuthFormDraft({email, password});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
28
|
return (
|
|
97
29
|
<AuthShell title={headerTitle} description={headerDescription}>
|
|
98
|
-
<
|
|
99
|
-
className="flex flex-col gap-18"
|
|
100
|
-
noValidate
|
|
101
|
-
onSubmit={(event) => {
|
|
102
|
-
event.preventDefault();
|
|
103
|
-
event.stopPropagation();
|
|
104
|
-
void form.handleSubmit();
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{formError ? (
|
|
108
|
-
<Callout role="alert" type="error">
|
|
109
|
-
{formError}
|
|
110
|
-
</Callout>
|
|
111
|
-
) : null}
|
|
112
|
-
<form.Field
|
|
113
|
-
name="email"
|
|
114
|
-
validators={{onBlur: loginBodySchema.shape.email, onSubmit: loginBodySchema.shape.email}}
|
|
115
|
-
>
|
|
116
|
-
{(field) => (
|
|
117
|
-
<FormField label="Email" id="email" error={fieldError(field)}>
|
|
118
|
-
<FormFieldInput
|
|
119
|
-
autoComplete="email"
|
|
120
|
-
name="email"
|
|
121
|
-
type="email"
|
|
122
|
-
value={field.state.value}
|
|
123
|
-
onChange={(event) => field.handleChange(event.target.value)}
|
|
124
|
-
onBlur={() => {
|
|
125
|
-
field.handleBlur();
|
|
126
|
-
persistDraft();
|
|
127
|
-
}}
|
|
128
|
-
readOnly={isInvitationEmailLocked}
|
|
129
|
-
iconRight={
|
|
130
|
-
isInvitationEmailLocked ? (
|
|
131
|
-
<Icon
|
|
132
|
-
aria-hidden="true"
|
|
133
|
-
className="size-16 text-foreground-neutral-disabled"
|
|
134
|
-
name="lockLine"
|
|
135
|
-
/>
|
|
136
|
-
) : undefined
|
|
137
|
-
}
|
|
138
|
-
/>
|
|
139
|
-
</FormField>
|
|
140
|
-
)}
|
|
141
|
-
</form.Field>
|
|
142
|
-
<form.Field
|
|
143
|
-
name="password"
|
|
144
|
-
validators={{
|
|
145
|
-
onBlur: loginBodySchema.shape.password,
|
|
146
|
-
onSubmit: loginBodySchema.shape.password,
|
|
147
|
-
}}
|
|
148
|
-
>
|
|
149
|
-
{(field) => (
|
|
150
|
-
<FormField label="Password" id="password" error={fieldError(field)}>
|
|
151
|
-
<FormFieldInput
|
|
152
|
-
autoComplete="current-password"
|
|
153
|
-
name="password"
|
|
154
|
-
type="password"
|
|
155
|
-
value={field.state.value}
|
|
156
|
-
onChange={(event) => field.handleChange(event.target.value)}
|
|
157
|
-
onBlur={() => {
|
|
158
|
-
field.handleBlur();
|
|
159
|
-
persistDraft();
|
|
160
|
-
}}
|
|
161
|
-
/>
|
|
162
|
-
</FormField>
|
|
163
|
-
)}
|
|
164
|
-
</form.Field>
|
|
30
|
+
<PasswordLoginForm invitationEmail={invitationPending?.email}>
|
|
165
31
|
<ButtonLink asChild variant="subtle" className="-mt-8 self-end">
|
|
166
32
|
<Link to="/auth/reset">Forgot password?</Link>
|
|
167
33
|
</ButtonLink>
|
|
168
|
-
|
|
169
|
-
{login.isPending ? 'Logging in...' : 'Log in'}
|
|
170
|
-
</Button>
|
|
171
|
-
</form>
|
|
34
|
+
</PasswordLoginForm>
|
|
172
35
|
<Text size="sm" className="text-center text-foreground-neutral-subtle">
|
|
173
36
|
New to Shipfox?{' '}
|
|
174
37
|
<ButtonLink asChild variant="interactive" underline>
|