@scalio-cloud/ui 0.26.0 → 0.27.1

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,26 @@ 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. The panel passes its `lang` to every `client` call;
310
+ `licenseApi` sends it as `?lang=`, so scalio-license 0.4.0 renders warnings and errors in the language of the page.
311
+
312
+ ## Gruppe System: Zurücksetzen and helpers (React)
313
+
314
+ Every app shows Einstellungen → System in the same order: Lizenz, Updates, Sicherung, Zurücksetzen (`settingsGroups`
315
+ sorts the group `system` that way). The reset block talks to scalio-platform `factory_reset`
316
+ (`GET`/`POST /api/admin/factory-reset`):
317
+
318
+ ```jsx
319
+ import { FactoryReset, factoryResetApi, SettingsField, SettingsRow } from "@scalio-cloud/ui/react";
320
+
321
+ const reset = factoryResetApi(api); // once, outside the component
322
+ <FactoryReset client={reset} lang={lang} tokenKey="citadel_token" backupTo="/settings/backup" link={Link} />
323
+ ```
324
+
325
+ `SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
326
+ keep the fields inside a `SettingsSection` the same in every app.
327
+
307
328
  ## Command palette (React)
308
329
 
309
330
  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.1",
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(" ");
@@ -24,14 +25,17 @@ function errorText(err) {
24
25
  }
25
26
 
26
27
  // api: axios instance with the app's base URL (/api). Every method resolves to the license as the backend sends it.
28
+ // lang ("de"/"en", last argument): scalio-license 0.4.0 renders its messages in that language (?lang=); older
29
+ // backends ignore it.
27
30
  function licenseApi(api) {
28
31
  const data = (p) => p.then((r) => r.data);
32
+ const q = (lang) => (lang === "de" || lang === "en" ? { params: { lang } } : undefined);
29
33
  return {
30
- get: () => data(api.get("/license")),
31
- activate: (key) => data(api.put("/license", { key })),
32
- removeKey: () => data(api.delete("/license")),
33
- addAddon: (key) => data(api.post("/license/addons", { key })),
34
- removeAddon: (addon) => data(api.delete(`/license/addons/${encodeURIComponent(addon.id)}`)),
34
+ get: (lang) => data(api.get("/license", q(lang))),
35
+ activate: (key, lang) => data(api.put("/license", { key }, q(lang))),
36
+ removeKey: (lang) => data(api.delete("/license", q(lang))),
37
+ addAddon: (key, lang) => data(api.post("/license/addons", { key }, q(lang))),
38
+ removeAddon: (addon, lang) => data(api.delete(`/license/addons/${encodeURIComponent(addon.id)}`, q(lang))),
35
39
  };
36
40
  }
37
41
 
@@ -105,7 +109,8 @@ function UsageBar({ item }) {
105
109
  // Props: client (licenseApi or the same shape), map(license, {L, lang, kindLabel, featureLabel}) -> view model,
106
110
  // lang, labels, product ("Citadel", for the remove question), kindLabel(k), featureLabel(f), noFeatures (text when
107
111
  // 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.
112
+ // (extra parts at the end, e.g. a policy form; extraTitle/extraHint make them an own area), formatError(err) -> text,
113
+ // className.
109
114
  function LicensePanel(props) {
110
115
  const { client, lang, notify, confirm, onChange } = props;
111
116
  const L = labelsFor(lang, props.labels).license;
@@ -119,9 +124,9 @@ function LicensePanel(props) {
119
124
 
120
125
  React.useEffect(() => {
121
126
  let alive = true;
122
- client.get().then((d) => alive && setLic(d)).catch((err) => alive && setLoadError(fmtErr(err)));
127
+ client.get(lang).then((d) => alive && setLic(d)).catch((err) => alive && setLoadError(fmtErr(err)));
123
128
  return () => { alive = false; };
124
- }, [client]);
129
+ }, [client, lang]);
125
130
 
126
131
  const say = (text, tone) => {
127
132
  if (notify) notify(text, tone || "success");
@@ -149,15 +154,15 @@ function LicensePanel(props) {
149
154
  }
150
155
  };
151
156
  const activate = async () => {
152
- if (await run(() => client.activate(key.trim()), L.activated)) setKey("");
157
+ if (await run(() => client.activate(key.trim(), lang), L.activated)) setKey("");
153
158
  };
154
159
  const remove = async () => {
155
160
  const title = fill(L.removeConfirm, { product: props.product || "Scalio" });
156
161
  const yes = confirm ? await confirm({ title, action: L.remove, danger: true }) : window.confirm(title);
157
- if (yes) run(() => client.removeKey(), L.removed);
162
+ if (yes) run(() => client.removeKey(lang), L.removed);
158
163
  };
159
164
  const addAddon = async () => {
160
- if (await run(() => client.addAddon(addon.trim()), L.addonActivated)) setAddon("");
165
+ if (await run(() => client.addAddon(addon.trim(), lang), L.addonActivated)) setAddon("");
161
166
  };
162
167
  const copy = async (value, copied) => {
163
168
  try {
@@ -179,133 +184,131 @@ function LicensePanel(props) {
179
184
  : 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
185
  );
181
186
 
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,
187
+ // ADR 0069 (wartung/W1): three areas in the settings form, title and hint left, card right.
188
+ const statusCard = [
189
+ note ? h("div", { key: "note", className: cx("scalio-notice", note.tone === "error" ? "is-error" : "is-ok"), role: "status" }, note.text) : null,
192
190
  m.messages.length
193
- ? h("div", { className: "scalio-notice is-warn scalio-license-messages", "data-testid": "license-messages" },
191
+ ? h("div", { key: "msg", className: "scalio-notice is-warn scalio-license-messages", "data-testid": "license-messages" },
194
192
  m.messages.map((t) => h("div", { key: t }, h(Icons.Alert, { className: "scalio-icon" }), h("span", null, t))))
195
193
  : null,
196
194
  h(
197
195
  "div",
198
- { className: "scalio-license-part scalio-license-grid" },
196
+ { key: "status", className: "scalio-license-status" },
199
197
  h(
200
198
  "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
199
+ { className: "scalio-license-edition" },
200
+ h("span", { className: cx("scalio-badge", m.licensed ? "is-ok" : "is-warn"), "data-testid": "license-edition" }, m.edition),
201
+ m.customer ? h("span", { className: "scalio-license-customer" }, m.customer) : null
221
202
  ),
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
203
+ h("dl", { className: "scalio-license-rows" }, m.rows.map(row)),
204
+ m.features
231
205
  ? h(
232
206
  "div",
233
- { className: "scalio-license-addons" },
234
- m.addons.map((a) =>
207
+ null,
208
+ h("div", { className: "scalio-license-hint", style: { marginBottom: "0.25rem" } }, L.features),
209
+ m.features.length
210
+ ? h("ul", { className: "scalio-license-features", "data-testid": "license-features" },
211
+ m.features.map((f) => h("li", { key: f.key }, h(Icons.Check, { className: "scalio-icon" }), f.label)))
212
+ : h("p", { className: "scalio-license-hint", "data-testid": "license-no-features" }, props.noFeatures || L.noFeatures)
213
+ )
214
+ : null,
215
+ props.statusExtra || null
216
+ ),
217
+ m.usage.length
218
+ ? 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 })))
219
+ : null,
220
+ ];
221
+
222
+ const addonCard = [
223
+ m.addons.length
224
+ ? h(
225
+ "div",
226
+ { key: "list", className: "scalio-license-addons" },
227
+ m.addons.map((a) =>
228
+ h(
229
+ "div",
230
+ { key: a.id, className: cx("scalio-license-addon", a.tone && `tone-${a.tone}`), "data-testid": `addon-card-${a.id}` },
235
231
  h(
236
232
  "div",
237
- { key: a.id, className: cx("scalio-license-addon", a.tone && `tone-${a.tone}`), "data-testid": `addon-card-${a.id}` },
233
+ { style: { minWidth: 0 } },
238
234
  h(
239
235
  "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}` : ""])
