@betterportal/config-manager 10.6.30 → 10.6.32

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 (55) hide show
  1. package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +1 -1
  2. package/lib/.bsb/clients/service-betterportal-config-manager.js +1 -1
  3. package/lib/bp-contracts/org.betterportal.config-manager.json +1 -1
  4. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +3 -2
  5. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/adminApi.js +58 -36
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  8. package/lib/plugins/service-betterportal-config-manager/appQueries.d.ts +976 -0
  9. package/lib/plugins/service-betterportal-config-manager/appQueries.d.ts.map +1 -0
  10. package/lib/plugins/service-betterportal-config-manager/appQueries.js +330 -0
  11. package/lib/plugins/service-betterportal-config-manager/appQueries.js.map +1 -0
  12. package/lib/plugins/service-betterportal-config-manager/index.d.ts +6 -0
  13. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  14. package/lib/plugins/service-betterportal-config-manager/index.js +83 -23
  15. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  16. package/lib/plugins/service-betterportal-config-manager/m2mConnections.d.ts +6 -3
  17. package/lib/plugins/service-betterportal-config-manager/m2mConnections.d.ts.map +1 -1
  18. package/lib/plugins/service-betterportal-config-manager/m2mConnections.js.map +1 -1
  19. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts +2 -1
  20. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -1
  21. package/lib/plugins/service-betterportal-config-manager/menuEditor.js +131 -160
  22. package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -1
  23. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts +1 -1
  24. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts.map +1 -1
  25. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -1
  26. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts +2 -0
  27. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts.map +1 -1
  28. package/lib/plugins/service-betterportal-config-manager/routeContext.js.map +1 -1
  29. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts +1 -1
  30. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts.map +1 -1
  31. package/lib/plugins/service-betterportal-config-manager/storage/appData.js +12 -5
  32. package/lib/plugins/service-betterportal-config-manager/storage/appData.js.map +1 -1
  33. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +5 -3
  34. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  35. package/lib/plugins/service-betterportal-config-manager/storage/core.js +1 -1
  36. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  37. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts.map +1 -1
  38. package/lib/plugins/service-betterportal-config-manager/storage/entities.js +4 -2
  39. package/lib/plugins/service-betterportal-config-manager/storage/entities.js.map +1 -1
  40. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +19 -2
  41. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  42. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +74 -22
  43. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  44. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +4 -3
  45. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  46. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  47. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
  48. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +3 -1
  49. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js.map +1 -1
  50. package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts.map +1 -1
  51. package/lib/plugins/service-betterportal-config-manager/webhooks.js +147 -54
  52. package/lib/plugins/service-betterportal-config-manager/webhooks.js.map +1 -1
  53. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  54. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  55. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
- import { htmlResponse, jsonResponse, uuidv7 } from "@betterportal/framework";
2
- import { getManifestCache } from "./syncApi.js";
1
+ import { eventObservability, menuItemVisible, htmlResponse, jsonResponse, uuidv7 } from "@betterportal/framework";
2
+ import { getManifestCache, injectResolvedServicePaths } from "./syncApi.js";
3
3
  import { isApiRoute, isMenuRouteExcluded } from "./routeMounts.js";
4
+ import { editAppRows, readApp } from "./appQueries.js";
4
5
  const API_BASE = "/.well-known/bp/admin";
5
6
  // Parse-only base for relative request URLs. Never emit this origin.
6
7
  const RELATIVE_URL_PARSE_BASE = "http://betterportal.invalid";
