@betterportal/config-manager 10.1.52 → 10.1.54

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 (44) 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 +129 -1
  4. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +13 -0
  5. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/adminApi.js +242 -51
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  8. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.d.ts.map +1 -1
  9. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js +129 -1
  10. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js.map +1 -1
  11. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.d.ts +8 -0
  12. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.d.ts.map +1 -1
  13. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.js +4 -0
  14. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.js.map +1 -1
  15. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.d.ts.map +1 -1
  16. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.js +36 -8
  17. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.js.map +1 -1
  18. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.d.ts.map +1 -1
  19. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.js +8 -1
  20. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_renderer.bootstrap5/GET.js.map +1 -1
  21. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.d.ts +30 -0
  22. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.d.ts.map +1 -1
  23. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js +41 -0
  24. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js.map +1 -1
  25. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  26. package/lib/plugins/service-betterportal-config-manager/index.js +4 -0
  27. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  28. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +1 -0
  29. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  30. package/lib/plugins/service-betterportal-config-manager/storage/core.js +38 -0
  31. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  32. package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts.map +1 -1
  33. package/lib/plugins/service-betterportal-config-manager/storage/file.js +2 -2
  34. package/lib/plugins/service-betterportal-config-manager/storage/file.js.map +1 -1
  35. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  36. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +2 -2
  37. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  38. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +14 -0
  39. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  40. package/lib/plugins/service-betterportal-config-manager/syncApi.js +32 -5
  41. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  42. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  43. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  44. package/package.json +3 -3
@@ -90,6 +90,76 @@ function requiredRouteString(body, key, label) {
90
90
  return { error: `${label} is required.` };
91
91
  return { value };
92
92
  }
