@gopherium/react-auth 0.15.0 → 0.16.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 (51) hide show
  1. package/README.md +64 -6
  2. package/dist/account.d.ts +3 -0
  3. package/dist/account.js +3 -0
  4. package/dist/admin/errors.d.ts +62 -0
  5. package/dist/admin/errors.js +63 -0
  6. package/dist/admin/fieldError.d.ts +19 -0
  7. package/dist/admin/fieldError.js +46 -0
  8. package/dist/admin/index.d.ts +10 -37
  9. package/dist/admin/index.js +88 -94
  10. package/dist/languages/es-ES.json +1 -1
  11. package/dist/languages/fr-FR.json +1 -0
  12. package/dist/languages/languages/es-ES.json +1 -1
  13. package/dist/languages/languages/fr-FR.json +1 -0
  14. package/dist/languages.js +1 -0
  15. package/dist/testing/index.d.ts +9 -0
  16. package/dist/testing/index.js +21 -0
  17. package/dist/testing/setup.js +2 -0
  18. package/dist/testing/users.d.ts +91 -0
  19. package/dist/testing/users.js +84 -0
  20. package/dist/transport.d.ts +1 -0
  21. package/dist/wpds/PasswordField.d.ts +21 -0
  22. package/dist/wpds/PasswordField.js +130 -0
  23. package/dist/wpds/SetupAccountStep.js +17 -177
  24. package/dist/wpds/index.d.ts +3 -2
  25. package/dist/wpds/index.js +2 -2
  26. package/dist/wpds/style.css +11 -22
  27. package/dist/wpds/usePasswordField.d.ts +37 -0
  28. package/dist/wpds/usePasswordField.js +86 -0
  29. package/dist/wpds/users/ChangeRoleDialog.d.ts +35 -0
  30. package/dist/wpds/users/ChangeRoleDialog.js +90 -0
  31. package/dist/wpds/users/ResendModal.d.ts +13 -0
  32. package/dist/wpds/users/ResendModal.js +60 -0
  33. package/dist/wpds/users/RoleSelect.d.ts +20 -0
  34. package/dist/wpds/users/RoleSelect.js +18 -0
  35. package/dist/wpds/users/UsersPage.d.ts +34 -0
  36. package/dist/wpds/users/UsersPage.js +91 -0
  37. package/dist/wpds/users/actions.d.ts +31 -0
  38. package/dist/wpds/users/actions.js +79 -0
  39. package/dist/wpds/users/defaults.d.ts +14 -0
  40. package/dist/wpds/users/defaults.js +17 -0
  41. package/dist/wpds/users/fields.d.ts +40 -0
  42. package/dist/wpds/users/fields.js +172 -0
  43. package/dist/wpds/users/index.d.ts +9 -0
  44. package/dist/wpds/users/index.js +5 -0
  45. package/dist/wpds/users/words.d.ts +47 -0
  46. package/dist/wpds/users/words.js +151 -0
  47. package/package.json +33 -1
  48. package/dist/wpds/NewUserScreen.d.ts +0 -9
  49. package/dist/wpds/NewUserScreen.js +0 -70
  50. package/dist/wpds/UsersScreen.d.ts +0 -11
  51. package/dist/wpds/UsersScreen.js +0 -46
package/README.md CHANGED
@@ -6,15 +6,24 @@ Session authentication for React apps backed by gouncer's authkit.
6
6
 
7
7
  - `@gopherium/react-auth` is the headless core: the auth API client,
8
8
  `useSession`, `useLogout`, `sessionQueryKey`, the slot-based
9
- `AuthGate`, `createAuthQueryClient`, and `isSessionRevoked`.
9
+ `AuthGate`, `createAuthQueryClient`, `isSessionRevoked`, and
10
+ `catalogFor`, which answers the `es-ES` and `fr-FR` catalogues of the
11
+ text domain `DOMAIN`.
10
12
  - `@gopherium/react-auth/admin` is the user administration API client.
11
13
  - `@gopherium/react-auth/wpds` renders the WordPress Design System UI:
