@byline/admin 5.1.4 → 5.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.
Files changed (78) hide show
  1. package/package.json +14 -14
  2. package/dist/exports-parity.test.node.js +0 -37
  3. package/dist/fields/array/array-field.jsx +0 -209
  4. package/dist/fields/blocks/blocks-field.jsx +0 -248
  5. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  6. package/dist/fields/code/code-editor.jsx +0 -211
  7. package/dist/fields/code/code-field.jsx +0 -109
  8. package/dist/fields/column-formatter.jsx +0 -25
  9. package/dist/fields/date-time-formatter.jsx +0 -18
  10. package/dist/fields/datetime/datetime-field.jsx +0 -22
  11. package/dist/fields/draggable-context-menu.jsx +0 -50
  12. package/dist/fields/field-admin.test.node.js +0 -40
  13. package/dist/fields/field-renderer.jsx +0 -136
  14. package/dist/fields/field-services-context.jsx +0 -17
  15. package/dist/fields/file/file-field.jsx +0 -176
  16. package/dist/fields/file/file-upload-field.jsx +0 -89
  17. package/dist/fields/group/group-field.jsx +0 -46
  18. package/dist/fields/image/image-field.jsx +0 -183
  19. package/dist/fields/image/image-upload-field.jsx +0 -157
  20. package/dist/fields/local-date-time.jsx +0 -69
  21. package/dist/fields/locale-badge.jsx +0 -19
  22. package/dist/fields/numerical/numerical-field.jsx +0 -93
  23. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  24. package/dist/fields/relation/relation-display.jsx +0 -118
  25. package/dist/fields/relation/relation-field.jsx +0 -108
  26. package/dist/fields/relation/relation-many-field.jsx +0 -142
  27. package/dist/fields/relation/relation-picker.jsx +0 -240
  28. package/dist/fields/relation/relation-summary.jsx +0 -45
  29. package/dist/fields/select/select-field.jsx +0 -27
  30. package/dist/fields/sortable-item.jsx +0 -54
  31. package/dist/fields/text/text-field.jsx +0 -79
  32. package/dist/fields/text-area/text-area-field.jsx +0 -79
  33. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  34. package/dist/forms/available-locales-widget.jsx +0 -63
  35. package/dist/forms/document-actions.jsx +0 -486
  36. package/dist/forms/form-context.jsx +0 -598
  37. package/dist/forms/form-modals.jsx +0 -128
  38. package/dist/forms/form-renderer.jsx +0 -532
  39. package/dist/forms/form-status-display.jsx +0 -69
  40. package/dist/forms/navigation-guard.jsx +0 -68
  41. package/dist/forms/nested-path.test.node.js +0 -115
  42. package/dist/forms/path-widget.jsx +0 -125
  43. package/dist/forms/pending-uploads.test.node.js +0 -19
  44. package/dist/forms/repeating-items.test.node.js +0 -29
  45. package/dist/forms/scheduled-publication-control.jsx +0 -389
  46. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  47. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  48. package/dist/forms/status-transitions.test.node.js +0 -76
  49. package/dist/forms/tree-placement-widget.jsx +0 -160
  50. package/dist/forms/upload-executor.test.node.js +0 -368
  51. package/dist/forms/use-form-layout.test.node.js +0 -69
  52. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  53. package/dist/modules/admin-account/components/container.jsx +0 -164
  54. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  55. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  56. package/dist/modules/admin-account/components/update.jsx +0 -186
  57. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  58. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  59. package/dist/modules/admin-roles/components/create.jsx +0 -155
  60. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  61. package/dist/modules/admin-roles/components/update.jsx +0 -148
  62. package/dist/modules/admin-users/components/create.jsx +0 -206
  63. package/dist/modules/admin-users/components/roles.jsx +0 -126
  64. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  65. package/dist/modules/admin-users/components/update.jsx +0 -215
  66. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  67. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  68. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  69. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  70. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  71. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  72. package/dist/presentation/group.jsx +0 -26
  73. package/dist/presentation/row.jsx +0 -23
  74. package/dist/presentation/tabs.jsx +0 -39
  75. package/dist/services/admin-services-context.jsx +0 -17
  76. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  77. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  78. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -1,239 +0,0 @@
