@byline/admin 3.15.2 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/fields/relation/relation-many-field.js +19 -11
  2. package/dist/fields/relation/relation-picker.d.ts +40 -17
  3. package/dist/fields/relation/relation-picker.js +84 -28
  4. package/dist/fields/relation/relation-picker.module.js +9 -0
  5. package/dist/fields/relation/relation-picker_module.css +41 -0
  6. package/dist/forms/form-context.d.ts +2 -2
  7. package/dist/forms/form-modals.d.ts +1 -1
  8. package/dist/forms/form-renderer.d.ts +2 -2
  9. package/dist/forms/tree-placement-widget.d.ts +1 -1
  10. package/dist/lib/create-command.d.ts +1 -1
  11. package/dist/modules/admin-activity/abilities.d.ts +1 -1
  12. package/dist/modules/admin-activity/index.d.ts +1 -1
  13. package/dist/modules/admin-permissions/abilities.d.ts +1 -1
  14. package/dist/modules/admin-users/repository.d.ts +1 -1
  15. package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +1 -1
  16. package/package.json +5 -6
  17. package/src/fields/field-services-types.ts +1 -1
  18. package/src/fields/relation/relation-many-field.tsx +22 -15
  19. package/src/fields/relation/relation-picker.module.css +49 -0
  20. package/src/fields/relation/relation-picker.tsx +130 -23
  21. package/src/forms/available-locales-reconcile.test.node.ts +1 -1
  22. package/src/forms/form-context.tsx +3 -3
  23. package/src/forms/form-modals.tsx +1 -1
  24. package/src/forms/form-renderer.tsx +4 -4
  25. package/src/forms/tree-placement-widget.tsx +1 -1
  26. package/src/lib/create-command.ts +1 -1
  27. package/src/modules/admin-activity/abilities.ts +1 -1
  28. package/src/modules/admin-activity/index.ts +1 -1
  29. package/src/modules/admin-permissions/abilities.ts +1 -1
  30. package/src/modules/admin-permissions/components/inspector.tsx +1 -1
  31. package/src/modules/admin-users/repository.ts +1 -1
  32. package/src/widgets/source-locale-badge/source-locale-badge.tsx +1 -1
@@ -74,20 +74,26 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
74
74
  if (Array.isArray(v)) return v;
75
75
  return Array.isArray(defaultValue) ? defaultValue : [];
76
76
  };
77
- const handleAdd = (selection)=>{
77
+ const handleAddMany = (selections)=>{
78
78
  setPickerOpen(false);
79
79
  const current = currentArray();
80
- if (current.some((v)=>v.targetDocumentId === selection.targetDocumentId)) return;
81
- if (selection.record) setPickedRecords((prev)=>({
82
- ...prev,
83
- [selection.targetDocumentId]: selection.record
84
- }));
80
+ const existing = new Set(current.map((v)=>v.targetDocumentId));
81
+ const additions = selections.filter((s)=>!existing.has(s.targetDocumentId));
82
+ if (0 === additions.length) return;
83
+ const records = additions.filter((s)=>null != s.record);
84
+ if (records.length > 0) setPickedRecords((prev)=>{
85
+ const next = {
86
+ ...prev
87
+ };
88
+ for (const s of records)next[s.targetDocumentId] = s.record;
89
+ return next;
90
+ });
85
91
  setFieldValue(fieldPath, [
86
92
  ...current,
87
- {
88
- targetDocumentId: selection.targetDocumentId,
89
- targetCollectionId: selection.targetCollectionId
90
- }
93
+ ...additions.map((s)=>({
94
+ targetDocumentId: s.targetDocumentId,
95
+ targetCollectionId: s.targetCollectionId
96
+ }))
91
97
  ]);
92
98
  };
93
99
  const handleRemove = (targetDocumentId)=>{
@@ -178,11 +184,13 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
178
184
  })
179
185
  }),
180
186
  /*#__PURE__*/ jsx(RelationPicker, {
187
+ multiple: true,
181
188
  targetCollectionPath: field.targetCollection,
182
189
  targetDefinition: targetDef,
183
190
  displayField: field.displayField,
184
191
  isOpen: pickerOpen,
185
- onSelect: handleAdd,
192
+ excludeIds: items.map((v)=>v.targetDocumentId),
193
+ onSelectMany: handleAddMany,
186
194
  onDismiss: ()=>setPickerOpen(false)
187
195
  })
188
196
  ]
@@ -18,7 +18,20 @@ import type { CollectionDefinition } from '@byline/core';
18
18
  *
19
19
  * Paths 2–4 render a single-line label (primary) + `path` (secondary).
20
20
  */
21
- interface RelationPickerProps {
21
+ /**
22
+ * One confirmed pick. `record` is the raw document the picker row rendered —
23
+ * the caller can use it to show the selected value in its own tile without a
24
+ * refetch. The fields available on `record` are whatever `resolveSelectFields`
25
+ * asked the listing endpoint for (picker columns + `useAsTitle` +
26
+ * `displayField`), so any display surface downstream of the picker that also
27
+ * renders from those same columns will find the data it needs.
28
+ */
29
+ export interface RelationPickerSelection {
30
+ targetDocumentId: string;
31
+ targetCollectionId: string;
32
+ record?: Record<string, any>;
33
+ }
34
+ interface RelationPickerBaseProps {
22
35
  /** The target collection path (e.g. `'media'`). */
23
36
  targetCollectionPath: string;
24
37
  /** The target collection definition (used for labels + displayField fallback). */
@@ -37,23 +50,33 @@ interface RelationPickerProps {
37
50
  extraSelectFields?: string[];
38
51
  /** Modal open/close state. */
39
52
  isOpen: boolean;
40
- /**
41
- * Called with the picked selection when the user confirms.
42
- *
43
- * `record` is the raw document the picker row rendered — the caller can
44
- * use it to show the selected value in its own tile without a refetch.
45
- * The fields available on `record` are whatever `resolveSelectFields`
46
- * asked the listing endpoint for (picker columns + `useAsTitle` +
47
- * `displayField`), so any display surface downstream of the picker that
48
- * also renders from those same columns will find the data it needs.
49
- */
50
- onSelect: (selection: {
51
- targetDocumentId: string;
52
- targetCollectionId: string;
53
- record?: Record<string, any>;
54
- }) => void;
55
53
  /** Called when the user dismisses the modal. */
56
54
  onDismiss: () => void;
57
55
  }
58
- export declare const RelationPicker: ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, onSelect, onDismiss, }: RelationPickerProps) => import("react").JSX.Element;
56
+ interface RelationPickerSingleProps extends RelationPickerBaseProps {
57
+ /** Single-select (default): clicking a row selects it; confirm returns one pick. */
58
+ multiple?: false;
59
+ /** Called with the picked selection when the user confirms. */
60
+ onSelect: (selection: RelationPickerSelection) => void;
61
+ onSelectMany?: never;
62
+ excludeIds?: never;
63
+ }
64
+ interface RelationPickerMultiProps extends RelationPickerBaseProps {
65
+ /**
66
+ * Multi-select mode (`hasMany` widgets): rows toggle a check state and the
67
+ * confirm action returns every selection in pick order — several picks in
68
+ * one trip instead of reopening the modal per item.
69
+ */
70
+ multiple: true;
71
+ /** Called with the full selection set (pick order) when the user confirms. */
72
+ onSelectMany: (selections: RelationPickerSelection[]) => void;
73
+ onSelect?: never;
74
+ /**
75
+ * Target ids already present on the caller's value. Rendered as disabled
76
+ * "already added" rows so the same target can't be picked twice.
77
+ */
78
+ excludeIds?: string[];
79
+ }
80
+ type RelationPickerProps = RelationPickerSingleProps | RelationPickerMultiProps;
81
+ export declare const RelationPicker: ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple, excludeIds, onSelect, onSelectMany, onDismiss, }: RelationPickerProps) => import("react").JSX.Element;
59
82
  export {};