236
+ { className: "scalio-license-addon-name" },
237
+ a.name,
238
+ (a.badges || []).map((b) =>
239
+ h("span", { key: b.label, className: cx("scalio-badge", b.tone && `is-${b.tone}`), "data-testid": b.testId }, b.label))
252
240
  ),
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
- )
241
+ h("p", { className: "scalio-license-addon-detail" },
242
+ a.detail !== undefined
243
+ ? a.detail
244
+ : [a.seats ? `+${a.seats}` : h("span", { key: "i", className: "is-ok" }, L.included), a.exp ? ` · ${L.maintenanceUntil} ${a.exp}` : ""])
245
+ ),
246
+ m.managed || m.addonsReadOnly || a.removable === false
247
+ ? null
248
+ : h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
249
+ "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a, lang), L.addonRemoved) },
250
+ h(Icons.Trash, { className: "scalio-icon" }))
259
251
  )
260
252
  )
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
253
  )
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
- )
254
+ : h("p", { key: "empty", className: "scalio-license-empty" }, L.noAddons),
255
+ m.addonErrors.map((e) =>
256
+ h(
257
+ "div",
258
+ { key: `err-${e.key}`, className: "scalio-license-addon-error", "data-testid": e.testId },
259
+ h("span", null, e.error),
260
+ e.addon && !m.managed && !m.addonsReadOnly
261
+ ? h("button", { type: "button", className: "scalio-license-link", disabled: busy, "data-testid": `addon-remove-${e.addon.id}`,
262
+ onClick: () => run(() => client.removeAddon(e.addon, lang), L.addonRemoved) }, L.remove)
263
+ : null
264
+ )
286
265
  ),
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))
266
+ m.addonsReadOnly
267
+ ? h("div", { key: "ro", className: "scalio-notice is-info", "data-testid": "license-addon-readonly" }, m.addonsReadOnly)
289
268
  : m.managed
