@betterportal/config-manager 10.6.31 → 10.6.33

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 (65) 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 +69 -1
  4. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts +32 -0
  5. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +3 -2
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  8. package/lib/plugins/service-betterportal-config-manager/adminApi.js +58 -36
  9. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  10. package/lib/plugins/service-betterportal-config-manager/appQueries.d.ts +1096 -0
  11. package/lib/plugins/service-betterportal-config-manager/appQueries.d.ts.map +1 -0
  12. package/lib/plugins/service-betterportal-config-manager/appQueries.js +330 -0
  13. package/lib/plugins/service-betterportal-config-manager/appQueries.js.map +1 -0
  14. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.d.ts.map +1 -1
  15. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js +35 -5
  16. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js.map +1 -1
  17. package/lib/plugins/service-betterportal-config-manager/index.d.ts +4 -0
  18. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  19. package/lib/plugins/service-betterportal-config-manager/index.js +70 -22
  20. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  21. package/lib/plugins/service-betterportal-config-manager/m2mConnections.d.ts +6 -3
  22. package/lib/plugins/service-betterportal-config-manager/m2mConnections.d.ts.map +1 -1
  23. package/lib/plugins/service-betterportal-config-manager/m2mConnections.js.map +1 -1
  24. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts +2 -1
  25. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -1
  26. package/lib/plugins/service-betterportal-config-manager/menuEditor.js +165 -176
  27. package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -1
  28. package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.d.ts +10 -0
  29. package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.d.ts.map +1 -1
  30. package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.js +90 -1
  31. package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.js.map +1 -1
  32. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts +1 -1
  33. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts.map +1 -1
  34. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +133 -23
  35. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -1
  36. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts +2 -0
  37. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts.map +1 -1
  38. package/lib/plugins/service-betterportal-config-manager/routeContext.js.map +1 -1
  39. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts +1 -1
  40. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts.map +1 -1
  41. package/lib/plugins/service-betterportal-config-manager/storage/appData.js +4 -4
  42. package/lib/plugins/service-betterportal-config-manager/storage/appData.js.map +1 -1
  43. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +5 -3
  44. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  45. package/lib/plugins/service-betterportal-config-manager/storage/core.js +1 -1
  46. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  47. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts.map +1 -1
  48. package/lib/plugins/service-betterportal-config-manager/storage/entities.js +4 -2
  49. package/lib/plugins/service-betterportal-config-manager/storage/entities.js.map +1 -1
  50. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +14 -0
  51. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  52. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +11 -0
  53. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  54. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +4 -3
  55. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  56. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  57. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
  58. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +3 -1
  59. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js.map +1 -1
  60. package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts.map +1 -1
  61. package/lib/plugins/service-betterportal-config-manager/webhooks.js +147 -54
  62. package/lib/plugins/service-betterportal-config-manager/webhooks.js.map +1 -1
  63. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  64. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  65. 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";
@@ -101,7 +102,10 @@ function lookupServiceViews(serviceId) {
101
102
  }));
102
103
  }
103
104
  function rowAttrs(item, depth, editing = false) {
104
- return `id="bp-menu-row-${item.id}" draggable="${editing ? "false" : "true"}" data-bp-drag-item="${item.id}" data-bp-drag-type="${item.type}" data-bp-drag-depth="${depth}"${editing ? " data-bp-menu-editing" : ""} style="padding-left: ${depth * 1.5 + 1}rem;"`;
105
+ return `hx-indicator:inherited="#bp-menu-loading-${item.id}" hx-sync:inherited="closest li:queue" id="bp-menu-row-${item.id}" draggable="${editing ? "false" : "true"}" data-bp-drag-item="${item.id}" data-bp-drag-type="${item.type}" data-bp-drag-depth="${depth}"${editing ? " data-bp-menu-editing" : ""} style="padding-left: ${depth * 1.5 + 1}rem;"`;
106
+ }
107
+ function rowLoader(item) {
108
+ return `<div id="bp-menu-loading-${item.id}" class="htmx-indicator bp-menu-loading" role="status"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Updating…</div>`;
105
109
  }