1
- 'use client';
2
- /**
3
- * This Source Code is subject to the terms of the Mozilla Public
4
- * License, v. 2.0. If a copy of the MPL was not distributed with this
5
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
- *
7
- * Copyright (c) Infonomic Company Limited
8
- */
9
- /**
10
- * Inline per-role ability editor. Renders below the role-details
11
- * header on the role detail page — full-width, no drawer.
12
- *
13
- * Mode-switched: defaults to **view** (checkboxes disabled, no edit
14
- * affordances). Click Edit to switch to **edit** mode — checkboxes
15
- * become interactive, per-group "Select all / Clear" buttons appear,
16
- * and a Save button materialises once the selection diverges from the
17
- * stored set. Cancel reverts the local set and returns to view mode.
18
- *
19
- * The View toggle is disabled while there are unsaved changes — this
20
- * is the explicit-intent equivalent of a confirm dialog and keeps the
21
- * UX honest about whether changes have been persisted.
22
- *
23
- * Vid-less by design (see Phase B notes): abilities live in
24
- * `byline_admin_permissions`, not on the role row, so editing them
25
- * does not bump the role's `vid`. Last-writer-wins on a per-role basis.
26
- *
27
- * Stable override handles: see `permissions.module.css`.
28
- */
29
- import { useMemo, useState } from 'react';
30
- import { useTranslation } from '@byline/i18n/react';
31
- import { Alert, Button, Checkbox, LoaderEllipsis } from '@byline/ui/react';
32
- import cx from 'clsx';
33
- import { useBylineAdminServices } from '../../../services/admin-services-context.js';
34
- import styles from './permissions.module.css';
35
- function setsEqual(a, b) {
36
- if (a.size !== b.size)
37
- return false;
38
- for (const item of a)
39
- if (!b.has(item))
40
- return false;
41
- return true;
42
- }
43
- function GroupSection({ group, selected, mode, saving, onToggle, onSelectAll, onClearAll, }) {
44
- const { t } = useTranslation('byline-admin');
45
- const groupKeys = useMemo(() => group.abilities.map((a) => a.key), [group.abilities]);
46
- const selectedInGroup = groupKeys.filter((key) => selected.has(key)).length;
47
- const isEdit = mode === 'edit';
48
- return (<div className={cx('byline-role-permissions-group', styles.group)}>
49
- <div className={cx('byline-role-permissions-group-head', styles['group-head'])}>
50
- <div>
51
- <span className={cx('byline-role-permissions-group-name', styles['group-name'])}>
52
- {group.group}
53
- </span>
54
- <span className={cx('muted', 'byline-role-permissions-group-count', styles['group-count'])}>
55
- {t('adminRoles.permissions.groupCount', {
56
- selected: selectedInGroup,
57
- total: group.abilities.length,
58
- mode,
59
- })}
60
- </span>
61
- </div>
62
- {isEdit ? (<div className={cx('byline-role-permissions-group-buttons', styles['group-buttons'])}>
63
- <Button size="xs" intent="secondary" type="button" disabled={saving || selectedInGroup === group.abilities.length} onClick={() => onSelectAll(groupKeys)}>
64
- {t('adminRoles.permissions.selectAll')}
65
- </Button>
66
- <Button size="xs" intent="secondary" type="button" disabled={saving || selectedInGroup === 0} onClick={() => onClearAll(groupKeys)}>
67
- {t('adminRoles.permissions.clear')}
68
- </Button>
69
- </div>) : null}
70
- </div>
71
- <div className={cx('byline-role-permissions-grid', styles.grid)}>
72
- {group.abilities.map((ability) => (<div key={ability.key} className={cx('byline-role-permissions-row', styles.row)}>
73
- <Checkbox id={`ability-${ability.key}`} name={`ability-${ability.key}`} checked={selected.has(ability.key)} disabled={!isEdit || saving} onCheckedChange={(checked) => onToggle(ability.key, checked === true)}
74
- // Override the uikit Checkbox container's `width: 100%` so it
75
- // shrinks to its button width — otherwise the external label
76
- // is pushed away by an empty 100%-wide container.
77
- containerClasses={cx('byline-role-permissions-checkbox-auto', styles['checkbox-auto'])} componentClasses={cx('byline-role-permissions-checkbox-auto', styles['checkbox-auto'])}/>
78
- <label htmlFor={`ability-${ability.key}`} className={cx('byline-role-permissions-label', styles.label, isEdit && ['byline-role-permissions-label-edit', styles['label-edit']])}>
79
- <div className={cx('byline-role-permissions-label-head', styles['label-head'])}>
80
- <span className={cx('byline-role-permissions-label-name', styles['label-name'])}>
81
- {ability.label}
82
- </span>
83
- <code className={cx('byline-role-permissions-key', styles.key)}>{ability.key}</code>
84
- </div>
85
- {ability.description ? (<p className={cx('muted', 'byline-role-permissions-description', styles.description)}>
86
- {ability.description}
87
- </p>) : null}
88
- </label>
89
- </div>))}
90
- </div>
91
- </div>);
92
- }
93
- export function RolePermissions({ role, registered, initialAbilities, onSaved, }) {
94
- const { setRoleAbilities } = useBylineAdminServices();
95
- const { t } = useTranslation('byline-admin');
96
- const [mode, setMode] = useState('view');
97
- const [initialSet, setInitialSet] = useState(() => new Set(initialAbilities));
98
- const [selected, setSelected] = useState(() => new Set(initialAbilities));
99
- const [saving, setSaving] = useState(false);
100
- const [error, setError] = useState(null);
101
- const isDirty = !setsEqual(selected, initialSet);
102
- const totalSelected = selected.size;
103
- function handleToggle(key, checked) {
104
- if (mode !== 'edit')
105
- return;
106
- setSelected((current) => {
107
- const next = new Set(current);
108
- if (checked)
109
- next.add(key);
110
- else
111
- next.delete(key);
112
- return next;
113
- });
114
- }
115
- function handleSelectAll(groupKeys) {
116
- setSelected((current) => {
117
- const next = new Set(current);
118
- for (const key of groupKeys)
119
- next.add(key);
120
- return next;
121
- });
122
- }
123
- function handleClearAll(groupKeys) {
124
- setSelected((current) => {
125
- const next = new Set(current);
126
- for (const key of groupKeys)
127
- next.delete(key);
128
- return next;
129
- });
130
- }
131
- function handleCancel() {
132
- setSelected(new Set(initialSet));
133
- setError(null);
134
- setMode('view');
135
- }
136
- function handleEnterEdit() {
137
- setError(null);
138
- setMode('edit');
139
- }
140
- function handleEnterView() {
141
- // Disabled while dirty (the toggle's `disabled` prop guards this) —
142
- // belt-and-suspenders so a stray click can't slip through.
143
- if (isDirty)
144
- return;
145
- setError(null);
146
- setMode('view');
147
- }
148
- async function handleSave() {
149
- if (saving)
150
- return;
151
- setSaving(true);
152
- setError(null);
153
- try {
154
- const response = await setRoleAbilities({
155
- data: { id: role.id, abilities: Array.from(selected) },
156
- });
157
- // Reset baseline to the authoritative stored set — guards against
158
- // any dedupe/normalisation the server might apply.
159
- const storedSet = new Set(response.abilities);
160
- setInitialSet(storedSet);
161
- setSelected(new Set(storedSet));
162
- onSaved?.(response);
163
- }
164
- catch (err) {
165
- const code = getErrorCode(err);
166
- if (code === 'admin.permissions.roleNotFound') {
167
- setError(t('adminRoles.permissions.errors.roleNotFound'));
168
- }
169
- else if (code === 'admin.permissions.abilityUnregistered') {
170
- setError(t('adminRoles.permissions.errors.abilityUnregistered'));
171
- }
172
- else {
173
- setError(t('adminRoles.permissions.errors.fallback'));
174
- }
175
- }
176
- finally {
177
- setSaving(false);
178
- }
179
- }
180
- const isEdit = mode === 'edit';
181
- return (<div className={cx('byline-role-permissions-wrap', styles.wrap)}>
182
- <div className={cx('byline-role-permissions-toolbar', styles.toolbar)}>
183
- <ModeToggle mode={mode} dirty={isDirty} saving={saving} onView={handleEnterView} onEdit={handleEnterEdit}/>
184
- <p className={cx('muted', 'byline-role-permissions-counter', styles.counter)}>
185
- {t('adminRoles.permissions.counter', {
186
- selected: totalSelected,
187
- total: registered.total,
188
- mode,
189
- role: role.name,
190
- })}
191
- </p>
192
- {isEdit && isDirty ? (<div className={cx('byline-role-permissions-actions', styles.actions)}>
193
- <Button type="button" intent="secondary" size="xs" onClick={handleCancel} disabled={saving} className={cx('byline-role-permissions-action', styles.action)}>
194
- {t('common.actions.cancel')}
195
- </Button>
196
- <Button type="button" intent="primary" size="xs" onClick={() => void handleSave()} disabled={saving} className={cx('byline-role-permissions-action', styles.action)}>
197
- {saving ? <LoaderEllipsis size={30}/> : t('common.actions.save')}
198
- </Button>
199
- </div>) : null}
200
- </div>
201
-
202
- {error ? <Alert intent="danger">{error}</Alert> : null}
203
-
204
- <div className={cx('byline-role-permissions-groups', styles.groups)}>
205
- {registered.groups.map((group) => (<GroupSection key={group.group} group={group} selected={selected} mode={mode} saving={saving} onToggle={handleToggle} onSelectAll={handleSelectAll} onClearAll={handleClearAll}/>))}
206
- </div>
207
- </div>);
208
- }
209
- function ModeToggle({ mode, dirty, saving, onView, onEdit }) {
210
- const { t } = useTranslation('byline-admin');
211
- // Segmented two-state toggle. View is disabled while dirty so the
212
- // user has to commit to Save or Cancel — avoids accidentally
213
- // discarding a draft selection.
214
- const isView = mode === 'view';
215
- const isEdit = mode === 'edit';
216
- const viewDisabled = dirty || saving;
217
- const editDisabled = saving;
218
- return (<div role="group" aria-label={t('adminRoles.permissions.modeAriaLabel')} className={cx('byline-role-permissions-mode-toggle', styles['mode-toggle'])}>
219
- <button type="button" onClick={onView} disabled={viewDisabled} className={cx('byline-role-permissions-mode-button', styles['mode-button'], isView && ['byline-role-permissions-mode-button-active', styles['mode-button-active']], viewDisabled &&
220
- !isView && [
221
- 'byline-role-permissions-mode-button-disabled',
222
- styles['mode-button-disabled'],
223
- ])}>
224
- {t('adminRoles.permissions.viewMode')}
225
- </button>
226
- <button type="button" onClick={onEdit} disabled={editDisabled} className={cx('byline-role-permissions-mode-button', 'byline-role-permissions-mode-button-divider', styles['mode-button'], styles['mode-button-divider'], isEdit && ['byline-role-permissions-mode-button-active', styles['mode-button-active']], editDisabled &&
227
- !isEdit && [
228
- 'byline-role-permissions-mode-button-disabled',
229
- styles['mode-button-disabled'],
230
- ])}>
231
- {t('adminRoles.permissions.editMode')}
232
- </button>
233
- </div>);
234
- }
235
- function getErrorCode(err) {
236
- return typeof err?.code === 'string'
237
- ? err.code
238
- : null;
239
- }
@@ -1,148 +0,0 @@
1
- 'use client';
2
- /**
3
- * This Source Code is subject to the terms of the Mozilla Public
4
- * License, v. 2.0. If a copy of the MPL was not distributed with this
5
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
- *
7
- * Copyright (c) Infonomic Company Limited
8
- */
9
- /**
10
- * Role details drawer form.
11
- *
12
- * Mirrors the admin-users update pattern: TanStack Form + Zod, blur-then-
13
- * change validation, diff-against-original patch, optimistic concurrency
14
- * via `vid`. `machine_name` is read-only here — it is captured at create
15
- * time and immutable thereafter.
16
- */
17
- import { useMemo, useState } from 'react';
18
- import { revalidateLogic, useForm } from '@tanstack/react-form-start';
19
- import { useTranslation } from '@byline/i18n/react';
20
- import { Alert, Button, Input, LoaderEllipsis, TextArea } from '@byline/ui/react';
21
- import cx from 'clsx';
22
- import { z } from 'zod';
23
- import { useBylineAdminServices } from '../../../services/admin-services-context.js';
24
- import styles from './update.module.css';
25
- const MAX_NAME = 128;
26
- const MAX_DESCRIPTION = 2000;
27
- function defaultsFrom(role) {
28
- return {
29
- name: role.name,
30
- description: role.description ?? '',
31
- };
32
- }
33
- /** Build a patch object containing only fields whose values differ from the original role. */
34
- function buildPatch(values, role) {
35
- const patch = {};
36
- const normaliseText = (value) => (value.trim().length > 0 ? value : null);
37
- const nextDescription = normaliseText(values.description);
38
- const nextName = values.name.trim();
39
- if (nextName !== role.name)
40
- patch.name = nextName;
41
- if (nextDescription !== role.description)
42
- patch.description = nextDescription;
43
- return patch;
44
- }
45
- export function UpdateRole({ role, onClose, onSuccess }) {
46
- const { updateAdminRole } = useBylineAdminServices();
47
- const { t } = useTranslation('byline-admin');
48
- const [formError, setFormError] = useState(null);
49
- const [successMessage, setSuccessMessage] = useState(null);
50
- const updateRoleSchema = useMemo(() => z.object({
51
- name: z
52
- .string()
53
- .min(1, t('adminRoles.create.errors.nameRequired'))
54
- .max(MAX_NAME, t('adminRoles.create.errors.nameTooLong', { max: MAX_NAME })),
55
- description: z
56
- .string()
57
- .max(MAX_DESCRIPTION, t('adminRoles.create.errors.descriptionTooLong', { max: MAX_DESCRIPTION })),
58
- }), [t]);
59
- const form = useForm({
60
- defaultValues: defaultsFrom(role),
61
- validationLogic: revalidateLogic({
62
- mode: 'blur',
63
- modeAfterSubmission: 'change',
64
- }),
65
- validators: {
66
- onDynamic: updateRoleSchema,
67
- },
68
- onSubmit: async ({ value }) => {
69
- setFormError(null);
70
- setSuccessMessage(null);
71
- const patch = buildPatch(value, role);
72
- if (Object.keys(patch).length === 0) {
73
- setSuccessMessage(t('common.feedback.noChanges'));
74
- return;
75
- }
76
- try {
77
- const updated = await updateAdminRole({
78
- data: { id: role.id, vid: role.vid, patch },
79
- });
80
- setSuccessMessage(t('common.feedback.saved'));
81
- onSuccess?.(updated);
82
- }
83
- catch (err) {
84
- const code = getErrorCode(err);
85
- if (code === 'admin.roles.versionConflict') {
86
- setFormError(t('adminRoles.update.errors.versionConflict'));
87
- return;
88
- }
89
- if (code === 'admin.roles.notFound') {
90
- setFormError(t('adminRoles.update.errors.notFound'));
91
- return;
92
- }
93
- setFormError(t('common.errors.couldNotSave'));
94
- }
95
- },
96
- });
97
- return (<div className={cx('byline-role-update-wrap', styles.wrap)}>
98
- <form method="post" noValidate onSubmit={(event) => {
99
- event.preventDefault();
100
- event.stopPropagation();
101
- void form.handleSubmit();
102
- }} className={cx('byline-role-update-form', styles.form)}>
103
- {formError ? <Alert intent="danger">{formError}</Alert> : null}
104
- {successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
105
-
106
- <form.Field name="name">
107
- {(field) => (<Input label={t('adminRoles.fields.name')} id="role-name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} required/>)}
108
- </form.Field>
109
-
110
- <Input label={t('adminRoles.fields.machineName')} id="role-machine-name" name="machine_name" value={role.machine_name} readOnly disabled helpText={t('adminRoles.update.fields.machineNameHelp')}/>
111
-
112
- <form.Field name="description">
113
- {(field) => (<TextArea label={t('adminRoles.fields.description')} id="role-description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} rows={3}/>)}
114
- </form.Field>
115
-
116
- <div className={cx('byline-role-update-actions', styles.actions)}>
117
- <Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-role-update-action', styles.action)}>
118
- {successMessage ? t('common.actions.close') : t('common.actions.cancel')}
119
- </Button>
120
- <form.Subscribe selector={(state) => ({
121
- canSubmit: state.canSubmit,
122
- isSubmitting: state.isSubmitting,
123
- })}>
124
- {({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-role-update-action', styles.action)}>
125
- {isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
126
- </Button>)}
127
- </form.Subscribe>
128
- </div>
129
- </form>
130
- </div>);
131
- }
132
- function firstError(errors) {
133
- for (const err of errors) {
134
- if (typeof err === 'string')
135
- return err;
136
- if (err && typeof err === 'object' && 'message' in err) {
137
- const msg = err.message;
138
- if (typeof msg === 'string')
139
- return msg;
140
- }
141
- }
142
- return undefined;
143
- }
144
- function getErrorCode(err) {
145
- return typeof err?.code === 'string'
146
- ? err.code
147
- : null;
148
- }
@@ -1,206 +0,0 @@
1
- 'use client';
2
- /**
3
- * This Source Code is subject to the terms of the Mozilla Public
4
- * License, v. 2.0. If a copy of the MPL was not distributed with this
5
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
- *
7
- * Copyright (c) Infonomic Company Limited
8
- */
9
- /**
10
- * Create-admin-user drawer form.
11
- *
12
- * Rendered inside the list-view drawer. Same TanStack Form + Zod shape
13
- * as the AccountDetails form — fields, blur-then-change validation,
14
- * form-level alerts for errors that don't map onto a single field. On
15
- * success the parent list-view navigates to the new user's detail page
16
- * so the admin can finish configuring (roles, initial enablement).
17
- *
18
- * Extension point: future props (e.g. a `roles` array once the
19
- * admin-roles module lands) plug in here — the list-route loader
20
- * already runs in parallel, so adding a side-data fetch is a one-line
21
- * change in the loader and a prop here.
22
- */
23
- import { useMemo, useState } from 'react';
24
- import { revalidateLogic, useForm } from '@tanstack/react-form-start';
25
- import { passwordSchema } from '@byline/core/validation';
26
- import { useTranslation } from '@byline/i18n/react';
27
- import { Alert, Button, Checkbox, Input, LoaderEllipsis } from '@byline/ui/react';
28
- import cx from 'clsx';
29
- import { z } from 'zod';
30
- import { translateValidationError } from '../../../lib/translate-validation-error.js';
31
- import { useBylineAdminServices } from '../../../services/admin-services-context.js';
32
- import styles from './create.module.css';
33
- const MAX_NAME = 100;
34
- const MAX_USERNAME = 100;
35
- const MAX_EMAIL = 254;
36
- const initialValues = {
37
- email: '',
38
- password: '',
39
- given_name: '',
40
- family_name: '',
41
- username: '',
42
- is_super_admin: false,
43
- // Sensible defaults for an admin-created row. A brand-new admin user
44
- // is almost always meant to sign in straight away; the admin picks
45
- // whether they arrive pre-verified.
46
- is_enabled: true,
47
- is_email_verified: false,
48
- };
49
- function normaliseText(value) {
50
- return value.trim().length > 0 ? value : null;
51
- }
52
- export function CreateAdminUser({ onClose, onSuccess }) {
53
- const { createAdminUser } = useBylineAdminServices();
54
- const { t } = useTranslation('byline-admin');
55
- const [formError, setFormError] = useState(null);
56
- // Schema rebuilt per-render so error messages reflect the active
57
- // locale; wrapped in useMemo([t]) to keep validator identity stable.
58
- const createAdminUserFormSchema = useMemo(() => z.object({
59
- email: z
60
- .email({ message: t('account.update.errors.invalidEmail') })
61
- .min(3)
62
- .max(MAX_EMAIL, t('account.update.errors.emailTooLong', { max: MAX_EMAIL })),
63
- password: passwordSchema,
64
- given_name: z
65
- .string()
66
- .max(MAX_NAME, t('account.update.errors.givenNameTooLong', { max: MAX_NAME })),
67
- family_name: z
68
- .string()
69
- .max(MAX_NAME, t('account.update.errors.familyNameTooLong', { max: MAX_NAME })),
70
- username: z
71
- .string()
72
- .max(MAX_USERNAME, t('account.update.errors.usernameTooLong', { max: MAX_USERNAME })),
73
- is_super_admin: z.boolean(),
74
- is_enabled: z.boolean(),
75
- is_email_verified: z.boolean(),
76
- }), [t]);
77
- const form = useForm({
78
- defaultValues: initialValues,
79
- validationLogic: revalidateLogic({
80
- mode: 'blur',
81
- modeAfterSubmission: 'change',
82
- }),
83
- validators: {
84
- onDynamic: createAdminUserFormSchema,
85
- },
86
- onSubmit: async ({ value }) => {
87
- setFormError(null);
88
- try {
89
- const created = await createAdminUser({
90
- data: {
91
- email: value.email,
92
- password: value.password,
93
- given_name: normaliseText(value.given_name),
94
- family_name: normaliseText(value.family_name),
95
- username: normaliseText(value.username),
96
- is_super_admin: value.is_super_admin,
97
- is_enabled: value.is_enabled,
98
- is_email_verified: value.is_email_verified,
99
- },
100
- });
101
- // Clear the form so the next drawer open starts fresh — the
102
- // list-view shows the success as a toast and keeps the drawer
103
- // closed until the user clicks "+" again.
104
- form.reset(initialValues);
105
- onSuccess?.(created);
106
- }
107
- catch (err) {
108
- const code = getErrorCode(err);
109
- if (code === 'admin.users.emailInUse') {
110
- const message = t('account.update.errors.emailInUse');
111
- form.setFieldMeta('email', (meta) => ({
112
- ...meta,
113
- errorMap: { ...meta.errorMap, onServer: message },
114
- errors: [message],
115
- }));
116
- return;
117
- }
118
- setFormError(t('adminUsers.create.errors.fallback'));
119
- }
120
- },
121
- });
122
- return (<div className={cx('byline-user-create-wrap', styles.wrap)}>
123
- <form method="post" noValidate onSubmit={(event) => {
124
- event.preventDefault();
125
- event.stopPropagation();
126
- void form.handleSubmit();
127
- }} className={cx('byline-user-create-form', styles.form)}>
128
- {formError ? <Alert intent="danger">{formError}</Alert> : null}
129
-
130
- <div className={cx('byline-user-create-grid', styles.grid)}>
131
- <form.Field name="given_name">
132
- {(field) => (<Input label={t('account.update.fields.givenName')} id="new-given-name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} autoComplete="given-name"/>)}
133
- </form.Field>
134
-
135
- <form.Field name="family_name">
136
- {(field) => (<Input label={t('account.update.fields.familyName')} id="new-family-name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} autoComplete="family-name"/>)}
137
- </form.Field>
138
- </div>
139
-
140
- <form.Field name="username">
141
- {(field) => (<Input label={t('account.update.fields.username')} id="new-username" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} helpText={t('adminUsers.create.fields.usernameHelp')} autoComplete="username"/>)}
142
- </form.Field>
143
-
144
- <form.Field name="email">
145
- {(field) => (<Input label={t('common.fields.email')} id="new-email" name={field.name} type="email" value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={firstError(field.state.meta.errors)} autoComplete="email" required/>)}
146
- </form.Field>
147
-
148
- <form.Field name="password">
149
- {(field) => (<Input label={t('adminUsers.create.fields.password')} id="new-password" name={field.name} type="password" value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.currentTarget.value)} error={field.state.meta.errors.length > 0} errorText={translateValidationError(t, firstError(field.state.meta.errors))} helpText={t('adminUsers.create.fields.passwordHelp')} autoComplete="new-password" required/>)}
150
- </form.Field>
151
-
152
- <div className={cx('byline-user-create-flags', styles.flags)}>
153
- <form.Field name="is_enabled">
154
- {(field) => (<Checkbox id="new-is-enabled" name={field.name} label={t('adminUsers.create.flags.enabledLabel')} checked={field.state.value} onCheckedChange={(checked) => field.handleChange(checked === true)} helpText={t('adminUsers.create.flags.enabledHelp')}/>)}
155
- </form.Field>
156
-
157
- <form.Field name="is_email_verified">
158
- {(field) => (<Checkbox id="new-is-email-verified" name={field.name} label={t('adminUsers.create.flags.emailVerifiedLabel')} checked={field.state.value} onCheckedChange={(checked) => field.handleChange(checked === true)} helpText={t('adminUsers.create.flags.emailVerifiedHelp')}/>)}
159
- </form.Field>
160
-
161
- <form.Field name="is_super_admin">
162
- {(field) => (<Checkbox id="new-is-super-admin" name={field.name} label={t('adminUsers.create.flags.superAdminLabel')} checked={field.state.value} onCheckedChange={(checked) => field.handleChange(checked === true)} helpText={t('adminUsers.create.flags.superAdminHelp')}/>)}
163
- </form.Field>
164
- </div>
165
-
166
- <div className={cx('byline-user-create-actions', styles.actions)}>
167
- <Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-user-create-action', styles.action)}>
168
- {t('common.actions.cancel')}
169
- </Button>
170
- <form.Subscribe selector={(state) => ({
171
- canSubmit: state.canSubmit,
172
- isSubmitting: state.isSubmitting,
173
- })}>
174
- {({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-user-create-action', styles.action)}>
175
- {isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
176
- </Button>)}
177
- </form.Subscribe>
178
- </div>
179
- </form>
180
- </div>);
181
- }
182
- function firstError(errors) {
183
- for (const err of errors) {
184
- if (typeof err === 'string')
185
- return err;
186
- if (err && typeof err === 'object' && 'message' in err) {
187
- const msg = err.message;
188
- if (typeof msg === 'string')
189
- return msg;
190
- }
191
- }
192
- return undefined;
193
- }
194
- function getErrorCode(err) {
195
- if (err && typeof err === 'object') {
196
- const e = err;
197
- if (typeof e.code === 'string')
198
- return e.code;
199
- if (e.cause && typeof e.cause === 'object' && 'code' in e.cause) {
200
- const cause = e.cause;
201
- if (typeof cause.code === 'string')
202
- return cause.code;
203
- }
204
- }
205
- return null;
206
- }