@byline/admin 5.2.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
}
|