106
110
  function editableTitleText(item, route) {
107
111
  if (item.type === "section")
@@ -124,12 +128,12 @@ function titleDisplayHtml(item, route, appId) {
124
128
  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
129
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-title"
126
130
  hx-target="#bp-menu-title-${item.id}"
127
- hx-swap="outerHTML"
131
+ hx-swap="outerHTML settle:0"
128
132
  title="Click to rename">${escapeHtml(titleText)}</button>`;
129
133
  }
130
134
  function titleEditorHtml(item, route, appId) {
131
135
  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"
136
+ hx-post="${API_BASE}/menu-editor/save-title" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0"
133
137
  class="d-flex align-items-center gap-1 flex-grow-1 m-0">
134
138
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
135
139
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
@@ -137,7 +141,7 @@ function titleEditorHtml(item, route, appId) {
137
141
  <button type="submit" class="btn btn-sm btn-success" title="Save">OK</button>
138
142
  <button type="button" class="btn btn-sm btn-outline-secondary" title="Cancel"
139
143
  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>
144
+ hx-target="#bp-menu-title-${item.id}" hx-swap="outerHTML settle:0">X</button>
141
145
  </form>`;
142
146
  }
143
147
  function subLineHtml(item, route, config, appId) {
@@ -154,7 +158,7 @@ function subLineHtml(item, route, config, appId) {
154
158
  <button type="button" class="btn btn-sm btn-link p-0"
155
159
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-external"
156
160
  hx-target="#bp-menu-row-${item.id}"
157
- hx-swap="outerHTML"
161
+ hx-swap="outerHTML settle:0"
158
162
  title="Edit URL">Edit</button>
159
163
  </div>`;
160
164
  }
@@ -173,12 +177,12 @@ function subLineHtml(item, route, config, appId) {
173
177
  <button type="button" class="btn btn-sm btn-link p-0 ms-1"
174
178
  hx-get="${API_BASE}/menu-editor/item?appId=${encodeURIComponent(appId)}&itemId=${encodeURIComponent(item.id)}&mode=edit-link"
175
179
  hx-target="#bp-menu-row-${item.id}"
176
- hx-swap="outerHTML"
180
+ hx-swap="outerHTML settle:0"
177
181
  title="Edit URL & paths">Edit</button>
178
182
  </div>`;
179
183
  }
180
184
  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">
185
+ 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
186
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
183
187
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
184
188
  <button type="submit" class="btn btn-sm ${btnClass}" title="${escapeHtml(title)}">${label}</button>
@@ -192,13 +196,52 @@ function actionButtons(item, appId) {
192
196
  ${btn("remove", "x", "btn-outline-danger", "Remove")}
193
197
  </div>`;
194
198
  }
195
- function renderRow(item, depth, mode, config, appDef, appId) {
199
+ function visibilityControls(item) {
200
+ const options = [["auto", "Automatic"], ["show", "Everyone"], ["hide-unauthenticated", "Signed in"],
201
+ ["show-unauthenticated", "Only signed out"], ["hide-unauthorized", "Has permission"]];
202
+ return `<fieldset class="mb-0"><legend class="small mb-1">Audience</legend>
203
+ <div class="d-flex flex-wrap gap-1">${options.map(([value, label]) => {
204
+ const id = `bp-audience-${item.id}-${value}`;
205
+ return `<input type="radio" class="btn-check" name="authStatus" id="${id}" value="${value}"${(item.authStatus ?? "auto") === value ? " checked" : ""} />
206
+ <label class="btn btn-sm btn-outline-primary" style="--bs-btn-color:var(--bs-body-color)" for="${id}">${label}</label>`;
207
+ }).join("")}</div>${item.type === "group" ? `<div class="small text-secondary mt-1">Saving this audience also updates all children, including nested groups.</div>` : ""}</fieldset>
208
+ ${item.type === "link" ? `<fieldset class="mb-0"><legend class="small mb-1">When service is unavailable</legend>
209
+ ${[["show", "Show warning"], ["hide", "Hide"]].map(([value, label]) => `<label class="form-check form-check-inline small mb-0">
210
+ <input type="radio" class="form-check-input" name="serviceStatus" value="${value}"${(item.serviceStatus ?? "show") === value ? " checked" : ""} />${label}</label>`).join("")}</fieldset>` : ""}
211
+ ${item.type !== "link" && item.rolesAnyOf?.length ? `<div class="small text-warning">Legacy role restriction: ${escapeHtml(item.rolesAnyOf.join(", "))}.
212
+ <label><input type="checkbox" name="clearRoleRestriction" value="true" /> Remove this restriction</label></div>` : ""}`;
213
+ }
214
+ function accessSummary(config, appDef, item, accessApp) {
215
+ if (item.type !== "link")
216
+ return item.type === "group" ? "Hidden when it has no visible children." : "Audience controls visibility; no linked route permissions.";
217
+ const app = accessApp ?? injectResolvedServicePaths({ tenants: config.tenants, apps: [appDef], managementOrigins: [] }).apps[0];
218
+ const route = app.routes.find(route => route.id === item.routeId);
219
+ if (!route || route.authRequired === undefined || route.menuPermissions === undefined)
220
+ return "Route access unknown until its service syncs. Automatic visibility stays hidden.";
221
+ if (!route.authRequired)
222
+ return "Public route. Access is derived automatically.";
223
+ if (!route.menuPermissions.length)
224
+ return "Requires sign-in. No additional route permissions.";
225
+ const aliases = Object.fromEntries(config.tenants.flatMap((tenant) => tenant.services.map((service) => [service.id, service.serviceId ?? service.id])));
226
+ for (const service of config.platformServices ?? [])
227
+ aliases[service.id] = service.serviceId ?? service.id;
228
+ for (const activation of config.sharedServiceActivations ?? []) {
229
+ const service = config.sharedServiceCatalog?.find((candidate) => candidate.id === activation.sharedServiceId);
230
+ if (service)
231
+ aliases[activation.id] = service.serviceId ?? service.id;
232
+ }
233
+ const roles = (app.auth?.roles ?? []).filter(role => role.id !== "*" && menuItemVisible({ authStatus: "hide-unauthorized" }, route, { status: "authenticated", permissions: role.permissions }, aliases));
234
+ const requirements = route.menuPermissions.map(requirement => `${requirement.viewId}: ${requirement.permissions.join(", ")}`).join("; ");
235
+ 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."}`;
236
+ }
237
+ function renderRow(item, depth, mode, config, appDef, appId, accessApp) {
196
238
  const routes = getRoutes(appDef);
197
239
  const route = item.routeId ? routes.find((r) => r.id === item.routeId) ?? null : null;
198
240
  const typeBadgeClass = item.type === "group" ? "text-bg-warning" : item.type === "external" ? "text-bg-info" : item.type === "link" ? "text-bg-primary" : "text-bg-secondary";
199
241
  if (mode === "edit-external" && item.type === "external") {
200
- 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"
242
+ return `<li ${rowAttrs(item, depth, true)} class="list-group-item position-relative">
243
+ ${rowLoader(item)}
244
+ <form hx-post="${API_BASE}/menu-editor/save-external" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0"
202
245
  class="d-flex flex-column gap-2">
203
246
  <div class="d-flex align-items-center gap-2">
204
247
  <span class="badge ${typeBadgeClass}">${escapeHtml(item.type)}</span>
@@ -208,38 +251,19 @@ function renderRow(item, depth, mode, config, appDef, appId) {
208
251
  <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
209
252
  <label class="form-label small mb-0">URL</label>
210
253
  <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>
254
+ ${visibilityControls(item)}
232
255
  <div class="d-flex gap-2 justify-content-end">
233
256
  <button type="submit" class="btn btn-sm btn-success">OK Save</button>
234
257
  <button type="button" class="btn btn-sm btn-outline-secondary"
235
258
  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>
259
+ hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0">X Cancel</button>
237
260
  </div>
238
261
  </form>
239
262
  </li>`;
240
263
  }
241
264
  // display mode
242
- return `<li ${rowAttrs(item, depth)} class="list-group-item">
265
+ return `<li ${rowAttrs(item, depth)} class="list-group-item position-relative">
266
+ ${rowLoader(item)}
243
267
  <div class="d-flex align-items-start gap-3">
244
268
  <span class="text-secondary" style="cursor:grab; padding-top:0.3rem; user-select:none; font-size:0.75rem; line-height:1;" title="Drag to reorder">drag</span>
245
269
  <div class="d-flex flex-column gap-1 flex-grow-1 min-width-0">
@@ -248,21 +272,13 @@ function renderRow(item, depth, mode, config, appDef, appId) {
248
272
  ${titleDisplayHtml(item, route, appId)}
249
273
  </div>
250
274
  ${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>
275
+ <div class="small text-body-secondary">${accessSummary(config, appDef, item, accessApp)}</div>
276
+ <form hx-post="${API_BASE}/menu-editor/save-visibility" hx-target="${item.type === "group" ? "#bp-menu-editor" : `#bp-menu-row-${item.id}`}" hx-swap="outerHTML settle:0" class="d-flex flex-column gap-2 mt-2">
277
+ <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
278
+ <input type="hidden" name="itemId" value="${escapeHtml(item.id)}" />
279
+ ${visibilityControls(item)}
280
+ <div><button type="submit" class="btn btn-sm btn-primary">Save visibility</button></div>
281
+ </form>
266
282
  </div>
267
283
  ${actionButtons(item, appId)}
268
284
  </div>
@@ -295,8 +311,9 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
295
311
  ...services.map((s) => `<option value="${escapeHtml(s.id)}"${s.id === route?.serviceId ? " selected" : ""}>${escapeHtml(s.title)}</option>`)
296
312
  ].join("");
297
313
  const viewOpts = renderViewOptions(views, route?.viewId ?? "");
298
- 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"
314
+ return `<li ${rowAttrs(item, depth, true)} class="list-group-item position-relative">
315
+ ${rowLoader(item)}
316
+ <form hx-post="${API_BASE}/menu-editor/save-link" hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0"
300
317
  class="d-flex flex-column gap-2">
301
318
  <div class="d-flex align-items-center gap-2">
302
319
  <span class="badge ${typeBadgeClass}">link</span>
@@ -320,7 +337,7 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
320
337
  <select id="bp-views-${item.id}" name="viewId" class="form-select form-select-sm" required
321
338
  hx-get="${API_BASE}/menu-editor/default-target"
322
339
  hx-target="#bp-paths-${item.id}"
323
- hx-swap="outerHTML"
340
+ hx-swap="outerHTML settle:0"
324
341
  hx-trigger="change"
325
342
  hx-include="closest form">${viewOpts}</select>
326
343
  </div>
@@ -338,42 +355,22 @@ async function renderEditLink(item, route, depth, config, appDef, appId) {
338
355
  </div>
339
356
  </div>
340
357
  ${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>
358
+ ${visibilityControls(item)}
362
359
  <div class="d-flex gap-2 justify-content-end">
363
360
  <button type="submit" class="btn btn-sm btn-success">OK Save</button>
364
361
  <button type="button" class="btn btn-sm btn-outline-secondary"
365
362
  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>
363
+ hx-target="#bp-menu-row-${item.id}" hx-swap="outerHTML settle:0">X Cancel</button>
367
364
  </div>
368
365
  <div class="small text-secondary">Path/target/service/view edits update the underlying route - affects any other menu items referencing it.</div>
369
366
  </form>
370
367
  </li>`;
371
368
  }
372
- function renderTree(items, depth, config, appDef, appId) {
369
+ function renderTree(items, depth, config, appDef, appId, accessApp = injectResolvedServicePaths({ tenants: config.tenants, apps: [appDef], managementOrigins: [] }).apps[0]) {
373
370
  return items.map((item) => {
374
- const row = renderRow(item, depth, "display", config, appDef, appId);
371
+ const row = renderRow(item, depth, "display", config, appDef, appId, accessApp);
375
372
  const children = item.type === "group" && item.children && item.children.length > 0
376
- ? renderTree(item.children, depth + 1, config, appDef, appId)
373
+ ? renderTree(item.children, depth + 1, config, appDef, appId, accessApp)
377
374
  : "";
378
375
  return row + children;
379
376
  }).join("");
@@ -401,7 +398,7 @@ function renderAddForms(appId, routes, groups) {
401
398
  <div class="card">
402
399
  <div class="card-header"><strong>Add View Link</strong></div>
403
400
  <div class="card-body">
404
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
401
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
405
402
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
406
403
  <input type="hidden" name="type" value="link" />
407
404
  <div class="mb-2">
@@ -428,7 +425,7 @@ function renderAddForms(appId, routes, groups) {
428
425
  <div class="card">
429
426
  <div class="card-header"><strong>Add External Link</strong></div>
430
427
  <div class="card-body">
431
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
428
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
432
429
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
433
430
  <input type="hidden" name="type" value="external" />
434
431
  <div class="mb-2">
@@ -452,7 +449,7 @@ function renderAddForms(appId, routes, groups) {
452
449
  <div class="card">
453
450
  <div class="card-header"><strong>Add Group</strong></div>
454
451
  <div class="card-body">
455
- <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML">
452
+ <form hx-post="${API_BASE}/menu-editor/add" hx-target="#bp-menu-editor" hx-swap="outerHTML settle:0">
456
453
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
457
454
  <input type="hidden" name="type" value="group" />
458
455
  <div class="mb-2">
@@ -478,10 +475,14 @@ function renderEditor(config, appDef, appId) {
478
475
  : renderTree(menu, 0, config, appDef, appId);
479
476
  const groups = collectGroups(menu);
480
477
  return `<div id="bp-menu-editor" data-bp-app-id="${escapeHtml(appId)}">
478
+ <style>
479
+ .bp-menu-loading { display: none; }
480
+ .bp-menu-loading.htmx-request { display: flex; position: absolute; inset: 0; z-index: 5; align-items: center; justify-content: center; gap: .5rem; background: var(--bs-body-bg); opacity: .9; }
481
+ </style>
481
482
  <form id="bp-drag-move-form" style="display:none"
482
483
  hx-post="${API_BASE}/menu-editor/move-after"
483
484
  hx-target="#bp-menu-editor"
484
- hx-swap="outerHTML">
485
+ hx-swap="outerHTML settle:0">
485
486
  <input type="hidden" name="appId" value="${escapeHtml(appId)}" />
486
487
  <input type="hidden" name="itemId" />
487
488
  <input type="hidden" name="anchorId" />
@@ -492,9 +493,25 @@ function renderEditor(config, appDef, appId) {
492
493
  </div>`;
493
494
  }
494
495
  // -- Endpoint registration --------------------------------------------
495
- export function registerMenuEditorRoutes(app, store) {
496
- const respondEditor = async (appId) => {
497
- const config = await store.loadConfig();
496
+ export function registerMenuEditorRoutes(app, store, database) {
497
+ const read = async (appId) => {
498
+ if (!database)
499
+ return store.loadConfig();
500
+ return readApp(database, appId);
501
+ };
502
+ const post = (path, action) => {
503
+ app.post(`${API_BASE}/menu-editor/${path}`, async (event) => {
504
+ const form = await readFormBody(event);
505
+ if (database)
506
+ return editAppRows(database, form.appId, eventObservability(event), data => action(form, data));
507
+ const config = await store.loadConfig();
508
+ const response = await action(form, config);
509
+ if (response.ok)
510
+ await store.saveConfig(config);
511
+ return response;
512
+ });
513
+ };
514
+ const respondEditor = async (config, appId) => {
498
515
  const appDef = getApp(config, appId);
499
516
  if (!appDef)
500
517
  return htmlResponse(`<div class="alert alert-danger">App not found</div>`, 200, "text/html; mode=fragment");
@@ -502,8 +519,7 @@ export function registerMenuEditorRoutes(app, store) {
502
519
  "HX-Trigger": "bp:menu-changed"
503
520
  });
504
521
  };
505
- const respondRow = async (appId, itemId, mode) => {
506
- const config = await store.loadConfig();
522
+ const respondRow = async (config, appId, itemId, mode) => {
507
523
  const appDef = getApp(config, appId);
508
524
  if (!appDef)
509
525
  return htmlResponse("", 200, "text/html; mode=fragment");
@@ -547,18 +563,16 @@ export function registerMenuEditorRoutes(app, store) {
547
563
  const appId = url.searchParams.get("appId") ?? "";
548
564
  if (!appId)
549
565
  return htmlResponse(`<div class="alert alert-secondary">Select an app</div>`, 200, "text/html; mode=fragment");
550
- return respondEditor(appId);
566
+ return respondEditor(await read(appId), appId);
551
567
  });
552
568
  app.get(`${API_BASE}/menu-editor/item`, async (event) => {
553
569
  const url = new URL(event.req.url ?? "", RELATIVE_URL_PARSE_BASE);
554
570
  const appId = url.searchParams.get("appId") ?? "";
555
571
  const itemId = url.searchParams.get("itemId") ?? "";
556
572
  const mode = (url.searchParams.get("mode") ?? "display");
557
- return respondRow(appId, itemId, mode);
573
+ return respondRow(await read(appId), appId, itemId, mode);
558
574
  });
559
- app.post(`${API_BASE}/menu-editor/save-visibility`, async (event) => {
560
- const f = await readFormBody(event);
561
- const config = await store.loadConfig();
575
+ post("save-visibility", async (f, config) => {
562
576
  const appDef = getApp(config, f.appId);
563
577
  if (!appDef)
564
578
  return jsonResponse({ error: "App not found" }, 404);
@@ -568,14 +582,31 @@ export function registerMenuEditorRoutes(app, store) {
568
582
  if (!["auto", "show", "show-unauthenticated", "hide-unauthenticated", "hide-unauthorized"].includes(f.authStatus)) {
569
583
  return jsonResponse({ error: "Invalid visibility" }, 400);
570
584
  }
571
- 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);
585
+ const audience = f.authStatus;
586
+ if (found.item.type === "group") {
587
+ const applyAudience = (items) => {
588
+ for (const item of items) {
589
+ item.authStatus = audience;
590
+ if (item.type === "link")
591
+ delete item.rolesAnyOf;
592
+ if (item.type === "group")
593
+ applyAudience(item.children ?? []);
594
+ }
595
+ };
596
+ applyAudience(found.item.children ?? []);
597
+ }
598
+ found.item.authStatus = audience;
599
+ if (found.item.type === "link")
600
+ found.item.serviceStatus = f.serviceStatus === "hide" ? "hide" : "show";
601
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
602
+ delete found.item.rolesAnyOf;
603
+ if (found.item.type === "group")
604
+ return respondEditor(config, f.appId);
605
+ const response = await respondRow(config, f.appId, f.itemId, "display");
606
+ response.headers.set("HX-Trigger", "bp:menu-changed");
607
+ return response;
575
608
  });
576
- app.post(`${API_BASE}/menu-editor/save-title`, async (event) => {
577
- const f = await readFormBody(event);
578
- const config = await store.loadConfig();
609
+ post("save-title", async (f, config) => {
579
610
  const appDef = getApp(config, f.appId);
580
611
  if (!appDef)
581
612
  return jsonResponse({ error: "App not found" }, 404);
@@ -584,23 +615,11 @@ export function registerMenuEditorRoutes(app, store) {
584
615
  if (found)
585
616
  found.item.title = f.title || undefined;
586
617
  appDef.menu = menu;
587
- await store.saveConfig(config);
588
- // Return single row + HX-Trigger to refresh sidebar nav
589
- const config2 = await store.loadConfig();
590
- const appDef2 = getApp(config2, f.appId);
591
- const found2 = appDef2 ? locate(getMenu(appDef2), f.itemId) : null;
592
- if (!appDef2 || !found2)
593
- return htmlResponse("", 200, "text/html; mode=fragment");
594
- const route = found2.item.routeId
595
- ? (appDef2.routes ?? []).find((candidate) => candidate.id === found2.item.routeId) ?? null
596
- : null;
597
- return htmlResponse(titleDisplayHtml(found2.item, route, f.appId), 200, "text/html; mode=fragment", {
598
- "HX-Trigger": "bp:menu-changed"
599
- });
618
+ const response = await respondRow(config, f.appId, f.itemId, "display");
619
+ response.headers.set("HX-Trigger", "bp:menu-changed");
620
+ return response;
600
621
  });
601
- app.post(`${API_BASE}/menu-editor/save-link`, async (event) => {
602
- const f = await readFormBody(event);
603
- const config = await store.loadConfig();
622
+ post("save-link", async (f, config) => {
604
623
  const appDef = getApp(config, f.appId);
605
624
  if (!appDef)
606
625
  return jsonResponse({ error: "App not found" }, 404);
@@ -610,7 +629,8 @@ export function registerMenuEditorRoutes(app, store) {
610
629
  return htmlResponse(`<div class="alert alert-danger">Item not found</div>`, 200, "text/html; mode=fragment");
611
630
  }
612
631
  found.item.title = f.title || undefined;
613
- found.item.rolesAnyOf = (f.rolesAnyOf ?? "").split(",").map(role => role.trim()).filter(Boolean);
632
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
633
+ delete found.item.rolesAnyOf;
614
634
  found.item.serviceStatus = f.serviceStatus === "hide" ? "hide" : "show";
615
635
  found.item.authStatus = ["auto", "show", "hide-unauthenticated", "hide-unauthorized", "show-unauthenticated"].includes(f.authStatus) ? f.authStatus : "auto";
616
636
  const route = (appDef.routes ?? []).find((r) => r.id === found.item.routeId);
@@ -628,8 +648,7 @@ export function registerMenuEditorRoutes(app, store) {
628
648
  return jsonResponse({ error: "Route cannot be added to the menu" }, 400);
629
649
  }
630
650
  appDef.menu = menu;
631
- await store.saveConfig(config);
632
- return respondRow(f.appId, f.itemId, "display").then((r) => {
651
+ return respondRow(config, f.appId, f.itemId, "display").then((r) => {
633
652
  const headers = new Headers(r.headers);
634
653
  headers.set("HX-Trigger", "bp:menu-changed");
635
654
  return new Response(r.body, { status: r.status, headers });
@@ -656,7 +675,7 @@ export function registerMenuEditorRoutes(app, store) {
656
675
  if (url.searchParams.get("autoSetPaths") === "true" && serviceId && viewId) {
657
676
  const views = lookupServiceViews(serviceId);
658
677
  targetPath = views.find((v) => v.viewId === viewId)?.path ?? "";
659
- const config = await store.loadConfig();
678
+ const config = await read(appId);
660
679
  const appDef = getApp(config, appId);
661
680
  const assignedRoute = (appDef?.routes ?? []).find((route) => route.kind !== "api" && route.serviceId === serviceId && route.viewId === viewId);
662
681
  if (assignedRoute) {
@@ -666,16 +685,15 @@ export function registerMenuEditorRoutes(app, store) {
666
685
  }
667
686
  return htmlResponse(renderPathFields(itemId, path, targetPath), 200, "text/html; mode=fragment");
668
687
  });
669
- app.post(`${API_BASE}/menu-editor/save-external`, async (event) => {
670
- const f = await readFormBody(event);
671
- const config = await store.loadConfig();
688
+ post("save-external", async (f, config) => {
672
689
  const appDef = getApp(config, f.appId);
673
690
  if (!appDef)
674
691
  return jsonResponse({ error: "App not found" }, 404);
675
692
  const menu = getMenu(appDef);
676
693
  const found = locate(menu, f.itemId);
677
694
  if (found) {
678
- found.item.rolesAnyOf = (f.rolesAnyOf ?? "").split(",").map(role => role.trim()).filter(Boolean);
695
+ if (found.item.type === "link" || f.clearRoleRestriction === "true")
696
+ delete found.item.rolesAnyOf;
679
697
  found.item.authStatus = ["auto", "show", "hide-unauthenticated", "hide-unauthorized", "show-unauthenticated"].includes(f.authStatus) ? f.authStatus : "auto";
680
698
  found.item.href = f.href || "";
681
699
  if (f.title !== undefined && f.title !== "")
@@ -685,17 +703,14 @@ export function registerMenuEditorRoutes(app, store) {
685
703
  return jsonResponse({ error: "Route cannot be added to the menu" }, 400);
686
704
  }
687
705
  appDef.menu = menu;
688
- await store.saveConfig(config);
689
- return respondRow(f.appId, f.itemId, "display").then((r) => {
706
+ return respondRow(config, f.appId, f.itemId, "display").then((r) => {
690
707
  // Add HX-Trigger to existing response
691
708
  const headers = new Headers(r.headers);
692
709
  headers.set("HX-Trigger", "bp:menu-changed");
693
710
  return new Response(r.body, { status: r.status, headers });
694
711
  });
695
712
  });
696
- app.post(`${API_BASE}/menu-editor/add`, async (event) => {
697
- const f = await readFormBody(event);
698
- const config = await store.loadConfig();
713
+ post("add", async (f, config) => {
699
714
  const appDef = getApp(config, f.appId);
700
715
  if (!appDef)
701
716
  return jsonResponse({ error: "App not found" }, 404);
@@ -727,12 +742,9 @@ export function registerMenuEditorRoutes(app, store) {
727
742
  menu.push(newItem);
728
743
  }
729
744
  appDef.menu = menu;
730
- await store.saveConfig(config);
731
- return respondEditor(f.appId);
745
+ return respondEditor(config, f.appId);
732
746
  });
733
- app.post(`${API_BASE}/menu-editor/remove`, async (event) => {
734
- const f = await readFormBody(event);
735
- const config = await store.loadConfig();
747
+ post("remove", async (f, config) => {
736
748
  const appDef = getApp(config, f.appId);
737
749
  if (!appDef)
738
750
  return jsonResponse({ error: "App not found" }, 404);
@@ -741,12 +753,9 @@ export function registerMenuEditorRoutes(app, store) {
741
753
  if (found)
742
754
  found.parent.splice(found.index, 1);
743
755
  appDef.menu = menu;
744
- await store.saveConfig(config);
745
- return respondEditor(f.appId);
756
+ return respondEditor(config, f.appId);
746
757
  });
747
- app.post(`${API_BASE}/menu-editor/toggle`, async (event) => {
748
- const f = await readFormBody(event);
749
- const config = await store.loadConfig();
758
+ post("toggle", async (f, config) => {
750
759
  const appDef = getApp(config, f.appId);
751
760
  if (!appDef)
752
761
  return jsonResponse({ error: "App not found" }, 404);
@@ -755,12 +764,9 @@ export function registerMenuEditorRoutes(app, store) {
755
764
  if (found)
756
765
  found.item.enabled = !found.item.enabled;
757
766
  appDef.menu = menu;
758
- await store.saveConfig(config);
759
- return respondEditor(f.appId);
767
+ return respondEditor(config, f.appId);
760
768
  });
761
- app.post(`${API_BASE}/menu-editor/toggle-expanded`, async (event) => {
762
- const f = await readFormBody(event);
763
- const config = await store.loadConfig();
769
+ post("toggle-expanded", async (f, config) => {
764
770
  const appDef = getApp(config, f.appId);
765
771
  if (!appDef)
766
772
  return jsonResponse({ error: "App not found" }, 404);
@@ -770,12 +776,9 @@ export function registerMenuEditorRoutes(app, store) {
770
776
  found.item.defaultExpanded = !found.item.defaultExpanded;
771
777
  }
772
778
  appDef.menu = menu;
773
- await store.saveConfig(config);
774
- return respondEditor(f.appId);
779
+ return respondEditor(config, f.appId);
775
780
  });
776
- app.post(`${API_BASE}/menu-editor/move-up`, async (event) => {
777
- const f = await readFormBody(event);
778
- const config = await store.loadConfig();
781
+ post("move-up", async (f, config) => {
779
782
  const appDef = getApp(config, f.appId);
780
783
  if (!appDef)
781
784
  return jsonResponse({ error: "App not found" }, 404);
@@ -786,12 +789,9 @@ export function registerMenuEditorRoutes(app, store) {
786
789
  found.parent.splice(found.index - 1, 0, it);
787
790
  }
788
791
  appDef.menu = menu;
789
- await store.saveConfig(config);
790
- return respondEditor(f.appId);
792
+ return respondEditor(config, f.appId);
791
793
  });
792
- app.post(`${API_BASE}/menu-editor/move-down`, async (event) => {
793
- const f = await readFormBody(event);
794
- const config = await store.loadConfig();
794
+ post("move-down", async (f, config) => {
795
795
  const appDef = getApp(config, f.appId);
796
796
  if (!appDef)
797
797
  return jsonResponse({ error: "App not found" }, 404);
@@ -802,12 +802,9 @@ export function registerMenuEditorRoutes(app, store) {
802
802
  found.parent.splice(found.index + 1, 0, it);
803
803
  }
804
804
  appDef.menu = menu;
805
- await store.saveConfig(config);
806
- return respondEditor(f.appId);
805
+ return respondEditor(config, f.appId);
807
806
  });
808
- app.post(`${API_BASE}/menu-editor/move-in`, async (event) => {
809
- const f = await readFormBody(event);
810
- const config = await store.loadConfig();
807
+ post("move-in", async (f, config) => {
811
808
  const appDef = getApp(config, f.appId);
812
809
  if (!appDef)
813
810
  return jsonResponse({ error: "App not found" }, 404);
@@ -822,12 +819,9 @@ export function registerMenuEditorRoutes(app, store) {
822
819
  }
823
820
  }
824
821
  appDef.menu = menu;
825
- await store.saveConfig(config);
826
- return respondEditor(f.appId);
822
+ return respondEditor(config, f.appId);
827
823
  });
828
- app.post(`${API_BASE}/menu-editor/move-after`, async (event) => {
829
- const f = await readFormBody(event);
830
- const config = await store.loadConfig();
824
+ post("move-after", async (f, config) => {
831
825
  const appDef = getApp(config, f.appId);
832
826
  if (!appDef)
833
827
  return jsonResponse({ error: "App not found" }, 404);
@@ -836,10 +830,10 @@ export function registerMenuEditorRoutes(app, store) {
836
830
  const anchorId = f.anchorId || "";
837
831
  const targetDepth = Math.max(0, parseInt(f.targetDepth ?? "0", 10) || 0);
838
832
  if (!srcId || srcId === anchorId)
839
- return respondEditor(f.appId);
833
+ return respondEditor(config, f.appId);
840
834
  const src = locate(menu, srcId);
841
835
  if (!src)
842
- return respondEditor(f.appId);
836
+ return respondEditor(config, f.appId);
843
837
  // Prevent dropping into own descendant
844
838
  const isDescendant = (items, id) => {
845
839
  for (const it of items) {
@@ -851,7 +845,7 @@ export function registerMenuEditorRoutes(app, store) {
851
845
  return false;
852
846
  };
853
847
  if (anchorId && src.item.type === "group" && src.item.children && isDescendant(src.item.children, anchorId)) {
854
- return respondEditor(f.appId);
848
+ return respondEditor(config, f.appId);
855
849
  }
856
850
  // Remove src
857
851
  const [srcItem] = src.parent.splice(src.index, 1);
@@ -859,8 +853,7 @@ export function registerMenuEditorRoutes(app, store) {
859
853
  // Insert at start of root
860
854
  menu.unshift(srcItem);
861
855
  appDef.menu = menu;
862
- await store.saveConfig(config);
863
- return respondEditor(f.appId);
856
+ return respondEditor(config, f.appId);
864
857
  }
865
858
  // Build ancestor chain to anchor
866
859
  const ancestorChain = (items, id, chain = [], depth = 0) => {
@@ -879,7 +872,7 @@ export function registerMenuEditorRoutes(app, store) {
879
872
  const chain = ancestorChain(menu, anchorId);
880
873
  if (!chain) {
881
874
  src.parent.splice(src.index, 0, srcItem);
882
- return respondEditor(f.appId);
875
+ return respondEditor(config, f.appId);
883
876
  }
884
877
  const anchor = chain[chain.length - 1];
885
878
  const anchorDepth = anchor.depth;
@@ -904,12 +897,9 @@ export function registerMenuEditorRoutes(app, store) {
904
897
  }
905
898
  }
906
899
  appDef.menu = menu;
907
- await store.saveConfig(config);
908
- return respondEditor(f.appId);
900
+ return respondEditor(config, f.appId);
909
901
  });
910
- app.post(`${API_BASE}/menu-editor/move-out`, async (event) => {
911
- const f = await readFormBody(event);
912
- const config = await store.loadConfig();
902
+ post("move-out", async (f, config) => {
913
903
  const appDef = getApp(config, f.appId);
914
904
  if (!appDef)
915
905
  return jsonResponse({ error: "App not found" }, 404);
@@ -924,8 +914,7 @@ export function registerMenuEditorRoutes(app, store) {
924
914
  }
925
915
  }
926
916
  appDef.menu = menu;
927
- await store.saveConfig(config);
928
- return respondEditor(f.appId);
917
+ return respondEditor(config, f.appId);
929
918
  });
930
919
  }
931
920
  //# sourceMappingURL=menuEditor.js.map