@jini-ai/admin 0.4.12 → 0.6.10

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 (151) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +76 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +23 -14
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. package/dist/media/styles.js +0 -5
@@ -16,24 +16,36 @@ export function useAgentPluginsPage(props, _optional = {}) {
16
16
  const permissions = props.description.visible ? props.description.grants : [], key = permissions.join('\0');
17
17
  const { controller, snapshot } = useController({ create: () => createAgentPluginsController({ api: agentPluginsApi, permissions }), dependencies: [agentPluginsApi, key] }, { start: ({ controller }) => { void controller.load({}); } });
18
18
  const [localTab, setLocalTab] = useState('installed'), uninstallNoteId = useId();
19
- const visible = props.description.visible ? props.description.tabs.filter(t => t.visible) : [];
20
- const active = visible.find(t => t.id.endsWith(`.${props.requestedTab ?? localTab}`)) ?? visible[0];
21
- const activeId = active?.id.slice(active.id.lastIndexOf('.') + 1) ?? 'installed';
22
- const pending = snapshot?.pendingDisable, confirming = !!pending && !!snapshot?.busyIds.includes(pending.pluginId);
23
- const confirmation = pending ? agentPluginDisableCopy(pending) : null;
24
- const inspected = snapshot?.plugins?.find(p => p.pluginId === snapshot.inspectedId);
19
+ const { visible, active, activeId } = pageTabs({ description: props.description, tab: props.requestedTab ?? localTab });
25
20
  return {
26
21
  denied: !props.description.visible || !permissions.includes(AGENT_PLUGINS_READ),
27
22
  scope: { controller, snapshot, permissions, uninstallNoteId },
28
23
  activeId, ActiveTab: active ? props.tabs[activeId] : undefined, params: active?.params ?? {}, permissions,
29
- items: visible.map(t => ({ id: t.id.slice(t.id.lastIndexOf('.') + 1), label: t.label, content: null })),
24
+ items: visible.map(t => ({ id: tabSuffix(t.id), label: t.label, content: null })),
30
25
  onValueChange({ value }, _optional = {}) { setLocalTab(value); props.onTabChange?.({ tab: value }); },
31
- inspector: inspected ? { pluginId: inspected.pluginId, name: humanizeAgentPluginId({ pluginId: inspected.pluginId }) } : null,
26
+ ...pageDialogs({ snapshot }),
32
27
  closeInspector(_required, _optional = {}) { controller?.closeInspector({}); },
33
- confirmation, confirming,
34
- async confirm(_required, _optional = {}) { if (!await controller?.confirmDisable({}))
35
- throw new Error('Activation was not updated'); },
28
+ async confirm(_required, _optional = {}) {
29
+ const pluginId = snapshot?.pendingDisable?.pluginId ?? '(none pending)';
30
+ if (!await controller?.confirmDisable({}))
31
+ throw new Error(`Activation was not updated for agent plugin ${pluginId}: the disable was refused or failed; retry, or reload the list if it changed`);
32
+ },
36
33
  cancel(_required, _optional = {}) { controller?.cancelDisable({}); },
37
34
  };
38
35
  }
36
+ /** A tab id is namespaced (`agentPlugins.installed`); the page addresses tabs by the last segment. */
37
+ const tabSuffix = (id) => id.slice(id.lastIndexOf('.') + 1);
38
+ function pageTabs({ description, tab }, _optional = {}) {
39
+ const visible = description.visible ? description.tabs.filter(t => t.visible) : [];
40
+ const active = visible.find(t => t.id.endsWith(`.${tab}`)) ?? visible[0];
41
+ return { visible, active, activeId: active ? tabSuffix(active.id) : 'installed' };
42
+ }
43
+ function pageDialogs({ snapshot }, _optional = {}) {
44
+ const pending = snapshot?.pendingDisable, confirming = !!pending && !!snapshot?.busyIds.includes(pending.pluginId);
45
+ const inspected = snapshot?.plugins?.find(p => p.pluginId === snapshot.inspectedId);
46
+ return {
47
+ inspector: inspected ? { pluginId: inspected.pluginId, name: humanizeAgentPluginId({ pluginId: inspected.pluginId }) } : null,
48
+ confirmation: pending ? agentPluginDisableCopy(pending) : null, confirming,
49
+ };
50
+ }
39
51
  //# sourceMappingURL=AgentPluginsPage.hooks.js.map
@@ -52,6 +52,45 @@ export function navigate({ routePath, window }, options = {}) {
52
52
  window.history.pushState(null, '', url);
53
53
  window.dispatchEvent(new Event(NAVIGATION_EVENT));
54
54
  }
