@byline/admin 6.7.1 → 6.8.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 (163) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +31 -24
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. package/src/store.ts +4 -0
  87. package/dist/exports-parity.test.node.js +0 -37
  88. package/dist/fields/array/array-field.jsx +0 -209
  89. package/dist/fields/blocks/blocks-field.jsx +0 -248
  90. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  91. package/dist/fields/code/code-editor.jsx +0 -211
  92. package/dist/fields/code/code-field.jsx +0 -109
  93. package/dist/fields/column-formatter.jsx +0 -25
  94. package/dist/fields/date-time-formatter.jsx +0 -18
  95. package/dist/fields/datetime/datetime-field.jsx +0 -22
  96. package/dist/fields/draggable-context-menu.jsx +0 -50
  97. package/dist/fields/field-admin.test.node.js +0 -40
  98. package/dist/fields/field-renderer.jsx +0 -136
  99. package/dist/fields/field-services-context.jsx +0 -17
  100. package/dist/fields/file/file-field.jsx +0 -176
  101. package/dist/fields/file/file-upload-field.jsx +0 -89
  102. package/dist/fields/group/group-field.jsx +0 -46
  103. package/dist/fields/image/image-field.jsx +0 -183
  104. package/dist/fields/image/image-upload-field.jsx +0 -157
  105. package/dist/fields/local-date-time.jsx +0 -69
  106. package/dist/fields/locale-badge.jsx +0 -19
  107. package/dist/fields/numerical/numerical-field.jsx +0 -93
  108. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  109. package/dist/fields/relation/relation-display.jsx +0 -118
  110. package/dist/fields/relation/relation-field.jsx +0 -108
  111. package/dist/fields/relation/relation-many-field.jsx +0 -142
  112. package/dist/fields/relation/relation-picker.jsx +0 -240
  113. package/dist/fields/relation/relation-summary.jsx +0 -45
  114. package/dist/fields/select/select-field.jsx +0 -27
  115. package/dist/fields/sortable-item.jsx +0 -54
  116. package/dist/fields/text/text-field.jsx +0 -79
  117. package/dist/fields/text-area/text-area-field.jsx +0 -79
  118. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  119. package/dist/forms/available-locales-widget.jsx +0 -63
  120. package/dist/forms/document-actions.jsx +0 -486
  121. package/dist/forms/form-context.jsx +0 -598
  122. package/dist/forms/form-modals.jsx +0 -128
  123. package/dist/forms/form-renderer.jsx +0 -532
  124. package/dist/forms/form-status-display.jsx +0 -69
  125. package/dist/forms/navigation-guard.jsx +0 -68
  126. package/dist/forms/nested-path.test.node.js +0 -115
  127. package/dist/forms/path-widget.jsx +0 -125
  128. package/dist/forms/pending-uploads.test.node.js +0 -19
  129. package/dist/forms/repeating-items.test.node.js +0 -29
  130. package/dist/forms/scheduled-publication-control.jsx +0 -389
  131. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  132. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  133. package/dist/forms/status-transitions.test.node.js +0 -76
  134. package/dist/forms/tree-placement-widget.jsx +0 -160
  135. package/dist/forms/upload-executor.test.node.js +0 -368
  136. package/dist/forms/use-form-layout.test.node.js +0 -69
  137. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  138. package/dist/modules/admin-account/components/container.jsx +0 -164
  139. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  140. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  141. package/dist/modules/admin-account/components/update.jsx +0 -186
  142. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  143. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  144. package/dist/modules/admin-roles/components/create.jsx +0 -155
  145. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  146. package/dist/modules/admin-roles/components/update.jsx +0 -148
  147. package/dist/modules/admin-users/components/create.jsx +0 -206
  148. package/dist/modules/admin-users/components/roles.jsx +0 -126
  149. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  150. package/dist/modules/admin-users/components/update.jsx +0 -215
  151. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  152. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  153. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  154. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  155. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  156. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  157. package/dist/presentation/group.jsx +0 -26
  158. package/dist/presentation/row.jsx +0 -23
  159. package/dist/presentation/tabs.jsx +0 -39
  160. package/dist/services/admin-services-context.jsx +0 -17
  161. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  162. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  163. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -1,186 +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
- * 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
- }
@@ -1,200 +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
- * 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">&nbsp;·&nbsp;{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
- }
@@ -1,48 +0,0 @@
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
- });
@@ -1,155 +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-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
- }