@@ -124,12 +125,12 @@ function titleDisplayHtml(item, route, appId) {
124
125
  style="border:1px solid var(--bs-border-color); border-radius:0.375rem; padding:0.25rem 0.6rem; background:transparent; cursor:text; text-align:left; min-width:200px; font-weight:500;"
125
126
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-title"
126
127
  hx-target="#bp-menu-title-${item.id}"
127
- hx-swap="outerHTML"
128
+ hx-swap="outerHTML settle:0"
128
129
  title="Click to rename">${escapeHtml(titleText)}</button>`;
129
130
  }
130
131
  function titleEditorHtml(item, route, appId) {
131
132
  return `<form id="bp-menu-title-${item.id}" data-bp-menu-editing
132
- hx-post="${API_BASE}/menu-editor/save-title" hx-target="#bp-menu-title-${item.id}" hx-swap="outerHTML"
133
+ hx-post="${API_BASE}/menu-editor/save-title" hx-target="#bp-menu-title-${item.id}" hx-swap="outerHTML settle:0"
133
134
  class="d-flex align-items-center gap-1 flex-grow-1 m-0">
134
135
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
135
136
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
@@ -137,7 +138,7 @@ function titleEditorHtml(item, route, appId) {
137
138
  <button type="submit" class="btn btn-sm btn-success" title="Save">OK</button>
138
139
  <button type="button" class="btn btn-sm btn-outline-secondary" title="Cancel"
139
140
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display-title"
140
- hx-target="#bp-menu-title-${item.id}" hx-swap="outerHTML">X</button>
141
+ hx-target="#bp-menu-title-${item.id}" hx-swap="outerHTML settle:0">X</button>
141
142
  </form>`;
142
143
  }
143
144
  function subLineHtml(item, route, config, appId) {
@@ -154,7 +155,7 @@ function subLineHtml(item, route, config, appId) {
154
155
  <button type="button" class="btn btn-sm btn-link p-0"
155
156
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-external"
156
157
  hx-target="#bp-menu-row-${item.id}"
157
- hx-swap="outerHTML"
158
+ hx-swap="outerHTML settle:0"
158
159
  title="Edit URL">Edit</button>
159
160
  </div>`;
160
161
  }
@@ -173,12 +174,12 @@ function subLineHtml(item, route, config, appId) {
173
174
  <button type="button" class="btn btn-sm btn-link p-0 ms-1"
174
175
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-link"
175
176
  hx-target="#bp-menu-row-${item.id}"
176
- hx-swap="outerHTML"
177
+ hx-swap="outerHTML settle:0"
177
178
  title="Edit URL & paths">Edit</button>
178
179
  </div>`;
179
180
  }
180
181
  function actionButtons(item, appId) {
181
- const btn = (action, label, btnClass, title) => `<form hx-post="${API_BASE}/menu-editor/${action}" hx-target="#bp-menu-editor" hx-swap="outerHTML" class="d-inline m-0">
182
+ const btn = (action, label, btnClass, title) => `<form hx-post="${API_BASE}/menu-editor/${action}" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0" class="d-inline m-0">
182
183
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
183
184
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
184
185
  <button type="submit" class="btn btn-sm ${btnClass}" title="${escapeHtml(title)}">${label}</button>
@@ -192,13 +193,51 @@ function actionButtons(item, appId) {
192
193
  ${btn("remove", "x", "btn-outline-danger", "Remove")}
193
194
  </div>`;
194
195
  }
195
- function renderRow(item, depth, mode, config, appDef, appId) {
196
+ function visibilityControls(item) {
197
+ const options = [["auto", "Automatic"], ["show", "Everyone"], ["hide-unauthenticated", "Signed in"],
198
+ ["show-unauthenticated", "Only signed out"], ["hide-unauthorized", "Has permission"]];
199
+ return `<fieldset class="mb-0"><legend class="small mb-1">Audience</legend>
200
+ <div class="d-flex flex-wrap gap-1">${options.map(([value, label]) => {
201
+ const id = `bp-audience-${item.id}-${value}`;
202
+ return `<input type="radio" class="btn-check" name="authStatus" id="${id}" value="${value}"${(item.authStatus ?? "auto") === value ? " checked" : ""} />
203
+ <label class="btn btn-sm btn-outline-primary" style="--bs-btn-color:var(--bs-body-color)" for="${id}">${label}</label>`;
204
+ }).join("")}</div></fieldset>
205
+ ${item.type === "link" ? `<fieldset class="mb-0"><legend class="small mb-1">When service is unavailable</legend>
206
+ ${[["show", "Show warning"], ["hide", "Hide"]].map(([value, label]) => `<label class="form-check form-check-inline small mb-0">
207
+ <input type="radio" class="form-check-input" name="serviceStatus" value="${value}"${(item.serviceStatus ?? "show") === value ? " checked" : ""} />${label}</label>`).join("")}</fieldset>` : ""}
208
+ ${item.type !== "link" && item.rolesAnyOf?.length ? `<div class="small text-warning">Legacy role restriction: ${escapeHtml(item.rolesAnyOf.join(", "))}.
209
+ <label><input type="checkbox" name="clearRoleRestriction" value="true" /> Remove this restriction</label></div>` : ""}`;
210
+ }
211
+ function accessSummary(config, appDef, item, accessApp) {
212
+ if (item.type !== "link")
213
+ return item.type === "group" ? "Hidden when it has no visible children." : "Audience controls visibility; no linked route permissions.";
214
+ const app = accessApp ?? injectResolvedServicePaths({ tenants: config.tenants, apps: [appDef], managementOrigins: [] }).apps[0];
215
+ const route = app.routes.find(route => route.id === item.routeId);
216
+ if (!route || route.authRequired === undefined || route.menuPermissions === undefined)
217
+ return "Route access unknown until its service syncs. Automatic visibility stays hidden.";
218
+ if (!route.authRequired)
219
+ return "Public route. Access is derived automatically.";
220
+ if (!route.menuPermissions.length)
221
+ return "Requires sign-in. No additional route permissions.";
222
+ const aliases = Object.fromEntries(config.tenants.flatMap((tenant) => tenant.services.map((service) => [service.id, service.serviceId ?? service.id])));
223
+ for (const service of config.platformServices ?? [])
224
+ aliases[service.id] = service.serviceId ?? service.id;
225
+ for (const activation of config.sharedServiceActivations ?? []) {
226
+ const service = config.sharedServiceCatalog?.find((candidate) => candidate.id === activation.sharedServiceId);
227
+ if (service)
228
+ aliases[activation.id] = service.serviceId ?? service.id;
229
+ }
230
+ const roles = (app.auth?.roles ?? []).filter(role => role.id !== "*" && menuItemVisible({ authStatus: "hide-unauthorized" }, route, { status: "authenticated", permissions: role.permissions }, aliases));
231
+ const requirements = route.menuPermissions.map(requirement => `${requirement.viewId}: ${requirement.permissions.join(", ")}`).join("; ");
232
+ return `Route permissions: ${escapeHtml(requirements)}. ${roles.length ? `Roles with access: ${escapeHtml(roles.map(role => role.title).join(", "))}.` : "Access may require a combination of assigned roles."}`;
233
+ }
234
+ function renderRow(item, depth, mode, config, appDef, appId, accessApp) {
196
235
  const routes = getRoutes(appDef);
197
236
  const route = item.routeId ? routes.find((r) => r.id === item.routeId) ?? null : null;
198
237
  const typeBadgeClass = item.type === "group" ? "text-bg-warning" : item.type === "external" ? "text-bg-info" : item.type === "link" ? "text-bg-primary" : "text-bg-secondary";
199
238
  if (mode === "edit-external" && item.type === "external") {
200
239
  return `<li ${rowAttrs(item, depth, true)} class="list-group-item">
201
- <form hx-post="${API_BASE}/menu-editor/save-external" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML"
240
+ <form hx-post="${API_BASE}/menu-editor/save-external" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0"
202
241
  class="d-flex flex-column gap-2">
203
242
  <div class="d-flex align-items-center gap-2">
204
243
  <span class="badge ${typeBadgeClass}">${escapeHtml(item.type)}</span>
@@ -208,32 +247,12 @@ function renderRow(item, depth, mode, config, appDef, appId) {
208
247
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
209
248
  <label class="form-label small mb-0">URL</label>
210
249
  <input type="url" name="href" class="form-control form-control-sm" value="${escapeHtml(item.href ?? "")}" placeholder="https://..." required />
211
- <div class="row g-2">
212
- <div class="col-md-6">
213
- <label class="form-label small mb-0">Unavailable service</label>
214
- <select name="serviceStatus" class="form-select form-select-sm">
215
- <option value="show"${item.serviceStatus !== "hide" ? " selected" : ""}>Show with warning</option>
216
- <option value="hide"${item.serviceStatus === "hide" ? " selected" : ""}>Hide</option>
217
- </select>
218
- </div>
219
- <div class="col-md-6">
220
- <label class="form-label small mb-0">Required roles (any, comma separated)</label>
221
- <input name="rolesAnyOf" class="form-control form-control-sm" value="${escapeHtml((item.rolesAnyOf ?? []).join(", "))}" />
222
- <label class="form-label small mb-0">Authorization</label>
223
- <select name="authStatus" class="form-select form-select-sm">
224
- <option value="auto"${!item.authStatus || item.authStatus === "auto" ? " selected" : ""}>Automatic (route permissions)</option>
225
- <option value="show"${item.authStatus === "show" ? " selected" : ""}>Always show</option>
226
- <option value="hide-unauthenticated"${item.authStatus === "hide-unauthenticated" ? " selected" : ""}>Hide when signed out</option>
227
- <option value="hide-unauthorized"${item.authStatus === "hide-unauthorized" ? " selected" : ""}>Hide when unauthorized</option>
228
- <option value="show-unauthenticated"${item.authStatus === "show-unauthenticated" ? " selected" : ""}>Show only when signed out</option>
229
- </select>
230
- </div>
231
- </div>
250
+ ${visibilityControls(item)}
232
251
  <div class="d-flex gap-2 justify-content-end">
233
252
  <button type="submit" class="btn btn-sm btn-success">OK Save</button>
234
253
  <button type="button" class="btn btn-sm btn-outline-secondary"
235
254
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display"
236
- hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML">X Cancel</button>
255
+ hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0">X Cancel</button>
237
256
  </div>
238
257
  </form>
239
258
  </li>`;
@@ -248,21 +267,13 @@ function renderRow(item, depth, mode, config, appDef, appId) {
248
267
  ${titleDisplayHtml(item, route, appId)}
249
268
  </div>
250
269
  ${subLineHtml(item, route, config, appId)}
251
- <details><summary class="small">Visibility</summary>
252
- <form hx-post="${API_BASE}/menu-editor/save-visibility" hx-target="#bp-menu-editor" hx-swap="outerHTML" class="d-flex gap-2 flex-wrap">
253
- <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
254
- <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
255
- <label class="small">Audience
256
- <select name="authStatus" class="form-select form-select-sm">
257
- ${[["auto", "Automatic (route permissions)"], ["show", "Always show"], ["show-unauthenticated", "Only signed out"], ["hide-unauthenticated", "Only signed in"], ["hide-unauthorized", "Only with permission"]].map(([value, label]) => `<option value="${value}"${(item.authStatus ?? "auto") === value ? " selected" : ""}>${label}</option>`).join("")}
258
- </select>
259
- </label>
260
- <label class="small">Required roles (any, comma separated)
261
- <input name="rolesAnyOf" class="form-control form-control-sm" value="${escapeHtml((item.rolesAnyOf ?? []).join(", "))}" />
262
- </label>
263
- <button type="submit" class="btn btn-sm btn-primary">Save visibility</button>
264
- </form>
265
- </details>
270
+ <div class="small text-body-secondary">${accessSummary(config, appDef, item, accessApp)}</div>
271
+ <form hx-post="${API_BASE}/menu-editor/save-visibility" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0" class="d-flex flex-column gap-2 mt-2">
272
+ <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
273
+ <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
274
+ ${visibilityControls(item)}
275
+ <div><button type="submit" class="btn btn-sm btn-primary">Save visibility</button></div>
276
+ </form>
266
277
  </div>
267
278
  ${actionButtons(item, appId)}
268
279
  </div>
@@ -296,7 +307,7 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
296
307
  ].join("");
297
308
  const viewOpts = renderViewOptions(views, route?.viewId ?? "");
298
309
  return `<li ${rowAttrs(item, depth, true)} class="list-group-item">
299
- <form hx-post="${API_BASE}/menu-editor/save-link" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML"
310
+ <form hx-post="${API_BASE}/menu-editor/save-link" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0"
300
311
  class="d-flex flex-column gap-2">
301
312
  <div class="d-flex align-items-center gap-2">
302
313
  <span class="badge ${typeBadgeClass}">link</span>
@@ -320,7 +331,7 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
320
331
  <select id="bp-views-${item.id}" name="viewId" class="form-select form-select-sm" required
321
332
  hx-get="${API_BASE}/menu-editor/default-target"
322
333
  hx-target="#bp-paths-${item.id}"
323
- hx-swap="outerHTML"
334
+ hx-swap="outerHTML settle:0"
324
335
  hx-trigger="change"
325
336
  hx-include="closest form">${viewOpts}</select>
326
337
  </div>
@@ -338,42 +349,22 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
338
349
  </div>
339
350
  </div>
340
351
  ${renderPathFields(item.id, route?.path ?? "", route?.targetPath ?? "")}
341
- <div class="row g-2">
342
- <div class="col-md-6">
343
- <label class="form-label small mb-0">Unavailable service</label>
344
- <select name="serviceStatus" class="form-select form-select-sm">
345
- <option value="show"${item.serviceStatus !== "hide" ? " selected" : ""}>Show with warning</option>
346
- <option value="hide"${item.serviceStatus === "hide" ? " selected" : ""}>Hide</option>
347
- </select>
348
- </div>
349
- <div class="col-md-6">
350
- <label class="form-label small mb-0">Required roles (any, comma separated)</label>
351
- <input name="rolesAnyOf" class="form-control form-control-sm" value="${escapeHtml((item.rolesAnyOf ?? []).join(", "))}" />
352
- <label class="form-label small mb-0">Authorization</label>
353
- <select name="authStatus" class="form-select form-select-sm">
354
- <option value="auto"${!item.authStatus || item.authStatus === "auto" ? " selected" : ""}>Automatic (route permissions)</option>
355
- <option value="show"${item.authStatus === "show" ? " selected" : ""}>Always show</option>
356
- <option value="hide-unauthenticated"${item.authStatus === "hide-unauthenticated" ? " selected" : ""}>Hide when signed out</option>
357
- <option value="hide-unauthorized"${item.authStatus === "hide-unauthorized" ? " selected" : ""}>Hide when unauthorized</option>
358
- <option value="show-unauthenticated"${item.authStatus === "show-unauthenticated" ? " selected" : ""}>Show only when signed out</option>
359
- </select>
360
- </div>
361
- </div>
352
+ ${visibilityControls(item)}
362
353
  <div class="d-flex gap-2 justify-content-end">
363
354
  <button type="submit" class="btn btn-sm btn-success">OK Save</button>
364
355
  <button type="button" class="btn btn-sm btn-outline-secondary"
365
356
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=display"
366
- hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML">X Cancel</button>
357
+ hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0">X Cancel</button>
367
358
  </div>
368
359
  <div class="small text-secondary">Path/target/service/view edits update the underlying route - affects any other menu items referencing it.</div>
369
360
  </form>
370
361
  </li>`;
371
362
  }
372
- function renderTree(items, depth, config, appDef, appId) {
363
+ function renderTree(items, depth, config, appDef, appId, accessApp = injectResolvedServicePaths({ tenants: config.tenants, apps: [appDef], managementOrigins: [] }).apps[0]) {
373
364
  return items.map((item) => {
374
- const row = renderRow(item, depth, "display", config, appDef, appId);
365
+ const row = renderRow(item, depth, "display", config, appDef, appId, accessApp);
375
366
  const children = item.type === "group" && item.children && item.children.length > 0
376
- ? renderTree(item.children, depth + 1, config, appDef, appId)
367
+ ? renderTree(item.children, depth + 1, config, appDef, appId, accessApp)
377
368
  : "";
378
369
  return row + children;
379
370
  }).join("");
@@ -401,7 +392,7 @@ function renderAddForms(appId, routes, groups) {
401
392
  <div class="card">
402
393
  <div class="card-header"><strong>Add View Link</strong></div>
403
394
  <div class="card-body">
404
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
395
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
405
396
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
406
397
  <input type="hidden" name="type" value="link" />
407
398
  <div class="mb-2">
@@ -428,7 +419,7 @@ function renderAddForms(appId, routes, groups) {
428
419
  <div class="card">
429
420
  <div class="card-header"><strong>Add External Link</strong></div>
430
421
  <div class="card-body">
431
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
422
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
432
423
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
433
424
  <input type="hidden" name="type" value="external" />
434
425
  <div class="mb-2">
@@ -452,7 +443,7 @@ function renderAddForms(appId, routes, groups) {
452
443
  <div class="card">
453
444
  <div class="card-header"><strong>Add Group</strong></div>
454
445
  <div class="card-body">
455
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
446
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
456
447
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
457
448
  <input type="hidden" name="type" value="group" />
458
449
  <div class="mb-2">
@@ -481,7 +472,7 @@ function renderEditor(config, appDef, appId) {
481
472
  <form id="bp-drag-move-form" style="display:none"
482
473
  hx-post="${API_BASE}/menu-editor/move-after"
483
474
  hx-target="#bp-menu-editor"
484
- hx-swap="outerHTML">
475
+ hx-swap="outerHTML settle:0">
485
476
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
486
477
  <input type="hidden" name="itemId" />
487
478
  <input type="hidden" name="anchorId" />
@@ -492,9 +483,25 @@ function renderEditor(config, appDef, appId) {
492
483
  </div>`;
493
484
  }
494
485
  // -- Endpoint registration --------------------------------------------
495
- export function registerMenuEditorRoutes(app, store) {
496
- const respondEditor = async (appId) => {
497
- const config = await store.loadConfig();
486
+ export function registerMenuEditorRoutes(app, store, database) {
487
+ const read = async (appId) => {
488
+ if (!database)
489
+ return store.loadConfig();
490
+ return readApp(database, appId);
491
+ };
492
+ const post = (path, action) => {
493
+ app.post(`${API_BASE}/menu-editor/${path}`, async (event) => {
494
+ const form = await readFormBody(event);
495
+ if (database)
496
+ return editAppRows(database, form.appId, eventObservability(event), data => action(form, data));
497
+ const config = await store.loadConfig();
498
+ const response = await action(form, config);
499
+ if (response.ok)
500
+ await store.saveConfig(config);
501
+ return response;
502
+ });
503
+ };
504
+ const respondEditor = async (config, appId) => {
498
505
  const appDef = getApp(config, appId);
499
506
  if (!appDef)
500
507
  return htmlResponse(`<div class="alert alert-danger">App not found</div>`, 200, "text/html; mode=fragment");
@@ -502,8 +509,7 @@ export function registerMenuEditorRoutes(app, store) {
502
509
  "HX-Trigger": "bp:menu-changed"
503
510
  });
504
511
  };
505
- const respondRow = async (appId, itemId, mode) => {
506
- const config = await store.loadConfig();
512
+ const respondRow = async (config, appId, itemId, mode) => {
507
513
  const appDef = getApp(config, appId);
508
514
  if (!appDef)
509
515
  return htmlResponse("", 200, "text/html; mode=fragment");
@@ -547,18 +553,16 @@ export function registerMenuEditorRoutes(app, store) {
547
553
  const appId = url.searchParams.get("appId") ?? "";
548
554
  if (!appId)
549
555
  return htmlResponse(`<div class="alert alert-secondary">Select an app</div>`, 200, "text/html; mode=fragment");
550
- return respondEditor(appId);
556
+ return respondEditor(await read(appId), appId);
551
557
  });
552
558
  app.get(`${API_BASE}/menu-editor/item`, async (event) => {
553
559
  const url = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE);
554
560
  const appId = url.searchParams.get("appId") ?? "";
555
561
  const itemId = url.searchParams.get("itemId") ?? "";
556
562
  const mode = (url.searchParams.get("mode") ?? "display");
557
- return respondRow(appId, itemId, mode);
563
+ return respondRow(await read(appId), appId, itemId, mode);
558
564
  });
559
- app.post(`${API_BASE}/menu-editor/save-visibility`, async (event) => {
560
- const f = await readFormBody(event);
561
- const config = await store.loadConfig();
565
+ post("save-visibility", async (f, config) => {
562
566
  const appDef = getApp(config, f.appId);
563
567
  if (!appDef)
564
568
  return jsonResponse({ error: "App not found" }, 404);
@@ -569,13 +573,13 @@ export function registerMenuEditorRoutes(app, store) {
569
573
  return jsonResponse({ error: "Invalid visibility" }, 400);
570
574
  }
571
575
  found.item.authStatus = f.authStatus;
572
- found.item.rolesAnyOf = [...new Set((f.rolesAnyOf ?? "").split(",").map(role => role.trim()).filter(Boolean))];
573
- await store.saveConfig(config);
574
- return respondEditor(f.appId);
576
+ if (found.item.type === "link")
577
+ found.item.serviceStatus = f.serviceStatus === "hide" ? "hide" : "show";
578
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
579
+ delete found.item.rolesAnyOf;
580
+ return respondEditor(config, f.appId);
575
581
  });
576
- app.post(`${API_BASE}/menu-editor/save-title`, async (event) => {
577
- const f = await readFormBody(event);
578
- const config = await store.loadConfig();
582
+ post("save-title", async (f, config) => {
579
583
  const appDef = getApp(config, f.appId);
580
584
  if (!appDef)
581
585
  return jsonResponse({ error: "App not found" }, 404);
@@ -584,9 +588,8 @@ export function registerMenuEditorRoutes(app, store) {
584
588
  if (found)
585
589
  found.item.title = f.title || undefined;
586
590
  appDef.menu = menu;
587
- await store.saveConfig(config);
588
591
  // Return single row + HX-Trigger to refresh sidebar nav
589
- const config2 = await store.loadConfig();
592
+ const config2 = config;
590
593
  const appDef2 = getApp(config2, f.appId);
591
594
  const found2 = appDef2 ? locate(getMenu(appDef2), f.itemId) : null;
592
595
  if (!appDef2 || !found2)
@@ -598,9 +601,7 @@ export function registerMenuEditorRoutes(app, store) {
598
601
  "HX-Trigger": "bp:menu-changed"
599
602
  });
600
603
  });
601
- app.post(`${API_BASE}/menu-editor/save-link`, async (event) => {
602
- const f = await readFormBody(event);
603
- const config = await store.loadConfig();
604
+ post("save-link", async (f, config) => {
604
605
  const appDef = getApp(config, f.appId);
605
606
  if (!appDef)
606
607
  return jsonResponse({ error: "App not found" }, 404);
@@ -610,7 +611,8 @@ export function registerMenuEditorRoutes(app, store) {
610
611
  return htmlResponse(`<div class="alert alert-danger">Item not found</div>`, 200, "text/html; mode=fragment");
611
612
  }
612
613
  found.item.title = f.title || undefined;
613
- found.item.rolesAnyOf = (f.rolesAnyOf ?? "").split(",").map(role => role.trim()).filter(Boolean);
614
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
615
+ delete found.item.rolesAnyOf;
614
616
  found.item.serviceStatus = f.serviceStatus === "hide" ? "hide" : "show";
615
617
  found.item.authStatus = ["auto", "show", "hide-unauthenticated", "hide-unauthorized", "show-unauthenticated"].includes(f.authStatus) ? f.authStatus : "auto";
616
618
  const route = (appDef.routes ?? []).find((r) => r.id === found.item.routeId);
@@ -628,8 +630,7 @@ export function registerMenuEditorRoutes(app, store) {
628
630
  return jsonResponse({ error: "Route cannot be added to the menu" }, 400);
629
631
  }
630
632
  appDef.menu = menu;
631
- await store.saveConfig(config);
632
- return respondRow(f.appId, f.itemId, "display").then((r) => {
633
+ return respondRow(config, f.appId, f.itemId, "display").then((r) => {
633
634
  const headers = new Headers(r.headers);
634
635
  headers.set("HX-Trigger", "bp:menu-changed");
635
636
  return new Response(r.body, { status: r.status, headers });
@@ -656,7 +657,7 @@ export function registerMenuEditorRoutes(app, store) {
656
657
  if (url.searchParams.get("autoSetPaths") === "true" && serviceId && viewId) {
657
658
  const views = lookupServiceViews(serviceId);
658
659
  targetPath = views.find((v) => v.viewId === viewId)?.path ?? "";
659
- const config = await store.loadConfig();
660
+ const config = await read(appId);
660
661
  const appDef = getApp(config, appId);
661
662
  const assignedRoute = (appDef?.routes ?? []).find((route) => route.kind !== "api" && route.serviceId === serviceId && route.viewId === viewId);
662
663
  if (assignedRoute) {
@@ -666,16 +667,15 @@ export function registerMenuEditorRoutes(app, store) {
666
667
  }
667
668
  return htmlResponse(renderPathFields(itemId, path, targetPath), 200, "text/html; mode=fragment");
668
669
  });
669
- app.post(`${API_BASE}/menu-editor/save-external`, async (event) => {
670
- const f = await readFormBody(event);
671
- const config = await store.loadConfig();
670
+ post("save-external", async (f, config) => {
672
671
  const appDef = getApp(config, f.appId);
673
672
  if (!appDef)
674
673
  return jsonResponse({ error: "App not found" }, 404);
675
674
  const menu = getMenu(appDef);
676
675
  const found = locate(menu, f.itemId);
677
676
  if (found) {
678
- found.item.rolesAnyOf = (f.rolesAnyOf ?? "").split(",").map(role => role.trim()).filter(Boolean);
677
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
678
+ delete found.item.rolesAnyOf;
679
679
  found.item.authStatus = ["auto", "show", "hide-unauthenticated", "hide-unauthorized", "show-unauthenticated"].includes(f.authStatus) ? f.authStatus : "auto";
680
680
  found.item.href = f.href || "";
681
681
  if (f.title !== undefined && f.title !== "")
@@ -685,17 +685,14 @@ export function registerMenuEditorRoutes(app, store) {
685
685
  return jsonResponse({ error: "Route cannot be added to the menu" }, 400);
686
686
  }
687
687
  appDef.menu = menu;
688
- await store.saveConfig(config);
689
- return respondRow(f.appId, f.itemId, "display").then((r) => {
688
+ return respondRow(config, f.appId, f.itemId, "display").then((r) => {
690
689
  // Add HX-Trigger to existing response
691
690
  const headers = new Headers(r.headers);
692
691
  headers.set("HX-Trigger", "bp:menu-changed");
693
692
  return new Response(r.body, { status: r.status, headers });
694
693
  });
695
694
  });
696
- app.post(`${API_BASE}/menu-editor/add`, async (event) => {
697
- const f = await readFormBody(event);
698
- const config = await store.loadConfig();
695
+ post("add", async (f, config) => {
699
696
  const appDef = getApp(config, f.appId);
700
697
  if (!appDef)
701
698
  return jsonResponse({ error: "App not found" }, 404);
@@ -727,12 +724,9 @@ export function registerMenuEditorRoutes(app, store) {
727
724
  menu.push(newItem);
728
725
  }
729
726
  appDef.menu = menu;
730
- await store.saveConfig(config);
731
- return respondEditor(f.appId);
727
+ return respondEditor(config, f.appId);
732
728
  });
733
- app.post(`${API_BASE}/menu-editor/remove`, async (event) => {
734
- const f = await readFormBody(event);
735
- const config = await store.loadConfig();
729
+ post("remove", async (f, config) => {
736
730
  const appDef = getApp(config, f.appId);
737
731
  if (!appDef)
738
732
  return jsonResponse({ error: "App not found" }, 404);
@@ -741,12 +735,9 @@ export function registerMenuEditorRoutes(app, store) {
741
735
  if (found)
742
736
  found.parent.splice(found.index, 1);
743
737
  appDef.menu = menu;
744
- await store.saveConfig(config);
745
- return respondEditor(f.appId);
738
+ return respondEditor(config, f.appId);
746
739
  });
747
- app.post(`${API_BASE}/menu-editor/toggle`, async (event) => {
748
- const f = await readFormBody(event);
749
- const config = await store.loadConfig();
740
+ post("toggle", async (f, config) => {
750
741
  const appDef = getApp(config, f.appId);
751
742
  if (!appDef)
752
743
  return jsonResponse({ error: "App not found" }, 404);
@@ -755,12 +746,9 @@ export function registerMenuEditorRoutes(app, store) {
755
746
  if (found)
756
747
  found.item.enabled = !found.item.enabled;
757
748
  appDef.menu = menu;
758
- await store.saveConfig(config);
759
- return respondEditor(f.appId);
749
+ return respondEditor(config, f.appId);
760
750
  });
761
- app.post(`${API_BASE}/menu-editor/toggle-expanded`, async (event) => {
762
- const f = await readFormBody(event);
763
- const config = await store.loadConfig();
751
+ post("toggle-expanded", async (f, config) => {
764
752
  const appDef = getApp(config, f.appId);
765
753
  if (!appDef)
766
754
  return jsonResponse({ error: "App not found" }, 404);
@@ -770,12 +758,9 @@ export function registerMenuEditorRoutes(app, store) {
770
758
  found.item.defaultExpanded = !found.item.defaultExpanded;
771
759
  }
772
760
  appDef.menu = menu;
773
- await store.saveConfig(config);
774
- return respondEditor(f.appId);
761
+ return respondEditor(config, f.appId);
775
762
  });
776
- app.post(`${API_BASE}/menu-editor/move-up`, async (event) => {
777
- const f = await readFormBody(event);
778
- const config = await store.loadConfig();
763
+ post("move-up", async (f, config) => {
779
764
  const appDef = getApp(config, f.appId);
780
765
  if (!appDef)
781
766
  return jsonResponse({ error: "App not found" }, 404);
@@ -786,12 +771,9 @@ export function registerMenuEditorRoutes(app, store) {
786
771
  found.parent.splice(found.index - 1, 0, it);
787
772
  }
788
773
  appDef.menu = menu;
789
- await store.saveConfig(config);
790
- return respondEditor(f.appId);
774
+ return respondEditor(config, f.appId);
791
775
  });
792
- app.post(`${API_BASE}/menu-editor/move-down`, async (event) => {
793
- const f = await readFormBody(event);
794
- const config = await store.loadConfig();
776
+ post("move-down", async (f, config) => {
795
777
  const appDef = getApp(config, f.appId);
796
778
  if (!appDef)
797
779
  return jsonResponse({ error: "App not found" }, 404);
@@ -802,12 +784,9 @@ export function registerMenuEditorRoutes(app, store) {
802
784
  found.parent.splice(found.index + 1, 0, it);
803
785
  }
804
786
  appDef.menu = menu;
805
- await store.saveConfig(config);
806
- return respondEditor(f.appId);
787
+ return respondEditor(config, f.appId);
807
788
  });
808
- app.post(`${API_BASE}/menu-editor/move-in`, async (event) => {
809
- const f = await readFormBody(event);
810
- const config = await store.loadConfig();
789
+ post("move-in", async (f, config) => {
811
790
  const appDef = getApp(config, f.appId);
812
791
  if (!appDef)
813
792
  return jsonResponse({ error: "App not found" }, 404);
@@ -822,12 +801,9 @@ export function registerMenuEditorRoutes(app, store) {
822
801
  }
823
802
  }
824
803
  appDef.menu = menu;
825
- await store.saveConfig(config);
826
- return respondEditor(f.appId);
804
+ return respondEditor(config, f.appId);
827
805
  });
828
- app.post(`${API_BASE}/menu-editor/move-after`, async (event) => {
829
- const f = await readFormBody(event);
830
- const config = await store.loadConfig();
806
+ post("move-after", async (f, config) => {
831
807
  const appDef = getApp(config, f.appId);
832
808
  if (!appDef)
833
809
  return jsonResponse({ error: "App not found" }, 404);
@@ -836,10 +812,10 @@ export function registerMenuEditorRoutes(app, store) {
836
812
  const anchorId = f.anchorId || "";
837
813
  const targetDepth = Math.max(0, parseInt(f.targetDepth ?? "0", 10) || 0);
838
814
  if (!srcId || srcId === anchorId)
839
- return respondEditor(f.appId);
815
+ return respondEditor(config, f.appId);
840
816
  const src = locate(menu, srcId);
841
817
  if (!src)
842
- return respondEditor(f.appId);
818
+ return respondEditor(config, f.appId);
843
819
  // Prevent dropping into own descendant
844
820
  const isDescendant = (items, id) => {
845
821
  for (const it of items) {
@@ -851,7 +827,7 @@ export function registerMenuEditorRoutes(app, store) {
851
827
  return false;
852
828
  };
853
829
  if (anchorId && src.item.type === "group" && src.item.children && isDescendant(src.item.children, anchorId)) {
854
- return respondEditor(f.appId);
830
+ return respondEditor(config, f.appId);
855
831
  }
856
832
  // Remove src
857
833
  const [srcItem] = src.parent.splice(src.index, 1);
@@ -859,8 +835,7 @@ export function registerMenuEditorRoutes(app, store) {
859
835
  // Insert at start of root
860
836
  menu.unshift(srcItem);
861
837
  appDef.menu = menu;
862
- await store.saveConfig(config);
863
- return respondEditor(f.appId);
838
+ return respondEditor(config, f.appId);
864
839
  }
865
840
  // Build ancestor chain to anchor
866
841
  const ancestorChain = (items, id, chain = [], depth = 0) => {
@@ -879,7 +854,7 @@ export function registerMenuEditorRoutes(app, store) {
879
854
  const chain = ancestorChain(menu, anchorId);
880
855
  if (!chain) {
881
856
  src.parent.splice(src.index, 0, srcItem);
882
- return respondEditor(f.appId);
857
+ return respondEditor(config, f.appId);
883
858
  }
884
859
  const anchor = chain[chain.length - 1];
885
860
  const anchorDepth = anchor.depth;
@@ -904,12 +879,9 @@ export function registerMenuEditorRoutes(app, store) {
904
879
  }
905
880
  }
906
881
  appDef.menu = menu;
907
- await store.saveConfig(config);
908
- return respondEditor(f.appId);
882
+ return respondEditor(config, f.appId);
909
883
  });
910
- app.post(`${API_BASE}/menu-editor/move-out`, async (event) => {
911
- const f = await readFormBody(event);
912
- const config = await store.loadConfig();
884
+ post("move-out", async (f, config) => {
913
885
  const appDef = getApp(config, f.appId);
914
886
  if (!appDef)
915
887
  return jsonResponse({ error: "App not found" }, 404);
@@ -924,8 +896,7 @@ export function registerMenuEditorRoutes(app, store) {
924
896
  }
925
897
  }
926
898
  appDef.menu = menu;
927
- await store.saveConfig(config);
928
- return respondEditor(f.appId);
899
+ return respondEditor(config, f.appId);
929
900
  });
930
901
  }
931
902
  //# sourceMappingURL=menuEditor.js.map