55
+ /**
56
+ * Whether the browser should leave this click to us.
57
+ *
58
+ * Anything but an unmodified primary click is the browser's to handle: cmd/ctrl-click opens a
59
+ * tab, shift-click a window, alt-click downloads.
60
+ */
61
+ function isPlainPrimaryClick({ event }) {
62
+ if (event.defaultPrevented || event.button !== 0)
63
+ return false;
64
+ return !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
65
+ }
66
+ /** The clicked `<a>` when it asks for an ordinary in-place navigation to a non-fragment href, else `null`. */
67
+ function findInPlaceAnchor({ event }) {
68
+ const anchor = event.target?.closest?.('a');
69
+ if (!anchor || anchor.hasAttribute('download'))
70
+ return null;
71
+ // An explicit target (`_blank`, a frame name) is an explicit request not to navigate in place.
72
+ const target = anchor.getAttribute('target');
73
+ if (target && target !== '_self')
74
+ return null;
75
+ const href = anchor.getAttribute('href');
76
+ if (!href || href.startsWith('#'))
77
+ return null;
78
+ return anchor;
79
+ }
80
+ /** The route path (with query and hash) `anchor` points at, or `null` when it leaves the same-origin `base`. */
81
+ function internalRoutePath({ anchor, window, base }) {
82
+ // `anchor.href` is the resolved absolute form, which is what makes relative hrefs and
83
+ // cross-origin links both fall out correctly.
84
+ const url = new URL(anchor.href, window.location.href);
85
+ if (url.origin !== window.location.origin)
86
+ return null;
87
+ if (url.pathname !== base && !url.pathname.startsWith(`${base}/`))
88
+ return null;
89
+ // `url.hash` is carried through: an in-page fragment (`/admin/settings#seo-defaults`) is not a
90
+ // routing concern, but dropping it silently loses the anchor. It never reaches the matcher —
91
+ // the route snapshot is path + query only — so preserving it cannot affect routing.
92
+ return `${currentRoutePath({ pathname: url.pathname }, { base })}${url.search}${url.hash}`;
93
+ }
55
94
  /**
56
95
  * Turns clicks on internal `<a href="/admin/...">` links into SPA navigations.
57
96
  *
@@ -65,38 +104,16 @@ export function navigate({ routePath, window }, options = {}) {
65
104
  */
