@scalio-cloud/ui 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -295,7 +295,8 @@ const license = licenseApi(api);
295
295
 
296
296
  // Einstellungen -> System -> Lizenz (scalio-license backend: /license, /license/addons)
297
297
  <LicensePanel client={license} lang={lang} product="Citadel" kindLabel={licenseKindLabel}
298
- featureLabel={licenseFeatureLabel} noFeatures="Nur Drift-Erkennung" notify={...} confirm={confirm} />
298
+ featureLabel={licenseFeatureLabel} noFeatures={t("Drift detection only")} notify={...} confirm={confirm} />
299
+ // noFeatures only when the app has a better text than «Nur die Grundfunktionen»
299
300
  ```
300
301
 
301
302
  `securityApi` reads snake_case and camelCase answers. `notify(text, tone)` gets `"success"` or `"error"`; without it
@@ -304,6 +305,25 @@ the blocks show the message themselves. `confirm` is the app's dialog (`{title,
304
305
  that returns the view model described in `react/license.js`. Nexus does this: its `map` adds trial badges, seat hints, invalid
305
306
  add-ons and read-only notes (demo mode), and passes its trial notices as `statusExtra` and the reminder settings as `children`.
306
307
 
308
+ Since 0.27.0 (ADR 0069) the panel shows three areas in the settings form (title and hint left, card right): «Status und
309
+ Nutzung», «Add-ons», «Lizenzschlüssel». `children` with `extraTitle` (and `extraHint`) becomes a fourth area.
310
+
311
+ ## Gruppe System: Zurücksetzen and helpers (React)
312
+
313
+ Every app shows Einstellungen → System in the same order: Lizenz, Updates, Sicherung, Zurücksetzen (`settingsGroups`
314
+ sorts the group `system` that way). The reset block talks to scalio-platform `factory_reset`
315
+ (`GET`/`POST /api/admin/factory-reset`):
316
+
317
+ ```jsx
318
+ import { FactoryReset, factoryResetApi, SettingsField, SettingsRow } from "@scalio-cloud/ui/react";
319
+
320
+ const reset = factoryResetApi(api); // once, outside the component
321
+ <FactoryReset client={reset} lang={lang} tokenKey="citadel_token" backupTo="/settings/backup" link={Link} />
322
+ ```
323
+
324
+ `SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
325
+ keep the fields inside a `SettingsSection` the same in every app.
326
+
307
327
  ## Command palette (React)
308
328
 
309
329
  Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
package/nav.css CHANGED
@@ -356,6 +356,18 @@
356
356
  .scalio-settings-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
357
357
  .scalio-settings-body { display: flex; flex-direction: column; }
358
358
  .scalio-settings-section-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.25rem; }
359
+ /* Content of a settings area (ADR 0069 W1): text, help under a field, checkbox with text, a form. */
360
+ .scalio-settings-form { display: flex; flex-direction: column; gap: 1rem; margin: 0; }
361
+ .scalio-settings-text { margin: 0; font-size: 0.875rem; line-height: 1.5; }
362
+ .scalio-settings-help { display: block; margin: 0.25rem 0 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); line-height: 1.5; }
363
+ .scalio-settings-check { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
364
+ .scalio-settings-check input { margin-top: 0.2rem; }
365
+ .scalio-settings-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
366
+ .scalio-settings-row-text { min-width: 0; font-size: 0.875rem; }
367
+ .scalio-settings-row-text strong { display: block; font-weight: 600; }
368
+ .scalio-settings-row + .scalio-settings-row { padding-top: 1rem; border-top: 1px solid hsl(var(--border)); }
369
+ .scalio-settings-row.is-danger strong { color: hsl(var(--destructive)); }
370
+ .scalio-notice .scalio-btn { margin-left: auto; flex-shrink: 0; }
359
371
 
360
372
  /* Button variants for page blocks (base: .scalio-btn in shell.css) */
361
373
  .scalio-btn.is-primary { border-color: hsl(var(--primary)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
@@ -363,6 +375,9 @@
363
375
  .scalio-btn.is-ghost { border-color: transparent; background: transparent; color: hsl(var(--muted-foreground)); }
364
376
  .scalio-btn.is-ghost:hover { color: hsl(var(--foreground)); background: transparent; }
365
377
  .scalio-btn.is-danger { color: hsl(var(--destructive)); }
378
+ .scalio-btn.is-danger.is-solid { border-color: hsl(var(--destructive)); background: hsl(var(--destructive)); color: hsl(var(--destructive-foreground, 0 0% 100%)); }
379
+ .scalio-btn.is-danger.is-solid:hover { background: hsl(var(--destructive) / 0.9); }
380
+ .scalio-btn:disabled { opacity: 0.5; cursor: not-allowed; }
366
381
  .scalio-btn.is-ghost.is-danger { color: hsl(var(--muted-foreground)); }
367
382
  .scalio-btn.is-ghost.is-danger:hover { color: hsl(var(--destructive)); }
368
383
  .scalio-btn:disabled { opacity: 0.6; cursor: default; }
@@ -535,13 +550,13 @@
535
550
  .scalio-wizard-success-icon { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 999px; }
536
551
 
537
552
  /* License panel (LicensePanel) */
538
- .scalio-license { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
553
+ .scalio-license { font-size: 0.875rem; } /* areas in the settings form (ADR 0069); the cards come from .scalio-profile-section-body */
539
554
  .scalio-license-part { padding: 1.25rem; }
540
555
  .scalio-license-part + .scalio-license-part { border-top: 1px solid hsl(var(--border)); }
541
556
  .scalio-license-title { display: flex; align-items: center; gap: 0.5rem; font-family: 'Chivo', sans-serif; font-weight: 700; }
542
557
  .scalio-license-title .scalio-icon { color: hsl(var(--primary)); }
543
558
  .scalio-license-hint { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
544
- .scalio-license-messages { display: flex; flex-direction: column; gap: 0.25rem; margin: 1.25rem 1.25rem 0; }
559
+ .scalio-license-messages { display: flex; flex-direction: column; gap: 0.25rem; }
545
560
  .scalio-license-messages div { display: flex; gap: 0.5rem; }
546
561
  .scalio-license-messages .scalio-icon { flex-shrink: 0; margin-top: 0.125rem; }
547
562
  .scalio-license-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
package/react/icons.js CHANGED
@@ -132,7 +132,8 @@ const Globe = icon([
132
132
  ["path", { d: "M2 12h20" }],
133
133
  ]);
134
134
  const Clock = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 6v6l4 2" }]]);
135
+ const Download = icon([["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }], ["path", { d: "m7 10 5 5 5-5" }], ["path", { d: "M12 15V3" }]]);
135
136
 
136
137
  module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
137
138
  Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Pencil, Link2, Alert, History, CornerDownLeft,
138
- Users, Settings, ScrollText, Info, RadioTower, Globe, Clock };
139
+ Users, Settings, ScrollText, Info, RadioTower, Globe, Clock, Download };
package/react/index.js CHANGED
@@ -378,6 +378,6 @@ module.exports = {
378
378
  TEST_IDS,
379
379
  initials,
380
380
  };
381
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"));
382
382
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
383
383
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -325,10 +325,26 @@ const LABELS = {
325
325
  continue: "Weiter",
326
326
  cancel: "Abbrechen",
327
327
  },
328
+ reset: {
329
+ title: "Auf Werkseinstellungen zurücksetzen",
330
+ hint: "Alle Daten löschen und die Instanz neu einrichten.",
331
+ saas: "Diese Instanz wird im Kundenportal (Scalio Compass) zurückgesetzt: Abos, Mandant verwalten, Instanz zurücksetzen. Das Portal zeigt den neuen Einrichtungscode.",
332
+ text: "Löscht alle Daten dieser Instanz: Benutzer, Einstellungen und alles, was Sie erfasst haben. Die Installations-ID bleibt, die Lizenz auch, ausser Sie entfernen sie. Danach startet die Instanz wieder mit der Ersteinrichtung; den neuen Einrichtungscode finden Sie im Log des Backends.",
333
+ warn: "Das lässt sich nicht rückgängig machen. Sichern Sie vorher, was Sie noch brauchen.",
334
+ backupFirst: "Zuerst sichern",
335
+ confirm: "Zur Bestätigung RESET eingeben",
336
+ removeLicense: "Lizenz ebenfalls entfernen",
337
+ removeLicenseHint: "Danach läuft die Instanz als freie Edition, bis wieder ein Lizenzschlüssel eingetragen ist. Die Installations-ID bleibt.",
338
+ password: "Ihr Passwort",
339
+ run: "Auf Werkseinstellungen zurücksetzen",
340
+ },
328
341
  license: {
329
342
  title: "Lizenz",
330
343
  hint: "Lizenzen kaufen und verlängern Sie in Scalio Compass. Der Schlüssel gilt nur für die Installations-ID unten.",
331
344
  managed: "Die Lizenz dieser Instanz wird von Ihrem Anbieter verwaltet.",
345
+ statusTitle: "Status und Nutzung",
346
+ addonsHint: "Zusätzliche Module oder Plätze. Jedes Add-on hat einen eigenen Schlüssel aus Scalio Compass.",
347
+ keyHint: "Der Schlüssel aus Scalio Compass. Er gilt sofort, ohne Neustart.",
332
348
  status: "Status",
333
349
  active: "Aktiv",
334
350
  limited: "Eingeschränkt",
@@ -717,10 +733,26 @@ const LABELS = {
717
733
  continue: "Continue",
718
734
  cancel: "Cancel",
719
735
  },
736
+ reset: {
737
+ title: "Reset to factory settings",
738
+ hint: "Wipe all data and set the instance up again.",
739
+ saas: "This instance is reset in the customer portal (Scalio Compass): Subscriptions, Manage tenant, Reset instance. The portal shows the new setup code.",
740
+ text: "Deletes all data of this instance: users, settings and everything you created. The installation ID stays, and the license too unless you remove it. Afterwards the instance starts with the first-run setup again; the new setup code is in the log of the backend.",
741
+ warn: "This cannot be undone. Back up what you still need first.",
742
+ backupFirst: "Back up first",
743
+ confirm: "Type RESET to confirm",
744
+ removeLicense: "Remove the license as well",
745
+ removeLicenseHint: "Afterwards the instance runs as the free edition until a license key is entered again. The installation ID stays.",
746
+ password: "Your password",
747
+ run: "Reset to factory settings",
748
+ },
720
749
  license: {
721
750
  title: "License",
722
751
  hint: "Buy or extend a license in Scalio Compass. The key is bound to the installation ID below.",
723
752
  managed: "The license of this instance is managed by your provider.",
753
+ statusTitle: "Status and usage",
754
+ addonsHint: "Extra modules or seats. Every add-on has its own key from Scalio Compass.",
755
+ keyHint: "The key from Scalio Compass. It applies right away, no restart needed.",
724
756
  status: "Status",
725
757
  active: "Active",
726
758
  limited: "Limited",
package/react/license.js CHANGED
@@ -6,6 +6,7 @@ const React = require("react");
6
6
  const h = React.createElement;
7
7
  const Icons = require("./icons");
8
8
  const { labelsFor } = require("./labels");
9
+ const { SettingsSection } = require("./settings");
9
10
 
10
11
  function cx() {
11
12
  return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
@@ -105,7 +106,8 @@ function UsageBar({ item }) {
105
106
  // Props: client (licenseApi or the same shape), map(license, {L, lang, kindLabel, featureLabel}) -> view model,
106
107
  // lang, labels, product ("Citadel", for the remove question), kindLabel(k), featureLabel(f), noFeatures (text when
107
108
  // the license has no features), notify(message, tone), confirm({title, action, danger}), onChange(license), children
108
- // (extra parts at the end, e.g. a policy form), formatError(err) -> text, className.
109
+ // (extra parts at the end, e.g. a policy form; extraTitle/extraHint make them an own area), formatError(err) -> text,
110
+ // className.
109
111
  function LicensePanel(props) {
110
112
  const { client, lang, notify, confirm, onChange } = props;
111
113
  const L = labelsFor(lang, props.labels).license;
@@ -179,133 +181,131 @@ function LicensePanel(props) {
179
181
  : h("dd", { className: cx(r.mono && "is-mono"), "data-testid": r.testId }, r.tone ? h("span", { className: `is-${r.tone}` }, r.value) : r.value)
180
182
  );
181
183
 
182
- return h(
183
- "section",
184
- { className: cx("scalio-license", props.className), "data-testid": props.testId || "license-panel" },
185
- h(
186
- "div",
187
- { className: "scalio-license-part" },
188
- h("div", { className: "scalio-license-title" }, h(Icons.ShieldCheck, { className: "scalio-icon" }), L.title),
189
- h("p", { className: "scalio-license-hint" }, m.managed ? L.managed : L.hint)
190
- ),
191
- note ? h("div", { className: cx("scalio-notice", note.tone === "error" ? "is-error" : "is-ok"), style: { margin: "1.25rem 1.25rem 0" }, role: "status" }, note.text) : null,
184
+ // ADR 0069 (wartung/W1): three areas in the settings form, title and hint left, card right.
185
+ const statusCard = [
186
+ note ? h("div", { key: "note", className: cx("scalio-notice", note.tone === "error" ? "is-error" : "is-ok"), role: "status" }, note.text) : null,
192
187
  m.messages.length
193
- ? h("div", { className: "scalio-notice is-warn scalio-license-messages", "data-testid": "license-messages" },
188
+ ? h("div", { key: "msg", className: "scalio-notice is-warn scalio-license-messages", "data-testid": "license-messages" },
194
189
  m.messages.map((t) => h("div", { key: t }, h(Icons.Alert, { className: "scalio-icon" }), h("span", null, t))))
195
190
  : null,
196
191
  h(
197
192
  "div",
198
- { className: "scalio-license-part scalio-license-grid" },
193
+ { key: "status", className: "scalio-license-status" },
199
194
  h(
200
195
  "div",
201
- { className: "scalio-license-status" },
202
- h(
203
- "div",
204
- { className: "scalio-license-edition" },
205
- h("span", { className: cx("scalio-badge", m.licensed ? "is-ok" : "is-warn"), "data-testid": "license-edition" }, m.edition),
206
- m.customer ? h("span", { className: "scalio-license-customer" }, m.customer) : null
207
- ),
208
- h("dl", { className: "scalio-license-rows" }, m.rows.map(row)),
209
- m.features
210
- ? h(
211
- "div",
212
- null,
213
- h("div", { className: "scalio-license-hint", style: { marginBottom: "0.25rem" } }, L.features),
214
- m.features.length
215
- ? h("ul", { className: "scalio-license-features", "data-testid": "license-features" },
216
- m.features.map((f) => h("li", { key: f.key }, h(Icons.Check, { className: "scalio-icon" }), f.label)))
217
- : h("p", { className: "scalio-license-hint" }, props.noFeatures || L.noFeatures)
218
- )
219
- : null,
220
- props.statusExtra || null
196
+ { className: "scalio-license-edition" },
197
+ h("span", { className: cx("scalio-badge", m.licensed ? "is-ok" : "is-warn"), "data-testid": "license-edition" }, m.edition),
198
+ m.customer ? h("span", { className: "scalio-license-customer" }, m.customer) : null
221
199
  ),
222
- m.usage.length
223
- ? h("div", { className: "scalio-license-usage" }, h("div", { className: "scalio-license-sub" }, L.usage), m.usage.map((u) => h(UsageBar, { key: u.key, item: u })))
224
- : null
225
- ),
226
- m.addonsHidden ? null : h(
227
- "div",
228
- { className: "scalio-license-part", "data-testid": "license-addons" },
229
- h("div", { className: "scalio-license-sub" }, h(Icons.Puzzle, { className: "scalio-icon", width: 14, height: 14 }), L.addons),
230
- m.addons.length
200
+ h("dl", { className: "scalio-license-rows" }, m.rows.map(row)),
201
+ m.features
231
202
  ? h(
232
203
  "div",
233
- { className: "scalio-license-addons" },
234
- m.addons.map((a) =>
204
+ null,
205
+ h("div", { className: "scalio-license-hint", style: { marginBottom: "0.25rem" } }, L.features),
206
+ m.features.length
207
+ ? h("ul", { className: "scalio-license-features", "data-testid": "license-features" },
208
+ m.features.map((f) => h("li", { key: f.key }, h(Icons.Check, { className: "scalio-icon" }), f.label)))
209
+ : h("p", { className: "scalio-license-hint", "data-testid": "license-no-features" }, props.noFeatures || L.noFeatures)
210
+ )
211
+ : null,
212
+ props.statusExtra || null
213
+ ),
214
+ m.usage.length
215
+ ? h("div", { key: "usage", className: "scalio-license-usage" }, h("div", { className: "scalio-license-sub" }, L.usage), m.usage.map((u) => h(UsageBar, { key: u.key, item: u })))
216
+ : null,
217
+ ];
218
+
219
+ const addonCard = [
220
+ m.addons.length
221
+ ? h(
222
+ "div",
223
+ { key: "list", className: "scalio-license-addons" },
224
+ m.addons.map((a) =>
225
+ h(
226
+ "div",
227
+ { key: a.id, className: cx("scalio-license-addon", a.tone && `tone-${a.tone}`), "data-testid": `addon-card-${a.id}` },
235
228
  h(
236
229
  "div",
237
- { key: a.id, className: cx("scalio-license-addon", a.tone && `tone-${a.tone}`), "data-testid": `addon-card-${a.id}` },
230
+ { style: { minWidth: 0 } },
238
231
  h(
239
232
  "div",
240
- { style: { minWidth: 0 } },
241
- h(
242
- "div",
243
- { className: "scalio-license-addon-name" },
244
- a.name,
245
- (a.badges || []).map((b) =>
246
- h("span", { key: b.label, className: cx("scalio-badge", b.tone && `is-${b.tone}`), "data-testid": b.testId }, b.label))
247
- ),
248
- h("p", { className: "scalio-license-addon-detail" },
249
- a.detail !== undefined
250
- ? a.detail
251
- : [a.seats ? `+${a.seats}` : h("span", { key: "i", className: "is-ok" }, L.included), a.exp ? ` · ${L.maintenanceUntil} ${a.exp}` : ""])
233
+ { className: "scalio-license-addon-name" },
234
+ a.name,
235
+ (a.badges || []).map((b) =>
236
+ h("span", { key: b.label, className: cx("scalio-badge", b.tone && `is-${b.tone}`), "data-testid": b.testId }, b.label))
252
237
  ),
253
- m.managed || m.addonsReadOnly || a.removable === false
254
- ? null
255
- : h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
256
- "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a), L.addonRemoved) },
257
- h(Icons.Trash, { className: "scalio-icon" }))
258
- )
238
+ h("p", { className: "scalio-license-addon-detail" },
239
+ a.detail !== undefined
240
+ ? a.detail
241
+ : [a.seats ? `+${a.seats}` : h("span", { key: "i", className: "is-ok" }, L.included), a.exp ? ` · ${L.maintenanceUntil} ${a.exp}` : ""])
242
+ ),
243
+ m.managed || m.addonsReadOnly || a.removable === false
244
+ ? null
245
+ : h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
246
+ "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a), L.addonRemoved) },
247
+ h(Icons.Trash, { className: "scalio-icon" }))
259
248
  )
260
249
  )
261
- : h("p", { className: "scalio-license-empty" }, L.noAddons),
262
- m.addonErrors.map((e) =>
263
- h(
264
- "div",
265
- { key: e.key, className: "scalio-license-addon-error", "data-testid": e.testId },
266
- h("span", null, e.error),
267
- e.addon && !m.managed && !m.addonsReadOnly
268
- ? h("button", { type: "button", className: "scalio-license-link", disabled: busy, "data-testid": `addon-remove-${e.addon.id}`,
269
- onClick: () => run(() => client.removeAddon(e.addon), L.addonRemoved) }, L.remove)
270
- : null
271
250
  )
272
- ),
273
- m.addonsReadOnly
274
- ? h("div", { className: "scalio-notice is-info", "data-testid": "license-addon-readonly" }, m.addonsReadOnly)
275
- : m.managed
276
- ? null
277
- : h(
278
- "div",
279
- { className: "scalio-license-form" },
280
- h("textarea", { className: "scalio-input scalio-textarea", rows: 2, value: addon, placeholder: L.addonPlaceholder, disabled: busy,
281
- "aria-label": L.addonPlaceholder, "data-testid": "license-addon-input", onChange: (e) => setAddon(e.target.value) }),
282
- h("div", { className: "scalio-license-actions" },
283
- h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !addon.trim(), onClick: addAddon, "data-testid": "license-addon-add" },
284
- h(Icons.Plus, { className: "scalio-icon" }), L.add))
285
- )
251
+ : h("p", { key: "empty", className: "scalio-license-empty" }, L.noAddons),
252
+ m.addonErrors.map((e) =>
253
+ h(
254
+ "div",
255
+ { key: `err-${e.key}`, className: "scalio-license-addon-error", "data-testid": e.testId },
256
+ h("span", null, e.error),
257
+ e.addon && !m.managed && !m.addonsReadOnly
258
+ ? h("button", { type: "button", className: "scalio-license-link", disabled: busy, "data-testid": `addon-remove-${e.addon.id}`,
259
+ onClick: () => run(() => client.removeAddon(e.addon), L.addonRemoved) }, L.remove)
260
+ : null
261
+ )
286
262
  ),
287
- m.keyReadOnly && !m.managed
288
- ? h("div", { className: "scalio-license-part" }, h("div", { className: "scalio-notice is-info", "data-testid": "license-key-readonly" }, m.keyReadOnly))
263
+ m.addonsReadOnly
264
+ ? h("div", { key: "ro", className: "scalio-notice is-info", "data-testid": "license-addon-readonly" }, m.addonsReadOnly)
289
265
  : m.managed
290
266
  ? null
291
267
  : h(
292
268
  "div",
293
- { className: "scalio-license-part scalio-license-form" },
294
- h("div", { className: "scalio-license-sub" }, h(Icons.KeyRound, { className: "scalio-icon", width: 14, height: 14 }), L.key),
295
- h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: key, placeholder: L.keyPlaceholder, disabled: busy,
296
- "aria-label": L.keyPlaceholder, "data-testid": "license-key-input", onChange: (e) => setKey(e.target.value) }),
297
- h(
298
- "div",
299
- { className: "scalio-license-actions" },
300
- h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !key.trim(), onClick: activate, "data-testid": "license-activate" },
301
- h(Icons.ShieldCheck, { className: "scalio-icon" }), L.activate),
302
- m.keyPresent
303
- ? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, onClick: remove, "data-testid": "license-remove" },
304
- h(Icons.Trash, { className: "scalio-icon" }), L.remove)
305
- : null
306
- )
269
+ { key: "form", className: "scalio-license-form" },
270
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 2, value: addon, placeholder: L.addonPlaceholder, disabled: busy,
271
+ "aria-label": L.addonPlaceholder, "data-testid": "license-addon-input", onChange: (e) => setAddon(e.target.value) }),
272
+ h("div", { className: "scalio-license-actions" },
273
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !addon.trim(), onClick: addAddon, "data-testid": "license-addon-add" },
274
+ h(Icons.Plus, { className: "scalio-icon" }), L.add))
307
275
  ),
308
- props.children ? h("div", { className: "scalio-license-part" }, props.children) : null
276
+ ];
277
+
278
+ const keyCard = m.keyReadOnly
279
+ ? h("div", { className: "scalio-notice is-info", "data-testid": "license-key-readonly" }, m.keyReadOnly)
280
+ : h(
281
+ "div",
282
+ { className: "scalio-license-form" },
283
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: key, placeholder: L.keyPlaceholder, disabled: busy,
284
+ "aria-label": L.keyPlaceholder, "data-testid": "license-key-input", onChange: (e) => setKey(e.target.value) }),
285
+ h(
286
+ "div",
287
+ { className: "scalio-license-actions" },
288
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !key.trim(), onClick: activate, "data-testid": "license-activate" },
289
+ h(Icons.ShieldCheck, { className: "scalio-icon" }), L.activate),
290
+ m.keyPresent
291
+ ? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, onClick: remove, "data-testid": "license-remove" },
292
+ h(Icons.Trash, { className: "scalio-icon" }), L.remove)
293
+ : null
294
+ )
295
+ );
296
+
297
+ return h(
298
+ "div",
299
+ { className: cx("scalio-license", props.className), "data-testid": props.testId || "license-panel" },
300
+ h(SettingsSection, { id: "license-status", title: L.statusTitle, description: m.managed ? L.managed : L.hint, testId: "license-status-section" }, statusCard),
301
+ m.addonsHidden ? null : h(SettingsSection, { id: "license-addons", title: L.addons, description: L.addonsHint, testId: "license-addons" }, addonCard),
302
+ m.managed ? null : h(SettingsSection, { id: "license-key", title: L.key, description: L.keyHint, testId: "license-key-section" }, keyCard),
303
+ // Extra parts of an app (Nexus: reminders): with extraTitle as an own area, else as they are.
304
+ props.children
305
+ ? props.extraTitle
306
+ ? h(SettingsSection, { id: "license-extra", title: props.extraTitle, description: props.extraHint, testId: "license-extra" }, props.children)
307
+ : props.children
308
+ : null
309
309
  );
310
310
  }
311
311
 
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ // Einstellungen -> System (ADR 0069, wartung/W2): blocks every app shows the same way. Today: Zurücksetzen (W7, after
3
+ // Citadel). Backend: scalio-platform `factory_reset` (GET/POST /api/admin/factory-reset).
4
+ const React = require("react");
5
+ const h = React.createElement;
6
+ const Icons = require("./icons");
7
+ const { labelsFor } = require("./labels");
8
+ const { SettingsSection, SettingsField: Field } = require("./settings");
9
+
10
+ function errorText(err) {
11
+ const d = err && err.response && err.response.data && err.response.data.detail;
12
+ if (typeof d === "string") return d;
13
+ if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
14
+ return (err && err.message) || String(err);
15
+ }
16
+
17
+ // api: axios instance with the app's base URL (/api).
18
+ function factoryResetApi(api) {
19
+ return {
20
+ info: () => api.get("/admin/factory-reset").then((r) => r.data),
21
+ run: (body) => api.post("/admin/factory-reset", body).then((r) => r.data),
22
+ };
23
+ }
24
+
25
+ // Props: client (factoryResetApi), lang, labels, tokenKey (localStorage key of the app's token, removed after the
26
+ // reset), onDone() (default: go to /login), backupTo (address of Einstellungen -> Sicherung; shows «Zuerst sichern»),
27
+ // link (the router's Link for backupTo), notify(message, tone), formatError(err).
28
+ function FactoryReset(props) {
29
+ const { client, lang } = props;
30
+ const L = labelsFor(lang, props.labels).reset;
31
+ const fmtErr = props.formatError || errorText;
32
+ const [info, setInfo] = React.useState(null);
33
+ const [confirm, setConfirm] = React.useState("");
34
+ const [password, setPassword] = React.useState("");
35
+ const [removeLicense, setRemoveLicense] = React.useState(false);
36
+ const [busy, setBusy] = React.useState(false);
37
+ const [error, setError] = React.useState("");
38
+
39
+ React.useEffect(() => {
40
+ let alive = true;
41
+ client.info().then((d) => alive && setInfo(d)).catch(() => alive && setInfo({ available: false }));
42
+ return () => { alive = false; };
43
+ }, [client]);
44
+
45
+ const ready = confirm.trim() === "RESET" && (!info || !info.password || !!password);
46
+ const run = async (e) => {
47
+ e.preventDefault();
48
+ if (!ready) return;
49
+ setBusy(true);
50
+ setError("");
51
+ try {
52
+ await client.run({ confirm, password, remove_license: removeLicense });
53
+ try { if (props.tokenKey) window.localStorage.removeItem(props.tokenKey); } catch (x) { /* private mode */ }
54
+ if (props.onDone) props.onDone();
55
+ else window.location.href = "/login";
56
+ } catch (err) {
57
+ const text = fmtErr(err);
58
+ if (props.notify) props.notify(text, "error");
59
+ else setError(text);
60
+ setBusy(false);
61
+ }
62
+ };
63
+
64
+ const backup = props.backupTo
65
+ ? h(props.link || "a", Object.assign({ className: "scalio-btn", "data-testid": "factory-reset-backup" },
66
+ props.link ? { to: props.backupTo } : { href: props.backupTo }), h(Icons.Download, { className: "scalio-icon" }), L.backupFirst)
67
+ : null;
68
+
69
+ return h(
70
+ SettingsSection,
71
+ { id: "reset", title: L.title, description: L.hint, testId: "factory-reset" },
72
+ info && !info.available
73
+ ? h("p", { className: "scalio-settings-text", "data-testid": "factory-reset-saas" }, L.saas)
74
+ : null,
75
+ info && info.available
76
+ ? h(
77
+ "form",
78
+ { className: "scalio-settings-form", onSubmit: run },
79
+ h("p", { className: "scalio-settings-text" }, props.text || L.text),
80
+ h("div", { className: "scalio-notice is-warn", "data-testid": "factory-reset-warn" },
81
+ h("span", null, L.warn), backup),
82
+ h(Field, { id: "factory-reset-confirm", label: L.confirm },
83
+ h("input", { id: "factory-reset-confirm", className: "scalio-input", value: confirm, autoComplete: "off", disabled: busy,
84
+ "data-testid": "factory-reset-confirm", onChange: (e) => setConfirm(e.target.value) })),
85
+ h(
86
+ "label",
87
+ { className: "scalio-settings-check" },
88
+ h("input", { type: "checkbox", checked: removeLicense, disabled: busy, "data-testid": "factory-reset-remove-license",
89
+ onChange: (e) => setRemoveLicense(e.target.checked) }),
90
+ h("span", null, L.removeLicense, h("span", { className: "scalio-settings-help" }, L.removeLicenseHint))
91
+ ),
92
+ info.password
93
+ ? h(Field, { id: "factory-reset-password", label: L.password },
94
+ h("input", { id: "factory-reset-password", type: "password", className: "scalio-input", value: password, disabled: busy,
95
+ autoComplete: "current-password", "data-testid": "factory-reset-password", onChange: (e) => setPassword(e.target.value) }))
96
+ : null,
97
+ error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "factory-reset-error" }, error) : null,
98
+ h("div", { className: "scalio-settings-section-footer" },
99
+ h("button", { type: "submit", className: "scalio-btn is-danger is-solid", disabled: busy || !ready, "data-testid": "factory-reset-run" },
100
+ h(Icons.Trash, { className: "scalio-icon" }), L.run))
101
+ )
102
+ : null
103
+ );
104
+ }
105
+
106
+ module.exports = { FactoryReset, factoryResetApi };
package/react/settings.js CHANGED
@@ -23,12 +23,19 @@ function orderGroups(groups) {
23
23
  return pick(FIXED_HEAD).concat(list.filter((g) => fixed.indexOf(g.id) < 0), pick(FIXED_TAIL));
24
24
  }
25
25
 
26
+ // Gruppe System is the same in every app (ADR 0069 W2): Lizenz, Updates, Sicherung, Zurücksetzen, then the rest.
27
+ const SYSTEM_ORDER = ["license", "updates", "backup", "reset"];
28
+ function systemFirst(items) {
29
+ const rank = (i) => (SYSTEM_ORDER.indexOf(i.id) < 0 ? SYSTEM_ORDER.length : SYSTEM_ORDER.indexOf(i.id));
30
+ return items.map((i, n) => [i, n]).sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]).map((x) => x[0]);
31
+ }
32
+
26
33
  // Visible groups with their visible items, in the fixed order. Empty groups disappear.
27
34
  function settingsGroups(groups, L) {
28
35
  return orderGroups(groups)
29
36
  .map((g) => Object.assign({}, g, {
30
37
  label: g.label || (L && L.settingsGroups && L.settingsGroups[g.id]) || g.id,
31
- items: (g.items || []).filter((i) => i && !i.hidden),
38
+ items: (g.id === "system" ? systemFirst : (x) => x)((g.items || []).filter((i) => i && !i.hidden)),
32
39
  }))
33
40
  .filter((g) => g.items.length > 0);
34
41
  }
@@ -215,4 +222,25 @@ function SettingsSection({ id, title, description, children, footer, testId }) {
215
222
  );
216
223
  }
217
224
 
218
- module.exports = { SettingsLayout, SettingsSection, settingsGroups, matchSetting, SETTINGS_GROUP_ORDER: FIXED_HEAD.concat(["<app>"], FIXED_TAIL) };
225
+ // A labelled field inside an area: label above, help text below (ADR 0069 W1). htmlFor ties the label to the input.
226
+ function SettingsField({ id, label, hint, children, testId }) {
227
+ return h(
228
+ "div",
229
+ { className: "scalio-profile-field", "data-testid": testId },
230
+ label ? h("label", { className: "scalio-field-label", htmlFor: id }, label) : null,
231
+ children,
232
+ hint ? h("p", { className: "scalio-settings-help" }, hint) : null
233
+ );
234
+ }
235
+
236
+ // A switch or button on the right of a text: title, help, control (e.g. the app's own Switch).
237
+ function SettingsRow({ title, hint, children, testId, tone }) {
238
+ return h(
239
+ "div",
240
+ { className: cx("scalio-settings-row", tone && `is-${tone}`), "data-testid": testId },
241
+ h("div", { className: "scalio-settings-row-text" }, h("strong", null, title), hint ? h("span", { className: "scalio-settings-help" }, hint) : null),
242
+ children
243
+ );
244
+ }
245
+
246
+ module.exports = { SettingsLayout, SettingsSection, SettingsField, SettingsRow, settingsGroups, matchSetting, SETTINGS_GROUP_ORDER: FIXED_HEAD.concat(["<app>"], FIXED_TAIL), SYSTEM_ORDER };