aimeat 1.25.0 → 1.26.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 (252) hide show
  1. package/README.md +8 -1
  2. package/dist/locales/en.json +144 -0
  3. package/dist/locales/fi.json +144 -0
  4. package/dist/public/components/ImageDeliverable.js +2 -1
  5. package/dist/public/components/Modal.js +3 -2
  6. package/dist/public/components/NotificationBell.js +2 -1
  7. package/dist/public/components/PresenceDot.js +53 -0
  8. package/dist/public/css/components/markdown.css +67 -0
  9. package/dist/public/css/theme.css +30 -0
  10. package/dist/public/css/views/notebook.css +195 -0
  11. package/dist/public/css/views/profile.css +15 -0
  12. package/dist/public/js/api.js +5 -3
  13. package/dist/public/js/components/auth-image.js +3 -2
  14. package/dist/public/js/components/tag-cloud.js +1 -1
  15. package/dist/public/js/components/tag-editor.js +1 -1
  16. package/dist/public/js/hooks.js +1 -2
  17. package/dist/public/js/presence-store.js +97 -0
  18. package/dist/public/js/services/admin.js +14 -5
  19. package/dist/public/js/services/boards.js +1 -1
  20. package/dist/public/js/services/ecosystem.js +1 -1
  21. package/dist/public/js/services/foundry-prompts-base.js +2 -2
  22. package/dist/public/js/services/foundry-smoke.js +2 -1
  23. package/dist/public/js/services/foundry-validate.js +6 -8
  24. package/dist/public/js/services/foundry.js +4 -3
  25. package/dist/public/js/services/generator-prompts-base.js +2 -2
  26. package/dist/public/js/services/generator-smoke.js +2 -1
  27. package/dist/public/js/services/generator-validate.js +5 -7
  28. package/dist/public/js/services/generator.js +2 -1
  29. package/dist/public/js/services/memory.js +13 -0
  30. package/dist/public/js/services/messages.js +2 -1
  31. package/dist/public/js/services/notebook.js +124 -0
  32. package/dist/public/js/services/packages.js +3 -2
  33. package/dist/public/js/services/presence.js +35 -0
  34. package/dist/public/js/style-testbed.js +6 -5
  35. package/dist/public/lib/minidenticons.min.js +1 -0
  36. package/dist/public/lib/yaml.mjs +1 -0
  37. package/dist/public/spa.html +90 -8
  38. package/dist/public/types/aimeat-globals.d.ts +52 -0
  39. package/dist/public/types/vendored-libs.d.ts +56 -0
  40. package/dist/public/views/_template.js +3 -1
  41. package/dist/public/views/admin/agent-integration-tab.js +4 -3
  42. package/dist/public/views/admin/agents-tab.js +3 -2
  43. package/dist/public/views/admin/config-tab.js +4 -3
  44. package/dist/public/views/admin/economy-tab.js +4 -3
  45. package/dist/public/views/admin/federation-tab.js +138 -1
  46. package/dist/public/views/admin/maintenance-tab.js +1 -1
  47. package/dist/public/views/admin/messages-tab.js +115 -0
  48. package/dist/public/views/admin/overview-tab.js +4 -3
  49. package/dist/public/views/admin/portal-tab.js +90 -2
  50. package/dist/public/views/admin/push-tab.js +8 -6
  51. package/dist/public/views/admin/security-tab.js +7 -7
  52. package/dist/public/views/admin.js +29 -9
  53. package/dist/public/views/doc-solo.js +3 -1
  54. package/dist/public/views/guides.js +8 -4
  55. package/dist/public/views/hobbies.js +4 -2
  56. package/dist/public/views/marketplace.js +10 -9
  57. package/dist/public/views/openclaw.js +1 -1
  58. package/dist/public/views/portal-dev.js +5 -2
  59. package/dist/public/views/portal.js +3 -2
  60. package/dist/public/views/profile/agents/shared-board.js +4 -2
  61. package/dist/public/views/profile/agents/tab-messages.js +13 -11
  62. package/dist/public/views/profile/agents-messages-subtab.js +2 -1
  63. package/dist/public/views/profile/apps-tab.js +2 -1
  64. package/dist/public/views/profile/boards-tab.js +3 -2
  65. package/dist/public/views/profile/calibrator-batch.js +3 -2
  66. package/dist/public/views/profile/data-wallet-tab.js +2 -1
  67. package/dist/public/views/profile/extensions-tab.js +4 -3
  68. package/dist/public/views/profile/federation-tab.js +4 -0
  69. package/dist/public/views/profile/foundry-dashboard/use-autopilot.js +3 -2
  70. package/dist/public/views/profile/foundry-detail.js +1 -1
  71. package/dist/public/views/profile/generator-detail.js +1 -1
  72. package/dist/public/views/profile/inbox-tab.js +7 -3
  73. package/dist/public/views/profile/inline-panels.js +3 -1
  74. package/dist/public/views/profile/knowledge-tab.js +2 -1
  75. package/dist/public/views/profile/landing-page.js +88 -0
  76. package/dist/public/views/profile/memory-tab.js +6 -5
  77. package/dist/public/views/profile/notebook-tab.js +500 -0
  78. package/dist/public/views/profile/organisms/activity-panel.js +135 -0
  79. package/dist/public/views/profile/organisms/agents.js +141 -0
  80. package/dist/public/views/profile/organisms/document.js +274 -0
  81. package/dist/public/views/profile/organisms/helpers.js +61 -0
  82. package/dist/public/views/profile/organisms/home.js +315 -0
  83. package/dist/public/views/profile/organisms/members.js +220 -0
  84. package/dist/public/views/profile/organisms/panels.js +173 -0
  85. package/dist/public/views/profile/organisms/participants-panel.js +160 -0
  86. package/dist/public/views/profile/organisms/schema-form.js +127 -0
  87. package/dist/public/views/profile/organisms/sources-panel.js +164 -0
  88. package/dist/public/views/profile/organisms/widgets.js +58 -0
  89. package/dist/public/views/profile/organisms/workspace-comments.js +99 -0
  90. package/dist/public/views/profile/organisms/workspace-list.js +268 -0
  91. package/dist/public/views/profile/organisms/workspace.js +1272 -0
  92. package/dist/public/views/profile/organisms-tab.js +456 -3819
  93. package/dist/public/views/profile/shared.js +64 -2
  94. package/dist/public/views/profile/workflows-form.js +1 -1
  95. package/dist/public/views/profile.js +16 -14
  96. package/dist/public/views/public-knowledge-viewer.js +3 -2
  97. package/dist/public/views/public-workspace-viewer.js +2 -1
  98. package/dist/scripts/check-importmap.d.ts +2 -0
  99. package/dist/scripts/check-importmap.d.ts.map +1 -0
  100. package/dist/scripts/check-importmap.js +102 -0
  101. package/dist/scripts/check-importmap.js.map +1 -0
  102. package/dist/src/cli/scaffold.d.ts +26 -3
  103. package/dist/src/cli/scaffold.d.ts.map +1 -1
  104. package/dist/src/cli/scaffold.js +33 -3
  105. package/dist/src/cli/scaffold.js.map +1 -1
  106. package/dist/src/config.d.ts +10 -0
  107. package/dist/src/config.d.ts.map +1 -1
  108. package/dist/src/config.js +5 -0
  109. package/dist/src/config.js.map +1 -1
  110. package/dist/src/generated/api-types.d.ts +927 -11
  111. package/dist/src/generated/api-types.d.ts.map +1 -1
  112. package/dist/src/generated/prisma-postgres/edge.js +32 -6
  113. package/dist/src/generated/prisma-postgres/index-browser.js +29 -3
  114. package/dist/src/generated/prisma-postgres/index.d.ts +1970 -160
  115. package/dist/src/generated/prisma-postgres/index.js +32 -6
  116. package/dist/src/generated/prisma-postgres/package.json +1 -1
  117. package/dist/src/generated/prisma-postgres/schema.prisma +45 -12
  118. package/dist/src/generated/prisma-postgres/wasm.js +32 -6
  119. package/dist/src/index.js +36 -4
  120. package/dist/src/index.js.map +1 -1
  121. package/dist/src/routes/admin-monitoring.d.ts.map +1 -1
  122. package/dist/src/routes/admin-monitoring.js +8 -0
  123. package/dist/src/routes/admin-monitoring.js.map +1 -1
  124. package/dist/src/routes/federation-peer.d.ts.map +1 -1
  125. package/dist/src/routes/federation-peer.js +327 -43
  126. package/dist/src/routes/federation-peer.js.map +1 -1
  127. package/dist/src/routes/federation-sync.d.ts +2 -0
  128. package/dist/src/routes/federation-sync.d.ts.map +1 -1
  129. package/dist/src/routes/federation-sync.js +20 -1
  130. package/dist/src/routes/federation-sync.js.map +1 -1
  131. package/dist/src/routes/librarian.d.ts +18 -0
  132. package/dist/src/routes/librarian.d.ts.map +1 -0
  133. package/dist/src/routes/librarian.js +88 -0
  134. package/dist/src/routes/librarian.js.map +1 -0
  135. package/dist/src/routes/messages.d.ts.map +1 -1
  136. package/dist/src/routes/messages.js +2 -1
  137. package/dist/src/routes/messages.js.map +1 -1
  138. package/dist/src/routes/portal.d.ts.map +1 -1
  139. package/dist/src/routes/portal.js +9 -1
  140. package/dist/src/routes/portal.js.map +1 -1
  141. package/dist/src/routes/presence.d.ts +23 -0
  142. package/dist/src/routes/presence.d.ts.map +1 -0
  143. package/dist/src/routes/presence.js +88 -0
  144. package/dist/src/routes/presence.js.map +1 -0
  145. package/dist/src/routes/site.d.ts +12 -0
  146. package/dist/src/routes/site.d.ts.map +1 -1
  147. package/dist/src/routes/site.js +45 -1
  148. package/dist/src/routes/site.js.map +1 -1
  149. package/dist/src/routes/sse.d.ts +2 -0
  150. package/dist/src/routes/sse.d.ts.map +1 -1
  151. package/dist/src/routes/sse.js +9 -0
  152. package/dist/src/routes/sse.js.map +1 -1
  153. package/dist/src/routes/wellknown.d.ts.map +1 -1
  154. package/dist/src/routes/wellknown.js +5 -0
  155. package/dist/src/routes/wellknown.js.map +1 -1
  156. package/dist/src/routes/work.d.ts.map +1 -1
  157. package/dist/src/routes/work.js +6 -0
  158. package/dist/src/routes/work.js.map +1 -1
  159. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  160. package/dist/src/server-bootstrap/routes-loader.js +13 -0
  161. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  162. package/dist/src/server-bootstrap/service-init.d.ts.map +1 -1
  163. package/dist/src/server-bootstrap/service-init.js +34 -0
  164. package/dist/src/server-bootstrap/service-init.js.map +1 -1
  165. package/dist/src/services/config-schema.d.ts.map +1 -1
  166. package/dist/src/services/config-schema.js +2 -0
  167. package/dist/src/services/config-schema.js.map +1 -1
  168. package/dist/src/services/federation-availability.d.ts +28 -0
  169. package/dist/src/services/federation-availability.d.ts.map +1 -0
  170. package/dist/src/services/federation-availability.js +58 -0
  171. package/dist/src/services/federation-availability.js.map +1 -0
  172. package/dist/src/services/federation-book.d.ts +70 -0
  173. package/dist/src/services/federation-book.d.ts.map +1 -0
  174. package/dist/src/services/federation-book.js +198 -0
  175. package/dist/src/services/federation-book.js.map +1 -0
  176. package/dist/src/services/federation-tiers.d.ts +45 -0
  177. package/dist/src/services/federation-tiers.d.ts.map +1 -0
  178. package/dist/src/services/federation-tiers.js +58 -0
  179. package/dist/src/services/federation-tiers.js.map +1 -0
  180. package/dist/src/services/federation.d.ts +15 -0
  181. package/dist/src/services/federation.d.ts.map +1 -1
  182. package/dist/src/services/federation.js +12 -0
  183. package/dist/src/services/federation.js.map +1 -1
  184. package/dist/src/services/librarian.d.ts +62 -0
  185. package/dist/src/services/librarian.d.ts.map +1 -0
  186. package/dist/src/services/librarian.js +135 -0
  187. package/dist/src/services/librarian.js.map +1 -0
  188. package/dist/src/services/message-delivery.d.ts +3 -1
  189. package/dist/src/services/message-delivery.d.ts.map +1 -1
  190. package/dist/src/services/message-delivery.js +23 -2
  191. package/dist/src/services/message-delivery.js.map +1 -1
  192. package/dist/src/services/network-policy.d.ts +88 -0
  193. package/dist/src/services/network-policy.d.ts.map +1 -0
  194. package/dist/src/services/network-policy.js +154 -0
  195. package/dist/src/services/network-policy.js.map +1 -0
  196. package/dist/src/services/notebook-classify-prompt.d.ts +15 -0
  197. package/dist/src/services/notebook-classify-prompt.d.ts.map +1 -0
  198. package/dist/src/services/notebook-classify-prompt.js +39 -0
  199. package/dist/src/services/notebook-classify-prompt.js.map +1 -0
  200. package/dist/src/services/notebook-classify.d.ts +80 -0
  201. package/dist/src/services/notebook-classify.d.ts.map +1 -0
  202. package/dist/src/services/notebook-classify.js +150 -0
  203. package/dist/src/services/notebook-classify.js.map +1 -0
  204. package/dist/src/services/presence.d.ts +108 -0
  205. package/dist/src/services/presence.d.ts.map +1 -0
  206. package/dist/src/services/presence.js +304 -0
  207. package/dist/src/services/presence.js.map +1 -0
  208. package/dist/src/services/prompt-defaults.d.ts.map +1 -1
  209. package/dist/src/services/prompt-defaults.js +13 -0
  210. package/dist/src/services/prompt-defaults.js.map +1 -1
  211. package/dist/src/services/site.d.ts +20 -0
  212. package/dist/src/services/site.d.ts.map +1 -1
  213. package/dist/src/services/site.js +70 -0
  214. package/dist/src/services/site.js.map +1 -1
  215. package/dist/src/storage/interface.d.ts +48 -0
  216. package/dist/src/storage/interface.d.ts.map +1 -1
  217. package/dist/src/storage/interface.js.map +1 -1
  218. package/dist/src/storage/providers/mongodb/index.d.ts +11 -1
  219. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  220. package/dist/src/storage/providers/mongodb/index.js +128 -2
  221. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  222. package/dist/src/storage/providers/sqlite/index.d.ts +7 -1
  223. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  224. package/dist/src/storage/providers/sqlite/index.js +27 -2
  225. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  226. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts +5 -1
  227. package/dist/src/storage/providers/sqlite/repos/direct-message.d.ts.map +1 -1
  228. package/dist/src/storage/providers/sqlite/repos/direct-message.js +48 -0
  229. package/dist/src/storage/providers/sqlite/repos/direct-message.js.map +1 -1
  230. package/dist/src/storage/providers/sqlite/repos/memory.d.ts +2 -0
  231. package/dist/src/storage/providers/sqlite/repos/memory.d.ts.map +1 -1
  232. package/dist/src/storage/providers/sqlite/repos/memory.js +55 -0
  233. package/dist/src/storage/providers/sqlite/repos/memory.js.map +1 -1
  234. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  235. package/dist/src/storage/providers/sqlite/schema.js +61 -0
  236. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  237. package/dist/src/storage/repositories/direct-message.repository.d.ts +5 -1
  238. package/dist/src/storage/repositories/direct-message.repository.d.ts.map +1 -1
  239. package/dist/src/storage/repositories/memory.repository.d.ts +25 -0
  240. package/dist/src/storage/repositories/memory.repository.d.ts.map +1 -1
  241. package/dist/src/utils/node-descriptor.d.ts +34 -0
  242. package/dist/src/utils/node-descriptor.d.ts.map +1 -0
  243. package/dist/src/utils/node-descriptor.js +42 -0
  244. package/dist/src/utils/node-descriptor.js.map +1 -0
  245. package/dist/src/utils/version.d.ts +3 -0
  246. package/dist/src/utils/version.d.ts.map +1 -0
  247. package/dist/src/utils/version.js +40 -0
  248. package/dist/src/utils/version.js.map +1 -0
  249. package/dist/static/app-catalog.html +643 -4
  250. package/package.json +5 -2
  251. package/prisma/schema.postgres.prisma +51 -18
  252. package/prisma/schema.prisma +29 -0