66
105
  export function installInternalLinkInterceptor({ window, document }, { base = DEFAULT_ADMIN_BASE } = {}) {
67
106
  const onClick = (event) => {
68
- // Anything but an unmodified primary click is the browser's to handle: cmd/ctrl-click opens a
69
- // tab, shift-click a window, alt-click downloads.
70
- if (event.defaultPrevented)
107
+ if (!isPlainPrimaryClick({ event }))
71
108
  return;
72
- if (event.button !== 0)
73
- return;
74
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
75
- return;
76
- const anchor = event.target?.closest?.('a');
109
+ const anchor = findInPlaceAnchor({ event });
77
110
  if (!anchor)
78
111
  return;
79
- if (anchor.hasAttribute('download'))
80
- return;
81
- // An explicit target (`_blank`, a frame name) is an explicit request not to navigate in place.
82
- const target = anchor.getAttribute('target');
83
- if (target && target !== '_self')
84
- return;
85
- const href = anchor.getAttribute('href');
86
- if (!href || href.startsWith('#'))
87
- return;
88
- // `anchor.href` is the resolved absolute form, which is what makes relative hrefs and
89
- // cross-origin links both fall out correctly.
90
- const url = new URL(anchor.href, window.location.href);
91
- if (url.origin !== window.location.origin)
92
- return;
93
- if (url.pathname !== base && !url.pathname.startsWith(`${base}/`))
112
+ const routePath = internalRoutePath({ anchor, window, base });
113
+ if (routePath === null)
94
114
  return;
95
115
  event.preventDefault();
96
- // `url.hash` is carried through: an in-page fragment (`/admin/settings#seo-defaults`) is not a
97
- // routing concern, but dropping it silently loses the anchor. It never reaches the matcher —
98
- // the route snapshot is path + query only — so preserving it cannot affect routing.
99
- navigate({ window, routePath: `${currentRoutePath({ pathname: url.pathname }, { base })}${url.search}${url.hash}` }, { base });
116
+ navigate({ window, routePath }, { base });
100
117
  };
101
118
  document.addEventListener('click', onClick);
102
119
  return () => document.removeEventListener('click', onClick);
@@ -5,6 +5,24 @@ const DEFAULT_SETTINGS = {
5
5
  enabled: true, requireModeration: true, maxDepth: 5, closeAfterDays: null,
6
6
  spamAutoRejectScore: 0.5, maxPerIpPerHour: 20,
7
7
  };
8
+ const MODERATION_STATUSES = { approve: 'approved', spam: 'spam', trash: 'trash', restore: 'approved' };
9
+ function isNumberIn({ value, min, max }, { integer = false } = {}) {
10
+ return Number.isFinite(value) && (!integer || Number.isInteger(value)) && value >= min && value <= max;
11
+ }
12
+ /** Each settings field with its fixture-level shape check and the expectation a refusal names. */
13
+ const SETTINGS_RULES = [
14
+ ['enabled', value => typeof value === 'boolean', 'a boolean'],
15
+ ['requireModeration', value => typeof value === 'boolean', 'a boolean'],
16
+ ['maxDepth', value => isNumberIn({ value, min: 0, max: 20 }, { integer: true }), 'an integer from 0 to 20'],
17
+ ['closeAfterDays', value => value === null || isNumberIn({ value, min: 0, max: Infinity }, { integer: true }), 'null or a non-negative integer'],
18
+ ['spamAutoRejectScore', value => isNumberIn({ value, min: 0, max: 1 }), 'a number from 0 to 1'],
19
+ ['maxPerIpPerHour', value => isNumberIn({ value, min: 1, max: 1000 }, { integer: true }), 'an integer from 1 to 1000'],
20
+ ];
21
+ /** @returns A refusal reason naming the first invalid field, or null when every field is valid. */
22
+ function describeInvalidSettings({ settings }, _optional = {}) {
23
+ const rule = SETTINGS_RULES.find(([field, isValid]) => !isValid(settings[field]));
24
+ return rule ? `${rule[0]} must be ${rule[2]}` : null;
25
+ }
8
26
  /** Isolated admin contract fixture over the existing CMS memory repository.
9
27
  * Keyset paging, optimistic concurrency and deletion belong to that repository, not this screen.
10
28
  * Settings here are an ephemeral read-your-writes fixture; production persistence stays in the
@@ -28,31 +46,31 @@ export function createMemoryCommentsApi({ items = [], settings = DEFAULT_SETTING
28
46
  return Object.freeze({ items: Object.freeze(page.items.map(item => Object.freeze({ ...item }))), nextCursor: page.nextCursor });
29
47
  },
30
48
  async moderateComment({ commentId, action, expectedVersion }, { note } = {}) {
31
- const statuses = { approve: 'approved', spam: 'spam', trash: 'trash', restore: 'approved' };
32
- const toStatus = Object.hasOwn(statuses, action) ? statuses[action] : undefined;
49
+ const toStatus = Object.hasOwn(MODERATION_STATUSES, action) ? MODERATION_STATUSES[action] : undefined;
33
50
  if (!toStatus)
34
- failure('Invalid moderation action', 400);
51
+ failure(`Invalid moderation action "${action}": use approve, spam, trash or restore`, 400);
35
52
  const result = await repo.applyModeration({ workspaceId, id: commentId, expectedVersion,
36
53
  action: action === 'spam' ? 'mark_spam' : action, toStatus,
37
54
  actorPrincipalId: '__admin-memory__', note: note ?? null, at: now() });
38
- if (!result.ok)
39
- failure(result.reason === 'conflict' ? 'Comment changed' : 'Comment was not found', result.reason === 'conflict' ? 409 : 404, result.reason === 'conflict' ? { currentVersion: result.currentVersion } : undefined);
55
+ if (result.ok)
56
+ return;
57
+ if (result.reason === 'conflict') {
58
+ failure(`Comment ${commentId} changed since it was loaded (current version ${result.currentVersion}); reload the queue and retry`, 409, { currentVersion: result.currentVersion });
59
+ }
60
+ failure(`Comment ${commentId} was not found in this workspace`, 404);
40
61
  },
41
62
  async purgeComment({ commentId }, { note } = {}) {
42
63
  const result = await repo.purge({ workspaceId, id: commentId, actorPrincipalId: '__admin-memory__', note: note ?? null, at: now() });
43
64
  if (!result.ok)
44
- failure('Comment was not found', 404);
65
+ failure(`Comment ${commentId} was not found in this workspace`, 404);
45
66
  },
46
67
  async getCommentsSettings(_required, _options = {}) { return Object.freeze({ ...storedSettings }); },
47
68
  async putCommentsSettings(_required, patch = {}) {
48
69
  // Only fixture-level shape checks: the durable ledger remains the production validator.
49
70
  const next = { ...storedSettings, ...patch };
50
- if (typeof next.enabled !== 'boolean' || typeof next.requireModeration !== 'boolean'
51
- || !Number.isInteger(next.maxDepth) || next.maxDepth < 0 || next.maxDepth > 20
52
- || (next.closeAfterDays !== null && (!Number.isInteger(next.closeAfterDays) || next.closeAfterDays < 0))
53
- || !Number.isFinite(next.spamAutoRejectScore) || next.spamAutoRejectScore < 0 || next.spamAutoRejectScore > 1
54
- || !Number.isInteger(next.maxPerIpPerHour) || next.maxPerIpPerHour < 1 || next.maxPerIpPerHour > 1000)
55
- failure('Invalid Comments settings', 400);
71
+ const invalid = describeInvalidSettings({ settings: next });
72
+ if (invalid)
73
+ failure(`Invalid Comments settings: ${invalid}`, 400);
56
74
  storedSettings = Object.freeze(next);
57
75
  return Object.freeze({ ...storedSettings });
58
76
  },
@@ -1,24 +1,24 @@
1
- /** Destructive checklist for an isolated fixture containing at least two pending comments.
2
- * Uses only the core port, works against HTTP and memory, and throws at the first failed check.
3
- * @returns Named passed checks. The first seeded comment is purged; never use production data.
4
- */
5
- export async function runCommentsApiConformance({ api }, _optional = {}) {
6
- const checks = [];
7
- function assert(condition, name) {
8
- if (!condition)
9
- throw new Error(`Comments API conformance: ${name}`);
10
- checks.push(name);
1
+ async function settle({ run }, _optional = {}) {
2
+ try {
3
+ await run();
4
+ return { threw: false };
11
5
  }
12
- async function rejects(run, name) {
13
- let rejected = false;
14
- try {
15
- await run();
16
- }
17
- catch {
18
- rejected = true;
19
- }
20
- assert(rejected, name);
6
+ catch (error) {
7
+ return { threw: true, error };
21
8
  }
9
+ }
10
+ async function rejects({ check, run, name }, _optional = {}) {
11
+ check.assert((await settle({ run })).threw, name);
12
+ }
13
+ function hasConflictVersion({ settled, version }, _optional = {}) {
14
+ if (!settled.threw)
15
+ return false;
16
+ const e = settled.error;
17
+ return e instanceof Error && 'status' in e && e.status === 409 && 'body' in e
18
+ && e.body?.currentVersion === version;
19
+ }
20
+ async function checkPaging({ check }, _optional = {}) {
21
+ const { api, assert } = check;
22
22
  const first = await api.listCommentsQueue({}, { status: 'pending', limit: 1 });
23
23
  const comment = first.items[0];
24
24
  if (!comment || !first.nextCursor)
@@ -26,37 +26,59 @@ export async function runCommentsApiConformance({ api }, _optional = {}) {
26
26
  assert(first.items.length === 1 && comment.status === 'pending', 'status and limit filter');
27
27
  const second = await api.listCommentsQueue({}, { status: 'pending', limit: 1, cursor: first.nextCursor });
28
28
  assert(second.items.length === 1 && second.items[0]?.id !== comment.id, 'cursor advances without repeating rows');
29
+ return { first, comment, cursor: first.nextCursor, second };
30
+ }
31
+ async function checkSettings({ check }, _optional = {}) {
32
+ const { api, assert } = check;
29
33
  const settings = await api.getCommentsSettings({});
30
34
  const updated = await api.putCommentsSettings({}, { maxDepth: settings.maxDepth + 1, closeAfterDays: null });
31
35
  assert(updated.maxDepth === settings.maxDepth + 1 && updated.closeAfterDays === null && updated.enabled === settings.enabled, 'partial settings patch preserves siblings and null');
32
36
  assert((await api.getCommentsSettings({})).maxDepth === updated.maxDepth, 'settings read your writes');
33
- await rejects(() => api.putCommentsSettings({}, { spamAutoRejectScore: 2 }), 'invalid settings patch rejects');
37
+ await rejects({ check, run: () => api.putCommentsSettings({}, { spamAutoRejectScore: 2 }), name: 'invalid settings patch rejects' });
34
38
  assert((await api.getCommentsSettings({})).spamAutoRejectScore === settings.spamAutoRejectScore, 'invalid settings patch is atomic');
39
+ return settings;
40
+ }
41
+ async function checkModerationCycle({ check, comment }, _optional = {}) {
42
+ const { api, assert } = check;
35
43
  let current = comment;
36
44
  for (const [action, status] of [['approve', 'approved'], ['spam', 'spam'], ['trash', 'trash'], ['restore', 'approved']]) {
37
45
  const reply = await api.moderateComment({ commentId: current.id, action, expectedVersion: current.version });
38
46
  assert(reply === undefined, `${action} has no response body`);
39
- const page = await api.listCommentsQueue({}, { status: status });
47
+ const page = await api.listCommentsQueue({}, { status });
40
48
  const row = page.items.find(item => item.id === current.id);
41
49
  assert(row?.status === status && row.version === current.version + 1, `${action} changes status and increments version`);
42
50
  current = row;
43
51
  }
44
- assert(comment.version === first.items[0]?.version && comment.status === 'pending', 'earlier queue snapshot is unchanged');
45
- try {
46
- await api.moderateComment({ commentId: current.id, action: 'trash', expectedVersion: comment.version });
47
- throw new Error('Stale moderation unexpectedly succeeded');
48
- }
49
- catch (e) {
50
- assert(e instanceof Error && 'status' in e && e.status === 409 && 'body' in e
51
- && e.body?.currentVersion === current.version, 'stale version rejects with status and currentVersion');
52
- }
53
- const remaining = await api.listCommentsQueue({}, { status: 'pending', limit: 1, cursor: first.nextCursor });
52
+ return current;
53
+ }
54
+ async function checkCursorAndPurge({ check, current, cursor, second }, _optional = {}) {
55
+ const { api, assert } = check;
56
+ const remaining = await api.listCommentsQueue({}, { status: 'pending', limit: 1, cursor });
54
57
  assert(remaining.items[0]?.id === second.items[0]?.id, 'cursor survives its marker leaving the status');
55
58
  await api.moderateComment({ commentId: current.id, action: 'trash', expectedVersion: current.version });
56
59
  assert(await api.purgeComment({ commentId: current.id }) === undefined, 'purge has no version guard or response body');
57
60
  assert(!(await api.listCommentsQueue({}, { status: 'trash' })).items.some(item => item.id === current.id), 'purge removes the row');
58
- await rejects(() => api.purgeComment({ commentId: current.id }), 'missing purge rejects');
59
- await rejects(() => api.moderateComment({ commentId: current.id, action: 'approve', expectedVersion: current.version }), 'missing moderation rejects');
61
+ await rejects({ check, run: () => api.purgeComment({ commentId: current.id }), name: 'missing purge rejects' });
62
+ await rejects({ check, run: () => api.moderateComment({ commentId: current.id, action: 'approve', expectedVersion: current.version }), name: 'missing moderation rejects' });
63
+ }
64
+ /** Destructive checklist for an isolated fixture containing at least two pending comments.
65
+ * Uses only the core port, works against HTTP and memory, and throws at the first failed check.
66
+ * @returns Named passed checks. The first seeded comment is purged; never use production data.
67
+ */
68
+ export async function runCommentsApiConformance({ api }, _optional = {}) {
69
+ const checks = [];
70
+ const check = { api, assert(condition, name) {
71
+ if (!condition)
72
+ throw new Error(`Comments API conformance: ${name}`);
73
+ checks.push(name);
74
+ } };
75
+ const { first, comment, cursor, second } = await checkPaging({ check });
76
+ const settings = await checkSettings({ check });
77
+ const current = await checkModerationCycle({ check, comment });
78
+ check.assert(comment.version === first.items[0]?.version && comment.status === 'pending', 'earlier queue snapshot is unchanged');
79
+ const stale = await settle({ run: () => api.moderateComment({ commentId: current.id, action: 'trash', expectedVersion: comment.version }) });
80
+ check.assert(hasConflictVersion({ settled: stale, version: current.version }), 'stale version rejects with status and currentVersion');
81
+ await checkCursorAndPurge({ check, current, cursor, second });
60
82
  await api.putCommentsSettings({}, settings);
61
83
  return Object.freeze(checks);
62
84
  }
@@ -57,6 +57,8 @@ export function createCommentQueueController({ api, permissions = [] }, { t = en
57
57
  if (store.signal.aborted)
58
58
  return;
59
59
  permissions = nextPermissions;
60
+ if (t === nextTranslator)
61
+ return;
60
62
  t = nextTranslator;
61
63
  publishPages();
62
64
  }
@@ -81,6 +83,13 @@ export function createCommentQueueController({ api, permissions = [] }, { t = en
81
83
  firstError = error;
82
84
  publishPages();
83
85
  }
86
+ /** A cursor read may only publish while it is still the newest one and the store is live. */
87
+ function isCurrentRead({ generation }, _optional = {}) {
88
+ return generation === pageGeneration && !store.signal.aborted;
89
+ }
90
+ function readMorePage({ status, cursor }, _optional = {}) {
91
+ return loadPage ? loadPage({ status, cursor }) : api.listCommentsQueue({}, { status, cursor });
92
+ }
84
93
  async function loadMore(_required = {}, _optional = {}) {
85
94
  const { nextCursor, status } = store.getSnapshot();
86
95
  if (store.signal.aborted || !nextCursor || loadingMore)
@@ -91,22 +100,22 @@ export function createCommentQueueController({ api, permissions = [] }, { t = en
91
100
  store.set({ patch: { loadingMore: true } });
92
101
  publishPages();
93
102
  try {
94
- const page = await (loadPage ? loadPage({ status, cursor: nextCursor }) : api.listCommentsQueue({}, { status, cursor: nextCursor }));
95
- if (generation !== pageGeneration || store.signal.aborted)
103
+ const page = await readMorePage({ status, cursor: nextCursor });
104
+ if (!isCurrentRead({ generation }))
96
105
  return;
97
106
  morePages = [...morePages, ...page.items];
98
107
  store.set({ patch: { nextCursor: page.nextCursor } });
99
108
  publishPages();
100
109
  }
101
110
  catch (e) {
102
- if (generation !== pageGeneration || store.signal.aborted)
111
+ if (!isCurrentRead({ generation }))
103
112
  return;
104
113
  moreError = describeApiError({ e, fallback: t('failed to load the moderation queue') });
105
114
  publishPages();
106
115
  }
107
116
  finally {
108
117
  // The reset already released a superseded call's lock; it must not unlock a newer call.
109
- if (generation === pageGeneration && !store.signal.aborted) {
118
+ if (isCurrentRead({ generation })) {
110
119
  loadingMore = false;
111
120
  store.set({ patch: { loadingMore: false } });
112
121
  }
@@ -1,8 +1,20 @@
1
- import { type Dispatch, type SetStateAction } from 'react';
1
+ import { type Dispatch, type SetStateAction, type ReactNode } from 'react';
2
2
  import type { AdminComment, AdminCommentsPort, CommentModerationAction, CommentStatus } from '../../../core/ports/comments.js';
3
3
  import { type RowActionState } from '../../rules.js';
4
4
  import type { CommentsEventsPort } from '../../ports.js';
5
5
  import { type CommentsTranslator } from '../../messages.en.js';
6
+ /** Host-owned identity; never infer tenancy or the current operator from returned rows. */
7
+ export interface CommentQueueScope {
8
+ readonly workspaceId: string;
9
+ readonly principalId: string;
10
+ }
11
+ /** Supply live workspace/principal identity to wired queues, including with a singleton API.
12
+ * Update scope at the authentication boundary before rendering the next principal's children.
13
+ */
14
+ export declare function CommentQueueScopeProvider({ scope, children }: {
15
+ scope: CommentQueueScope;
16
+ children: ReactNode;
17
+ }): import("react").FunctionComponentElement<import("react").ProviderProps<CommentQueueScope | null>>;
6
18
  export interface CommentQueueController {
7
19
  status: CommentStatus;
8
20
  setStatus: Dispatch<SetStateAction<CommentStatus>>;
@@ -20,9 +32,12 @@ export interface CommentQueueController {
20
32
  /** Effects attach to injected ports; cursor/row state belongs to the headless controller.
21
33
  * The shared cache guards stale page-one keys. Cursor settlement is guarded separately because
22
34
  * one fixed query key cannot represent an accumulated list of appended pages.
35
+ * All read/invalidation keys include explicit workspace/principal identity and API identity.
36
+ * A scope change hides the previous controller immediately, before effect cleanup runs.
23
37
  */
24
- export declare function useCommentQueue({ api, permissions }: {
38
+ export declare function useCommentQueue({ api, scope, permissions }: {
25
39
  api: AdminCommentsPort;
40
+ scope: CommentQueueScope;
26
41
  permissions?: readonly string[];
27
42
  }, { t, events }?: {
28
43
  t?: CommentsTranslator;
@@ -1,58 +1,107 @@
1
- import { useCallback, useEffect, useMemo, useRef } from 'react';
1
+ import { createContext, createElement, useContext, useCallback, useEffect, useMemo, useRef } from 'react';
2
2
  import { useCachedLoader, useFetchQuery, useInvalidate } from '@jini-ai/ui/fetch-query';
3
+ import { AdminConfigError } from '../../../core/module/create-admin.js';
3
4
  import { useController } from '../../../core/react/use-controller.js';
4
5
  import { createCommentQueueController } from '../../controllers/comment-queue.controller.js';
5
6
  import { KEYS, emptyRowState } from '../../rules.js';
6
7
  import { englishComments } from '../../messages.en.js';
7
8
  import { useCommentsPorts, useCommentsTranslation } from './CommentsPorts.hooks.js';
8
9
  const NO_GRANTS = [];
10
+ /** What renders before the controller's first snapshot; mirrors the controller's initial state. */
11
+ const BEFORE_FIRST_SNAPSHOT = Object.freeze({
12
+ status: 'pending', items: null, nextCursor: null, error: null, loadingMore: false, rowState: {}, pendingPurge: null,
13
+ });
14
+ const CommentQueueScopeContext = createContext(null);
15
+ /** Supply live workspace/principal identity to wired queues, including with a singleton API.
16
+ * Update scope at the authentication boundary before rendering the next principal's children.
17
+ */
18
+ export function CommentQueueScopeProvider({ scope, children }) {
19
+ return createElement(CommentQueueScopeContext.Provider, { value: scope }, children);
20
+ }
21
+ // Weak API keys preserve sharing between consumers without retaining APIs from previous sessions.
22
+ // Explicit workspace/principal keys below also isolate hosts that retain one API across sessions.
23
+ const queueScopes = new WeakMap();
24
+ let nextQueueScope = 0;
25
+ /** Allocate the API portion of a namespace; workspace/principal identity is keyed separately. */
26
+ function queueScope({ api }, _optional = {}) {
27
+ let scope = queueScopes.get(api);
28
+ if (scope === undefined) {
29
+ scope = ++nextQueueScope;
30
+ queueScopes.set(api, scope);
31
+ }
32
+ return scope;
33
+ }
34
+ /** Preserve the hook's loading defaults while no controller from the current scope is attached. */
35
+ function queueDisplay({ snapshot }, _optional = {}) {
36
+ const { status, items, nextCursor, error, loadingMore, pendingPurge } = snapshot ?? BEFORE_FIRST_SNAPSHOT;
37
+ return { status, items, nextCursor, error, loadingMore, pendingPurge };
38
+ }
39
+ /** Resolve React's value/updater forms against the attached controller's current snapshot. */
40
+ function resolveSetState({ value, current }, _optional = {}) {
41
+ return typeof value === 'function' ? value(current) : value;
42
+ }
43
+ /** Adapt public React setters/actions to the attached headless controller.
44
+ * Before the controller exists every gesture is inert and every row reads as idle.
45
+ */
46
+ function bindQueueActions({ controller }, _optional = {}) {
47
+ return {
48
+ setStatus: value => { if (controller)
49
+ controller.setStatus({ status: resolveSetState({ value, current: controller.getSnapshot().status }) }); },
50
+ loadMore: () => controller?.loadMore({}),
51
+ stateFor: id => controller?.stateFor({ id }) ?? emptyRowState(),
52
+ onModerate: async (comment, action) => { await controller?.onModerate({ comment, action }); },
53
+ setPendingPurge: value => { if (controller)
54
+ controller.setPendingPurge({ comment: resolveSetState({ value, current: controller.getSnapshot().pendingPurge }) }); },
55
+ onPurge: async () => { await controller?.onPurge({}); },
56
+ };
57
+ }
58
+ /** Cursor pages share page one's scoped cache prefix, so invalidation stays within that scope. */
59
+ function useCursorPageLoader({ api, queueRoot, status, cursor }, _optional = {}) {
60
+ const cursorKey = useMemo(() => [...queueRoot, status, 'cursor', cursor], [queueRoot, status, cursor]);
61
+ const fetchCursor = useCallback(() => api.listCommentsQueue({}, { status, cursor }), [api, status, cursor]);
62
+ const cursorLoader = useCachedLoader({ key: cursorKey, fetch: fetchCursor }, { staleTime: 0 });
63
+ return ({ status: requestedStatus, cursor: requestedCursor }) => cursorLoader.load({
64
+ key: [...queueRoot, requestedStatus, 'cursor', requestedCursor],
65
+ fetch: () => api.listCommentsQueue({}, { status: requestedStatus, cursor: requestedCursor }),
66
+ });
67
+ }
9
68
  /** Effects attach to injected ports; cursor/row state belongs to the headless controller.
10
69
  * The shared cache guards stale page-one keys. Cursor settlement is guarded separately because
11
70
  * one fixed query key cannot represent an accumulated list of appended pages.
71
+ * All read/invalidation keys include explicit workspace/principal identity and API identity.
72
+ * A scope change hides the previous controller immediately, before effect cleanup runs.
12
73
  */
13
- export function useCommentQueue({ api, permissions = NO_GRANTS }, { t = englishComments, events } = {}) {
74
+ export function useCommentQueue({ api, scope, permissions = NO_GRANTS }, { t = englishComments, events } = {}) {
14
75
  const invalidate = useInvalidate();
15
- const invalidateQueue = useCallback(() => invalidate({ key: KEYS.queueRoot }), [invalidate]);
76
+ const queueRoot = useMemo(() => [...KEYS.queueRoot, queueScope({ api }), scope.workspaceId, scope.principalId], [api, scope.workspaceId, scope.principalId]);
77
+ const invalidateQueue = useCallback(() => invalidate({ key: queueRoot }), [invalidate, queueRoot]);
16
78
  const loadPageRef = useRef(null);
17
- const { controller, snapshot } = useController({
18
- create: () => createCommentQueueController({ api, permissions }, { t, onInvalidate: invalidateQueue, loadPage: required => loadPageRef.current(required) }),
79
+ const { controller: attachedController, snapshot: attachedSnapshot } = useController({
80
+ create: () => ({ ...createCommentQueueController({ api, permissions }, { t, onInvalidate: invalidateQueue, loadPage: required => loadPageRef.current(required) }), queueRoot }),
19
81
  dependencies: [api, invalidateQueue],
20
82
  });
83
+ // Effects replace the controller after commit; hide the previous scope during that render too.
84
+ const controller = attachedController?.queueRoot === queueRoot ? attachedController : null;
85
+ const snapshot = controller ? attachedSnapshot : null;
21
86
  useEffect(() => { controller?.configure({ permissions, t }); }, [controller, permissions, t]);
22
- const status = snapshot?.status ?? 'pending';
23
- const cursor = snapshot?.nextCursor ?? '';
24
- const cursorKey = useMemo(() => [...KEYS.queue(status), 'cursor', cursor], [status, cursor]);
25
- const fetchCursor = useCallback(() => api.listCommentsQueue({}, { status, cursor }), [api, status, cursor]);
26
- const cursorLoader = useCachedLoader({ key: cursorKey, fetch: fetchCursor }, { staleTime: 0 });
87
+ const display = queueDisplay({ snapshot });
88
+ const { status } = display;
27
89
  // The gesture remains controller-owned, including its same-tick lock and generation fence.
28
90
  // Only I/O changes: cursor pages now share page one's provider and status invalidation prefix.
29
- loadPageRef.current = ({ status: requestedStatus, cursor: requestedCursor }) => cursorLoader.load({
30
- key: [...KEYS.queue(requestedStatus), 'cursor', requestedCursor],
31
- fetch: () => api.listCommentsQueue({}, { status: requestedStatus, cursor: requestedCursor }),
32
- });
33
- const firstPage = useFetchQuery({ key: KEYS.queue(status), fetch: () => api.listCommentsQueue({}, { status }) });
91
+ loadPageRef.current = useCursorPageLoader({ api, queueRoot, status, cursor: display.nextCursor ?? '' });
92
+ const firstPage = useFetchQuery({ key: [...queueRoot, status], fetch: () => api.listCommentsQueue({}, { status }) });
34
93
  useEffect(() => { controller?.settleFirstPage({ page: firstPage.data ?? null, error: firstPage.error, status }); }, [controller, firstPage.data, firstPage.error, status]);
35
94
  // Stable identity avoids resubscribing on every render. The host filters out unrelated
36
95
  // resources. Unmount removes the subscription even if another cache observer remains active.
37
96
  useEffect(() => events?.subscribe({ onRefresh: invalidateQueue }), [events, invalidateQueue]);
38
- return {
39
- status,
40
- setStatus: value => { if (controller)
41
- controller.setStatus({ status: typeof value === 'function' ? value(controller.getSnapshot().status) : value }); },
42
- items: snapshot?.items ?? null, nextCursor: snapshot?.nextCursor ?? null,
43
- error: snapshot?.error ?? null, loadingMore: snapshot?.loadingMore ?? false,
44
- loadMore: () => controller?.loadMore({}),
45
- stateFor: id => controller?.stateFor({ id }) ?? emptyRowState(),
46
- onModerate: async (comment, action) => { await controller?.onModerate({ comment, action }); },
47
- pendingPurge: snapshot?.pendingPurge ?? null,
48
- setPendingPurge: value => { if (controller)
49
- controller.setPendingPurge({ comment: typeof value === 'function' ? value(controller.getSnapshot().pendingPurge) : value }); },
50
- onPurge: async () => { await controller?.onPurge({}); },
51
- };
97
+ return { ...display, ...bindQueueActions({ controller }) };
52
98
  }
53
99
  export function useWiredCommentQueue({ permissions }, _optional = {}) {
54
100
  const { commentsApi, commentsEvents } = useCommentsPorts();
55
101
  const t = useCommentsTranslation();
56
- return useCommentQueue({ api: commentsApi, permissions }, { t, ...(commentsEvents ? { events: commentsEvents } : {}) });
102
+ const scope = useContext(CommentQueueScopeContext);
103
+ if (!scope)
104
+ throw new AdminConfigError({ issues: ['Comment queue requires an explicit workspace/principal scope'] });
105
+ return useCommentQueue({ api: commentsApi, scope, permissions }, { t, ...(commentsEvents ? { events: commentsEvents } : {}) });
57
106
  }
58
107
  //# sourceMappingURL=use-comment-queue.hooks.js.map
@@ -5,6 +5,10 @@ import { createCommentSettingsController } from '../../controllers/comment-setti
5
5
  import { KEYS } from '../../rules.js';
6
6
  import { englishComments } from '../../messages.en.js';
7
7
  import { useCommentsPorts, useCommentsTranslation } from './CommentsPorts.hooks.js';
8
+ const CONFIGURE_GRANTS = Object.freeze(['comments.configure']);
9
+ const NO_GRANTS = Object.freeze([]);
10
+ /** What renders before the controller's first snapshot; mirrors the controller's initial state. */
11
+ const BEFORE_FIRST_SNAPSHOT = Object.freeze({ settings: null, error: null, saving: false, notice: null });
8
12
  /** No refresh-event subscription: background settings reads cannot re-seed an uncontrolled form.
9
13
  * The cache envelope is retained for observers of the existing KEYS.settings resource.
10
14
  */
@@ -12,15 +16,15 @@ export function useCommentSettings({ api, canConfigure }, { t = englishComments
12
16
  // AC-10: even GET is comments.configure-gated. Skip the doomed read for a denied principal.
13
17
  const list = useFetchQuery({ key: KEYS.settings, fetch: async () => ({ data: await api.getCommentsSettings({}) }) }, { enabled: canConfigure });
14
18
  const invalidate = useInvalidate();
19
+ const grants = canConfigure ? CONFIGURE_GRANTS : NO_GRANTS;
15
20
  const { controller, snapshot } = useController({
16
- create: () => createCommentSettingsController({ api, permissions: canConfigure ? ['comments.configure'] : [] }, { t, onInvalidate: () => invalidate({ key: KEYS.settings }) }),
21
+ create: () => createCommentSettingsController({ api, permissions: grants }, { t, onInvalidate: () => invalidate({ key: KEYS.settings }) }),
17
22
  dependencies: [api, invalidate],
18
23
  });
19
- useEffect(() => { controller?.configure({ permissions: canConfigure ? ['comments.configure'] : [], t }); }, [controller, canConfigure, t]);
24
+ useEffect(() => { controller?.configure({ permissions: grants, t }); }, [controller, grants, t]);
20
25
  useEffect(() => { controller?.settleRead({ settings: list.data?.data ?? null, error: list.error }); }, [controller, list.data, list.error]);
21
- return { settings: snapshot?.settings ?? null, error: snapshot?.error ?? null,
22
- saving: snapshot?.saving ?? false, notice: snapshot?.notice ?? null,
23
- save: async (form) => { await controller?.save({ form }); } };
26
+ const { settings, error, saving, notice } = snapshot ?? BEFORE_FIRST_SNAPSHOT;
27
+ return { settings, error, saving, notice, save: async (form) => { await controller?.save({ form }); } };
24
28
  }
25
29
  export function useWiredCommentSettings({ canConfigure }, _optional = {}) {
26
30
  const { commentsApi } = useCommentsPorts();
@@ -3,7 +3,8 @@ export type { CommentsProps } from './pages/CommentsPage.js';
3
3
  export type { QueueSectionProps } from './components/QueueSection.js';
4
4
  export type { SettingsSectionProps } from './components/SettingsSection.js';
5
5
  export { useComments, useWiredComments } from './hooks/use-comments.hooks.js';
6
- export { useCommentQueue, useWiredCommentQueue } from './hooks/use-comment-queue.hooks.js';
6
+ export type { CommentQueueScope } from './hooks/use-comment-queue.hooks.js';
7
+ export { CommentQueueScopeProvider, useCommentQueue, useWiredCommentQueue } from './hooks/use-comment-queue.hooks.js';
7
8
  export { useCommentSettings, useWiredCommentSettings } from './hooks/use-comment-settings.hooks.js';
8
9
  export { useCommentsPorts } from './hooks/CommentsPorts.hooks.js';
9
10
  /** One comments module per admin scope. Host translation is scoped without adding rendered DOM.
@@ -4,7 +4,7 @@ import { commentsModule } from '../comments.module.js';
4
4
  import { englishComments } from '../messages.en.js';
5
5
  import { CommentsPortsContext, CommentsTranslationContext } from './hooks/CommentsPorts.hooks.js';
6
6
  export { useComments, useWiredComments } from './hooks/use-comments.hooks.js';
7
- export { useCommentQueue, useWiredCommentQueue } from './hooks/use-comment-queue.hooks.js';
7
+ export { CommentQueueScopeProvider, useCommentQueue, useWiredCommentQueue } from './hooks/use-comment-queue.hooks.js';
8
8
  export { useCommentSettings, useWiredCommentSettings } from './hooks/use-comment-settings.hooks.js';
9
9
  export { useCommentsPorts } from './hooks/CommentsPorts.hooks.js';
10
10
  /** One comments module per admin scope. Host translation is scoped without adding rendered DOM.