12
- `LoginScreen` (brand prop), `AccountPanel`, `UsersScreen`,
13
- `NewUserScreen`, `usersMenuItem`, `SetupCodeStep`, which asks for
14
- the one-time setup code as Jenkins' "Unlock Jenkins" does, and
15
- `SetupAccountStep`, the account rows of WordPress's installer with
16
- its password strength meter. Import
14
+ `LoginScreen` (brand prop), `AccountPanel`, `usersMenuItem`,
15
+ `SetupCodeStep`, which asks for the one-time setup code as Jenkins'
16
+ "Unlock Jenkins" does, and `SetupAccountStep`, the account rows of
17
+ WordPress's installer with its password strength meter, and
18
+ `PasswordField` with `usePasswordField`, that step's password row for
19
+ any form that sets a password. Import
17
20
  `@gopherium/react-auth/wpds/style.css` alongside it.
21
+ - `@gopherium/react-auth/wpds/users` is the Users page on WordPress
22
+ DataViews: `UsersPage`, `usersListDefaults`, the view it opens on as
23
+ data for godmin's `useListView`, `RoleSelect`, the role menu with the
24
+ fewest capabilities first, `roleLabelOf`, and `errorMessageOf`, the
25
+ words the list toasts a failed account write in. It is its own entry,
26
+ so an app that only signs people in never resolves its peers.
18
27
  - `@gopherium/react-auth/testing` is the msw harness: `server`,
19
28
  `installTestEnvironment`, `seedSession`, `defaultUser`, and canned
20
29
  handlers for the auth endpoints.
@@ -63,6 +72,51 @@ with a `Field.VisualLabel`, as the step's Confirm Password row is.
63
72
  />