290
269
  ? null
291
270
  : h(
292
271
  "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
- )
272
+ { key: "form", className: "scalio-license-form" },
273
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 2, value: addon, placeholder: L.addonPlaceholder, disabled: busy,
274
+ "aria-label": L.addonPlaceholder, "data-testid": "license-addon-input", onChange: (e) => setAddon(e.target.value) }),
275
+ h("div", { className: "scalio-license-actions" },
276
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !addon.trim(), onClick: addAddon, "data-testid": "license-addon-add" },
277
+ h(Icons.Plus, { className: "scalio-icon" }), L.add))
307
278
  ),
308
- props.children ? h("div", { className: "scalio-license-part" }, props.children) : null
279
+ ];
280
+
281
+ const keyCard = m.keyReadOnly
282
+ ? h("div", { className: "scalio-notice is-info", "data-testid": "license-key-readonly" }, m.keyReadOnly)
283
+ : h(
284
+ "div",
285
+ { className: "scalio-license-form" },
286
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: key, placeholder: L.keyPlaceholder, disabled: busy,
287
+ "aria-label": L.keyPlaceholder, "data-testid": "license-key-input", onChange: (e) => setKey(e.target.value) }),
288
+ h(
289
+ "div",
290
+ { className: "scalio-license-actions" },
291
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !key.trim(), onClick: activate, "data-testid": "license-activate" },
292
+ h(Icons.ShieldCheck, { className: "scalio-icon" }), L.activate),
293
+ m.keyPresent
294
+ ? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, onClick: remove, "data-testid": "license-remove" },
295
+ h(Icons.Trash, { className: "scalio-icon" }), L.remove)
296
+ : null
297
+ )
298
+ );
299
+
300
+ return h(
301
+ "div",
302
+ { className: cx("scalio-license", props.className), "data-testid": props.testId || "license-panel" },
303
+ h(SettingsSection, { id: "license-status", title: L.statusTitle, description: m.managed ? L.managed : L.hint, testId: "license-status-section" }, statusCard),
304
+ m.addonsHidden ? null : h(SettingsSection, { id: "license-addons", title: L.addons, description: L.addonsHint, testId: "license-addons" }, addonCard),
305
+ m.managed ? null : h(SettingsSection, { id: "license-key", title: L.key, description: L.keyHint, testId: "license-key-section" }, keyCard),
306
+ // Extra parts of an app (Nexus: reminders): with extraTitle as an own area, else as they are.
307
+ props.children
308
+ ? props.extraTitle
309
+ ? h(SettingsSection, { id: "license-extra", title: props.extraTitle, description: props.extraHint, testId: "license-extra" }, props.children)
310
+ : props.children
311
+ : null
309
312
  );
310
313
  }
311
314
 
@@ -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 };