@@ -2,17 +2,18 @@ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { getCollectionAdminConfig, resolveItemViewColumns } from "@byline/core";
4
4
  import { useTranslation } from "@byline/i18n/react";
5
- import { Button, LoaderRing, Modal, Search } from "@byline/ui/react";
5
+ import { Button, CheckIcon, LoaderRing, Modal, Search } from "@byline/ui/react";
6
6
  import classnames from "classnames";
7
7
  import { useBylineFieldServices } from "../field-services-context.js";
8
8
  import { PickerCell, resolveFallbackDisplayField, resolveRowLabel, resolveSelectFields } from "./relation-display.js";
9
9
  import relation_picker_module from "./relation-picker.module.js";
10
10
  const PAGE_SIZE = 15;
11
- const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, onSelect, onDismiss })=>{
11
+ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple = false, excludeIds, onSelect, onSelectMany, onDismiss })=>{
12
12
  const [query, setQuery] = useState('');
13
13
  const [page, setPage] = useState(1);
14
14
  const { t } = useTranslation('byline-admin');
15
15
  const [selectedDocumentId, setSelectedDocumentId] = useState(null);
16
+ const [selectedMap, setSelectedMap] = useState(()=>new Map());
16
17
  const [loading, setLoading] = useState(false);
17
18
  const [error, setError] = useState(null);
18
19
  const [documents, setDocuments] = useState([]);
@@ -26,6 +27,7 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
26
27
  setQuery('');
27
28
  setPage(1);
28
29
  setSelectedDocumentId(null);
30
+ setSelectedMap(new Map());
29
31
  setError(null);
30
32
  }
31
33
  }, [
@@ -73,7 +75,7 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
73
75
  ]);
74
76
  const resolvedDisplayField = displayField ?? targetDefinition?.useAsTitle ?? resolveFallbackDisplayField(targetDefinition) ?? null;
