@shipfox/client-auth 22.0.2 → 22.0.3
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/preview.tsx +2 -2
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +15 -0
- package/dist/pages/form-errors.d.ts +1 -0
- package/dist/pages/form-errors.d.ts.map +1 -1
- package/dist/pages/form-errors.js +11 -1
- package/dist/pages/form-errors.js.map +1 -1
- package/dist/pages/form-utils.d.ts +1 -0
- package/dist/pages/form-utils.d.ts.map +1 -1
- package/dist/pages/form-utils.js +10 -3
- package/dist/pages/form-utils.js.map +1 -1
- package/dist/pages/signup-page.d.ts.map +1 -1
- package/dist/pages/signup-page.js +10 -3
- package/dist/pages/signup-page.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/pages/form-errors.test.ts +28 -0
- package/src/pages/form-errors.ts +10 -2
- package/src/pages/form-utils.test.ts +42 -1
- package/src/pages/form-utils.ts +12 -3
- package/src/pages/signup-page.test.tsx +47 -4
- package/src/pages/signup-page.tsx +13 -4
- 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": "22.0.
|
|
4
|
+
"version": "22.0.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"@swc/helpers": "^0.5.17",
|
|
37
37
|
"@tanstack/react-form": "^1.32.0",
|
|
38
38
|
"@shipfox/api-common-dto": "12.0.0",
|
|
39
|
-
"@shipfox/api-auth-dto": "12.0.0",
|
|
40
39
|
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
41
40
|
"@shipfox/client-api": "6.0.1",
|
|
42
|
-
"@shipfox/
|
|
43
|
-
"@shipfox/client-
|
|
44
|
-
"@shipfox/client-
|
|
45
|
-
"@shipfox/
|
|
41
|
+
"@shipfox/api-auth-dto": "12.0.0",
|
|
42
|
+
"@shipfox/client-invitations": "22.0.3",
|
|
43
|
+
"@shipfox/client-shell": "22.0.3",
|
|
44
|
+
"@shipfox/client-ui": "22.0.3",
|
|
45
|
+
"@shipfox/react-ui": "2.2.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -78,6 +78,34 @@ describe('signupErrorToFormError', () => {
|
|
|
78
78
|
message: 'We could not reach the API. Check your connection and try again.',
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
test('marks configured signup denial messages as Markdown', () => {
|
|
83
|
+
const message = 'Read the [access policy](https://example.test/access).';
|
|
84
|
+
const error = new ApiError({
|
|
85
|
+
code: 'signup-not-allowed',
|
|
86
|
+
message: 'Forbidden',
|
|
87
|
+
status: 403,
|
|
88
|
+
details: {code: 'signup-not-allowed', details: {message, format: 'markdown'}},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = signupErrorToFormError(error);
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({kind: 'form', message, format: 'markdown'});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('keeps unformatted signup policy messages as plain text', () => {
|
|
97
|
+
const message = 'Email <admin>@example.test for access.';
|
|
98
|
+
const error = new ApiError({
|
|
99
|
+
code: 'signup-not-allowed',
|
|
100
|
+
message: 'Forbidden',
|
|
101
|
+
status: 403,
|
|
102
|
+
details: {code: 'signup-not-allowed', details: {message}},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = signupErrorToFormError(error);
|
|
106
|
+
|
|
107
|
+
expect(result).toEqual({kind: 'form', message});
|
|
108
|
+
});
|
|
81
109
|
});
|
|
82
110
|
|
|
83
111
|
describe('passwordResetRequestErrorToFormError', () => {
|
package/src/pages/form-errors.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {ApiError} from '@shipfox/client-api';
|
|
2
|
-
import {authErrorMessage} from './form-utils.js';
|
|
2
|
+
import {authErrorMessage, authErrorMessageFormat} from './form-utils.js';
|
|
3
3
|
|
|
4
4
|
export type FormErrorMapping<TField extends string> =
|
|
5
5
|
| {kind: 'field'; field: TField; message: string}
|
|
6
|
-
| {kind: 'form'; message: string};
|
|
6
|
+
| {kind: 'form'; message: string; format?: 'markdown'};
|
|
7
7
|
|
|
8
8
|
type LoginField = 'email' | 'password';
|
|
9
9
|
type SignupField = 'email' | 'password' | 'name';
|
|
@@ -25,6 +25,14 @@ export function signupErrorToFormError(error: unknown): FormErrorMapping<SignupF
|
|
|
25
25
|
if (apiCode(error) === 'email-taken') {
|
|
26
26
|
return {kind: 'field', field: 'email', message: authErrorMessage(error)};
|
|
27
27
|
}
|
|
28
|
+
if (apiCode(error) === 'signup-not-allowed') {
|
|
29
|
+
const format = authErrorMessageFormat(error);
|
|
30
|
+
return {
|
|
31
|
+
kind: 'form',
|
|
32
|
+
message: authErrorMessage(error),
|
|
33
|
+
...(format ? {format} : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
28
36
|
return {kind: 'form', message: authErrorMessage(error)};
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {ApiError} from '@shipfox/client-api';
|
|
2
|
-
import {authErrorMessage} from './form-utils.js';
|
|
2
|
+
import {authErrorMessage, authErrorMessageFormat} from './form-utils.js';
|
|
3
3
|
|
|
4
4
|
describe('authErrorMessage', () => {
|
|
5
5
|
test.each([
|
|
@@ -42,3 +42,44 @@ describe('authErrorMessage', () => {
|
|
|
42
42
|
expect(result).toBe('Something went wrong. Try again.');
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
describe('authErrorMessageFormat', () => {
|
|
47
|
+
test('returns Markdown for signup denial errors that declare it', () => {
|
|
48
|
+
const error = new ApiError({
|
|
49
|
+
code: 'signup-not-allowed',
|
|
50
|
+
message: 'Forbidden',
|
|
51
|
+
status: 403,
|
|
52
|
+
details: {details: {format: 'markdown'}},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = authErrorMessageFormat(error);
|
|
56
|
+
|
|
57
|
+
expect(result).toBe('markdown');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test.each([
|
|
61
|
+
[
|
|
62
|
+
'a different error code',
|
|
63
|
+
new ApiError({
|
|
64
|
+
code: 'unknown-api-code',
|
|
65
|
+
message: 'Try later',
|
|
66
|
+
status: 400,
|
|
67
|
+
details: {details: {format: 'markdown'}},
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'an unsupported signup denial format',
|
|
72
|
+
new ApiError({
|
|
73
|
+
code: 'signup-not-allowed',
|
|
74
|
+
message: 'Forbidden',
|
|
75
|
+
status: 403,
|
|
76
|
+
details: {details: {format: 'html'}},
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
['a non-API error', new Error('boom')],
|
|
80
|
+
])('returns undefined for %s', (_case, error) => {
|
|
81
|
+
const result = authErrorMessageFormat(error);
|
|
82
|
+
|
|
83
|
+
expect(result).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/pages/form-utils.ts
CHANGED
|
@@ -4,12 +4,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
4
4
|
return typeof value === 'object' && value !== null;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function signupNotAllowedDetails(error: ApiError): Record<string, unknown> | undefined {
|
|
8
8
|
if (error.code !== 'signup-not-allowed') return undefined;
|
|
9
9
|
if (!isRecord(error.details)) return undefined;
|
|
10
10
|
const details = error.details.details;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
return isRecord(details) ? details : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function signupNotAllowedMessage(error: ApiError): string | undefined {
|
|
15
|
+
const details = signupNotAllowedDetails(error);
|
|
16
|
+
return typeof details?.message === 'string' ? details.message : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function authErrorMessageFormat(error: unknown): 'markdown' | undefined {
|
|
20
|
+
if (!(error instanceof ApiError)) return undefined;
|
|
21
|
+
return signupNotAllowedDetails(error)?.format === 'markdown' ? 'markdown' : undefined;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export function authErrorMessage(error: unknown): string {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
-
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
|
|
2
|
+
import {act, fireEvent, screen, waitFor, within} from '@testing-library/react';
|
|
3
3
|
import {pageUserFactory} from '#test/factories/user.js';
|
|
4
4
|
import {renderAuthPage} from '#test/pages.js';
|
|
5
5
|
import {jsonResponse, requestUrl} from '#test/utils.js';
|
|
@@ -7,6 +7,7 @@ import {SignupPage} from './signup-page.js';
|
|
|
7
7
|
|
|
8
8
|
const SUBMITTED_EMAIL_RE = /new@example.com/;
|
|
9
9
|
const RESEND_COUNTDOWN_RE = /^Resend in \d+s$/;
|
|
10
|
+
const ACCESS_POLICY_RE = /access policy/i;
|
|
10
11
|
function emailChallenge() {
|
|
11
12
|
return {
|
|
12
13
|
id: '019f814f-3cfd-779a-82f2-6588eefd572c',
|
|
@@ -236,7 +237,7 @@ describe('SignupPage', () => {
|
|
|
236
237
|
expect(await screen.findByRole('heading', {name: 'Authenticated home'})).toBeInTheDocument();
|
|
237
238
|
});
|
|
238
239
|
|
|
239
|
-
test('
|
|
240
|
+
test('renders ordinary form errors as plain text', async () => {
|
|
240
241
|
const fetchImpl = vi
|
|
241
242
|
.fn()
|
|
242
243
|
.mockResolvedValueOnce(
|
|
@@ -244,7 +245,7 @@ describe('SignupPage', () => {
|
|
|
244
245
|
)
|
|
245
246
|
.mockResolvedValueOnce(
|
|
246
247
|
jsonResponse(
|
|
247
|
-
{code: 'email-already-exists', message: 'Email already exists'},
|
|
248
|
+
{code: 'email-already-exists', message: 'Email *already* exists'},
|
|
248
249
|
{status: 409},
|
|
249
250
|
),
|
|
250
251
|
);
|
|
@@ -256,7 +257,49 @@ describe('SignupPage', () => {
|
|
|
256
257
|
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
257
258
|
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
258
259
|
|
|
259
|
-
|
|
260
|
+
const alert = await screen.findByRole('alert');
|
|
261
|
+
expect(alert).toHaveTextContent('Email *already* exists');
|
|
262
|
+
expect(within(alert).queryByText('already', {selector: 'em'})).not.toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('renders configured signup denial messages as Markdown', async () => {
|
|
266
|
+
const message = [
|
|
267
|
+
'# Signups require approval',
|
|
268
|
+
'',
|
|
269
|
+
'- Review the [access policy](https://example.test/access).',
|
|
270
|
+
].join('\n');
|
|
271
|
+
const fetchImpl = vi
|
|
272
|
+
.fn()
|
|
273
|
+
.mockResolvedValueOnce(
|
|
274
|
+
jsonResponse({code: 'unauthorized', message: 'Unauthorized'}, {status: 401}),
|
|
275
|
+
)
|
|
276
|
+
.mockResolvedValueOnce(
|
|
277
|
+
jsonResponse(
|
|
278
|
+
{
|
|
279
|
+
code: 'signup-not-allowed',
|
|
280
|
+
message: 'Forbidden',
|
|
281
|
+
details: {message, format: 'markdown'},
|
|
282
|
+
},
|
|
283
|
+
{status: 403},
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
configureApiClient({fetchImpl});
|
|
287
|
+
|
|
288
|
+
renderAuthPage('/auth/signup', <SignupPage />);
|
|
289
|
+
fireEvent.change(await screen.findByLabelText('Name'), {target: {value: 'New User'}});
|
|
290
|
+
fireEvent.change(screen.getByLabelText('Email'), {target: {value: 'new@example.com'}});
|
|
291
|
+
fireEvent.change(screen.getByLabelText('Password'), {target: {value: 'long secure password'}});
|
|
292
|
+
fireEvent.click(screen.getByRole('button', {name: 'Create account'}));
|
|
293
|
+
|
|
294
|
+
const alert = await screen.findByRole('alert');
|
|
295
|
+
expect(
|
|
296
|
+
within(alert).getByRole('heading', {level: 2, name: 'Signups require approval'}),
|
|
297
|
+
).toBeInTheDocument();
|
|
298
|
+
expect(within(alert).getByRole('listitem')).toBeInTheDocument();
|
|
299
|
+
expect(within(alert).getByRole('link', {name: ACCESS_POLICY_RE})).toHaveAttribute(
|
|
300
|
+
'href',
|
|
301
|
+
'https://example.test/access',
|
|
302
|
+
);
|
|
260
303
|
});
|
|
261
304
|
|
|
262
305
|
test('validates the name locally', async () => {
|
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
} from '@shipfox/client-shell/runtime';
|
|
8
8
|
import {displayNameFieldError} from '@shipfox/client-ui';
|
|
9
9
|
import {Button, ButtonLink} from '@shipfox/react-ui/button';
|
|
10
|
-
import {Callout} from '@shipfox/react-ui/callout';
|
|
10
|
+
import {Callout, CalloutContent} from '@shipfox/react-ui/callout';
|
|
11
11
|
import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
|
|
12
12
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
13
|
+
import {Markdown} from '@shipfox/react-ui/markdown';
|
|
13
14
|
import {toast} from '@shipfox/react-ui/toast';
|
|
14
15
|
import {Text} from '@shipfox/react-ui/typography';
|
|
15
16
|
import {useForm} from '@tanstack/react-form';
|
|
@@ -45,7 +46,7 @@ export function SignupPage() {
|
|
|
45
46
|
const [authFormDraft, setAuthFormDraft] = useAtom(authFormDraftAtom);
|
|
46
47
|
const [emailChallenge, setEmailChallenge] = useState<{email: string; id: string} | undefined>();
|
|
47
48
|
const [nextResendAvailableAt, setNextResendAvailableAt] = useState<string | undefined>();
|
|
48
|
-
const [formError, setFormError] = useState<string | undefined>();
|
|
49
|
+
const [formError, setFormError] = useState<{message: string; format?: 'markdown'} | undefined>();
|
|
49
50
|
const [resendError, setResendError] = useState<string | undefined>();
|
|
50
51
|
const [invitationRefreshFailure, setInvitationRefreshFailure] = useState<{
|
|
51
52
|
workspaceId: string;
|
|
@@ -138,7 +139,7 @@ export function SignupPage() {
|
|
|
138
139
|
errorMap: {...prev.errorMap, onServer: mapped.message},
|
|
139
140
|
}));
|
|
140
141
|
} else {
|
|
141
|
-
setFormError(mapped
|
|
142
|
+
setFormError(mapped);
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
},
|
|
@@ -295,7 +296,15 @@ export function SignupPage() {
|
|
|
295
296
|
>
|
|
296
297
|
{formError ? (
|
|
297
298
|
<Callout role="alert" type="error">
|
|
298
|
-
|
|
299
|
+
<CalloutContent>
|
|
300
|
+
{formError.format === 'markdown' ? (
|
|
301
|
+
<Markdown className="[&>*:last-child]:mb-0" headingLevelOffset={1}>
|
|
302
|
+
{formError.message}
|
|
303
|
+
</Markdown>
|
|
304
|
+
) : (
|
|
305
|
+
formError.message
|
|
306
|
+
)}
|
|
307
|
+
</CalloutContent>
|
|
299
308
|
</Callout>
|
|
300
309
|
) : null}
|
|
301
310
|
<form.Field
|