93
+ function routeParamNames(path) {
94
+ return [...new Set(path.split("/").flatMap((segment) => {
95
+ const match = segment.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/);
96
+ return match ? [match[1]] : [];
97
+ }))];
98
+ }
99
+ function validateCanonicalRoutePath(path, label) {
100
+ if (!path.startsWith("/"))
101
+ return `${label} must start with /.`;
102
+ if (/\{[^}]+\}/.test(path))
103
+ return `${label} must use :param syntax; {param} is no longer supported.`;
104
+ for (const segment of path.split("/")) {
105
+ if (segment.startsWith(":") && !/^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) {
106
+ return `${label} contains an invalid parameter segment: ${segment}.`;
107
+ }
108
+ }
109
+ return undefined;
110
+ }
111
+ function fixedParamsFromBody(body) {
112
+ const fixed = {};
113
+ for (const [key, value] of Object.entries(body)) {
114
+ if (!key.startsWith("fixedParam."))
115
+ continue;
116
+ const name = key.slice("fixedParam.".length);
117
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string")
118
+ continue;
119
+ const normalized = value.trim();
120
+ if (normalized)
121
+ fixed[name] = normalized;
122
+ }
123
+ return fixed;
124
+ }
125
+ function validateFixedParamValue(name, value, paramsSchema) {
126
+ const properties = paramsSchema?.properties;
127
+ const schema = properties && typeof properties === "object" && !Array.isArray(properties)
128
+ ? properties[name]
129
+ : undefined;
130
+ const rule = schema && typeof schema === "object" && !Array.isArray(schema)
131
+ ? schema
132
+ : {};
133
+ const minLength = typeof rule.minLength === "number" ? rule.minLength : 1;
134
+ const maxLength = Math.min(typeof rule.maxLength === "number" ? rule.maxLength : 100, 100);
135
+ if (value.length < minLength || value.length > maxLength) {
136
+ return `${name} must be between ${minLength} and ${maxLength} characters.`;
137
+ }
138
+ if (typeof rule.pattern === "string") {
139
+ try {
140
+ if (!new RegExp(rule.pattern).test(value))
141
+ return `${name} does not match its service validation pattern.`;
142
+ }
143
+ catch {
144
+ return `${name} has an invalid validation pattern in the service manifest.`;
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+ function validateRouteParamMapping(appPath, servicePath, fixedParams, paramsSchema) {
150
+ const appParams = new Set(routeParamNames(appPath));
151
+ for (const name of routeParamNames(servicePath)) {
152
+ if (appParams.has(name))
153
+ continue;
154
+ const value = fixedParams[name];
155
+ if (!value)
156
+ return `Service parameter :${name} must appear in the app path or have a fixed value.`;
157
+ const valueError = validateFixedParamValue(name, value, paramsSchema);
158
+ if (valueError)
159
+ return valueError;
160
+ }
161
+ return undefined;
162
+ }
93
163
  function routeMethodsFromManifest(methods) {
94
164
  const normalized = (methods ?? []).filter((method) => method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE" || method === "OPTIONS");
95
165
  return normalized.length ? normalized : ["GET"];
@@ -266,12 +336,24 @@ function parseRouteCreateBody(body) {
266
336
  const manifestView = getManifestCache().get(serviceId.value)?.viewIndex[viewId.value];
267
337
  const renderable = manifestView?.renderable !== false;
268
338
  const manifest = getManifestCache().get(serviceId.value);
269
- const serviceRoutePath = manifestView?.path ?? `/${viewId.value}`;
339
+ const servicePathVariant = trimmedString(body, "servicePathVariant") ?? manifestView?.path ?? `/${viewId.value}`;
340
+ if (manifestView && ![manifestView.path, ...manifestView.pathVariants].includes(servicePathVariant)) {
341
+ return { error: "Selected service path is not available for this view." };
342
+ }
343
+ const serviceRoutePath = servicePathVariant;
270
344
  const path = renderable ? requiredRouteString(body, "path", "Mount path") : { value: apiRoutePath(manifest?.serviceId ?? serviceId.value, serviceRoutePath) };
271
345
  if (path.error)
272
346
  return { error: path.error };
273
- if (!path.value.startsWith("/"))
274
- return { error: "Mount path must start with /." };
347
+ const pathError = validateCanonicalRoutePath(path.value, "Mount path");
348
+ if (pathError)
349
+ return { error: pathError };
350
+ const servicePathError = validateCanonicalRoutePath(serviceRoutePath, "Service path");
351
+ if (servicePathError)
352
+ return { error: servicePathError };
353
+ const fixedParams = fixedParamsFromBody(body);
354
+ const mappingError = validateRouteParamMapping(path.value, serviceRoutePath, fixedParams, manifestView?.paramsSchema);
355
+ if (mappingError)
356
+ return { error: mappingError };
275
357
  const title = renderable ? requiredRouteString(body, "title", "Display title") : { value: manifestView?.viewId ?? viewId.value };
276
358
  if (title.error)
277
359
  return { error: title.error };
@@ -280,12 +362,14 @@ function parseRouteCreateBody(body) {
280
362
  path: path.value,
281
363
  serviceId: serviceId.value,
282
364
  viewId: viewId.value,
365
+ ...(manifestView && servicePathVariant !== manifestView.path ? { servicePathVariant } : {}),
366
+ ...(Object.keys(fixedParams).length > 0 ? { fixedParams } : {}),
283
367
  title: title.value,
284
368
  enabled: true,
285
369
  methods: routeMethodsFromManifest(manifestView?.methods)
286
370
  };
287
371
  if (manifestView?.path)
288
- route.targetPath = manifestView.path;
372
+ route.targetPath = serviceRoutePath;
289
373
  const query = trimmedString(body, "query");
290
374
  if (query && renderable)
291
375
  route.query = query.replace(/^\?+/, "");
@@ -1845,8 +1929,9 @@ export function registerAdminApiRoutes(app, store, cpState) {
1845
1929
  const path = trimmedString(body, "path");
1846
1930
  if (!path)
1847
1931
  return validationError(event, "Mount path is required.");
1848
- if (!path.startsWith("/"))
1849
- return validationError(event, "Mount path must start with /.");
1932
+ const pathError = validateCanonicalRoutePath(path, "Mount path");
1933
+ if (pathError)
1934
+ return validationError(event, pathError);
1850
1935
  if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(path))) {
1851
1936
  return validationError(event, `A route already exists at ${path}.`);
1852
1937
  }
@@ -1870,11 +1955,38 @@ export function registerAdminApiRoutes(app, store, cpState) {
1870
1955
  const manifest = getManifestCache().get(route.serviceId);
1871
1956
  const manifestView = manifest?.viewIndex[route.viewId];
1872
1957
  if (manifestView) {
1958
+ const routeIdentityChanged = body.serviceId !== undefined || body.viewId !== undefined;
1959
+ const requestedServicePath = trimmedString(body, "servicePathVariant")
1960
+ ?? (routeIdentityChanged ? undefined : route.servicePathVariant)
1961
+ ?? manifestView.path;
1962
+ if (![manifestView.path, ...manifestView.pathVariants].includes(requestedServicePath)) {
1963
+ return validationError(event, "Selected service path is not available for this view.");
1964
+ }
1965
+ const servicePathError = validateCanonicalRoutePath(requestedServicePath, "Service path");
1966
+ if (servicePathError)
1967
+ return validationError(event, servicePathError);
1968
+ const submittedFixed = fixedParamsFromBody(body);
1969
+ const mappedAppPath = manifestView.renderable === false
1970
+ ? apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath)
1971
+ : route.path;
1972
+ const appParams = new Set(routeParamNames(mappedAppPath));
1973
+ const fixedParams = Object.fromEntries(Object.entries(submittedFixed).filter(([name]) => routeParamNames(requestedServicePath).includes(name) && !appParams.has(name)));
1974
+ const mappingError = validateRouteParamMapping(mappedAppPath, requestedServicePath, fixedParams, manifestView.paramsSchema);
1975
+ if (mappingError)
1976
+ return validationError(event, mappingError);
1977
+ if (requestedServicePath === manifestView.path)
1978
+ delete route.servicePathVariant;
1979
+ else
1980
+ route.servicePathVariant = requestedServicePath;
1981
+ if (Object.keys(fixedParams).length > 0)
1982
+ route.fixedParams = fixedParams;
1983
+ else
1984
+ delete route.fixedParams;
1873
1985
  route.methods = routeMethodsFromManifest(manifestView.methods);
1874
- route.targetPath = manifestView.path;
1986
+ route.targetPath = requestedServicePath;
1875
1987
  if (manifestView.renderable === false) {
1876
1988
  route.kind = "api";
1877
- route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, manifestView.path);
1989
+ route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath);
1878
1990
  route.title = manifestView.viewId;
1879
1991
  delete route.query;
1880
1992
  }
@@ -2552,7 +2664,7 @@ function renderConfigControl(field, value, placeholder, disabled) {
2552
2664
  const inputType = ["number", "color", "date", "time", "datetime-local", "url", "email", "password"].includes(control) ? control : "text";
2553
2665
  return `<input class="form-control" type="${inputType}" name="${name}" value="${escapeHtml(value)}" placeholder="${placeholderAttr}" ${attrs}${disabledAttr} />`;
2554
2666
  }
2555
- function renderConfigClientShell(d) {
2667
+ export function renderConfigClientShell(d) {
2556
2668
  const payload = JSON.stringify({
2557
2669
  hostname: d.hostname,
2558
2670
  tenantId: d.tenantId,
@@ -2631,6 +2743,26 @@ function renderConfigClientShell(d) {
2631
2743
  if (!response.ok) throw new Error(data.error || data.message || "config HTTP " + response.status);
2632
2744
  return data.values || {};
2633
2745
  };
2746
+ const writeValues = async (token, appId, values, clearKeys = []) => {
2747
+ const headers = {
2748
+ "Content-Type": "application/json",
2749
+ Accept: "application/json",
2750
+ Authorization: "Bearer " + token,
2751
+ "x-bp-tenant-id": cfg.tenantId
2752
+ };
2753
+ if (appId) headers["x-bp-app-id"] = appId;
2754
+ const payload = { tenantId: cfg.tenantId, values };
2755
+ if (appId) payload.appId = appId;
2756
+ if (clearKeys.length > 0) payload.clearKeys = clearKeys;
2757
+ const response = await fetch(serviceBase() + "/.well-known/bp/config", {
2758
+ method: "POST",
2759
+ headers,
2760
+ body: JSON.stringify(payload)
2761
+ });
2762
+ const data = await readJson(response, "config write");
2763
+ if (!response.ok) throw new Error(data.error || data.message || "config HTTP " + response.status);
2764
+ return data;
2765
+ };
2634
2766
  const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
2635
2767
  const compareOrder = (left, right) => {
2636
2768
  const leftOrder = typeof left.order === "number" ? left.order : 0;
@@ -2730,13 +2862,16 @@ function renderConfigClientShell(d) {
2730
2862
  const layout = schemaLayout(schema);
2731
2863
  const fields = layout.fields;
2732
2864
  const scope = selectedScope === "app" ? "app" : "tenant";
2733
- const selectedAppId = scope === "app" ? appId : "";
2865
+ const apps = cfg.tenantApps || [];
2866
+ const selectedAppId = scope === "app"
2867
+ ? (apps.some((app) => app.id === appId) ? appId : apps[0]?.id || "")
2868
+ : "";
2734
2869
  const tenantValues = await loadValues(token, "");
2735
2870
  const appValues = selectedAppId ? await loadValues(token, selectedAppId) : {};
2736
2871
  const scopeSelector = '<div class="row g-2 mb-3"><div class="col-5"><label class="form-label">Scope</label>' +
2737
2872
  '<select class="form-select" data-bp-config-scope><option value="tenant"' + (scope === "tenant" ? " selected" : "") + '>Tenant defaults</option><option value="app"' + (scope === "app" ? " selected" : "") + '>App override</option></select></div>' +
2738
2873
  '<div class="col-7"><label class="form-label">App</label><select class="form-select" data-bp-config-app ' + (scope === "app" ? "" : "disabled") + '><option value="">Select app...</option>' +
2739
- (cfg.tenantApps || []).map((app) => '<option value="' + escapeHtml(app.id) + '"' + (app.id === selectedAppId ? " selected" : "") + '>' + escapeHtml(app.title) + '</option>').join("") +
2874
+ apps.map((app) => '<option value="' + escapeHtml(app.id) + '"' + (app.id === selectedAppId ? " selected" : "") + '>' + escapeHtml(app.title) + '</option>').join("") +
2740
2875
  '</select></div></div>';
2741
2876
  if (scope === "app" && !selectedAppId) {
2742
2877
  body.innerHTML = scopeSelector + '<div class="alert alert-info">Select an app to edit overrides.</div>';
@@ -2779,8 +2914,88 @@ function renderConfigClientShell(d) {
2779
2914
  const content = sectionFields.map(renderField).join("");
2780
2915
  return '<div class="border rounded p-3 mb-3" data-bp-config-section="' + escapeHtml(section.id) + '">' + header + content + '</div>';
2781
2916
  }).join("");
2782
- body.innerHTML = scopeSelector + '<form data-bp-config-form>' + sectionsHtml + '<div data-bp-save-status></div><button type="submit" class="btn btn-primary w-100">Save Configuration</button></form>';
2917
+ const transferActions = '<div class="d-flex justify-content-end gap-2 mb-3">' +
2918
+ '<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-bp-config-export>Export</button>' +
2919
+ '<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-bp-config-import>Import</button>' +
2920
+ '<input type="file" accept="application/json,.json" hidden data-bp-config-import-file />' +
2921
+ '</div>';
2922
+ body.innerHTML = scopeSelector + transferActions + '<form data-bp-config-form>' + sectionsHtml + '<div data-bp-save-status></div><button type="submit" class="btn btn-primary w-100">Save Configuration</button></form>';
2783
2923
  wireScopeControls(schema, token);
2924
+ const currentValues = scope === "app" ? appValues : tenantValues;
2925
+ const importFile = root.querySelector("[data-bp-config-import-file]");
2926
+ root.querySelector("[data-bp-config-export]")?.addEventListener("click", () => {
2927
+ const values = {};
2928
+ let omittedSecrets = 0;
2929
+ for (const field of fields) {
2930
+ if (!hasOwn(currentValues, field.key)) continue;
2931
+ if (field.visibility === "secret" && currentValues[field.key] === "__redacted__") {
2932
+ omittedSecrets += 1;
2933
+ continue;
2934
+ }
2935
+ values[field.key] = currentValues[field.key];
2936
+ }
2937
+ const exported = {
2938
+ format: "betterportal.service-config",
2939
+ version: 1,
2940
+ serviceId: cfg.serviceId,
2941
+ tenantId: cfg.tenantId,
2942
+ scope,
2943
+ ...(selectedAppId ? { appId: selectedAppId } : {}),
2944
+ values
2945
+ };
2946
+ const blobUrl = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2) + "\\n"], { type: "application/json" }));
2947
+ const link = document.createElement("a");
2948
+ link.href = blobUrl;
2949
+ link.download = String(cfg.serviceId || "service").replace(/[^a-z0-9._-]+/gi, "-") + "-" + scope + ".json";
2950
+ document.body.appendChild(link);
2951
+ link.click();
2952
+ link.remove();
2953
+ URL.revokeObjectURL(blobUrl);
2954
+ setStatus("ok", "Configuration exported." + (omittedSecrets ? " Stored secrets were omitted." : ""));
2955
+ });
2956
+ root.querySelector("[data-bp-config-import]")?.addEventListener("click", () => importFile?.click());
2957
+ importFile?.addEventListener("change", async () => {
2958
+ const file = importFile.files?.[0];
2959
+ importFile.value = "";
2960
+ if (!file) return;
2961
+ setStatus("info", "Importing configuration...");
2962
+ try {
2963
+ const imported = JSON.parse(await file.text());
2964
+ if (!imported || typeof imported !== "object" || Array.isArray(imported)) throw new Error("Import must be a JSON object.");
2965
+ if (imported.format !== "betterportal.service-config" || imported.version !== 1) throw new Error("Unsupported config export format.");
2966
+ if (imported.serviceId !== cfg.serviceId) throw new Error("This export belongs to a different service.");
2967
+ if (!["tenant", "app"].includes(imported.scope)) throw new Error("Import scope must be tenant or app.");
2968
+ if (imported.scope !== scope) throw new Error("Select the " + imported.scope + " scope before importing this file.");
2969
+ if (!imported.values || typeof imported.values !== "object" || Array.isArray(imported.values)) throw new Error("Import values must be a JSON object.");
2970
+ const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
2971
+ const unknownKeys = Object.keys(imported.values).filter((key) => !fieldsByKey.has(key));
2972
+ if (unknownKeys.length > 0) throw new Error("Unknown config fields: " + unknownKeys.join(", "));
2973
+ const values = {};
2974
+ const clearKeys = [];
2975
+ for (const field of fields) {
2976
+ if (hasOwn(imported.values, field.key)) {
2977
+ if (field.visibility !== "secret" || imported.values[field.key] !== "__redacted__") {
2978
+ values[field.key] = imported.values[field.key];
2979
+ }
2980
+ } else if (field.visibility !== "secret") {
2981
+ clearKeys.push(field.key);
2982
+ }
2983
+ }
2984
+ const scopeLabel = selectedAppId
2985
+ ? "the selected app override"
2986
+ : "the tenant defaults";
2987
+ if (!window.confirm("Replace " + scopeLabel + " with this import? Existing stored secrets are preserved unless the file supplies replacements.")) {
2988
+ setStatus("info", "Import cancelled.");
2989
+ return;
2990
+ }
2991
+ await writeValues(token, selectedAppId, values, clearKeys);
2992
+ document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
2993
+ await renderForm(schema, token, scope, selectedAppId);
2994
+ setStatus("ok", "Configuration imported.");
2995
+ } catch (error) {
2996
+ setStatus("error", error instanceof Error ? error.message : String(error));
2997
+ }
2998
+ });
2784
2999
  root.querySelectorAll("[data-bp-override-key]").forEach((checkbox) => {
2785
3000
  checkbox.addEventListener("change", () => {
2786
3001
  const field = checkbox.closest("[data-bp-field]");
@@ -2803,24 +3018,14 @@ function renderConfigClientShell(d) {
2803
3018
  if (!key) return;
2804
3019
  button.disabled = true;
2805
3020
  setStatus("info", "Resetting " + key + "...");
2806
- const response = await fetch(serviceBase() + "/.well-known/bp/config", {
2807
- method: "POST",
2808
- headers: {
2809
- "Content-Type": "application/json",
2810
- Accept: "application/json",
2811
- Authorization: "Bearer " + token,
2812
- "x-bp-tenant-id": cfg.tenantId
2813
- },
2814
- body: JSON.stringify({ tenantId: cfg.tenantId, values: {}, clearKeys: [key] })
2815
- });
2816
- if (!response.ok) {
2817
- const error = await readJson(response, "reset").catch((err) => ({ error: err.message || "HTTP " + response.status }));
2818
- setStatus("error", error.error || "Reset failed.");
3021
+ try {
3022
+ await writeValues(token, "", {}, [key]);
3023
+ document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
3024
+ await renderForm(schema, token, "tenant", "");
3025
+ } catch (error) {
3026
+ setStatus("error", error instanceof Error ? error.message : String(error));
2819
3027
  button.disabled = false;
2820
- return;
2821
3028
  }
2822
- document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
2823
- await renderForm(schema, token, "tenant", "");
2824
3029
  });
2825
3030
  });
2826
3031
  root.querySelector("[data-bp-config-form]")?.addEventListener("submit", async (event) => {
@@ -2849,31 +3054,17 @@ function renderConfigClientShell(d) {
2849
3054
  if (value !== undefined && value !== "(unchanged)") valuesToSave[field.key] = value;
2850
3055
  }
2851
3056
  }
2852
- const payload = { tenantId: cfg.tenantId, values: valuesToSave };
2853
- if (selectedAppId) payload.appId = selectedAppId;
2854
- if (clearKeys.length > 0) payload.clearKeys = clearKeys;
2855
- const headers = {
2856
- "Content-Type": "application/json",
2857
- Accept: "application/json",
2858
- Authorization: "Bearer " + token,
2859
- "x-bp-tenant-id": cfg.tenantId
2860
- };
2861
- if (selectedAppId) headers["x-bp-app-id"] = selectedAppId;
2862
- const response = await fetch(serviceBase() + "/.well-known/bp/config", {
2863
- method: "POST",
2864
- headers,
2865
- body: JSON.stringify(payload)
2866
- });
2867
- if (!response.ok) {
2868
- const error = await readJson(response, "save").catch((err) => ({ error: err.message || "HTTP " + response.status }));
2869
- if (saveStatus) saveStatus.innerHTML = '<div class="alert alert-danger">' + escapeHtml(error.error || "Save failed") + '</div>';
2870
- setStatus("error", error.error || "Save failed.");
3057
+ try {
3058
+ await writeValues(token, selectedAppId, valuesToSave, clearKeys);
3059
+ document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
3060
+ await renderForm(schema, token, scope, selectedAppId);
3061
+ setStatus("ok", "Configuration saved.");
3062
+ } catch (error) {
3063
+ const message = error instanceof Error ? error.message : String(error);
3064
+ if (saveStatus) saveStatus.innerHTML = '<div class="alert alert-danger">' + escapeHtml(message) + '</div>';
3065
+ setStatus("error", message);
2871
3066
  if (submitButton) submitButton.disabled = false;
2872
- return;
2873
3067
  }
2874
- document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
2875
- await renderForm(schema, token, scope, selectedAppId);
2876
- setStatus("ok", "Configuration saved.");
2877
3068
  });
2878
3069
  setStatus("ok", "Configuration loaded.");
2879
3070
  };