64
73
  ```
65
74
 
75
+ ## Password field
76
+
77
+ An Add User form takes the step's password row with WordPress's
78
+ "Generate password" button first. `labelPosition="top"` stacks each
79
+ label above its field as a godmin form does, where the installer's
80
+ default `side` puts it beside the field. Send `password.value` once
81
+ `password.ready` is true.
82
+
83
+ ```tsx
84
+ const password = usePasswordField({ length: 24, userInputs: [name, email] })
85
+
86
+ <PasswordField password={password} generateFirst labelPosition="top" readOnly={sending} />
87
+ ```
88
+
89
+ ## Users page
90
+
91
+ The app holds the view in its address with godmin's `useListView`, as
92
+ every other list does, and hands the page the result and its own words
93
+ as data. Its route keeps the view with `validateSearch: listSearch`.
94
+
95
+ ```tsx
96
+ const list = useListView<View>(usersListDefaults({ perPage: settings?.pageSize }))
97
+
98
+ <UsersPage
99
+ list={list}
100
+ pageSizes={settings?.pageSizes}
101
+ sizesFailed={settingsFailed}
102
+ roleLabels={{ admin: __('Administrator'), member: __('Member') }}
103
+ addLink={<Link to="/users/new" />}
104
+ headingLevel={2}
105
+ />
106
+ ```
107
+
108
+ - An app with no page size setting passes a fixed `perPage` to
109
+ `usersListDefaults`. The list shows as loading until it has a page
110
+ size or `sizesFailed`.
111
+ - A row the server marks `editable: false`, and the reader's own row,
112
+ offer no action.
113
+ - `errorMessages` gives words to the error codes only the app's
114
+ transport answers. The codes authkit answers carry WordPress's words.
115
+ - `columns` adds a column after Role as data: a label and, for each
116
+ account id, the text, the words a screen reader hears and an optional
117
+ link element. Pass its id to `usersListDefaults` as well, to show it.
118
+ - `showAvatars={false}` drops the Avatar field when the site shows none.
119
+
66
120
  ## Consumer requirements
67
121
 
68
122
  - `react` and `@tanstack/react-query` are peer dependencies; the app
@@ -72,6 +126,10 @@ with a `Field.VisualLabel`, as the step's Confirm Password row is.
72
126
  development, proxy `/api` to your backend so the `__Host-` session
73
127
  cookie is first-party.
74
128
  - `/wpds` needs the `@wordpress/ui` and `@wordpress/icons` peers.
129
+ - `/wpds/users` also needs `@gopherium/godmin` 0.28, `@gopherium/gottext`,
130
+ `@wordpress/dataviews`, `@wordpress/components` and `@wordpress/date`,
131
+ and godmin's `Toaster` above the page, whose toasts show each name
132
+ whole. The app loads the DataViews and components stylesheets.
75
133
  - The account step loads zxcvbn, about 400 kB gzipped, as its own chunk
76
134
  the first time it renders. Vite reports that chunk as larger than
77
135
  500 kB.
package/dist/account.d.ts CHANGED
@@ -5,8 +5,11 @@ export declare const accountSchema: z.ZodObject<{
5
5
  email: z.ZodString;
6
6
  name: z.ZodString;
7
7
  disabled: z.ZodBoolean;
8
+ confirmed: z.ZodBoolean;
8
9
  role: z.ZodDefault<z.ZodString>;
9
10
  created_at: z.ZodCoercedDate<unknown>;
11
+ editable: z.ZodBoolean;
12
+ avatar: z.ZodOptional<z.ZodString>;
10
13
  }, z.core.$strip>;
11
14
  /** Account is a user account as the server answers it. */
12
15
  export type Account = z.infer<typeof accountSchema>;
package/dist/account.js CHANGED
@@ -6,6 +6,9 @@ export const accountSchema = z.object({
6
6
  email: z.string(),
7
7
  name: z.string(),
8
8
  disabled: z.boolean(),
9
+ confirmed: z.boolean(),
9
10
  role: z.string().default(''),
10
11
  created_at: z.coerce.date(),
12
+ editable: z.boolean(),
13
+ avatar: z.string().optional(),
11
14
  });
@@ -0,0 +1,62 @@
1
+ import { ValidationError } from '../api.js';
2
+ /**
3
+ * EmailTakenError is thrown when a new user's email is already in use.
4
+ */
5
+ export declare class EmailTakenError extends Error {
6
+ readonly code = "email_taken";
7
+ }
8
+ /**
9
+ * RoleRefusedError is thrown when the actor holds no role the server admits.
10
+ */
11
+ export declare class RoleRefusedError extends Error {
12
+ readonly code = "role_insufficient";
13
+ }
14
+ /**
15
+ * SelfRoleError is thrown when an account changes its own role.
16
+ */
17
+ export declare class SelfRoleError extends Error {
18
+ readonly code = "self_role_refused";
19
+ }
20
+ /**
21
+ * SelfDisableError is thrown when an account disables itself.
22
+ */
23
+ export declare class SelfDisableError extends Error {
24
+ readonly code = "self_disable_refused";
25
+ }
26
+ /**
27
+ * LastPrivilegedError is thrown when a write would leave no enabled
28
+ * account holding a privileged role.
29
+ */
30
+ export declare class LastPrivilegedError extends Error {
31
+ readonly code = "last_privileged_refused";
32
+ }
33
+ /**
34
+ * RoleUnknownError is thrown when a write names a role nobody declared.
35
+ */
36
+ export declare class RoleUnknownError extends ValidationError {
37
+ readonly code = "role_unknown";
38
+ }
39
+ /**
40
+ * UserNotEditableError is thrown when the reader may not edit the account.
41
+ */
42
+ export declare class UserNotEditableError extends Error {
43
+ readonly code = "user_not_editable";
44
+ }
45
+ /**
46
+ * RoleNotGrantableError is thrown when the reader may not give the role.
47
+ */
48
+ export declare class RoleNotGrantableError extends Error {
49
+ readonly code = "role_not_grantable";
50
+ }
51
+ /**
52
+ * RoleChangedError is thrown when the account's role changed before the write landed.
53
+ */
54
+ export declare class RoleChangedError extends Error {
55
+ readonly code = "role_changed";
56
+ }
57
+ /**
58
+ * UserNotFoundError is thrown when no account has the identifier a write names.
59
+ */
60
+ export declare class UserNotFoundError extends Error {
61
+ readonly code = "user_not_found";
62
+ }
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { ValidationError } from '../api.js';
3
+ /**
4
+ * EmailTakenError is thrown when a new user's email is already in use.
5
+ */
6
+ export class EmailTakenError extends Error {
7
+ code = 'email_taken';
8
+ }
9
+ /**
10
+ * RoleRefusedError is thrown when the actor holds no role the server admits.
11
+ */
12
+ export class RoleRefusedError extends Error {
13
+ code = 'role_insufficient';
14
+ }
15
+ /**
16
+ * SelfRoleError is thrown when an account changes its own role.
17
+ */
18
+ export class SelfRoleError extends Error {
19
+ code = 'self_role_refused';
20
+ }
21
+ /**
22
+ * SelfDisableError is thrown when an account disables itself.
23
+ */
24
+ export class SelfDisableError extends Error {
25
+ code = 'self_disable_refused';
26
+ }
27
+ /**
28
+ * LastPrivilegedError is thrown when a write would leave no enabled
29
+ * account holding a privileged role.
30
+ */
31
+ export class LastPrivilegedError extends Error {
32
+ code = 'last_privileged_refused';
33
+ }
34
+ /**
35
+ * RoleUnknownError is thrown when a write names a role nobody declared.
36
+ */
37
+ export class RoleUnknownError extends ValidationError {
38
+ code = 'role_unknown';
39
+ }
40
+ /**
41
+ * UserNotEditableError is thrown when the reader may not edit the account.
42
+ */
43
+ export class UserNotEditableError extends Error {
44
+ code = 'user_not_editable';
45
+ }
46
+ /**
47
+ * RoleNotGrantableError is thrown when the reader may not give the role.
48
+ */
49
+ export class RoleNotGrantableError extends Error {
50
+ code = 'role_not_grantable';
51
+ }
52
+ /**
53
+ * RoleChangedError is thrown when the account's role changed before the write landed.
54
+ */
55
+ export class RoleChangedError extends Error {
56
+ code = 'role_changed';
57
+ }
58
+ /**
59
+ * UserNotFoundError is thrown when no account has the identifier a write names.
60
+ */
61
+ export class UserNotFoundError extends Error {
62
+ code = 'user_not_found';
63
+ }
@@ -0,0 +1,19 @@
1
+ /** UserField is a field of the Add User form an error can be about. */
2
+ export type UserField = 'email' | 'name' | 'password';
3
+ /** FieldError is the Add User field an error is about and the message shown under it. */
4
+ export interface FieldError {
5
+ field: UserField;
6
+ message: string;
7
+ }
8
+ /**
9
+ * Returns the password hint with the server's minimum as its number.
10
+ * @param min - The fewest characters the server accepts.
11
+ * @returns The hint.
12
+ */
13
+ export declare function passwordHint(min: number): string;
14
+ /**
15
+ * Returns the Add User field an error is about and the message shown under it.
16
+ * @param error - The error createUser threw.
17
+ * @returns The field and its message, or undefined for an error about no one field.
18
+ */
19
+ export declare function fieldError(error: unknown): FieldError | undefined;
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { __, _n, sprintf } from '@wordpress/i18n';
3
+ import { ValidationError } from '../api.js';
4
+ import { DOMAIN } from '../domain.js';
5
+ import { EmailTakenError } from './errors.js';
6
+ const fieldMessages = new Map([
7
+ ['email_taken', ['email', () => __('This email is already registered. Please choose another one.', DOMAIN)]],
8
+ ['email_invalid', ['email', () => __('The email address is not correct.', DOMAIN)]],
9
+ ['name_required', ['name', () => __('Please provide a name.', DOMAIN)]],
10
+ ['name_too_long', ['name', () => __('This name is too long.', DOMAIN)]],
11
+ ['password_too_short', ['password', (meta) => (typeof meta.min === 'number' ? passwordHint(meta.min) : undefined)]],
12
+ ['password_too_long', ['password', () => __('This password is too long.', DOMAIN)]],
13
+ ]);
14
+ /**
15
+ * Returns the password hint with the server's minimum as its number.
16
+ * @param min - The fewest characters the server accepts.
17
+ * @returns The hint.
18
+ */
19
+ export function passwordHint(min) {
20
+ const hint = _n('Hint: The password should be at least %d character long. ' +
21
+ 'To make it stronger, use upper and lower case letters, numbers, and symbols like ! " ? $ %% ^ & ).', 'Hint: The password should be at least %d characters long. ' +
22
+ 'To make it stronger, use upper and lower case letters, numbers, and symbols like ! " ? $ %% ^ & ).', min, DOMAIN);
23
+ return sprintf(hint, min);
24
+ }
25
+ /**
26
+ * Returns the code and meta an error a create threw carries.
27
+ * @param error - The error the create threw.
28
+ * @returns The code, empty when it carries none, and the meta.
29
+ */
30
+ function detailsOf(error) {
31
+ if (error instanceof ValidationError) {
32
+ return [error.code ?? '', error.meta ?? {}];
33
+ }
34
+ return [error instanceof EmailTakenError ? error.code : '', {}];
35
+ }
36
+ /**
37
+ * Returns the Add User field an error is about and the message shown under it.
38
+ * @param error - The error createUser threw.
39
+ * @returns The field and its message, or undefined for an error about no one field.
40
+ */
41
+ export function fieldError(error) {
42
+ const [code, meta] = detailsOf(error);
43
+ const known = fieldMessages.get(code);
44
+ const message = known?.[1](meta);
45
+ return known && message !== undefined ? { field: known[0], message } : undefined;
46
+ }
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import type { Account as User } from '../account.js';
3
- import { ValidationError } from '../api.js';
4
3
  export declare const usersQueryKey: readonly ["users"];
5
4
  export type { Account as User } from '../account.js';
6
5
  export interface NewUser {
@@ -21,43 +20,10 @@ declare const invitationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
21
20
  activation_link: z.ZodString;
22
21
  }, z.core.$strip>], "delivered">;
23
22
  export type Invitation = z.infer<typeof invitationSchema>;
24
- /**
25
- * EmailTakenError is thrown when a new user's email is already in use.
26
- */
27
- export declare class EmailTakenError extends Error {
28
- }
29
23
  export { ValidationError } from '../api.js';
30
- /**
31
- * RoleRefusedError is thrown when the actor holds no role the server admits.
32
- */
33
- export declare class RoleRefusedError extends Error {
34
- }
35
- /**
36
- * SelfRoleError is thrown when an account changes its own role.
37
- */
38
- export declare class SelfRoleError extends Error {
39
- }
40
- /**
41
- * LastPrivilegedError is thrown when a write would leave no enabled
42
- * account holding a privileged role.
43
- */
44
- export declare class LastPrivilegedError extends Error {
45
- }
46
- /**
47
- * RoleUnknownError is thrown when a write names a role nobody declared.
48
- */
49
- export declare class RoleUnknownError extends ValidationError {
50
- }
51
- /**
52
- * RoleBeyondReachError is thrown when the actor may not hand out the role or change the account.
53
- */
54
- export declare class RoleBeyondReachError extends Error {
55
- }
56
- /**
57
- * RoleChangedError is thrown when the account's role changed before the write landed.
58
- */
59
- export declare class RoleChangedError extends Error {
60
- }
24
+ export { EmailTakenError, LastPrivilegedError, RoleChangedError, RoleNotGrantableError, RoleRefusedError, RoleUnknownError, SelfDisableError, SelfRoleError, UserNotEditableError, UserNotFoundError, } from './errors.js';
25
+ export { fieldError } from './fieldError.js';
26
+ export type { FieldError, UserField } from './fieldError.js';
61
27
  /**
62
28
  * Returns every user account.
63
29
  * @param signal - Aborts the in-flight request.
@@ -89,3 +55,10 @@ export declare function setUserRole(id: string, role: string): Promise<void>;
89
55
  * @returns The delivery report, carrying the link when nothing mailed it.
90
56
  */
91
57
  export declare function invite(input: NewInvite): Promise<Invitation>;
58
+ /**
59
+ * Sends a new invitation to a pending account, ending the link sent before,
60
+ * and holds every transport to the invitation contract.
61
+ * @param email - The address of the invited account.
62
+ * @returns The delivery report, carrying the link when nothing mailed it.
63
+ */
64
+ export declare function resendInvite(email: string): Promise<Invitation>;
@@ -2,81 +2,42 @@
2
2
  import { __ } from '@wordpress/i18n';
3
3
  import { z } from 'zod';
4
4
  import { accountSchema } from '../account.js';
5
- import { RateLimitedError, UnauthorizedError, ValidationError } from '../api.js';
5
+ import { RateLimitedError, UnauthorizedError, ValidationError, refusalOf } from '../api.js';
6
6
  import { DOMAIN } from '../domain.js';
7
7
  import { resolveTransport } from '../transport.js';
8
+ import { EmailTakenError, LastPrivilegedError, RoleChangedError, RoleNotGrantableError, RoleRefusedError, RoleUnknownError, SelfDisableError, SelfRoleError, UserNotEditableError, UserNotFoundError, } from './errors.js';
8
9
  export const usersQueryKey = ['users'];
9
10
  const invitationSchema = z.discriminatedUnion('delivered', [
10
11
  z.object({ delivered: z.literal(true) }),
11
12
  z.object({ delivered: z.literal(false), activation_link: z.string().trim().min(1) }),
12
13
  ]);
13
- /**
14
- * EmailTakenError is thrown when a new user's email is already in use.
15
- */
16
- export class EmailTakenError extends Error {
17
- }
18
14
  export { ValidationError } from '../api.js';
19
- /**
20
- * RoleRefusedError is thrown when the actor holds no role the server admits.
21
- */
22
- export class RoleRefusedError extends Error {
23
- }
24
- /**
25
- * SelfRoleError is thrown when an account changes its own role.
26
- */
27
- export class SelfRoleError extends Error {
28
- }
29
- /**
30
- * LastPrivilegedError is thrown when a write would leave no enabled
31
- * account holding a privileged role.
32
- */
33
- export class LastPrivilegedError extends Error {
34
- }
35
- /**
36
- * RoleUnknownError is thrown when a write names a role nobody declared.
37
- */
38
- export class RoleUnknownError extends ValidationError {
39
- }
40
- /**
41
- * RoleBeyondReachError is thrown when the actor may not hand out the role or change the account.
42
- */
43
- export class RoleBeyondReachError extends Error {
44
- }
45
- /**
46
- * RoleChangedError is thrown when the account's role changed before the write landed.
47
- */
48
- export class RoleChangedError extends Error {
49
- }
50
- const errorSchema = z.object({ error: z.string(), code: z.string().optional() });
15
+ export { EmailTakenError, LastPrivilegedError, RoleChangedError, RoleNotGrantableError, RoleRefusedError, RoleUnknownError, SelfDisableError, SelfRoleError, UserNotEditableError, UserNotFoundError, } from './errors.js';
16
+ export { fieldError } from './fieldError.js';
51
17
  const roleErrors = new Map([
52
18
  ['role_unknown', RoleUnknownError],
53
- ['role_beyond_reach', RoleBeyondReachError],
19
+ ['user_not_editable', UserNotEditableError],
20
+ ['role_not_grantable', RoleNotGrantableError],
54
21
  ]);
55
22
  const accountErrors = new Map([
56
23
  ...roleErrors,
57
24
  ['role_changed', RoleChangedError],
25
+ ['user_not_found', UserNotFoundError],
26
+ ['last_privileged_refused', LastPrivilegedError],
27
+ ['role_insufficient', RoleRefusedError],
28
+ ]);
29
+ const disableErrors = new Map([
30
+ ...accountErrors,
31
+ ['self_disable_refused', SelfDisableError],
58
32
  ]);
59
33
  const roleChangeErrors = new Map([
60
34
  ...accountErrors,
61
- ['role_insufficient', RoleRefusedError],
62
35
  ['self_role_refused', SelfRoleError],
63
- ['last_privileged_refused', LastPrivilegedError],
64
36
  ]);
65
- /**
66
- * Returns the code and message a refused response carries.
67
- * @param response - The refused HTTP response.
68
- * @param fallback - The message to use when the body is unreadable.
69
- * @returns The error code, if any, and its message.
70
- */
71
- async function errorOf(response, fallback) {
72
- try {
73
- const held = errorSchema.parse(await response.json());
74
- return { code: held.code, message: held.error };
75
- }
76
- catch {
77
- return { message: fallback };
78
- }
79
- }
37
+ const creationStatusErrors = new Map([[409, () => new EmailTakenError('email already in use')]]);
38
+ const invitationStatusErrors = new Map([[429, () => new RateLimitedError('too many attempts')]]);
39
+ const inviteRoute = { path: '/api/users/invite', write: 'inviting' };
40
+ const resendRoute = { path: '/api/users/invite/resend', write: 'resending invitation' };
80
41
  /**
81
42
  * Returns the typed error a refused response's code names.
82
43
  * @param refused - The code and message the response carries.
@@ -90,20 +51,42 @@ function typedError(refused, errors = roleErrors) {
90
51
  /**
91
52
  * Returns the error a refused account write throws.
92
53
  * @param response - The refused HTTP response.
93
- * @param fallback - The message to use when the body is unreadable.
94
- * @param write - Names the write in the error for any other status.
54
+ * @param post - The write, with the message for an unreadable body and its name for any other status.
95
55
  * @returns The error to throw.
96
56
  */
97
- async function writeError(response, fallback, write) {
98
- const refused = await errorOf(response, fallback);
57
+ async function writeError(response, post) {
58
+ const refused = await refusalOf(response, post.fallback);
99
59
  const typed = typedError(refused);
100
60
  if (typed) {
101
61
  return typed;
102
62
  }
103
63
  if (response.status === 422) {
104
- return new ValidationError(refused.message);
64
+ return new ValidationError(refused.message, refused);
105
65
  }
106
- return new Error(`${write} failed with status ${response.status}`);
66
+ return new Error(`${post.write} failed with status ${response.status}`);
67
+ }
68
+ /**
69
+ * Posts an account write over REST and returns the body it answers.
70
+ * @param post - The route, the fields it reads and the error each known status throws.
71
+ * @returns The answered body, still to be parsed.
72
+ */
73
+ async function postAccountWrite(post) {
74
+ const response = await fetch(post.path, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify(post.body),
78
+ });
79
+ if (response.status === 401) {
80
+ throw new UnauthorizedError('session expired');
81
+ }
82
+ const known = post.errorsByStatus.get(response.status);
83
+ if (known) {
84
+ throw known();
85
+ }
86
+ if (!response.ok) {
87
+ throw await writeError(response, post);
88
+ }
89
+ return response.json();
107
90
  }
108
91
  /**
109
92
  * Lists the accounts over REST.
@@ -126,21 +109,29 @@ async function restFetchUsers(signal) {
126
109
  * @returns The created user.
127
110
  */
128
111
  async function restCreateUser(input) {
129
- const response = await fetch('/api/users', {
130
- method: 'POST',
131
- headers: { 'Content-Type': 'application/json' },
132
- body: JSON.stringify(input),
112
+ const created = await postAccountWrite({
113
+ path: '/api/users',
114
+ body: input,
115
+ write: 'creating user',
116
+ fallback: __('invalid user details', DOMAIN),
117
+ errorsByStatus: creationStatusErrors,
133
118
  });
134
- if (response.status === 401) {
135
- throw new UnauthorizedError('session expired');
136
- }
137
- if (response.status === 409) {
138
- throw new EmailTakenError('email already in use');
139
- }
140
- if (!response.ok) {
141
- throw await writeError(response, __('invalid user details', DOMAIN), 'creating user');
142
- }
143
- return accountSchema.parse(await response.json());
119
+ return accountSchema.parse(created);
120
+ }
121
+ /**
122
+ * Posts an invitation write over REST and answers its delivery report.
123
+ * @param route - The invitation route and the name its errors give the write.
124
+ * @param body - The fields the route reads.
125
+ * @returns The delivery report, carrying the link when nothing mailed it.
126
+ */
127
+ async function postInvitation(route, body) {
128
+ const report = await postAccountWrite({
129
+ ...route,
130
+ body,
131
+ fallback: __('invalid invitation details', DOMAIN),
132
+ errorsByStatus: invitationStatusErrors,
133
+ });
134
+ return invitationSchema.parse(report);
144
135
  }
145
136
  /**
146
137
  * Sends an invitation over REST, answering the same shape whatever the
@@ -149,21 +140,15 @@ async function restCreateUser(input) {
149
140
  * @returns The delivery report, carrying the link when nothing mailed it.
150
141
  */
151
142
  async function restInvite(input) {
152
- const response = await fetch('/api/users/invite', {
153
- method: 'POST',
154
- headers: { 'Content-Type': 'application/json' },
155
- body: JSON.stringify(input),
156
- });
157
- if (response.status === 401) {
158
- throw new UnauthorizedError('session expired');
159
- }
160
- if (response.status === 429) {
161
- throw new RateLimitedError('too many attempts');
162
- }
163
- if (!response.ok) {
164
- throw await writeError(response, __('invalid invitation details', DOMAIN), 'inviting');
165
- }
166
- return invitationSchema.parse(await response.json());
143
+ return postInvitation(inviteRoute, input);
144
+ }
145
+ /**
146
+ * Sends a new invitation to a pending account over REST, ending the link sent before.
147
+ * @param email - The address of the invited account.
148
+ * @returns The delivery report, carrying the link when nothing mailed it.
149
+ */
150
+ async function restResendInvite(email) {
151
+ return postInvitation(resendRoute, { email });
167
152
  }
168
153
  /**
169
154
  * Updates an account's disabled flag over REST.
@@ -180,8 +165,8 @@ async function restSetUserDisabled(id, disabled) {
180
165
  throw new UnauthorizedError('session expired');
181
166
  }
182
167
  if (!response.ok) {
183
- const refused = await errorOf(response, __('the account could not be updated', DOMAIN));
184
- throw typedError(refused, accountErrors) ?? new Error(`updating user failed with status ${response.status}`);
168
+ const refused = await refusalOf(response, __('the account could not be updated', DOMAIN));
169
+ throw typedError(refused, disableErrors) ?? new Error(`updating user failed with status ${response.status}`);
185
170
  }
186
171
  }
187
172
  /**
@@ -201,7 +186,7 @@ async function restSetUserRole(id, role) {
201
186
  if (response.status === 401) {
202
187
  throw new UnauthorizedError('session expired');
203
188
  }
204
- const refused = await errorOf(response, __('the account could not be updated', DOMAIN));
189
+ const refused = await refusalOf(response, __('the account could not be updated', DOMAIN));
205
190
  throw typedError(refused, roleChangeErrors) ?? new Error(`updating role failed with status ${response.status}`);
206
191
  }
207
192
  /**
@@ -245,3 +230,12 @@ export async function setUserRole(id, role) {
245
230
  export async function invite(input) {
246
231
  return invitationSchema.parse(await resolveTransport('invite', restInvite)(input));
247
232
  }
233
+ /**
234
+ * Sends a new invitation to a pending account, ending the link sent before,
235
+ * and holds every transport to the invitation contract.
236
+ * @param email - The address of the invited account.
237
+ * @returns The delivery report, carrying the link when nothing mailed it.
238
+ */
239
+ export async function resendInvite(email) {
240
+ return invitationSchema.parse(await resolveTransport('resendInvite', restResendInvite)(email));
241
+ }