@byline/admin 5.1.2 → 5.1.4
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.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -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
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
* Self-service profile form.
|
|
11
|
+
*
|
|
12
|
+
* Editable surface is intentionally narrower than the admin-users
|
|
13
|
+
* `update.tsx`: no `is_super_admin`, `is_enabled`, or
|
|
14
|
+
* `is_email_verified` toggles. A user cannot grant themselves
|
|
15
|
+
* super-admin or flip their own status — those flow through the
|
|
16
|
+
* admin-users module on a privileged admin's session.
|
|
17
|
+
*
|
|
18
|
+
* Patch is built diff-style against the loaded row and submitted with
|
|
19
|
+
* the row's `vid` so a concurrent edit elsewhere surfaces as
|
|
20
|
+
* `admin.users.versionConflict` and we prompt for reload.
|
|
21
|
+
*/
|
|
22
|
+
import { useMemo, useState } from 'react';
|
|
23
|
+
import { revalidateLogic, useForm } from '@tanstack/react-form-start';
|
|
24
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
25
|
+
import { Alert, Button, Input, LoaderEllipsis } from '@byline/ui/react';
|
|
26
|
+
import cx from 'clsx';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { useBylineAdminServices } from '../../../services/admin-services-context.js';
|
|
29
|
+
import styles from './update.module.css';
|
|
30
|
+
// Field-length ceilings sourced from the `adminUsers` schema in @byline/admin
|
|
31
|
+
// (100/100/100/254). Kept as constants so error messages can ICU-format them.
|
|
32
|
+
const MAX_NAME = 100;
|
|
33
|
+
const MAX_USERNAME = 100;
|
|
34
|
+
const MAX_EMAIL = 254;
|
|
35
|
+
function defaultsFrom(account) {
|
|
36
|
+
return {
|
|
37
|
+
given_name: account.given_name ?? '',
|
|
38
|
+
family_name: account.family_name ?? '',
|
|
39
|
+
username: account.username ?? '',
|
|
40
|
+
email: account.email,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function buildPatch(values, account) {
|
|
44
|
+
const patch = {};
|
|
45
|
+
const normaliseText = (value) => (value.trim().length > 0 ? value : null);
|
|
46
|
+
const nextGiven = normaliseText(values.given_name);
|
|
47
|
+
const nextFamily = normaliseText(values.family_name);
|
|
48
|
+
const nextUsername = normaliseText(values.username);
|
|
49
|
+
if (nextGiven !== account.given_name)
|
|
50
|
+
patch.given_name = nextGiven;
|
|
51
|
+
if (nextFamily !== account.family_name)
|
|
52
|
+
patch.family_name = nextFamily;
|
|
53
|
+
if (nextUsername !== account.username)
|
|
54
|
+
patch.username = nextUsername;
|
|
55
|
+
if (values.email !== account.email)
|
|
56
|
+
patch.email = values.email;
|
|
57
|
+
return patch;
|
|
58
|
+
}
|
|
59
|
+
export function UpdateAccount({ account, onClose, onSuccess }) {
|
|
60
|
+
const { updateAccount } = useBylineAdminServices();
|
|
61
|
+
const { t } = useTranslation('byline-admin');
|
|
62
|
+
const [formError, setFormError] = useState(null);
|
|
63
|
+
const [successMessage, setSuccessMessage] = useState(null);
|
|
64
|
+
// Schema is rebuilt per-render so error messages reflect the active
|
|
65
|
+
// locale. Cheap — small schema, no ahead-of-time compilation cost
|
|
66
|
+
// that matters at this scale. `useMemo` keeps Tanstack Form's
|
|
67
|
+
// validator-identity stable across re-renders.
|
|
68
|
+
const updateAccountSchema = useMemo(() => z.object({
|
|
69
|
+
given_name: z
|
|
70
|
+
.string()
|
|
71
|
+
.max(MAX_NAME, t('account.update.errors.givenNameTooLong', { max: MAX_NAME })),
|
|
72
|
+
family_name: z
|
|
73
|
+
.string()
|
|
74
|
+
.max(MAX_NAME, t('account.update.errors.familyNameTooLong', { max: MAX_NAME })),
|
|
75
|
+
username: z
|
|
76
|
+
.string()
|
|
77
|
+
.max(MAX_USERNAME, t('account.update.errors.usernameTooLong', { max: MAX_USERNAME })),
|
|
78
|
+
email: z
|
|
79
|
+
.email({ message: t('account.update.errors.invalidEmail') })
|
|
80
|
+
.min(3)
|
|
81
|
+
.max(MAX_EMAIL, t('account.update.errors.emailTooLong', { max: MAX_EMAIL })),
|
|
82
|
+
}), [t]);
|
|
83
|
+
const form = useForm({
|
|
84
|
+
defaultValues: defaultsFrom(account),
|
|
85
|
+
validationLogic: revalidateLogic({
|
|
86
|
+
mode: 'blur',
|
|
87
|
+
modeAfterSubmission: 'change',
|
|
88
|
+
}),
|
|
89
|
+
validators: {
|
|
90
|
+
onDynamic: updateAccountSchema,
|
|
91
|
+
},
|
|
92
|
+
onSubmit: async ({ value }) => {
|
|
93
|
+
setFormError(null);
|
|
94
|
+
setSuccessMessage(null);
|
|
95
|
+
const patch = buildPatch(value, account);
|
|
96
|
+
if (Object.keys(patch).length === 0) {
|
|
97
|
+
setSuccessMessage(t('common.feedback.noChanges'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const updated = await updateAccount({ data: { vid: account.vid, patch } });
|
|
102
|
+
setSuccessMessage(t('common.feedback.saved'));
|
|
103
|
+
onSuccess?.(updated);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const code = getErrorCode(err);
|
|
107
|
+
if (code === 'admin.users.emailInUse') {
|
|
108
|
+
const message = t('account.update.errors.emailInUse');
|
|
109
|
+
form.setFieldMeta('email', (meta) => ({
|
|
110
|
+
...meta,
|
|
111
|
+
errorMap: { ...meta.errorMap, onServer: message },
|
|
112
|
+
errors: [message],
|
|
113
|
+
}));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (code === 'admin.users.versionConflict') {
|
|
117
|
+
setFormError(t('common.errors.versionConflict'));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (code === 'admin.account.notFound') {
|
|
121
|
+
setFormError(t('common.errors.accountNotFound'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
setFormError(t('common.errors.couldNotSave'));
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return (<div className={cx('byline-account-update-wrap', styles.wrap)}>
|
|
129
|
+
<form method="post" noValidate onSubmit={(event) => {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
void form.handleSubmit();
|
|
133
|
+
}} className={cx('byline-account-update-form', styles.form)}>
|
|
134
|
+
{formError ? <Alert intent="danger">{formError}</Alert> : null}
|
|
135
|
+
{successMessage ? <Alert intent="success">{successMessage}</Alert> : null}
|
|
136
|
+
|
|
137
|
+
<form.Field name="given_name">
|
|
138
|
+
{(field) => (<Input label={t('account.update.fields.givenName')} id="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"/>)}
|
|
139
|
+
</form.Field>
|
|
140
|
+
|
|
141
|
+
<form.Field name="family_name">
|
|
142
|
+
{(field) => (<Input label={t('account.update.fields.familyName')} id="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"/>)}
|
|
143
|
+
</form.Field>
|
|
144
|
+
|
|
145
|
+
<form.Field name="username">
|
|
146
|
+
{(field) => (<Input label={t('account.update.fields.username')} id="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('account.update.fields.usernameHelp')} autoComplete="username"/>)}
|
|
147
|
+
</form.Field>
|
|
148
|
+
|
|
149
|
+
<form.Field name="email">
|
|
150
|
+
{(field) => (<Input label={t('common.fields.email')} id="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/>)}
|
|
151
|
+
</form.Field>
|
|
152
|
+
|
|
153
|
+
<div className={cx('byline-account-update-actions', styles.actions)}>
|
|
154
|
+
<Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-account-update-action', styles.action)}>
|
|
155
|
+
{successMessage ? t('common.actions.close') : t('common.actions.cancel')}
|
|
156
|
+
</Button>
|
|
157
|
+
<form.Subscribe selector={(state) => ({
|
|
158
|
+
canSubmit: state.canSubmit,
|
|
159
|
+
isSubmitting: state.isSubmitting,
|
|
160
|
+
isDirty: state.isDirty,
|
|
161
|
+
})}>
|
|
162
|
+
{({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-account-update-action', styles.action)}>
|
|
163
|
+
{isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
|
|
164
|
+
</Button>)}
|
|
165
|
+
</form.Subscribe>
|
|
166
|
+
</div>
|
|
167
|
+
</form>
|
|
168
|
+
</div>);
|
|
169
|
+
}
|
|
170
|
+
function firstError(errors) {
|
|
171
|
+
for (const err of errors) {
|
|
172
|
+
if (typeof err === 'string')
|
|
173
|
+
return err;
|
|
174
|
+
if (err && typeof err === 'object' && 'message' in err) {
|
|
175
|
+
const msg = err.message;
|
|
176
|
+
if (typeof msg === 'string')
|
|
177
|
+
return msg;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
function getErrorCode(err) {
|
|
183
|
+
return typeof err?.code === 'string'
|
|
184
|
+
? err.code
|
|
185
|
+
: null;
|
|
186
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
* Read-only abilities inspector — see docs/07-auth-and-security/01-authn-authz.md.
|
|
11
|
+
*
|
|
12
|
+
* Top level: a collapsible group per ability source (collections.docs,
|
|
13
|
+
* admin.users, etc.), each containing the abilities that group
|
|
14
|
+
* registered. Group buckets and ordering come straight from the
|
|
15
|
+
* `AbilityRegistry.byGroup()` shape (registration order preserved).
|
|
16
|
+
*
|
|
17
|
+
* Per-ability: an inline-expandable row showing the roles that grant
|
|
18
|
+
* the ability and the distinct admin users who hold it transitively.
|
|
19
|
+
* The matrix is fetched lazily on first expand and cached for the
|
|
20
|
+
* lifetime of the page — the registry is small (~40 keys) but the
|
|
21
|
+
* matrix queries are not free, and most visitors only inspect a few
|
|
22
|
+
* keys.
|
|
23
|
+
*
|
|
24
|
+
* Stable override handles: see `inspector.module.css`.
|
|
25
|
+
*/
|
|
26
|
+
import { useState } from 'react';
|
|
27
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
28
|
+
import { Button, Container, LoaderRing, Section } from '@byline/ui/react';
|
|
29
|
+
import cx from 'clsx';
|
|
30
|
+
import { useBylineAdminServices } from '../../../services/admin-services-context.js';
|
|
31
|
+
import styles from './inspector.module.css';
|
|
32
|
+
// --- helpers ---------------------------------------------------------------
|
|
33
|
+
function sourceVariant(source) {
|
|
34
|
+
switch (source) {
|
|
35
|
+
case 'collection':
|
|
36
|
+
return {
|
|
37
|
+
global: 'byline-inspector-row-source-collection',
|
|
38
|
+
local: styles['row-source-collection'],
|
|
39
|
+
};
|
|
40
|
+
case 'admin':
|
|
41
|
+
return {
|
|
42
|
+
global: 'byline-inspector-row-source-admin',
|
|
43
|
+
local: styles['row-source-admin'],
|
|
44
|
+
};
|
|
45
|
+
case 'plugin':
|
|
46
|
+
return {
|
|
47
|
+
global: 'byline-inspector-row-source-plugin',
|
|
48
|
+
local: styles['row-source-plugin'],
|
|
49
|
+
};
|
|
50
|
+
case 'core':
|
|
51
|
+
return {
|
|
52
|
+
global: 'byline-inspector-row-source-core',
|
|
53
|
+
local: styles['row-source-core'],
|
|
54
|
+
};
|
|
55
|
+
default:
|
|
56
|
+
return {
|
|
57
|
+
global: 'byline-inspector-row-source-unknown',
|
|
58
|
+
local: styles['row-source-unknown'],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function displayUser(user) {
|
|
63
|
+
const parts = [user.given_name, user.family_name].filter((p) => typeof p === 'string' && p.length > 0);
|
|
64
|
+
return parts.length > 0 ? `${parts.join(' ')} (${user.email})` : user.email;
|
|
65
|
+
}
|
|
66
|
+
// --- expandable matrix row ------------------------------------------------
|
|
67
|
+
function MatrixPanel({ matrix }) {
|
|
68
|
+
const { t } = useTranslation('byline-admin');
|
|
69
|
+
return (<div className={cx('byline-inspector-matrix', styles.matrix)}>
|
|
70
|
+
<div>
|
|
71
|
+
<h4 className={cx('byline-inspector-matrix-title', styles['matrix-title'])}>
|
|
72
|
+
{t('adminPermissions.matrix.rolesTitle', { count: matrix.roles.length })}
|
|
73
|
+
</h4>
|
|
74
|
+
{matrix.roles.length === 0 ? (<p className={cx('muted', 'byline-inspector-matrix-empty', styles['matrix-empty'])}>
|
|
75
|
+
{t('adminPermissions.matrix.rolesEmpty')}
|
|
76
|
+
</p>) : (<ul className={cx('byline-inspector-matrix-list', styles['matrix-list'])}>
|
|
77
|
+
{matrix.roles.map((role) => (<li key={role.id} className={cx('byline-inspector-matrix-item', styles['matrix-item'])}>
|
|
78
|
+
<span className={cx('byline-inspector-matrix-name', styles['matrix-name'])}>
|
|
79
|
+
{role.name}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="muted"> · {role.machine_name}</span>
|
|
82
|
+
</li>))}
|
|
83
|
+
</ul>)}
|
|
84
|
+
</div>
|
|
85
|
+
<div>
|
|
86
|
+
<h4 className={cx('byline-inspector-matrix-title', styles['matrix-title'])}>
|
|
87
|
+
{t('adminPermissions.matrix.usersTitle', { count: matrix.users.length })}
|
|
88
|
+
</h4>
|
|
89
|
+
{matrix.users.length === 0 ? (<p className={cx('muted', 'byline-inspector-matrix-empty', styles['matrix-empty'])}>
|
|
90
|
+
{t('adminPermissions.matrix.usersEmpty')}
|
|
91
|
+
</p>) : (<ul className={cx('byline-inspector-matrix-list', styles['matrix-list'])}>
|
|
92
|
+
{matrix.users.map((user) => (<li key={user.id} className={cx('byline-inspector-matrix-item', styles['matrix-item'])}>
|
|
93
|
+
{displayUser(user)}
|
|
94
|
+
</li>))}
|
|
95
|
+
</ul>)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>);
|
|
98
|
+
}
|
|
99
|
+
function AbilityRow({ ability, matrix, loading, onToggle, expanded }) {
|
|
100
|
+
const { t } = useTranslation('byline-admin');
|
|
101
|
+
const sv = sourceVariant(ability.source);
|
|
102
|
+
const sourceKey = ability.source ?? 'unknown';
|
|
103
|
+
return (<div className={cx('byline-inspector-row', styles.row)}>
|
|
104
|
+
<div className={cx('byline-inspector-row-head', styles['row-head'])}>
|
|
105
|
+
<div className={cx('byline-inspector-row-info', styles['row-info'])}>
|
|
106
|
+
<div className={cx('byline-inspector-row-meta', styles['row-meta'])}>
|
|
107
|
+
<code className={cx('byline-inspector-row-key', styles['row-key'])}>{ability.key}</code>
|
|
108
|
+
<span className={cx('byline-inspector-row-source', styles['row-source'], sv.global, sv.local)}>
|
|
109
|
+
{t(`adminPermissions.source.${sourceKey}`)}
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
<p className={cx('byline-inspector-row-label', styles['row-label'])}>{ability.label}</p>
|
|
113
|
+
{ability.description ? (<p className={cx('muted', 'byline-inspector-row-description', styles['row-description'])}>
|
|
114
|
+
{ability.description}
|
|
115
|
+
</p>) : null}
|
|
116
|
+
</div>
|
|
117
|
+
<Button size="xs" intent="secondary" onClick={onToggle}>
|
|
118
|
+
{expanded
|
|
119
|
+
? t('adminPermissions.row.hideButton')
|
|
120
|
+
: t('adminPermissions.row.holdersButton')}
|
|
121
|
+
</Button>
|
|
122
|
+
</div>
|
|
123
|
+
{expanded ? (loading ? (<div className={cx('byline-inspector-loader', styles.loader)}>
|
|
124
|
+
<LoaderRing size={20} color="#888"/>
|
|
125
|
+
<span className="muted">{t('common.loading')}</span>
|
|
126
|
+
</div>) : matrix ? (<MatrixPanel matrix={matrix}/>) : null) : null}
|
|
127
|
+
</div>);
|
|
128
|
+
}
|
|
129
|
+
function GroupSection({ group, matrices, loading, expanded, onToggle }) {
|
|
130
|
+
const { t } = useTranslation('byline-admin');
|
|
131
|
+
return (<details open className={cx('byline-inspector-group', styles.group)}>
|
|
132
|
+
<summary className={cx('byline-inspector-group-summary', styles['group-summary'])}>
|
|
133
|
+
<span className={cx('byline-inspector-group-name', styles['group-name'])}>
|
|
134
|
+
{group.group}
|
|
135
|
+
</span>
|
|
136
|
+
<span className={cx('muted', 'byline-inspector-group-count', styles['group-count'])}>
|
|
137
|
+
{t('adminPermissions.group.abilitiesCount', { count: group.abilities.length })}
|
|
138
|
+
</span>
|
|
139
|
+
</summary>
|
|
140
|
+
<div className={cx('byline-inspector-group-body', styles['group-body'])}>
|
|
141
|
+
{group.abilities.map((ability) => (<AbilityRow key={ability.key} ability={ability} matrix={matrices[ability.key]} loading={loading.has(ability.key)} expanded={expanded.has(ability.key)} onToggle={() => onToggle(ability.key)}/>))}
|
|
142
|
+
</div>
|
|
143
|
+
</details>);
|
|
144
|
+
}
|
|
145
|
+
// --- top level ------------------------------------------------------------
|
|
146
|
+
export function AbilitiesInspector({ data }) {
|
|
147
|
+
const { whoHasAbility } = useBylineAdminServices();
|
|
148
|
+
const { t } = useTranslation('byline-admin');
|
|
149
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
150
|
+
const [loading, setLoading] = useState(new Set());
|
|
151
|
+
const [matrices, setMatrices] = useState({});
|
|
152
|
+
async function handleToggle(abilityKey) {
|
|
153
|
+
setExpanded((current) => {
|
|
154
|
+
const next = new Set(current);
|
|
155
|
+
if (next.has(abilityKey)) {
|
|
156
|
+
next.delete(abilityKey);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
next.add(abilityKey);
|
|
160
|
+
}
|
|
161
|
+
return next;
|
|
162
|
+
});
|
|
163
|
+
// Lazy-load the matrix the first time a row expands. Cached for
|
|
164
|
+
// the lifetime of the page after that.
|
|
165
|
+
if (!matrices[abilityKey] && !loading.has(abilityKey)) {
|
|
166
|
+
setLoading((current) => new Set(current).add(abilityKey));
|
|
167
|
+
try {
|
|
168
|
+
const result = await whoHasAbility({ data: { ability: abilityKey } });
|
|
169
|
+
setMatrices((current) => ({ ...current, [abilityKey]: result }));
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
setLoading((current) => {
|
|
173
|
+
const next = new Set(current);
|
|
174
|
+
next.delete(abilityKey);
|
|
175
|
+
return next;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return (<Section>
|
|
181
|
+
<Container>
|
|
182
|
+
<div className={cx('byline-inspector-head', styles.head)}>
|
|
183
|
+
<h1 className={cx('byline-inspector-title', styles.title)}>
|
|
184
|
+
{t('adminPermissions.title')}
|
|
185
|
+
</h1>
|
|
186
|
+
<span className={cx('byline-inspector-count-pill', styles['count-pill'])}>
|
|
187
|
+
{t('adminPermissions.countPill', { count: data.total })}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
<p className={cx('muted', 'byline-inspector-lead', styles.lead)}>
|
|
191
|
+
{t('adminPermissions.lead')}
|
|
192
|
+
</p>
|
|
193
|
+
{data.groups.length === 0 ? (<p className={cx('muted', 'byline-inspector-empty', styles.empty)}>
|
|
194
|
+
{t('adminPermissions.empty')}
|
|
195
|
+
</p>) : (<div className={cx('byline-inspector-groups', styles.groups)}>
|
|
196
|
+
{data.groups.map((group) => (<GroupSection key={group.group} group={group} matrices={matrices} loading={loading} expanded={expanded} onToggle={(key) => void handleToggle(key)}/>))}
|
|
197
|
+
</div>)}
|
|
198
|
+
</Container>
|
|
199
|
+
</Section>);
|
|
200
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { listViewPreferenceValueSchema, preferenceScopeSchema, setPreferenceRequestSchema, } from './schemas.js';
|
|
10
|
+
describe('preferenceScopeSchema', () => {
|
|
11
|
+
it('accepts dot-separated scope keys', () => {
|
|
12
|
+
expect(preferenceScopeSchema.safeParse('collections.docs.list').success).toBe(true);
|
|
13
|
+
expect(preferenceScopeSchema.safeParse('collections.media-items.list').success).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('rejects empty, spaced, and slash-separated keys', () => {
|
|
16
|
+
expect(preferenceScopeSchema.safeParse('').success).toBe(false);
|
|
17
|
+
expect(preferenceScopeSchema.safeParse('has space').success).toBe(false);
|
|
18
|
+
expect(preferenceScopeSchema.safeParse('a/b').success).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('listViewPreferenceValueSchema', () => {
|
|
22
|
+
it('accepts a page_size-only payload (partial writes are the norm)', () => {
|
|
23
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 50 }).success).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('accepts a sort-only payload', () => {
|
|
26
|
+
expect(listViewPreferenceValueSchema.safeParse({ order: 'title', desc: true }).success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('enforces the 1-100 page_size bounds', () => {
|
|
29
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 0 }).success).toBe(false);
|
|
30
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 101 }).success).toBe(false);
|
|
31
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 12.5 }).success).toBe(false);
|
|
32
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 1 }).success).toBe(true);
|
|
33
|
+
expect(listViewPreferenceValueSchema.safeParse({ page_size: 100 }).success).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('rejects an empty payload and unknown keys', () => {
|
|
36
|
+
expect(listViewPreferenceValueSchema.safeParse({}).success).toBe(false);
|
|
37
|
+
expect(listViewPreferenceValueSchema.safeParse({ page: 7 }).success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('setPreferenceRequestSchema', () => {
|
|
41
|
+
it('requires both scope and a non-empty value', () => {
|
|
42
|
+
expect(setPreferenceRequestSchema.safeParse({
|
|
43
|
+
scope: 'collections.docs.list',
|
|
44
|
+
value: { page_size: 30 },
|
|
45
|
+
}).success).toBe(true);
|
|
46
|
+
expect(setPreferenceRequestSchema.safeParse({ scope: 'collections.docs.list', value: {} }).success).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
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-role drawer form.
|
|
11
|
+
*
|
|
12
|
+
* Same TanStack Form + Zod shape as the admin-users equivalent. The
|
|
13
|
+
* `machine_name` field is captured at create time only — it is the
|
|
14
|
+
* stable code-side handle for the role and is immutable thereafter
|
|
15
|
+
* (see the repository contract).
|
|
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 './create.module.css';
|
|
25
|
+
const MAX_NAME = 128;
|
|
26
|
+
const MAX_MACHINE_NAME = 128;
|
|
27
|
+
const MAX_DESCRIPTION = 2000;
|
|
28
|
+
const initialValues = {
|
|
29
|
+
name: '',
|
|
30
|
+
machine_name: '',
|
|
31
|
+
description: '',
|
|
32
|
+
};
|
|
33
|
+
function normaliseText(value) {
|
|
34
|
+
return value.trim().length > 0 ? value : null;
|
|
35
|
+
}
|
|
36
|
+
export function CreateAdminRole({ onClose, onSuccess }) {
|
|
37
|
+
const { createAdminRole } = useBylineAdminServices();
|
|
38
|
+
const { t } = useTranslation('byline-admin');
|
|
39
|
+
const [formError, setFormError] = useState(null);
|
|
40
|
+
// Schema rebuilt per-render so error messages reflect the active
|
|
41
|
+
// locale; wrapped in useMemo([t]) to keep validator identity stable.
|
|
42
|
+
const createAdminRoleFormSchema = useMemo(() => z.object({
|
|
43
|
+
name: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1, t('adminRoles.create.errors.nameRequired'))
|
|
46
|
+
.max(MAX_NAME, t('adminRoles.create.errors.nameTooLong', { max: MAX_NAME })),
|
|
47
|
+
machine_name: z
|
|
48
|
+
.string()
|
|
49
|
+
.min(1, t('adminRoles.create.errors.machineNameRequired'))
|
|
50
|
+
.max(MAX_MACHINE_NAME, t('adminRoles.create.errors.machineNameTooLong', { max: MAX_MACHINE_NAME }))
|
|
51
|
+
.regex(/^[a-z0-9][a-z0-9_-]*$/, {
|
|
52
|
+
message: t('adminRoles.create.errors.machineNameInvalid'),
|
|
53
|
+
}),
|
|
54
|
+
description: z
|
|
55
|
+
.string()
|
|
56
|
+
.max(MAX_DESCRIPTION, t('adminRoles.create.errors.descriptionTooLong', { max: MAX_DESCRIPTION })),
|
|
57
|
+
}), [t]);
|
|
58
|
+
const form = useForm({
|
|
59
|
+
defaultValues: initialValues,
|
|
60
|
+
validationLogic: revalidateLogic({
|
|
61
|
+
mode: 'blur',
|
|
62
|
+
modeAfterSubmission: 'change',
|
|
63
|
+
}),
|
|
64
|
+
validators: {
|
|
65
|
+
onDynamic: createAdminRoleFormSchema,
|
|
66
|
+
},
|
|
67
|
+
onSubmit: async ({ value }) => {
|
|
68
|
+
setFormError(null);
|
|
69
|
+
try {
|
|
70
|
+
const created = await createAdminRole({
|
|
71
|
+
data: {
|
|
72
|
+
name: value.name.trim(),
|
|
73
|
+
machine_name: value.machine_name.trim(),
|
|
74
|
+
description: normaliseText(value.description),
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
form.reset(initialValues);
|
|
78
|
+
onSuccess?.(created);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const code = getErrorCode(err);
|
|
82
|
+
if (code === 'admin.roles.machineNameInUse') {
|
|
83
|
+
const message = t('adminRoles.create.errors.machineNameInUse');
|
|
84
|
+
form.setFieldMeta('machine_name', (meta) => ({
|
|
85
|
+
...meta,
|
|
86
|
+
errorMap: { ...meta.errorMap, onServer: message },
|
|
87
|
+
errors: [message],
|
|
88
|
+
}));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setFormError(t('adminRoles.create.errors.fallback'));
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return (<div className={cx('byline-role-create-wrap', styles.wrap)}>
|
|
96
|
+
<form method="post" noValidate onSubmit={(event) => {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
event.stopPropagation();
|
|
99
|
+
void form.handleSubmit();
|
|
100
|
+
}} className={cx('byline-role-create-form', styles.form)}>
|
|
101
|
+
{formError ? <Alert intent="danger">{formError}</Alert> : null}
|
|
102
|
+
|
|
103
|
+
<form.Field name="name">
|
|
104
|
+
{(field) => (<Input label={t('adminRoles.fields.name')} id="new-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)} helpText={t('adminRoles.create.fields.nameHelp')} required/>)}
|
|
105
|
+
</form.Field>
|
|
106
|
+
|
|
107
|
+
<form.Field name="machine_name">
|
|
108
|
+
{(field) => (<Input label={t('adminRoles.fields.machineName')} id="new-role-machine-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)} helpText={t('adminRoles.create.fields.machineNameHelp')} required/>)}
|
|
109
|
+
</form.Field>
|
|
110
|
+
|
|
111
|
+
<form.Field name="description">
|
|
112
|
+
{(field) => (<TextArea label={t('adminRoles.fields.description')} id="new-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}/>)}
|
|
113
|
+
</form.Field>
|
|
114
|
+
|
|
115
|
+
<div className={cx('byline-role-create-actions', styles.actions)}>
|
|
116
|
+
<Button type="button" intent="secondary" size="sm" onClick={onClose} className={cx('byline-role-create-action', styles.action)}>
|
|
117
|
+
{t('common.actions.cancel')}
|
|
118
|
+
</Button>
|
|
119
|
+
<form.Subscribe selector={(state) => ({
|
|
120
|
+
canSubmit: state.canSubmit,
|
|
121
|
+
isSubmitting: state.isSubmitting,
|
|
122
|
+
})}>
|
|
123
|
+
{({ canSubmit, isSubmitting }) => (<Button size="sm" intent="primary" type="submit" disabled={!canSubmit || isSubmitting} className={cx('byline-role-create-action', styles.action)}>
|
|
124
|
+
{isSubmitting === true ? <LoaderEllipsis size={42}/> : t('common.actions.save')}
|
|
125
|
+
</Button>)}
|
|
126
|
+
</form.Subscribe>
|
|
127
|
+
</div>
|
|
128
|
+
</form>
|
|
129
|
+
</div>);
|
|
130
|
+
}
|
|
131
|
+
function firstError(errors) {
|
|
132
|
+
for (const err of errors) {
|
|
133
|
+
if (typeof err === 'string')
|
|
134
|
+
return err;
|
|
135
|
+
if (err && typeof err === 'object' && 'message' in err) {
|
|
136
|
+
const msg = err.message;
|
|
137
|
+
if (typeof msg === 'string')
|
|
138
|
+
return msg;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
function getErrorCode(err) {
|
|
144
|
+
if (err && typeof err === 'object') {
|
|
145
|
+
const e = err;
|
|
146
|
+
if (typeof e.code === 'string')
|
|
147
|
+
return e.code;
|
|
148
|
+
if (e.cause && typeof e.cause === 'object' && 'code' in e.cause) {
|
|
149
|
+
const cause = e.cause;
|
|
150
|
+
if (typeof cause.code === 'string')
|
|
151
|
+
return cause.code;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|