@@ -8,11 +8,12 @@ import { num, dt, Empty, EconRow } from './shared.js';
8
8
  import { getAgentDetail } from '/js/services/admin.js';
9
9
 
10
10
  export default function AgentsTab({ data }) {
11
+ // Hooks must run unconditionally before any early return (Rules of Hooks).
12
+ const [expanded, setExpanded] = useState({});
13
+
11
14
  const ag = data.agents;
12
15
  if (!ag?.agents?.length) return html`<${Empty} text=${t('dashboard.noAgentsRegistered')} />`;
13
16
 
14
- const [expanded, setExpanded] = useState({});
15
-
16
17
  async function toggleDetail(gaii, idx) {
17
18
  if (expanded[idx]) {
18
19
  setExpanded(prev => { const n = { ...prev }; delete n[idx]; return n; });
@@ -33,12 +33,13 @@ const SOURCE_BADGE = {
33
33
  };
34
34
 
35
35
  export default function ConfigTab({ data, reload }) {
36
- const s = data.configSchema;
37
- if (!s || !s.schema) return html`<${Empty} text=${t('dashboard.configNotAvailable')} />`;
38
-
36
+ // Hooks must run unconditionally before any early return (Rules of Hooks).
39
37
  const [pending, setPending] = useState({});
40
38
  const [result, setResult] = useState(null);
41
39
 
40
+ const s = data.configSchema;
41
+ if (!s || !s.schema) return html`<${Empty} text=${t('dashboard.configNotAvailable')} />`;
42
+
42
43
  const schema = s.schema;
43
44
  const editable = s.editable !== false;
44
45
 
@@ -8,13 +8,14 @@ import { num, EconRow, StatsGrid } from './shared.js';
8
8
  import { mintMorsels } from '/js/services/admin.js';
9
9
 
10
10
  export default function EconomyTab({ data, reload }) {
11
- const e = data.dash?.economy;
12
- if (!e) return html`<div class="empty">${t('dashboard.loading')}</div>`;
13
-
11
+ // Hooks must run unconditionally before any early return (Rules of Hooks).
14
12
  const [mintGaii, setMintGaii] = useState('');
15
13
  const [mintAmount, setMintAmount] = useState('');
16
14
  const [mintResult, setMintResult] = useState(null);
17
15
 
16
+ const e = data.dash?.economy;
17
+ if (!e) return html`<div class="empty">${t('dashboard.loading')}</div>`;
18
+
18
19
  async function doMint() {
19
20
  const amount = parseInt(mintAmount, 10);
20
21
  if (!mintGaii || !amount || amount < 1 || amount > 1_000_000) {
@@ -5,6 +5,13 @@
5
5
  * @structure FederationTab (default)
6
6
  * @usage Mounted by the admin dashboard tab router.
7
7
  * @version-history
8
+ * v1.4.0 — 2026-06-19 — Federation book: a Version column + "behind" badge in the live-peers
9
+ * table, and a Federation Book section (operators + resources + version + settings per node,
10
+ * with Rebuild on the primary / Mirror-from-genesis on leaves).
11
+ * v1.3.0 — 2026-06-19 — Phase B: Promote button is eligibility-aware (uses promotion_eligible /
12
+ * promotion_failing from the API; forced override prompts when not yet eligible).
13
+ * v1.2.0 — 2026-06-19 — Visiting-node tier: Tier + Availability badges in the live-peers
14
+ * table, an "open join" toggle, and a Promote (vouch) button for visiting peers.
8
15
  * v1.1.0 — 2026-06-02 — Admin design unification: inline button color style
9
16
  * on the approve action → adm-btn-success.
10
17
  */
@@ -18,7 +25,8 @@ import { dt, num, Badge, EconRow, StatsGrid, Empty, ExpandableHelp, ErrorBox } f
18
25
  import {
19
26
  getFederationPeers, getFederationDirectory, approvePeeringRequest, rejectPeeringRequest,
20
27
  deletePeeringRequest, activatePeer, addPeerDirect, removePeer, removePeerEmergency,
21
- testFederationNode, joinGenesisNetwork, updatePeerPolicy, getNetworkDirectory,
28
+ testFederationNode, joinGenesisNetwork, updatePeerPolicy, promotePeer, getNetworkDirectory,
29
+ getFederationBook, rebuildFederationBook, pullFederationBook,
22
30
  getConfig, saveConfig,
23
31
  } from '/js/services/admin.js';
24
32
  import { useConfirm } from '/components/Modal.js';
@@ -45,6 +53,7 @@ export default function FederationTab({ data, reload }) {
45
53
  const [authPolicy, setAuthPolicy] = useState('disabled');
46
54
  const [defaultScopes, setDefaultScopes] = useState([]);
47
55
  const [authPolicyLoading, setAuthPolicyLoading] = useState(true);
56
+ const [openJoin, setOpenJoin] = useState(false);
48
57
 
49
58
  useEffect(() => {
50
59
  getConfig().then(res => {
@@ -53,11 +62,34 @@ export default function FederationTab({ data, reload }) {
53
62
  setAuthPolicy(s['federation.auth_policy']?.value ?? 'disabled');
54
63
  const scopes = s['federation.default_scopes']?.value ?? 'memory:read,catalogue:read';
55
64
  setDefaultScopes(typeof scopes === 'string' ? scopes.split(',').filter(Boolean) : (scopes || []));
65
+ const oj = s['federation.open_join']?.value;
66
+ setOpenJoin(oj === true || oj === 'true');
56
67
  }
57
68
  setAuthPolicyLoading(false);
58
69
  });
59
70
  }, []);
60
71
 
72
+ const doSaveOpenJoin = async (v) => {
73
+ setOpenJoin(v);
74
+ const res = await saveConfig([{ path: 'federation.open_join', value: v }]);
75
+ if (res.ok) { setActionSuccess(t('dashboard.fedOpenJoinUpdated')); setTimeout(() => setActionSuccess(null), 3000); }
76
+ else setActionError(res.error?.message || 'Failed to save');
77
+ };
78
+
79
+ // ── Federation Book state ──
80
+ const [book, setBook] = useState(null);
81
+ const [bookPrimary, setBookPrimary] = useState(false);
82
+ const loadBook = useCallback(async () => {
83
+ try { const r = await getFederationBook(); setBook(r.data?.book || null); setBookPrimary(!!r.data?.is_primary); }
84
+ catch { setBook(null); }
85
+ }, []);
86
+ useEffect(() => { loadBook(); }, [loadBook]);
87
+ useEffect(() => {
88
+ const h = () => loadBook();
89
+ window.addEventListener('aimeat-live-update', h);
90
+ return () => window.removeEventListener('aimeat-live-update', h);
91
+ }, [loadBook]);
92
+
61
93
  const doSaveAuthPolicy = async (policy, scopes) => {
62
94
  const changes = [
63
95
  { path: 'federation.auth_policy', value: policy },
@@ -176,6 +208,29 @@ export default function FederationTab({ data, reload }) {
176
208
  finally { setTesting(false); }
177
209
  }, [testUrl]);
178
210
 
211
+ const doPromote = useCallback((p) => {
212
+ // Eligible → vouch directly. Not eligible → confirm a forced (audited) override.
213
+ const run = async (force) => {
214
+ try { await promotePeer(p.node_id, force ? { force: true } : {}); flash(t('dashboard.fedPromoted')); reload(); }
215
+ catch (e) {
216
+ if (e.code === 'NOT_ELIGIBLE') {
217
+ confirm((t('dashboard.fedPromoteForceConfirm') || 'Not eligible yet. Promote anyway (vouch)?') + (p.promotion_failing?.length ? '\n' + p.promotion_failing.join(', ') : ''), () => run(true));
218
+ } else { flashErr(e.message); }
219
+ }
220
+ };
221
+ if (p.promotion_eligible) confirm(t('dashboard.fedPromoteConfirm'), () => run(false));
222
+ else run(false); // will 409 → prompts for forced override
223
+ }, [reload]);
224
+
225
+ const doRebuildBook = useCallback(async () => {
226
+ try { await rebuildFederationBook(); flash(t('dashboard.fedBookRebuilt') || 'Book rebuilt'); loadBook(); }
227
+ catch (e) { flashErr(e.message); }
228
+ }, [loadBook]);
229
+ const doPullBook = useCallback(async () => {
230
+ try { const r = await pullFederationBook(); flash(r.data?.applied ? (t('dashboard.fedBookPulled') || 'Book mirrored') : (t('dashboard.fedBookUpToDate') || 'Already up to date')); loadBook(); }
231
+ catch (e) { flashErr(e.message); }
232
+ }, [loadBook]);
233
+
179
234
  const doAddPeer = useCallback(async () => {
180
235
  if (!addNodeId || !addUrl) { flashErr(t('dashboard.fedAddPeerMissing')); return; }
181
236
  try { new URL(addUrl); } catch { flashErr(t('dashboard.fedInvalidUrl') || 'Invalid URL format'); return; }
@@ -203,6 +258,37 @@ export default function FederationTab({ data, reload }) {
203
258
  return html`<${Badge} type=${map[status] || 'watch'} label=${status || 'unknown'} />`;
204
259
  }
205
260
 
261
+ function tierBadge(tier) {
262
+ const tt = tier || 'member';
263
+ const map = { genesis: 'info', member: 'healthy', visiting: 'watch' };
264
+ const label = t(`dashboard.fedTier_${tt}`) || tt;
265
+ return html`<${Badge} type=${map[tt] || 'neutral'} label=${label} />`;
266
+ }
267
+
268
+ // Compare semver-ish version strings; returns 1/0/-1. Non-numeric/"unknown" sort lowest.
269
+ function cmpVersion(a, b) {
270
+ const pa = String(a || '').split('.').map(n => parseInt(n, 10));
271
+ const pb = String(b || '').split('.').map(n => parseInt(n, 10));
272
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
273
+ const x = Number.isFinite(pa[i]) ? pa[i] : -1;
274
+ const y = Number.isFinite(pb[i]) ? pb[i] : -1;
275
+ if (x !== y) return x < y ? -1 : 1;
276
+ }
277
+ return 0;
278
+ }
279
+ // Highest version seen across peers — peers below it are flagged "behind" (a feature-gap hint).
280
+ const maxPeerVersion = livePeers.reduce((m, p) => (p.software_version && cmpVersion(p.software_version, m) > 0 ? p.software_version : m), '');
281
+ function versionCell(v) {
282
+ if (!v) return html`<span class="adm-text-dim">—</span>`;
283
+ const behind = maxPeerVersion && cmpVersion(v, maxPeerVersion) < 0;
284
+ return html`<span>${v}</span>${behind ? html` <${Badge} type="watch" label=${t('dashboard.fedBehind') || 'behind'} />` : null}`;
285
+ }
286
+
287
+ function availabilityBadge(av) {
288
+ if (!av || av === 'unknown') return null;
289
+ return html`<${Badge} type=${av === 'permanent' ? 'healthy' : 'watch'} label=${t(`dashboard.fedAvail_${av}`) || av} />`;
290
+ }
291
+
206
292
  return html`
207
293
  <p class="adm-text-dim adm-mb-md" style="margin:0">${t('dashboard.federationExplain')}</p>
208
294
 
@@ -283,9 +369,53 @@ export default function FederationTab({ data, reload }) {
283
369
  </div>
284
370
  </div>
285
371
  `}
372
+ <div class="adm-mt-md" style="border-top:1px solid var(--border);padding-top:12px">
373
+ <label class="adm-text-sm" style="display:flex;gap:8px;align-items:center">
374
+ <input type="checkbox" checked=${openJoin} onChange=${(e) => doSaveOpenJoin(e.target.checked)} />
375
+ <span><b>${t('dashboard.fedOpenJoinLabel')}</b> — ${t('dashboard.fedOpenJoinDesc')}</span>
376
+ </label>
377
+ </div>
286
378
  `}
287
379
  </div>
288
380
 
381
+ <!-- ═══ Federation Book ═══ -->
382
+ <div class="adm-card adm-mt-lg">
383
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
384
+ <div>
385
+ <h4 class="adm-mb-sm" style="margin:0">${t('dashboard.fedBookTitle')}</h4>
386
+ <p class="adm-text-dim adm-text-base" style="margin:0">${t('dashboard.fedBookDesc')}</p>
387
+ </div>
388
+ ${bookPrimary
389
+ ? html`<button class="adm-btn-sm" onClick=${doRebuildBook}>↻ ${t('dashboard.fedBookRebuild')}</button>`
390
+ : html`<button class="adm-btn-sm" onClick=${doPullBook}>⬇ ${t('dashboard.fedBookPull')}</button>`}
391
+ </div>
392
+ ${!book || !book.nodes?.length
393
+ ? html`<${Empty} text=${t('dashboard.fedBookEmpty')} />`
394
+ : html`<div class="scrollable adm-mt-md"><table>
395
+ <thead><tr>
396
+ <th>${t('dashboard.nodeId')}</th>
397
+ <th>${t('dashboard.fedBookOperators')}</th>
398
+ <th>${t('dashboard.fedVersionLabel')}</th>
399
+ <th>${t('dashboard.fedBookResources')}</th>
400
+ <th>${t('dashboard.fedBookSettings')}</th>
401
+ </tr></thead>
402
+ <tbody>
403
+ ${book.nodes.map(n => html`<tr key=${n.node_id}>
404
+ <td class="mono adm-text-sm">${escHtml(n.node_id)}<div class="adm-text-dim" style="font-size:.7rem">${escHtml(n.node_type || '')}</div></td>
405
+ <td class="adm-text-sm">${(n.operators || []).map(o => html`<div title=${escHtml(o.ghii)}>${o.avatar ? o.avatar + ' ' : ''}${escHtml(o.display_name || o.ghii)}</div>`)}${!(n.operators || []).length ? html`<span class="adm-text-dim">—</span>` : null}</td>
406
+ <td class="adm-text-sm">${versionCell(n.software_version)}</td>
407
+ <td class="adm-text-sm">${n.resources ? html`<span title=${(n.resources.highlights || []).join(', ')}>${n.resources.actions}a · ${n.resources.agents}g · ${n.resources.boards}b · ${n.resources.csms}c</span>` : '—'}</td>
408
+ <td style="display:flex;gap:4px;flex-wrap:wrap">
409
+ ${n.settings?.open_join ? html`<${Badge} type="watch" label=${t('dashboard.fedBookOpenJoin')} />` : null}
410
+ ${n.settings?.auth_policy && n.settings.auth_policy !== 'disabled' ? html`<${Badge} type="info" label=${'auth:' + n.settings.auth_policy} />` : null}
411
+ ${n.settings?.cross_federation ? html`<${Badge} type="neutral" label=${t('dashboard.fedBookCrossFed')} />` : null}
412
+ </td>
413
+ </tr>`)}
414
+ </tbody>
415
+ </table></div>
416
+ <p class="adm-text-dim" style="font-size:.7rem;margin-top:6px">${t('dashboard.fedBookVersion')}: ${book.book_version} · ${escHtml(book.issued_by || '')}</p>`}
417
+ </div>
418
+
289
419
  <!-- ═══ Network Directory ═══ -->
290
420
  <div class="adm-card adm-mt-lg">
291
421
  <h4 class="adm-mb-sm" style="margin:0">${t('dashboard.fedNetworkDirectoryTitle')}</h4>
@@ -364,6 +494,8 @@ export default function FederationTab({ data, reload }) {
364
494
  <th>${t('dashboard.nodeId')}</th>
365
495
  <th>URL</th>
366
496
  <th>${t('dashboard.statusLabel')}</th>
497
+ <th>${t('dashboard.fedTierLabel')}</th>
498
+ <th>${t('dashboard.fedVersionLabel')}</th>
367
499
  <th>${t('dashboard.fedAddedAt')}</th>
368
500
  <th>${t('dashboard.lastSeen')}</th>
369
501
  <th>${t('dashboard.fedPublicKey')}</th>
@@ -375,6 +507,8 @@ export default function FederationTab({ data, reload }) {
375
507
  <td class="mono adm-text-sm">${escHtml(p.node_id)}</td>
376
508
  <td class="mono adm-text-sm">${escHtml(p.url || '\u2014')}</td>
377
509
  <td>${statusBadge(p.status)}</td>
510
+ <td style="display:flex;gap:4px;flex-wrap:wrap;align-items:center">${tierBadge(p.tier)}${availabilityBadge(p.availability)}</td>
511
+ <td class="adm-text-sm">${versionCell(p.software_version)}</td>
378
512
  <td class="adm-text-dim">${dt(p.added_at)}</td>
379
513
  <td class="adm-text-dim">${dt(p.last_seen)}</td>
380
514
  <td class="mono" style="font-size:.7rem;max-width:100px;overflow:hidden;text-overflow:ellipsis" title=${p.public_key || ''}>${p.public_key ? p.public_key.substring(0, 16) + '...' : '\u2014'}</td>
@@ -392,6 +526,9 @@ export default function FederationTab({ data, reload }) {
392
526
  ${p.status === 'approved' && html`
393
527
  <button class="adm-btn-sm" onClick=${() => doActivate(p.node_id)}>\u25B6 ${t('dashboard.fedActivate')}</button>
394
528
  `}
529
+ ${p.tier === 'visiting' && html`
530
+ <button class="adm-btn-sm" title=${p.promotion_eligible ? t('dashboard.fedPromoteHint') : ((t('dashboard.fedPromoteNotEligible') || 'Not yet eligible') + (p.promotion_failing?.length ? ': ' + p.promotion_failing.join(', ') : ''))} onClick=${() => doPromote(p)}>${p.promotion_eligible ? '\u2B06' : '\u26A0'} ${t('dashboard.fedPromote')}</button>
531
+ `}
395
532
  ${(p.status === 'active' || p.status === 'degraded') && html`
396
533
  <button class="adm-btn-sm" onClick=${() => doRemove(p.node_id)}>${t('dashboard.fedDepeer')}</button>
397
534
  `}
@@ -45,7 +45,7 @@ export default function MaintenanceTab({ data, reload }) {
45
45
  const reader = new FileReader();
46
46
  reader.onload = async () => {
47
47
  try {
48
- const parsed = JSON.parse(reader.result);
48
+ const parsed = JSON.parse(/** @type {string} */ (reader.result));
49
49
  await apiRestore(parsed);
50
50
  setBackupResult({ ok: true, msg: t('dashboard.dataRestored') });
51
51
  reload();
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file messages-tab.js
3
+ * @description Admin dashboard "Messages" tab — operator visibility into direct-message (user↔user)
4
+ * delivery: total + 24h counts by status (delivered / queued / failed / undeliverable), the busiest
5
+ * target nodes, and a recent-attempts table. Privacy by design: shows NO message content and NO
6
+ * participant identities — only routing/outcome metadata from the delivery-telemetry log.
7
+ * @structure MessagesTab (default) — fetches GET /v1/admin/messages/stats, re-fetches on live updates.
8
+ * @usage Registered in admin.js NAV_GROUPS (Data group).
9
+ * @version-history
10
+ * v1.0.0 -- 2026-06-16 -- Initial: delivery stats + target nodes + recent attempts.
11
+ */
12
+ import { h } from 'preact';
13
+ import { useState, useEffect, useCallback } from 'preact/hooks';
14
+ import htm from 'htm';
15
+ const html = htm.bind(h);
16
+ import { t } from '/js/i18n.js';
17
+ import { escHtml } from '/js/utils.js';
18
+ import { StatsGrid } from './shared.js';
19
+ import * as api from '/js/services/admin.js';
20
+
21
+ const STATUSES = ['delivered', 'queued', 'failed', 'undeliverable'];
22
+ const TONE = { delivered: 'good', queued: 'warn', failed: 'bad', undeliverable: 'bad' };
23
+
24
+ function relTime(iso) {
25
+ try {
26
+ const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
27
+ if (s < 60) return `${s}s`;
28
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
29
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
30
+ return new Date(iso).toLocaleDateString();
31
+ } catch { return ''; }
32
+ }
33
+
34
+ export default function MessagesTab() {
35
+ const [data, setData] = useState(null);
36
+ const [err, setErr] = useState(null);
37
+
38
+ const load = useCallback(async () => {
39
+ try {
40
+ const r = await api.getMessageStats();
41
+ setData(r?.data || null); setErr(null);
42
+ } catch (e) { setErr(e?.message || 'Failed to load'); }
43
+ }, []);
44
+
45
+ useEffect(() => { load(); }, [load]);
46
+ useEffect(() => {
47
+ const hh = () => load();
48
+ window.addEventListener('aimeat-live-update', hh);
49
+ return () => window.removeEventListener('aimeat-live-update', hh);
50
+ }, [load]);
51
+
52
+ if (err) return html`<div class="adm-section"><div class="adm-error">${escHtml(err)}</div></div>`;
53
+ if (!data) return html`<div class="adm-section">${t('common.loading') || 'Loading…'}</div>`;
54
+
55
+ const s = data.stats || { total: 0, total24h: 0, byStatus: {}, byStatus24h: {}, topTargetNodes: [] };
56
+ const recent = data.recent || [];
57
+
58
+ const cards = [
59
+ { label: t('admin.messages.total') || 'Total sent', value: s.total ?? 0, sub: `${s.total24h ?? 0} ${t('admin.messages.last24h') || 'in 24h'}` },
60
+ ...STATUSES.map(st => ({
61
+ label: t(`inbox.status.${st}`) || st,
62
+ value: s.byStatus?.[st] ?? 0,
63
+ sub: `${s.byStatus24h?.[st] ?? 0} ${t('admin.messages.last24h') || 'in 24h'}`,
64
+ tone: TONE[st],
65
+ })),
66
+ ];
67
+
68
+ return html`
69
+ <div class="adm-section">
70
+ <div class="section-title">${t('admin.messages.title') || 'Direct messages'}</div>
71
+ <div class="section-desc">${t('admin.messages.desc') || 'Delivery telemetry for user-to-user messages. No message content or participant identities are shown.'}</div>
72
+
73
+ <${StatsGrid} items=${cards} />
74
+
75
+ <div class="adm-subhead">${t('admin.messages.targetNodes') || 'Top target nodes'}</div>
76
+ ${(s.topTargetNodes || []).length === 0
77
+ ? html`<div class="adm-muted">${t('admin.messages.none') || 'No deliveries yet.'}</div>`
78
+ : html`<table class="data-table">
79
+ <thead><tr>
80
+ <th>${t('admin.messages.node') || 'Node'}</th>
81
+ <th>${t('admin.messages.totalCol') || 'Total'}</th>
82
+ <th>${t('admin.messages.failedCol') || 'Failed'}</th>
83
+ </tr></thead>
84
+ <tbody>
85
+ ${s.topTargetNodes.map(n => html`<tr key=${n.nodeId}>
86
+ <td class="mono">${escHtml(n.nodeId)}</td>
87
+ <td>${n.total}</td>
88
+ <td class=${n.failed > 0 ? 'adm-bad' : ''}>${n.failed}</td>
89
+ </tr>`)}
90
+ </tbody>
91
+ </table>`}
92
+
93
+ <div class="adm-subhead">${t('admin.messages.recent') || 'Recent attempts'}</div>
94
+ ${recent.length === 0
95
+ ? html`<div class="adm-muted">${t('admin.messages.none') || 'No deliveries yet.'}</div>`
96
+ : html`<table class="data-table">
97
+ <thead><tr>
98
+ <th>${t('admin.messages.when') || 'When'}</th>
99
+ <th>${t('admin.messages.origin') || 'Origin'}</th>
100
+ <th>${t('admin.messages.node') || 'Node'}</th>
101
+ <th>${t('admin.messages.statusCol') || 'Status'}</th>
102
+ <th>${t('admin.messages.detail') || 'Detail'}</th>
103
+ </tr></thead>
104
+ <tbody>
105
+ ${recent.map(r => html`<tr key=${r.id}>
106
+ <td>${relTime(r.createdAt)}</td>
107
+ <td>${escHtml(r.origin)}</td>
108
+ <td class="mono">${escHtml(r.targetNodeId)}</td>
109
+ <td class=${(r.status === 'failed' || r.status === 'undeliverable') ? 'adm-bad' : (r.status === 'queued' ? 'adm-warn' : 'adm-good')}>${escHtml(r.status)}</td>
110
+ <td class="mono">${escHtml([r.httpStatus ? `http ${r.httpStatus}` : '', r.errorMessage || '', `${r.latencyMs}ms`].filter(Boolean).join(' · '))}</td>
111
+ </tr>`)}
112
+ </tbody>
113
+ </table>`}
114
+ </div>`;
115
+ }
@@ -8,9 +8,7 @@ import { num, fmtUp, Badge, StatsGrid, EconRow, HealthRow } from './shared.js';
8
8
  import { apiGet } from '/js/api.js';
9
9
 
10
10
  export default function OverviewTab({ data }) {
11
- const d = data.dash;
12
- if (!d) return html`<div class="empty">${t('dashboard.loading')}</div>`;
13
-
11
+ // Hooks must run unconditionally before any early return (Rules of Hooks).
14
12
  const [activeTaskCount, setActiveTaskCount] = useState(null);
15
13
  const [sharingGroupCount, setSharingGroupCount] = useState(null);
16
14
 
@@ -32,6 +30,9 @@ export default function OverviewTab({ data }) {
32
30
  .catch(() => setSharingGroupCount(0));
33
31
  }, []);
34
32
 
33
+ const d = data.dash;
34
+ if (!d) return html`<div class="empty">${t('dashboard.loading')}</div>`;
35
+
35
36
  const h_ = d.health;
36
37
  const c = d.counts;
37
38
  const e = d.economy;
@@ -13,6 +13,8 @@
13
13
  * (now /v1/site/memory → __site__ so {{memory:portal/*}} resolves); add AI
14
14
  * bundle import (/v1/site/import) so the AI-Assisted result no longer 422s in
15
15
  * the template box; reload the preview iframe after every change + Clear Cache.
16
+ * v1.4.0 — 2026-06-19 — Add Header Navigation section: operator show/hide + reorder
17
+ * of the public header links (persisted via /v1/site/header-nav).
16
18
  */
17
19
  import { h } from 'preact';
18
20
  import { useState, useEffect } from 'preact/hooks';
@@ -24,10 +26,20 @@ import { dt, Badge, ExpandableHelp, useToast, Toast } from './shared.js';
24
26
  import {
25
27
  saveSiteTemplate, deleteSiteTemplate, clearSiteCache,
26
28
  getSiteMemoryKeys, getSitePrompt, setSiteMemory, deleteSiteMemory,
27
- importSiteBundle, triggerLbSync,
29
+ importSiteBundle, triggerLbSync, getHeaderNav, saveHeaderNav,
28
30
  } from '/js/services/admin.js';
29
31
  import { useConfirm } from '/components/Modal.js';
30
32
 
33
+ // Public header link ids → their nav i18n label key (mirror of PUBLIC_NAV_LINKS in spa.html
34
+ // and PUBLIC_NAV_LINK_IDS in src/services/site.ts). Gated links are not configurable.
35
+ const HEADER_LINK_LABELS = {
36
+ try: 'nav.try',
37
+ howItWorks: 'nav.howItWorks',
38
+ business: 'nav.business',
39
+ devView: 'nav.devView',
40
+ help: 'nav.help',
41
+ };
42
+
31
43
  export default function PortalTab({ data, reload }) {
32
44
  const [toast, showErr, showOk, clearToast] = useToast();
33
45
  const p = data.portal || {};
@@ -43,11 +55,14 @@ export default function PortalTab({ data, reload }) {
43
55
  const [newKey, setNewKey] = useState('');
44
56
  const [newVal, setNewVal] = useState('');
45
57
  const [aiBundle, setAiBundle] = useState('');
58
+ // Header nav: ordered list of { id, visible }. null while loading.
59
+ const [navLinks, setNavLinks] = useState(null);
60
+ const [navSaving, setNavSaving] = useState(false);
46
61
  // Bumped after any change so the preview iframe re-fetches `/` (busts browser cache).
47
62
  const [previewNonce, setPreviewNonce] = useState(0);
48
63
  const bumpPreview = () => setPreviewNonce(n => n + 1);
49
64
 
50
- useEffect(() => { loadMemKeys(); }, []);
65
+ useEffect(() => { loadMemKeys(); loadNav(); }, []);
51
66
 
52
67
  async function loadMemKeys() {
53
68
  try {
@@ -56,6 +71,44 @@ export default function PortalTab({ data, reload }) {
56
71
  } catch { setMemKeys([]); }
57
72
  }
58
73
 
74
+ async function loadNav() {
75
+ try {
76
+ const res = await getHeaderNav();
77
+ const order = res.data?.order || Object.keys(HEADER_LINK_LABELS);
78
+ const hidden = new Set(res.data?.hidden || []);
79
+ setNavLinks(order
80
+ .filter(id => HEADER_LINK_LABELS[id])
81
+ .map(id => ({ id, visible: !hidden.has(id) })));
82
+ } catch { setNavLinks([]); }
83
+ }
84
+
85
+ function toggleNav(id) {
86
+ setNavLinks(links => links.map(l => l.id === id ? { ...l, visible: !l.visible } : l));
87
+ }
88
+
89
+ function moveNav(idx, dir) {
90
+ setNavLinks(links => {
91
+ const next = links.slice();
92
+ const j = idx + dir;
93
+ if (j < 0 || j >= next.length) return next;
94
+ [next[idx], next[j]] = [next[j], next[idx]];
95
+ return next;
96
+ });
97
+ }
98
+
99
+ async function saveNav() {
100
+ if (!navLinks) return;
101
+ setNavSaving(true);
102
+ try {
103
+ const order = navLinks.map(l => l.id);
104
+ const hidden = navLinks.filter(l => !l.visible).map(l => l.id);
105
+ await saveHeaderNav(order, hidden);
106
+ showOk(t('dashboard.portalNavSaved'));
107
+ bumpPreview();
108
+ } catch (e) { showErr(e.message); }
109
+ finally { setNavSaving(false); }
110
+ }
111
+
59
112
  async function saveTemplate() {
60
113
  // The AI-Assisted prompt produces a JSON import bundle, not raw HTML. If the
61
114
  // user pasted that here, the template route would 422 — point them to Import.
@@ -188,6 +241,41 @@ export default function PortalTab({ data, reload }) {
188
241
  </div>
189
242
  </div>
190
243
 
244
+ <!-- Header navigation -->
245
+ <div class="adm-card">
246
+ <h3>${t('dashboard.portalNavTitle')}</h3>
247
+ <p class="adm-text-dim adm-text-base adm-mb-sm">${t('dashboard.portalNavExplain')}</p>
248
+ ${navLinks === null
249
+ ? html`<div class="adm-text-dim">${t('dashboard.loading')}...</div>`
250
+ : html`
251
+ <table>
252
+ <thead><tr>
253
+ <th>${t('dashboard.portalNavLink')}</th>
254
+ <th>${t('dashboard.portalNavVisible')}</th>
255
+ <th>${t('dashboard.portalNavOrder')}</th>
256
+ </tr></thead>
257
+ <tbody>
258
+ ${navLinks.map((l, idx) => html`<tr key=${l.id}>
259
+ <td>${t(HEADER_LINK_LABELS[l.id]) || l.id}</td>
260
+ <td>
261
+ <label class="adm-flex-center">
262
+ <input type="checkbox" checked=${l.visible} onChange=${() => toggleNav(l.id)} />
263
+ <span class="adm-text-dim">${l.visible ? t('dashboard.portalNavShown') : t('dashboard.portalNavHidden')}</span>
264
+ </label>
265
+ </td>
266
+ <td>
267
+ <button class="adm-btn-sm" disabled=${idx === 0} onClick=${() => moveNav(idx, -1)} title=${t('dashboard.portalNavMoveUp')}>↑</button>
268
+ <button class="adm-btn-sm" disabled=${idx === navLinks.length - 1} onClick=${() => moveNav(idx, 1)} title=${t('dashboard.portalNavMoveDown')}>↓</button>
269
+ </td>
270
+ </tr>`)}
271
+ </tbody>
272
+ </table>
273
+ <div class="adm-flex adm-mt-sm">
274
+ <button class="adm-btn-action" disabled=${navSaving} onClick=${saveNav}>\u{1F4BE} ${t('dashboard.portalNavSave')}</button>
275
+ </div>
276
+ `}
277
+ </div>
278
+
191
279
  <!-- Template editor -->
192
280
  <div class="adm-card">
193
281
  <h3>${t('dashboard.portalTemplate')}</h3>
@@ -21,13 +21,9 @@ import { useConfirm } from '/components/Modal.js';
21
21
 
22
22
  export default function PushTab({ data, reload }) {
23
23
  const push = data.push;
24
- if (!push) return html`<${Empty} text=${t('dashboard.pushNotConfigured')} />`;
25
-
26
- const subs = push.subscriptions || [];
27
- const templates = push.templates || [];
28
- const locales = push.locales || ['en'];
29
24
 
30
- const [tplLocale, setTplLocale] = useState(locales[0] || 'en');
25
+ // Hooks must run unconditionally before any early return (Rules of Hooks).
26
+ const [tplLocale, setTplLocale] = useState((push?.locales || ['en'])[0] || 'en');
31
27
  const [saving, setSaving] = useState(null);
32
28
  const [testStatus, setTestStatus] = useState(null);
33
29
  const [resetStatus, setResetStatus] = useState(null);
@@ -36,6 +32,12 @@ export default function PushTab({ data, reload }) {
36
32
  const { confirm, ConfirmUI } = useConfirm();
37
33
  const [toast, showErr, showOk, clearToast] = useToast();
38
34
 
35
+ if (!push) return html`<${Empty} text=${t('dashboard.pushNotConfigured')} />`;
36
+
37
+ const subs = push.subscriptions || [];
38
+ const templates = push.templates || [];
39
+ const locales = push.locales || ['en'];
40
+
39
41
  const localeTpls = templates.filter(tpl => tpl.locale === tplLocale);
40
42
 
41
43
  const handleSave = async (tpl) => {
@@ -19,22 +19,22 @@ const html = htm.bind(h);
19
19
 
20
20
  export default function SecurityTab() {
21
21
  const [state, setState] = useState({ incidents: [], open: 0, total: 0, loading: true });
22
- const { toast, showToast } = useToast();
22
+ const [toast, showErr, showOk, clearToast] = useToast();
23
23
  const { confirm, ConfirmUI } = useConfirm();
24
24
 
25
25
  const load = async () => {
26
26
  try { const r = await getSecurityIncidents(); setState({ incidents: r?.data?.incidents || [], open: r?.data?.open || 0, total: r?.data?.total || 0, loading: false }); }
27
- catch (e) { setState({ incidents: [], open: 0, total: 0, loading: false }); showToast((e && e.message) || 'Failed to load incidents'); }
27
+ catch (e) { setState({ incidents: [], open: 0, total: 0, loading: false }); showErr((e && e.message) || 'Failed to load incidents'); }
28
28
  };
29
29
  useEffect(() => { load(); }, []);
30
30
 
31
31
  const resolve = async (id) => {
32
- try { await resolveSecurityIncident(id); showToast(t('admin.security.resolved') || 'Incident resolved'); load(); }
33
- catch (e) { showToast((e && e.message) || 'Failed'); }
32
+ try { await resolveSecurityIncident(id); showOk(t('admin.security.resolved') || 'Incident resolved'); load(); }
33
+ catch (e) { showErr((e && e.message) || 'Failed'); }
34
34
  };
35
35
  const remove = (id) => confirm(
36
36
  t('admin.security.deleteConfirm') || 'Delete this incident and its quarantined file?',
37
- async () => { try { await deleteSecurityIncident(id); showToast(t('admin.security.deleted') || 'Deleted'); load(); } catch (e) { showToast((e && e.message) || 'Failed'); } },
37
+ async () => { try { await deleteSecurityIncident(id); showOk(t('admin.security.deleted') || 'Deleted'); load(); } catch (e) { showErr((e && e.message) || 'Failed'); } },
38
38
  { danger: true, title: t('admin.security.title') || 'Security' },
39
39
  );
40
40
  const downloadQuarantine = async (id) => {
@@ -46,12 +46,12 @@ export default function SecurityTab() {
46
46
  const url = URL.createObjectURL(blob);
47
47
  const a = document.createElement('a'); a.href = url; a.download = `quarantine-${id}.zip`;
48
48
  document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
49
- } catch (e) { showToast((e && e.message) || 'No quarantined file'); }
49
+ } catch (e) { showErr((e && e.message) || 'No quarantined file'); }
50
50
  };
51
51
 
52
52
  return html`
53
53
  <div class="adm-section">
54
- <${ConfirmUI} /><${Toast} toast=${toast} />
54
+ <${ConfirmUI} />${toast && html`<${Toast} ...${toast} onDismiss=${clearToast} />`}
55
55
  <div class="adm-card">
56
56
  <h2 class="adm-sec-title">${'🛡️ '}${t('admin.security.title') || 'Security'}</h2>
57
57
  <p class="adm-sec-desc">