75
77
  const handleSelect = useCallback(()=>{
76
- if (!selectedDocumentId || !collectionId) return;
78
+ if (!selectedDocumentId || !collectionId || !onSelect) return;
77
79
  const record = documents.find((d)=>d?.id === selectedDocumentId);
78
80
  onSelect({
79
81
  targetDocumentId: selectedDocumentId,
@@ -86,6 +88,27 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
86
88
  documents,
87
89
  onSelect
88
90
  ]);
91
+ const handleSelectMany = useCallback(()=>{
92
+ if (0 === selectedMap.size || !collectionId || !onSelectMany) return;
93
+ onSelectMany(Array.from(selectedMap, ([targetDocumentId, record])=>({
94
+ targetDocumentId,
95
+ targetCollectionId: collectionId,
96
+ record
97
+ })));
98
+ }, [
99
+ selectedMap,
100
+ collectionId,
101
+ onSelectMany
102
+ ]);
103
+ const toggleSelected = useCallback((doc)=>{
104
+ const id = doc.id;
105
+ setSelectedMap((prev)=>{
106
+ const next = new Map(prev);
107
+ if (next.has(id)) next.delete(id);
108
+ else next.set(id, doc);
109
+ return next;
110
+ });
111
+ }, []);
89
112
  const title = t('fields.relation.selectPickerTitle', {
90
113
  label: targetDefinition?.labels.singular ?? targetCollectionPath
91
114
  });
@@ -143,34 +166,65 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
143
166
  className: classnames('byline-field-relation-picker-rows', relation_picker_module.rows),
144
167
  children: documents.map((doc)=>{
145
168
  const id = doc.id;
146
- const selected = selectedDocumentId === id;
169
+ const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id;
170
+ const excluded = multiple && (excludeIds?.includes(id) ?? false);
147
171
  return /*#__PURE__*/ jsx("li", {
148
- children: /*#__PURE__*/ jsx("button", {
172
+ children: /*#__PURE__*/ jsxs("button", {
149
173
  type: "button",
150
- className: classnames('byline-field-relation-picker-row-button', relation_picker_module["row-button"], selected && [
174
+ disabled: excluded,
175
+ "aria-pressed": multiple ? selected : void 0,
176
+ title: excluded ? t('fields.relation.picker.alreadyAdded') : void 0,
177
+ className: classnames('byline-field-relation-picker-row-button', relation_picker_module["row-button"], multiple && [
178
+ 'byline-field-relation-picker-row-multi',
179
+ relation_picker_module["row-multi"]
180
+ ], selected && [
151
181
  'byline-field-relation-picker-row-selected',
152
182
  relation_picker_module["row-selected"]
183
+ ], excluded && [
184
+ 'byline-field-relation-picker-row-added',
185
+ relation_picker_module["row-added"]
153
186
  ]),
154
- onClick: ()=>setSelectedDocumentId(id),
155
- children: pickerColumns && pickerColumns.length > 0 ? /*#__PURE__*/ jsx("div", {
156
- className: classnames('byline-field-relation-picker-row-cells', relation_picker_module["row-cells"]),
157
- children: pickerColumns.map((col)=>/*#__PURE__*/ jsx(PickerCell, {
158
- column: col,
159
- record: doc
160
- }, String(col.fieldName)))
161
- }) : /*#__PURE__*/ jsxs("div", {
162
- className: classnames('byline-field-relation-picker-row-stack', relation_picker_module["row-stack"]),
163
- children: [
164
- /*#__PURE__*/ jsx("span", {
165
- className: classnames('byline-field-relation-picker-row-label', relation_picker_module["row-label"]),
166
- children: resolveRowLabel(doc, resolvedDisplayField) || id
167
- }),
168
- 'string' == typeof doc.path && doc.path.length > 0 && /*#__PURE__*/ jsx("span", {
169
- className: classnames('byline-field-relation-picker-row-path', relation_picker_module["row-path"]),
170
- children: doc.path
187
+ onClick: ()=>{
188
+ if (excluded) return;
189
+ if (multiple) toggleSelected(doc);
190
+ else setSelectedDocumentId(id);
191
+ },
192
+ children: [
193
+ multiple && /*#__PURE__*/ jsx("span", {
194
+ "aria-hidden": "true",
195
+ className: classnames('byline-field-relation-picker-check', relation_picker_module.check, (selected || excluded) && [
196
+ 'byline-field-relation-picker-check-checked',
197
+ relation_picker_module["check-checked"]
198
+ ]),
199
+ children: (selected || excluded) && /*#__PURE__*/ jsx(CheckIcon, {
200
+ width: "12px",
201
+ height: "12px"
171
202
  })
172
- ]
173
- })
203
+ }),
204
+ pickerColumns && pickerColumns.length > 0 ? /*#__PURE__*/ jsx("div", {
205
+ className: classnames('byline-field-relation-picker-row-cells', relation_picker_module["row-cells"]),
206
+ children: pickerColumns.map((col)=>/*#__PURE__*/ jsx(PickerCell, {
207
+ column: col,
208
+ record: doc
209
+ }, String(col.fieldName)))
210
+ }) : /*#__PURE__*/ jsxs("div", {
211
+ className: classnames('byline-field-relation-picker-row-stack', relation_picker_module["row-stack"]),
212
+ children: [
213
+ /*#__PURE__*/ jsx("span", {
214
+ className: classnames('byline-field-relation-picker-row-label', relation_picker_module["row-label"]),
215
+ children: resolveRowLabel(doc, resolvedDisplayField) || id
216
+ }),
217
+ 'string' == typeof doc.path && doc.path.length > 0 && /*#__PURE__*/ jsx("span", {
218
+ className: classnames('byline-field-relation-picker-row-path', relation_picker_module["row-path"]),
219
+ children: doc.path
220
+ })
221
+ ]
222
+ }),
223
+ excluded && /*#__PURE__*/ jsx("span", {
224
+ className: classnames('byline-field-relation-picker-added-hint', relation_picker_module["added-hint"]),
225
+ children: t('fields.relation.picker.alreadyAdded')
226
+ })
227
+ ]
174
228
  })
175
229
  }, id);
176
230
  })
@@ -224,9 +278,11 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
224
278
  className: classnames('byline-field-relation-picker-action', relation_picker_module.action),
225
279
  intent: "primary",
226
280
  type: "button",
227
- disabled: !selectedDocumentId,
228
- onClick: handleSelect,
229
- children: t('common.actions.select')
281
+ disabled: multiple ? 0 === selectedMap.size : !selectedDocumentId,
282
+ onClick: multiple ? handleSelectMany : handleSelect,
283
+ children: multiple ? t('fields.relation.picker.addSelected', {
284
+ count: selectedMap.size
285
+ }) : t('common.actions.select')
230
286
  })
231
287
  ]
232
288
  })
@@ -12,6 +12,15 @@ const relation_picker_module = {
12
12
  rowButton: "row-button-ZHfXmj",
13
13
  "row-selected": "row-selected-VlrgyO",
14
14
  rowSelected: "row-selected-VlrgyO",
15
+ "row-multi": "row-multi-o06YCW",
16
+ rowMulti: "row-multi-o06YCW",
17
+ check: "check-gYmVOq",
18
+ "check-checked": "check-checked-Nopylj",
19
+ checkChecked: "check-checked-Nopylj",
20
+ "row-added": "row-added-PmlIQL",
21
+ rowAdded: "row-added-PmlIQL",
22
+ "added-hint": "added-hint-QGoYN3",
23
+ addedHint: "added-hint-QGoYN3",
15
24
  "row-cells": "row-cells-aSIcf5",
16
25
  rowCells: "row-cells-aSIcf5",
17
26
  "row-stack": "row-stack-BOPzzj",
@@ -73,6 +73,47 @@
73
73
  border-left: 2px solid var(--primary-200);
74
74
  }
75
75
 
76
+ :is(.row-multi-o06YCW, .byline-field-relation-picker-row-multi) {
77
+ align-items: center;
78
+ gap: .625rem;
79
+ display: flex;
80
+ }
81
+
82
+ :is(.check-gYmVOq, .byline-field-relation-picker-check) {
83
+ border: 1px solid var(--gray-400);
84
+ color: #0000;
85
+ border-radius: 4px;
86
+ flex: none;
87
+ justify-content: center;
88
+ align-items: center;
89
+ width: 16px;
90
+ height: 16px;
91
+ display: inline-flex;
92
+ }
93
+
94
+ :is(.check-checked-Nopylj, .byline-field-relation-picker-check-checked) {
95
+ background-color: var(--primary-200);
96
+ border-color: var(--primary-200);
97
+ color: #fff;
98
+ }
99
+
100
+ :is(.row-added-PmlIQL, .byline-field-relation-picker-row-added) {
101
+ opacity: .55;
102
+ cursor: default;
103
+ }
104
+
105
+ :is(.row-added-PmlIQL:hover, .byline-field-relation-picker-row-added:hover) {
106
+ background-color: #0000;
107
+ }
108
+
109
+ :is(.added-hint-QGoYN3, .byline-field-relation-picker-added-hint) {
110
+ color: var(--gray-500);
111
+ font-size: var(--font-size-xs);
112
+ white-space: nowrap;
113
+ flex: none;
114
+ margin-left: auto;
115
+ }
116
+
76
117
  :is(.row-cells-aSIcf5, .byline-field-relation-picker-row-cells) {
77
118
  align-items: center;
78
119
  gap: .75rem;
@@ -35,7 +35,7 @@ type FieldUploadingListener = (uploading: boolean) => void;
35
35
  * Save button. `content` mints a new version (normal workflow). `direct-write`
36
36
  * is an immediate, non-versioned write of the document-grain system fields
37
37
  * (path / advertised locales) that does NOT reset workflow status. `both` does
38
- * each through its own write path. See docs/I18N.md.
38
+ * each through its own write path. See docs/07-internationalization/index.md.
39
39
  */
40
40
  export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both';
41
41
  export interface DirtyBreakdown {
@@ -68,7 +68,7 @@ interface FormContextType {
68
68
  /**
69
69
  * Partition the current dirty state into content vs. system-field (path /
70
70
  * advertised-locales) writes so the Save button can branch. See
71
- * docs/I18N.md.
71
+ * docs/07-internationalization/index.md.
72
72
  */
73
73
  getDirtyBreakdown: () => DirtyBreakdown;
74
74
  subscribeField: (name: string, listener: FieldListener) => () => void;
@@ -10,7 +10,7 @@ export declare const UnsavedChangesModal: ({ onClose }: {
10
10
  * Confirms an immediate, non-versioned write of the document-grain system
11
11
  * fields (path / advertised locales) — which does NOT reset workflow status.
12
12
  * When content is also dirty, the copy reassures that content edits still
13
- * follow the normal revision + publish workflow. See docs/I18N.md.
13
+ * follow the normal revision + publish workflow. See docs/07-internationalization/index.md.
14
14
  */
15
15
  export declare const SystemFieldsConfirmModal: ({ contentDirty, pathDirty, availableLocalesDirty, onCancel, onConfirm, }: {
16
16
  contentDirty: boolean;
@@ -23,7 +23,7 @@ export interface PublishedVersionInfo {
23
23
  * patches) alongside the document-grain system fields (path / advertised
24
24
  * locales) and per-bucket dirty flags so the host can route each piece to the
25
25
  * right write path — versioned for content, immediate/non-versioned for the
26
- * system fields. See docs/I18N.md.
26
+ * system fields. See docs/07-internationalization/index.md.
27
27
  */
28
28
  export interface SystemFieldsSubmitPayload {
29
29
  data: any;
@@ -104,7 +104,7 @@ export interface FormRendererProps {
104
104
  * Opts the document-tree placement widget into the sidebar (above the
105
105
  * available-locales widget). Sourced from `CollectionDefinition.tree` by the
106
106
  * caller. Renders only in edit mode (placement needs a persisted document)
107
- * and only when the host wires the tree services. See docs/DOCUMENT-TREE.md.
107
+ * and only when the host wires the tree services. See docs/04-collections/03-document-trees.md.
108
108
  */
109
109
  tree?: boolean;
110
110
  headingLabel?: string;
@@ -8,7 +8,7 @@ export interface TreePlacementWidgetProps {
8
8
  }
9
9
  /**
10
10
  * Sidebar widget for placing the current document within its collection's
11
- * single-parent document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md).
11
+ * single-parent document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md).
12
12
  *
13
13
  * The tree is document-grain and **unversioned**, so changes here write
14
14
  * immediately (independent of the form's content save). The editor picks a
@@ -12,7 +12,7 @@ import type { ZodType } from 'zod';
12
12
  * contract (validate → authorise → invoke → shape) into a single
13
13
  * declaration.
14
14
  *
15
- * Implements Phase 1 of `docs/CORE-COMPOSITION.md`. Today's scope is
15
+ * Implements Phase 1 of `docs/03-architecture/02-core-composition.md`. Today's scope is
16
16
  * `@byline/admin`-internal: it gates against admin actor identity using
17
17
  * the existing `assertAdminActor` / `requireAdminActor` helpers, which
18
18
  * inherit the super-admin bypass from `AdminAuth.assertAbility`.
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { AbilityRegistry } from '@byline/auth';
9
9
  /**
10
- * Ability keys for the admin-activity module (docs/AUDIT.md — Workstream 4).
10
+ * Ability keys for the admin-activity module (docs/06-auth-and-security/02-auditability.md — Workstream 4).
11
11
  *
12
12
  * `read` gates the system-wide activity area — the `/admin/activity` report
13
13
  * over the version-stream + audit-log union. It is deliberately a **separate**
@@ -7,7 +7,7 @@
7
7
  */
8
8
  /**
9
9
  * `@byline/admin/admin-activity` — the system-wide activity area
10
- * (docs/AUDIT.md — Workstream 4).
10
+ * (docs/06-auth-and-security/02-auditability.md — Workstream 4).
11
11
  *
12
12
  * Unlike the other admin modules this one owns no table and no AdminStore
13
13
  * repository: the activity feed is a read over the document db adapter's
@@ -9,7 +9,7 @@ import type { AbilityRegistry } from '@byline/auth';
9
9
  /**
10
10
  * Ability keys for the admin-permissions module.
11
11
  *
12
- * `read` gates the inspector view (see docs/AUTHN-AUTHZ.md).
12
+ * `read` gates the inspector view (see docs/06-auth-and-security/01-authn-authz.md).
13
13
  * `update` will gate the per-role ability editor mounted on the
14
14
  * admin-roles role detail page — declared here so the role editor can
15
15
  * assert against it once that surface lands. The per-role editor shares
@@ -113,7 +113,7 @@ export interface AdminUsersRepository {
113
113
  getById(id: string): Promise<AdminUserRow | null>;
114
114
  /**
115
115
  * Bulk lookup for audit actor-label resolution (the `actors`
116
- * map on admin document reads — see docs/AUDIT.md, Workstream 1). Ids
116
+ * map on admin document reads — see docs/06-auth-and-security/02-auditability.md, Workstream 1). Ids
117
117
  * with no matching row are simply absent from the result; callers
118
118
  * render a tombstone label for them.
119
119
  */
@@ -20,7 +20,7 @@ export interface SourceLocaleBadgeProps {
20
20
  * NOTE: currently rendered for *every* document so the anchor is visible during
21
21
  * development. The intended end state is to show it only when `locale` differs
22
22
  * from the system's current default content locale (a normal single-default
23
- * install then shows nothing). See docs/I18N.md.
23
+ * install then shows nothing). See docs/07-internationalization/index.md.
24
24
  *
25
25
  * Stable override handle: `.byline-source-locale-badge`.
26
26
  */
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.15.2",
5
+ "version": "3.16.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -155,11 +155,10 @@
155
155
  "react-diff-viewer-continued": "^4.2.2",
156
156
  "uuid": "^14.0.0",
157
157
  "zod": "^4.4.3",
158
- "zod-form-data": "^3.0.1",
159
- "@byline/auth": "3.15.2",
160
- "@byline/i18n": "3.15.2",
161
- "@byline/ui": "3.15.2",
162
- "@byline/core": "3.15.2"
158
+ "@byline/auth": "3.16.0",
159
+ "@byline/ui": "3.16.0",
160
+ "@byline/core": "3.16.0",
161
+ "@byline/i18n": "3.16.0"
163
162
  },
164
163
  "peerDependencies": {
165
164
  "react": "^19.0.0",
@@ -62,7 +62,7 @@ export type UploadFieldFn = (
62
62
  createDocument?: boolean
63
63
  ) => Promise<UploadedFileResult>
64
64
 
65
- // --- Document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md) -----
65
+ // --- Document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md) -----
66
66
 
67
67
  /** One hydrated ancestor in a document's breadcrumb trail (root-first). */
68
68
  export interface TreeAncestor {
@@ -32,7 +32,7 @@ import cx from 'classnames'
32
32
 
33
33
  import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
34
34
  import styles from './relation-field.module.css'
35
- import { RelationPicker } from './relation-picker'
35
+ import { RelationPicker, type RelationPickerSelection } from './relation-picker'
36
36
  import { RelationSummary } from './relation-summary'
37
37
 
38
38
  // A single stored item. On the edit path the loader's populate pass attaches
@@ -147,24 +147,29 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
147
147
  return Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : []
148
148
  }
149
149
 
150
- const handleAdd = (selection: {
151
- targetDocumentId: string
152
- targetCollectionId: string
153
- record?: Record<string, any>
154
- }) => {
150
+ const handleAddMany = (selections: RelationPickerSelection[]) => {
155
151
  setPickerOpen(false)
156
152
  const current = currentArray()
157
- // Dedup — a target may appear at most once.
158
- if (current.some((v) => v.targetDocumentId === selection.targetDocumentId)) return
159
- if (selection.record) {
160
- setPickedRecords((prev) => ({ ...prev, [selection.targetDocumentId]: selection.record! }))
153
+ // Dedup the batch against the current array — a target may appear at most
154
+ // once. The picker already disables already-added rows, so this is a
155
+ // belt-and-braces guard for stale state (e.g. a concurrent remove).
156
+ const existing = new Set(current.map((v) => v.targetDocumentId))
157
+ const additions = selections.filter((s) => !existing.has(s.targetDocumentId))
158
+ if (additions.length === 0) return
159
+ const records = additions.filter((s) => s.record != null)
160
+ if (records.length > 0) {
161
+ setPickedRecords((prev) => {
162
+ const next = { ...prev }
163
+ for (const s of records) next[s.targetDocumentId] = s.record!
164
+ return next
165
+ })
161
166
  }
162
167
  setFieldValue(fieldPath, [
163
168
  ...current,
164
- {
165
- targetDocumentId: selection.targetDocumentId,
166
- targetCollectionId: selection.targetCollectionId,
167
- },
169
+ ...additions.map((s) => ({
170
+ targetDocumentId: s.targetDocumentId,
171
+ targetCollectionId: s.targetCollectionId,
172
+ })),
168
173
  ])
169
174
  }
170
175
 
@@ -260,11 +265,13 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
260
265
  </div>
261
266
 
262
267
  <RelationPicker
268
+ multiple
263
269
  targetCollectionPath={field.targetCollection}
264
270
  targetDefinition={targetDef}
265
271
  displayField={field.displayField}
266
272
  isOpen={pickerOpen}
267
- onSelect={handleAdd}
273
+ excludeIds={items.map((v) => v.targetDocumentId)}
274
+ onSelectMany={handleAddMany}
268
275
  onDismiss={() => setPickerOpen(false)}
269
276
  />
270
277
  </>
@@ -108,6 +108,55 @@
108
108
  border-left: 2px solid var(--primary-200);
109
109
  }
110
110
 
111
+ /* Multi-select mode: rows lay out check indicator + content + trailing hint. */
112
+ .row-multi,
113
+ :global(.byline-field-relation-picker-row-multi) {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 0.625rem;
117
+ }
118
+
119
+ .check,
120
+ :global(.byline-field-relation-picker-check) {
121
+ flex: none;
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ width: 16px;
126
+ height: 16px;
127
+ border: 1px solid var(--gray-400);
128
+ border-radius: 4px;
129
+ color: transparent;
130
+ }
131
+
132
+ .check-checked,
133
+ :global(.byline-field-relation-picker-check-checked) {
134
+ background-color: var(--primary-200);
135
+ border-color: var(--primary-200);
136
+ color: #fff;
137
+ }
138
+
139
+ /* Already-added rows: inert, dimmed, no hover affordance. */
140
+ .row-added,
141
+ :global(.byline-field-relation-picker-row-added) {
142
+ opacity: 0.55;
143
+ cursor: default;
144
+ }
145
+
146
+ .row-added:hover,
147
+ :global(.byline-field-relation-picker-row-added):hover {
148
+ background-color: transparent;
149
+ }
150
+
151
+ .added-hint,
152
+ :global(.byline-field-relation-picker-added-hint) {
153
+ margin-left: auto;
154
+ flex: none;
155
+ color: var(--gray-500);
156
+ font-size: var(--font-size-xs);
157
+ white-space: nowrap;
158
+ }
159
+
111
160
  .row-cells,
112
161
  :global(.byline-field-relation-picker-row-cells) {
113
162
  display: flex;
@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from 'react'
11
11
  import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
12
12
  import { getCollectionAdminConfig, resolveItemViewColumns } from '@byline/core'
13
13
  import { useTranslation } from '@byline/i18n/react'
14
- import { Button, LoaderRing, Modal, Search } from '@byline/ui/react'
14
+ import { Button, CheckIcon, LoaderRing, Modal, Search } from '@byline/ui/react'
15
15
  import cx from 'classnames'
16
16
 
17
17
  import { useBylineFieldServices } from '../field-services-context'
@@ -39,7 +39,21 @@ import styles from './relation-picker.module.css'
39
39
  *
40
40
  * Paths 2–4 render a single-line label (primary) + `path` (secondary).
41
41
  */
42
- interface RelationPickerProps {
42
+ /**
43
+ * One confirmed pick. `record` is the raw document the picker row rendered —
44
+ * the caller can use it to show the selected value in its own tile without a
45
+ * refetch. The fields available on `record` are whatever `resolveSelectFields`
46
+ * asked the listing endpoint for (picker columns + `useAsTitle` +
47
+ * `displayField`), so any display surface downstream of the picker that also
48
+ * renders from those same columns will find the data it needs.
49
+ */
50
+ export interface RelationPickerSelection {
51
+ targetDocumentId: string
52
+ targetCollectionId: string
53
+ record?: Record<string, any>
54
+ }
55
+
56
+ interface RelationPickerBaseProps {
43
57
  /** The target collection path (e.g. `'media'`). */
44
58
  targetCollectionPath: string
45
59
  /** The target collection definition (used for labels + displayField fallback). */
@@ -58,25 +72,38 @@ interface RelationPickerProps {
58
72
  extraSelectFields?: string[]
59
73
  /** Modal open/close state. */
60
74
  isOpen: boolean
61
- /**
62
- * Called with the picked selection when the user confirms.
63
- *
64
- * `record` is the raw document the picker row rendered — the caller can
65
- * use it to show the selected value in its own tile without a refetch.
66
- * The fields available on `record` are whatever `resolveSelectFields`
67
- * asked the listing endpoint for (picker columns + `useAsTitle` +
68
- * `displayField`), so any display surface downstream of the picker that
69
- * also renders from those same columns will find the data it needs.
70
- */
71
- onSelect: (selection: {
72
- targetDocumentId: string
73
- targetCollectionId: string
74
- record?: Record<string, any>
75
- }) => void
76
75
  /** Called when the user dismisses the modal. */
77
76
  onDismiss: () => void
78
77
  }
79
78
 
79
+ interface RelationPickerSingleProps extends RelationPickerBaseProps {
80
+ /** Single-select (default): clicking a row selects it; confirm returns one pick. */
81
+ multiple?: false
82
+ /** Called with the picked selection when the user confirms. */
83
+ onSelect: (selection: RelationPickerSelection) => void
84
+ onSelectMany?: never
85
+ excludeIds?: never
86
+ }
87
+
88
+ interface RelationPickerMultiProps extends RelationPickerBaseProps {
89
+ /**
90
+ * Multi-select mode (`hasMany` widgets): rows toggle a check state and the
91
+ * confirm action returns every selection in pick order — several picks in
92
+ * one trip instead of reopening the modal per item.
93
+ */
94
+ multiple: true
95
+ /** Called with the full selection set (pick order) when the user confirms. */
96
+ onSelectMany: (selections: RelationPickerSelection[]) => void
97
+ onSelect?: never
98
+ /**
99
+ * Target ids already present on the caller's value. Rendered as disabled
100
+ * "already added" rows so the same target can't be picked twice.
101
+ */
102
+ excludeIds?: string[]
103
+ }
104
+
105
+ type RelationPickerProps = RelationPickerSingleProps | RelationPickerMultiProps
106
+
80
107
  const PAGE_SIZE = 15
81
108
 
82
109
  export const RelationPicker = ({
@@ -85,13 +112,22 @@ export const RelationPicker = ({
85
112
  displayField,
86
113
  extraSelectFields,
87
114
  isOpen,
115
+ multiple = false,
116
+ excludeIds,
88
117
  onSelect,
118
+ onSelectMany,
89
119
  onDismiss,
90
120
  }: RelationPickerProps) => {
91
121
  const [query, setQuery] = useState<string>('')
92
122
  const [page, setPage] = useState<number>(1)
93
123
  const { t } = useTranslation('byline-admin')
94
124
  const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
125
+ // Multi-select state. A Map keyed by target id preserves pick order (the
126
+ // confirmed batch appends in that order) and carries each row's record so
127
+ // picks survive page/search changes that swap out `documents`.
128
+ const [selectedMap, setSelectedMap] = useState<Map<string, Record<string, any> | undefined>>(
129
+ () => new Map()
130
+ )
95
131
  const [loading, setLoading] = useState<boolean>(false)
96
132
  const [error, setError] = useState<string | null>(null)
97
133
  const [documents, setDocuments] = useState<any[]>([])
@@ -110,6 +146,7 @@ export const RelationPicker = ({
110
146
  setQuery('')
111
147
  setPage(1)
112
148
  setSelectedDocumentId(null)
149
+ setSelectedMap(new Map())
113
150
  setError(null)
114
151
  }
115
152
  }, [isOpen])
@@ -174,7 +211,7 @@ export const RelationPicker = ({
174
211
  null
175
212
 
176
213
  const handleSelect = useCallback(() => {
177
- if (!selectedDocumentId || !collectionId) return
214
+ if (!selectedDocumentId || !collectionId || !onSelect) return
178
215
  const record = documents.find((d) => d?.id === selectedDocumentId)
179
216
  onSelect({
180
217
  targetDocumentId: selectedDocumentId,
@@ -183,6 +220,30 @@ export const RelationPicker = ({
183
220
  })
184
221
  }, [selectedDocumentId, collectionId, documents, onSelect])
185
222
 
223
+ const handleSelectMany = useCallback(() => {
224
+ if (selectedMap.size === 0 || !collectionId || !onSelectMany) return
225
+ onSelectMany(
226
+ Array.from(selectedMap, ([targetDocumentId, record]) => ({
227
+ targetDocumentId,
228
+ targetCollectionId: collectionId,
229
+ record,
230
+ }))
231
+ )
232
+ }, [selectedMap, collectionId, onSelectMany])
233
+
234
+ const toggleSelected = useCallback((doc: Record<string, any>) => {
235
+ const id = doc.id as string
236
+ setSelectedMap((prev) => {
237
+ const next = new Map(prev)
238
+ if (next.has(id)) {
239
+ next.delete(id)
240
+ } else {
241
+ next.set(id, doc)
242
+ }
243
+ return next
244
+ })
245
+ }, [])
246
+
186
247
  const title = t('fields.relation.selectPickerTitle', {
187
248
  label: targetDefinition?.labels.singular ?? targetCollectionPath,
188
249
  })
@@ -228,21 +289,55 @@ export const RelationPicker = ({
228
289
  <ul className={cx('byline-field-relation-picker-rows', styles.rows)}>
229
290
  {documents.map((doc) => {
230
291
  const id = doc.id as string
231
- const selected = selectedDocumentId === id
292
+ const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id
293
+ const excluded = multiple && (excludeIds?.includes(id) ?? false)
232
294
  return (
233
295
  <li key={id}>
234
296
  <button
235
297
  type="button"
298
+ disabled={excluded}
299
+ aria-pressed={multiple ? selected : undefined}
300
+ title={excluded ? t('fields.relation.picker.alreadyAdded') : undefined}
236
301
  className={cx(
237
302
  'byline-field-relation-picker-row-button',
238
303
  styles['row-button'],
304
+ multiple && [
305
+ 'byline-field-relation-picker-row-multi',
306
+ styles['row-multi'],
307
+ ],
239
308
  selected && [
240
309
  'byline-field-relation-picker-row-selected',
241
310
  styles['row-selected'],
311
+ ],
312
+ excluded && [
313
+ 'byline-field-relation-picker-row-added',
314
+ styles['row-added'],
242
315
  ]
243
316
  )}
244
- onClick={() => setSelectedDocumentId(id)}
317
+ onClick={() => {
318
+ if (excluded) return
319
+ if (multiple) {
320
+ toggleSelected(doc)
321
+ } else {
322
+ setSelectedDocumentId(id)
323
+ }
324
+ }}
245
325
  >
326
+ {multiple && (
327
+ <span
328
+ aria-hidden="true"
329
+ className={cx(
330
+ 'byline-field-relation-picker-check',
331
+ styles.check,
332
+ (selected || excluded) && [
333
+ 'byline-field-relation-picker-check-checked',
334
+ styles['check-checked'],
335
+ ]
336
+ )}
337
+ >
338
+ {(selected || excluded) && <CheckIcon width="12px" height="12px" />}
339
+ </span>
340
+ )}
246
341
  {pickerColumns && pickerColumns.length > 0 ? (
247
342
  <div
248
343
  className={cx(
@@ -281,6 +376,16 @@ export const RelationPicker = ({
281
376
  )}
282
377
  </div>
283
378
  )}
379
+ {excluded && (
380
+ <span
381
+ className={cx(
382
+ 'byline-field-relation-picker-added-hint',
383
+ styles['added-hint']
384
+ )}
385
+ >
386
+ {t('fields.relation.picker.alreadyAdded')}
387
+ </span>
388
+ )}
284
389
  </button>
285
390
  </li>
286
391
  )
@@ -331,10 +436,12 @@ export const RelationPicker = ({
331
436
  className={cx('byline-field-relation-picker-action', styles.action)}
332
437
  intent="primary"
333
438
  type="button"
334
- disabled={!selectedDocumentId}
335
- onClick={handleSelect}
439
+ disabled={multiple ? selectedMap.size === 0 : !selectedDocumentId}
440
+ onClick={multiple ? handleSelectMany : handleSelect}
336
441
  >
337
- {t('common.actions.select')}
442
+ {multiple
443
+ ? t('fields.relation.picker.addSelected', { count: selectedMap.size })
444
+ : t('common.actions.select')}
338
445
  </Button>
339
446
  </Modal.Actions>
340
447
  </Modal.Container>
@@ -14,7 +14,7 @@ import { reconcileLocaleState } from './available-locales-reconcile.js'
14
14
  * The widget's reconciliation is expressed purely as Checkbox intent +
15
15
  * disabled state — no per-row text. `reconcileLocaleState` is the pure heart
16
16
  * of that mapping; the four cells below are the full truth table from
17
- * docs/I18N.md.
17
+ * docs/07-internationalization/index.md.
18
18
  */
19
19
  describe('reconcileLocaleState', () => {
20
20
  it('ledger-complete + advertised → green, enabled (advertised & complete)', () => {
@@ -64,7 +64,7 @@ const sameLocaleSet = (a: string[], b: string[]): boolean => {
64
64
  * Save button. `content` mints a new version (normal workflow). `direct-write`
65
65
  * is an immediate, non-versioned write of the document-grain system fields
66
66
  * (path / advertised locales) that does NOT reset workflow status. `both` does
67
- * each through its own write path. See docs/I18N.md.
67
+ * each through its own write path. See docs/07-internationalization/index.md.
68
68
  */
69
69
  export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both'
70
70
 
@@ -103,7 +103,7 @@ interface FormContextType {
103
103
  /**
104
104
  * Partition the current dirty state into content vs. system-field (path /
105
105
  * advertised-locales) writes so the Save button can branch. See
106
- * docs/I18N.md.
106
+ * docs/07-internationalization/index.md.
107
107
  */
108
108
  getDirtyBreakdown: () => DirtyBreakdown
109
109
  subscribeField: (name: string, listener: FieldListener) => () => void
@@ -349,7 +349,7 @@ export const FormProvider = ({
349
349
  // button can route each piece correctly: content → versioned write; the
350
350
  // document-grain system fields (path / advertised locales) → immediate,
351
351
  // non-versioned direct write that leaves workflow status untouched.
352
- // See docs/I18N.md.
352
+ // See docs/07-internationalization/index.md.
353
353
  const getDirtyBreakdown = useCallback((): DirtyBreakdown => {
354
354
  const keys = dirtyFields.current
355
355
  const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY)
@@ -54,7 +54,7 @@ export const UnsavedChangesModal = ({ onClose }: { onClose: () => void }) => {
54
54
  * Confirms an immediate, non-versioned write of the document-grain system
55
55
  * fields (path / advertised locales) — which does NOT reset workflow status.
56
56
  * When content is also dirty, the copy reassures that content edits still
57
- * follow the normal revision + publish workflow. See docs/I18N.md.
57
+ * follow the normal revision + publish workflow. See docs/07-internationalization/index.md.
58
58
  */
59
59
  export const SystemFieldsConfirmModal = ({
60
60
  contentDirty,
@@ -56,7 +56,7 @@ export interface PublishedVersionInfo {
56
56
  * patches) alongside the document-grain system fields (path / advertised
57
57
  * locales) and per-bucket dirty flags so the host can route each piece to the
58
58
  * right write path — versioned for content, immediate/non-versioned for the
59
- * system fields. See docs/I18N.md.
59
+ * system fields. See docs/07-internationalization/index.md.
60
60
  */
61
61
  export interface SystemFieldsSubmitPayload {
62
62
  // biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
@@ -134,7 +134,7 @@ export interface FormRendererProps {
134
134
  * Opts the document-tree placement widget into the sidebar (above the
135
135
  * available-locales widget). Sourced from `CollectionDefinition.tree` by the
136
136
  * caller. Renders only in edit mode (placement needs a persisted document)
137
- * and only when the host wires the tree services. See docs/DOCUMENT-TREE.md.
137
+ * and only when the host wires the tree services. See docs/04-collections/03-document-trees.md.
138
138
  */
139
139
  tree?: boolean
140
140
  headingLabel?: string
@@ -235,7 +235,7 @@ const FormContent = ({
235
235
  const [showUnsavedModal, setShowUnsavedModal] = useState(false)
236
236
  // Holds the pending Save payload while the editor confirms an immediate,
237
237
  // non-versioned system-field write (path / advertised locales). Non-null
238
- // means the confirmation modal is open. See docs/I18N.md.
238
+ // means the confirmation modal is open. See docs/07-internationalization/index.md.
239
239
  const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
240
240
  useState<SystemFieldsSubmitPayload | null>(null)
241
241
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
@@ -532,7 +532,7 @@ const FormContent = ({
532
532
  {/* Source-locale anchor indicator removed pending heading-layout work.
533
533
  To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
534
534
  here from `initialData.sourceLocale` (mismatch-only is the intended
535
- end state). See docs/I18N.md. */}
535
+ end state). See docs/07-internationalization/index.md. */}
536
536
  {headerSlot}
537
537
  </div>
538
538
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
@@ -30,7 +30,7 @@ export interface TreePlacementWidgetProps {
30
30
 
31
31
  /**
32
32
  * Sidebar widget for placing the current document within its collection's
33
- * single-parent document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md).
33
+ * single-parent document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md).
34
34
  *
35
35
  * The tree is document-grain and **unversioned**, so changes here write
36
36
  * immediately (independent of the form's content save). The editor picks a
@@ -16,7 +16,7 @@ import { assertAdminActor, requireAdminActor } from './assert-admin-actor.js'
16
16
  * contract (validate → authorise → invoke → shape) into a single
17
17
  * declaration.
18
18
  *
19
- * Implements Phase 1 of `docs/CORE-COMPOSITION.md`. Today's scope is
19
+ * Implements Phase 1 of `docs/03-architecture/02-core-composition.md`. Today's scope is
20
20
  * `@byline/admin`-internal: it gates against admin actor identity using
21
21
  * the existing `assertAdminActor` / `requireAdminActor` helpers, which
22
22
  * inherit the super-admin bypass from `AdminAuth.assertAbility`.
@@ -9,7 +9,7 @@
9
9
  import type { AbilityRegistry } from '@byline/auth'
10
10
 
11
11
  /**
12
- * Ability keys for the admin-activity module (docs/AUDIT.md — Workstream 4).
12
+ * Ability keys for the admin-activity module (docs/06-auth-and-security/02-auditability.md — Workstream 4).
13
13
  *
14
14
  * `read` gates the system-wide activity area — the `/admin/activity` report
15
15
  * over the version-stream + audit-log union. It is deliberately a **separate**
@@ -8,7 +8,7 @@
8
8
 
9
9
  /**
10
10
  * `@byline/admin/admin-activity` — the system-wide activity area
11
- * (docs/AUDIT.md — Workstream 4).
11
+ * (docs/06-auth-and-security/02-auditability.md — Workstream 4).
12
12
  *
13
13
  * Unlike the other admin modules this one owns no table and no AdminStore
14
14
  * repository: the activity feed is a read over the document db adapter's
@@ -11,7 +11,7 @@ import type { AbilityRegistry } from '@byline/auth'
11
11
  /**
12
12
  * Ability keys for the admin-permissions module.
13
13
  *
14
- * `read` gates the inspector view (see docs/AUTHN-AUTHZ.md).
14
+ * `read` gates the inspector view (see docs/06-auth-and-security/01-authn-authz.md).
15
15
  * `update` will gate the per-role ability editor mounted on the
16
16
  * admin-roles role detail page — declared here so the role editor can
17
17
  * assert against it once that surface lands. The per-role editor shares
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  /**
12
- * Read-only abilities inspector — see docs/AUTHN-AUTHZ.md.
12
+ * Read-only abilities inspector — see docs/06-auth-and-security/01-authn-authz.md.
13
13
  *
14
14
  * Top level: a collapsible group per ability source (collections.docs,
15
15
  * admin.users, etc.), each containing the abilities that group
@@ -128,7 +128,7 @@ export interface AdminUsersRepository {
128
128
  getById(id: string): Promise<AdminUserRow | null>
129
129
  /**
130
130
  * Bulk lookup for audit actor-label resolution (the `actors`
131
- * map on admin document reads — see docs/AUDIT.md, Workstream 1). Ids
131
+ * map on admin document reads — see docs/06-auth-and-security/02-auditability.md, Workstream 1). Ids
132
132
  * with no matching row are simply absent from the result; callers
133
133
  * render a tombstone label for them.
134
134
  */
@@ -27,7 +27,7 @@ export interface SourceLocaleBadgeProps {
27
27
  * NOTE: currently rendered for *every* document so the anchor is visible during
28
28
  * development. The intended end state is to show it only when `locale` differs
29
29
  * from the system's current default content locale (a normal single-default
30
- * install then shows nothing). See docs/I18N.md.
30
+ * install then shows nothing). See docs/07-internationalization/index.md.
31
31
  *
32
32
  * Stable override handle: `.byline-source-locale-badge`.
33
33
  */