@shipfox/client-auth 6.0.3 → 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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/dist/components/password-login-form.d.ts +7 -0
  4. package/dist/components/password-login-form.d.ts.map +1 -0
  5. package/dist/components/password-login-form.js +164 -0
  6. package/dist/components/password-login-form.js.map +1 -0
  7. package/dist/continuation.d.ts +2 -1
  8. package/dist/continuation.d.ts.map +1 -1
  9. package/dist/continuation.js +2 -1
  10. package/dist/continuation.js.map +1 -1
  11. package/dist/pages/invitation-context.js +1 -1
  12. package/dist/pages/invitation-context.js.map +1 -1
  13. package/dist/pages/login-page.d.ts.map +1 -1
  14. package/dist/pages/login-page.js +12 -168
  15. package/dist/pages/login-page.js.map +1 -1
  16. package/dist/redirect-context.d.ts.map +1 -0
  17. package/dist/{components/redirect-context.js → redirect-context.js} +1 -1
  18. package/dist/redirect-context.js.map +1 -0
  19. package/dist/tsconfig.test.tsbuildinfo +1 -1
  20. package/package.json +7 -3
  21. package/src/components/password-login-form.test.tsx +34 -0
  22. package/src/components/password-login-form.tsx +149 -0
  23. package/src/continuation.test.ts +2 -0
  24. package/src/continuation.ts +5 -1
  25. package/src/pages/invitation-context.ts +1 -1
  26. package/src/pages/login-page.tsx +4 -141
  27. package/src/{components/redirect-context.test.ts → redirect-context.test.ts} +13 -2
  28. package/src/{components/redirect-context.ts → redirect-context.ts} +1 -1
  29. package/tsconfig.build.tsbuildinfo +1 -1
  30. package/dist/components/redirect-context.d.ts.map +0 -1
  31. package/dist/components/redirect-context.js.map +0 -1
  32. /package/dist/{components/redirect-context.d.ts → redirect-context.d.ts} +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-auth",
3
3
  "license": "MIT",
4
- "version": "6.0.3",
4
+ "version": "8.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/continuation.d.ts",
24
24
  "default": "./dist/continuation.js"
25
25
  },
26
+ "./redirect-context": {
27
+ "types": "./dist/redirect-context.d.ts",
28
+ "default": "./dist/redirect-context.js"
29
+ },
26
30
  "./routes/*": {
27
31
  "types": "./dist/routes/*.d.ts",
28
32
  "default": "./dist/routes/*.js"
@@ -34,8 +38,8 @@
34
38
  "@shipfox/api-auth-dto": "9.0.2",
35
39
  "@shipfox/api-workspaces-dto": "9.0.2",
36
40
  "@shipfox/client-api": "6.0.1",
37
- "@shipfox/client-invitations": "6.0.2",
38
- "@shipfox/client-shell": "6.0.2",
41
+ "@shipfox/client-invitations": "8.0.0",
42
+ "@shipfox/client-shell": "8.0.0",
39
43
  "@shipfox/client-ui": "6.0.2",
40
44
  "@shipfox/react-ui": "0.3.7"
41
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
+ }
@@ -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
  });
@@ -3,4 +3,8 @@ export {
3
3
  EmailCodeVerification,
4
4
  type EmailCodeVerificationProps,
5
5
  } from './components/email-code-verification.js';
6
- export {parseRedirectContext, type RedirectContext} from './components/redirect-context.js';
6
+ export {
7
+ PasswordLoginForm,
8
+ type PasswordLoginFormProps,
9
+ } from './components/password-login-form.js';
10
+ export {parseRedirectContext, type RedirectContext} from './redirect-context.js';
@@ -3,7 +3,7 @@ import {
3
3
  pendingInvitation,
4
4
  usePreviewInvitation,
5
5
  } from '@shipfox/client-invitations';
6
- import {parseRedirectContext} from '#/components/redirect-context.js';
6
+ import {parseRedirectContext} from '#/redirect-context.js';
7
7
 
8
8
  export function extractInvitationToken(redirect: unknown): string | undefined {
9
9
  return parseRedirectContext(redirect).invitationToken;
@@ -1,18 +1,9 @@
1
- import {loginBodySchema} from '@shipfox/api-auth-dto';
2
1
  import {AuthShell, useRouteSearch} from '@shipfox/client-shell/runtime';
3
- import {Button, ButtonLink} from '@shipfox/react-ui/button';
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 {useAtom} from 'jotai';
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
- <form
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
- <Button className="w-full" isLoading={login.isPending} type="submit">
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>
@@ -1,6 +1,12 @@
1
- import {parseRedirectContext} from './redirect-context.js';
1
+ import {parseRedirectContext, type RedirectContext} from '@shipfox/client-auth/redirect-context';
2
+
3
+ describe('@shipfox/client-auth/redirect-context', () => {
4
+ test('imports the parser and its type through the Node-safe public subpath', () => {
5
+ const context: RedirectContext = parseRedirectContext('/workspaces/acme');
6
+
7
+ expect(context).toEqual({returnTo: '/workspaces/acme'});
8
+ });
2
9
 
3
- describe('parseRedirectContext', () => {
4
10
  test('returns an ordinary safe return path', () => {
5
11
  const context = parseRedirectContext('/workspaces/acme?tab=runs');
6
12
 
@@ -25,6 +31,10 @@ describe('parseRedirectContext', () => {
25
31
  test.each([
26
32
  ['/%2569nvitations/accept?token=double-encoded-token', 'double-encoded-token'],
27
33
  ['/%252569nvitations/accept?token=triple-encoded-token', 'triple-encoded-token'],
34
+ [
35
+ '/safe%255c..%255cinvitations/accept?token=encoded-backslash-token',
36
+ 'encoded-backslash-token',
37
+ ],
28
38
  ])('separates an invitation token from a deeply encoded path: %s', (redirect, token) => {
29
39
  const context = parseRedirectContext(redirect);
30
40
 
@@ -41,6 +51,7 @@ describe('parseRedirectContext', () => {
41
51
  '/%252561uth/login',
42
52
  '/%E0%80%80',
43
53
  '/safe/%25252e%25252e/auth/login',
54
+ '/safe%255c..%255cauth/login',
44
55
  ])('rejects malformed or unsafe redirect %s', (redirect) => {
45
56
  const context = parseRedirectContext(redirect);
46
57
 
@@ -1,4 +1,4 @@
1
- import {isAuthPath, resolveRedirectPath} from './redirect-target.js';
1
+ import {isAuthPath, resolveRedirectPath} from './components/redirect-target.js';
2
2
 
3
3
  const INVITATION_ACCEPT_PATH = '/invitations/accept';
4
4