@scalio-cloud/ui 0.25.1 → 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; }
@@ -484,6 +499,19 @@
484
499
  .dark .scalio-roles-yes { color: #4ade80; }
485
500
  .scalio-roles-no { color: hsl(var(--muted-foreground)); }
486
501
  .scalio-roles-lic { margin-left: 0.375rem; }
502
+ .scalio-roles-cell > svg, .scalio-roles-cell > input, .scalio-roles-cell > .scalio-roles-no { display: block; margin-left: auto; margin-right: auto; }
503
+ .scalio-roles-table th.scalio-roles-col.is-mine, .scalio-roles-cell.is-mine { background: hsl(var(--primary) / 0.08); }
504
+ .scalio-roles-table th.scalio-roles-col.is-mine .scalio-roles-name { color: hsl(var(--primary)); }
505
+ .scalio-roles-mine { margin-bottom: 0.25rem; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: hsl(var(--primary)); }
506
+ .scalio-tenant-list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; border: 1px solid hsl(var(--border)); border-radius: 2px; }
507
+ .scalio-tenant-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.625rem 0.875rem; border-bottom: 1px solid hsl(var(--border)); }
508
+ .scalio-tenant-row:last-child { border-bottom: 0; }
509
+ .scalio-tenant-who { min-width: 0; }
510
+ .scalio-tenant-domain { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-weight: 600; overflow-wrap: anywhere; }
511
+ .scalio-tenant-id { margin-top: 0.125rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
512
+ .scalio-tenant-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
513
+ .scalio-tenant-manual { display: flex; flex-direction: column; gap: 0.5rem; max-width: 28rem; }
514
+ .scalio-btn.is-disabled { pointer-events: none; opacity: 0.5; }
487
515
  .scalio-roles-source { display: flex; flex-direction: column; gap: 0.875rem; padding: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); font-size: 0.875rem; }
488
516
 
489
517
  /* Setup wizard (SetupWizard, ADR 0060): design of the Nexus wizard, first built in Kontor */
@@ -493,7 +521,7 @@
493
521
  padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
494
522
  box-shadow: 0 16px 40px rgb(0 0 0 / 0.25); font-size: 0.875rem;
495
523
  }
496
- .scalio-wizard.is-wide { max-width: 56rem; }
524
+ .scalio-wizard.is-wide { max-width: 72rem; }
497
525
  .scalio-wizard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
498
526
  .scalio-wizard-title { margin: 0; font-family: 'Chivo', sans-serif; font-size: 1.5rem; font-weight: 900; line-height: 1.2; text-transform: uppercase; letter-spacing: -0.025em; }
499
527
  .scalio-wizard-subtitle { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
@@ -522,13 +550,13 @@
522
550
  .scalio-wizard-success-icon { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 999px; }
523
551
 
524
552
  /* License panel (LicensePanel) */
525
- .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 */
526
554
  .scalio-license-part { padding: 1.25rem; }
527
555
  .scalio-license-part + .scalio-license-part { border-top: 1px solid hsl(var(--border)); }
528
556
  .scalio-license-title { display: flex; align-items: center; gap: 0.5rem; font-family: 'Chivo', sans-serif; font-weight: 700; }
529
557
  .scalio-license-title .scalio-icon { color: hsl(var(--primary)); }
530
558
  .scalio-license-hint { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
531
- .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; }
532
560
  .scalio-license-messages div { display: flex; gap: 0.5rem; }
533
561
  .scalio-license-messages .scalio-icon { flex-shrink: 0; margin-top: 0.125rem; }
534
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.25.1",
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
@@ -64,7 +64,7 @@ const LABELS = {
64
64
  graceTip: "Diese Version liegt nach Ihrem Lizenzdatum und wechselt nach der Kulanzzeit in den Nur-Lesen-Modus.",
65
65
  },
66
66
  accountGroups: { people: "Personen", access: "Rechte" },
67
- accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Microsoft Entra", signIn: "Anmeldung", organizations: "Organisationen" },
67
+ accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Rollen aus Entra ID", signIn: "Anmeldung", organizations: "Organisationen" },
68
68
  passwordPolicy: {
69
69
  label: "Anmeldung mit Passwort",
70
70
  local: "Erlaubt",
@@ -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",
@@ -460,7 +476,7 @@ const LABELS = {
460
476
  graceTip: "This version is past your license date and turns read-only after the grace period.",
461
477
  },
462
478
  accountGroups: { people: "People", access: "Permissions" },
463
- accountTabs: { users: "Users", roles: "Roles", entra: "Microsoft Entra", signIn: "Sign-in", organizations: "Organizations" },
479
+ accountTabs: { users: "Users", roles: "Roles", entra: "Roles from Entra ID", signIn: "Sign-in", organizations: "Organizations" },
464
480
  passwordPolicy: {
465
481
  label: "Password sign-in",
466
482
  local: "Allowed",
@@ -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/roles.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
- // Konten und Rollen -> Rollen and -> Microsoft Entra, the same in every Scalio app (scalio-docs concepts/app-roles.md,
2
+ // Konten und Rollen -> Rollen and -> Rollen aus Entra ID, the same in every Scalio app (scalio-docs concepts/app-roles.md,
3
3
  // roles/R1-R6). rolesApi(api, opts) maps the shared role API of scalio-platform `roles_router` (GET /roles/permissions,
4
4
  // GET/POST /roles, PATCH/DELETE /roles/{id}); RoleAdmin shows "welche Rolle darf was" (permissions as rows, roles as
5
- // columns), built-in roles read-only, own roles with checkboxes, add / copy / rename / delete. RoleSourceSettings is
6
- // the role source (Scalio app or Entra app roles) with the Entra values and, where the app has them, the tenant IDs.
5
+ // columns), built-in roles read-only, own roles with checkboxes, add / copy / rename / delete; the column of the signed-in
6
+ // person's role is marked. RoleSourceSettings is the role source (Scalio app or Entra app roles) with the Entra values
7
+ // and the list of tenants whose roles count, with «Tenant hinzufügen» by admin consent (ADR 0068).
7
8
  const React = require("react");
8
9
  const h = React.createElement;
9
10
  const Icons = require("./icons");
@@ -47,8 +48,26 @@ const TEXT = {
47
48
  sourceEntra: "Microsoft Entra ID (App-Rollen)",
48
49
  hintLocal: "Sie vergeben die Rollen unter Benutzer. Werte für Entra ID, falls Sie später umstellen:",
49
50
  hintEntra: "Ab der nächsten Anmeldung entscheiden die App-Rollen der Unternehmensanwendung «Scalio» für jedes Microsoft-Konto. Wer keine Rolle hat, kann sich nicht mehr anmelden. Eigene Rollen und lokale Konten vergeben Sie weiter hier. Werte:",
50
- tenants: "Microsoft-Tenant-IDs",
51
- tenantsHint: "Nur App-Rollen aus diesen Tenants zählen, durch Komma getrennt.",
51
+ yourRole: "Ihre Rolle",
52
+ tenants: "Vertraute Tenants",
53
+ tenantsHint: "Nur App-Rollen aus diesen Tenants zählen, und Personen aus diesen Tenants können sich anmelden.",
54
+ tenantsEmpty: "Noch kein Tenant. Mit «Tenant hinzufügen» gibt ein Admin des Tenants die Zustimmung für die App «Scalio».",
55
+ tenantAdd: "Tenant hinzufügen",
56
+ tenantAddHint: "Sie melden sich als Admin des Tenants bei Microsoft an und stimmen der App «Scalio» zu.",
57
+ tenantManual: "Von Hand eintragen",
58
+ tenantManualHint: "Nur wenn ein Admin des Tenants der App «Scalio» schon zugestimmt hat.",
59
+ tenantId: "Tenant-ID",
60
+ tenantAddManual: "Eintragen",
61
+ tenantInvalid: "Das ist keine Tenant-ID (GUID).",
62
+ tenantRemove: "Entfernen",
63
+ tenantRemoveTitle: "Tenant «{name}» entfernen?",
64
+ tenantRemoveText: "App-Rollen aus diesem Tenant zählen dann nicht mehr. Die Unternehmensanwendung im Tenant bleibt; entfernen Sie sie dort bei Bedarf selbst.",
65
+ tenantFixed: "Fest",
66
+ tenantAdded: "Tenant hinzugefügt.",
67
+ tenantRemoved: "Tenant entfernt.",
68
+ consentCancelled: "Die Zustimmung wurde abgebrochen.",
69
+ consentFailed: "Die Zustimmung hat nicht geklappt. Melden Sie sich mit einem Konto an, das in diesem Tenant Admin ist.",
70
+ consentForbidden: "Nur Admins dieser App können einen Tenant hinzufügen.",
52
71
  entraConfirm: "Rollen aus Entra ID übernehmen?",
53
72
  entraConfirmText: "Ab der nächsten Anmeldung entscheiden die App-Rollen in Entra ID für jedes Microsoft-Konto. Personen ohne Rolle können sich nicht mehr anmelden.",
54
73
  entraAction: "Entra ID verwenden",
@@ -90,8 +109,26 @@ const TEXT = {
90
109
  sourceEntra: "Microsoft Entra ID (app roles)",
91
110
  hintLocal: "You give roles under Users. Values for Entra ID, in case you switch later:",
92
111
  hintEntra: "From the next sign-in, the app roles of the enterprise application “Scalio” decide for every Microsoft account. People without a role can no longer sign in. Own roles and local accounts are still given here. Values:",
93
- tenants: "Microsoft tenant IDs",
94
- tenantsHint: "Only app roles from these tenants count, separated by commas.",
112
+ yourRole: "Your role",
113
+ tenants: "Trusted tenants",
114
+ tenantsHint: "Only app roles from these tenants count, and people from these tenants can sign in.",
115
+ tenantsEmpty: "No tenant yet. With “Add tenant” an admin of the tenant consents to the app “Scalio”.",
116
+ tenantAdd: "Add tenant",
117
+ tenantAddHint: "You sign in to Microsoft as an admin of the tenant and consent to the app “Scalio”.",
118
+ tenantManual: "Enter by hand",
119
+ tenantManualHint: "Only if an admin of the tenant already consented to the app “Scalio”.",
120
+ tenantId: "Tenant ID",
121
+ tenantAddManual: "Add",
122
+ tenantInvalid: "This is not a tenant ID (GUID).",
123
+ tenantRemove: "Remove",
124
+ tenantRemoveTitle: "Remove the tenant “{name}”?",
125
+ tenantRemoveText: "App roles from this tenant no longer count. The enterprise application stays in the tenant; remove it there yourself if needed.",
126
+ tenantFixed: "Fixed",
127
+ tenantAdded: "Tenant added.",
128
+ tenantRemoved: "Tenant removed.",
129
+ consentCancelled: "The consent was cancelled.",
130
+ consentFailed: "The consent did not work. Sign in with an account that is an admin in this tenant.",
131
+ consentForbidden: "Only admins of this app can add a tenant.",
95
132
  entraConfirm: "Take roles from Entra ID?",
96
133
  entraConfirmText: "From the next sign-in, the app roles in Entra ID decide for every Microsoft account. People without a role can no longer sign in.",
97
134
  entraAction: "Use Entra ID",
@@ -265,9 +302,11 @@ function RoleAdmin(props) {
265
302
  const byGroup = (g) => catalog.permissions.filter((p) => (p.group || "") === g || (!groups.includes(p.group || "") && g === groups[0]));
266
303
  const usersText = (r) => (r.users === null ? "" : r.users === 1 ? L.oneUser : fill(L.users, { n: r.users }));
267
304
 
305
+ const mine = (r) => props.currentRole !== undefined && props.currentRole !== null && String(props.currentRole) === r.id;
268
306
  const head = h("tr", null,
269
307
  h("th", { className: "scalio-roles-perm" }, L.permission),
270
- roles.map((r) => h("th", { key: r.id, className: cx("scalio-roles-col", r.builtin ? "is-builtin" : "is-own"), "data-testid": `role-col-${r.id}` },
308
+ roles.map((r) => h("th", { key: r.id, className: cx("scalio-roles-col", r.builtin ? "is-builtin" : "is-own", mine(r) && "is-mine"), "data-testid": `role-col-${r.id}` },
309
+ mine(r) ? h("div", { className: "scalio-roles-mine", "data-testid": "role-yours" }, L.yourRole) : null,
271
310
  h("div", { className: "scalio-roles-name" }, tr(r.name, lang)),
272
311
  h("div", { className: "scalio-roles-meta" },
273
312
  h("span", null, r.builtin ? L.builtin : L.own),
@@ -282,10 +321,10 @@ function RoleAdmin(props) {
282
321
  const cell = (r, p) => {
283
322
  const has = r.admin || r.permissions.includes(p.key);
284
323
  if (r.builtin || !canEdit) {
285
- return h("td", { key: r.id, className: "scalio-roles-cell", "data-testid": `role-cell-${r.id}-${p.key}` },
324
+ return h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine"), "data-testid": `role-cell-${r.id}-${p.key}` },
286
325
  has ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no", "aria-label": "–" }, "–"));
287
326
  }
288
- return h("td", { key: r.id, className: "scalio-roles-cell" },
327
+ return h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine") },
289
328
  h("input", { type: "checkbox", checked: has, disabled: busy, onChange: () => toggle(r, p.key),
290
329
  "aria-label": `${tr(r.name, lang)}: ${tr(p.label, lang)}`, "data-testid": `role-check-${r.id}-${p.key}` }));
291
330
  };
@@ -303,7 +342,7 @@ function RoleAdmin(props) {
303
342
  body.push(h("tr", { key: "admin-only", className: "scalio-roles-group" }, h("td", { colSpan: roles.length + 1 }, L.adminOnly)));
304
343
  body.push(h("tr", { key: "admin-row" },
305
344
  h("td", { className: "scalio-roles-perm" }, L.adminAll),
306
- roles.map((r) => h("td", { key: r.id, className: "scalio-roles-cell" },
345
+ roles.map((r) => h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine") },
307
346
  r.admin ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no" }, "–")))));
308
347
 
309
348
  const dialogEl = dialog ? h(Dialog, { title: dialog.mode === "add" ? L.addTitle : L.renameTitle, onClose: close, testId: "role-dialog" },
@@ -339,46 +378,128 @@ function RoleAdmin(props) {
339
378
  dialogEl);
340
379
  }
341
380
 
342
- // Props: value {source: "local"|"entra", values: [...], tenants?: [...]}, onSave({source, tenants}) -> Promise,
343
- // tenants (show the tenant field), lang, labels, confirm, notify, formatError, disabled, testId.
381
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
382
+
383
+ // A tenant entry: "tid" (old lists) or {tid, domain, fixed, label, href}.
384
+ function tenantOf(x) {
385
+ if (typeof x === "string") return { tid: x.trim().toLowerCase(), domain: "" };
386
+ return Object.assign({}, x, { tid: String(x.tid || x.id || "").trim().toLowerCase(), domain: x.domain || "" });
387
+ }
388
+
389
+ // Back from «Tenant hinzufügen»: ?entra_tenant=added|cancelled|failed|forbidden (scalio-identity 0.5.0).
390
+ function consentResult() {
391
+ if (typeof window === "undefined" || !window.location) return "";
392
+ try {
393
+ const url = new URL(window.location.href);
394
+ const res = url.searchParams.get("entra_tenant") || "";
395
+ if (res && window.history && window.history.replaceState) {
396
+ url.searchParams.delete("entra_tenant");
397
+ window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
398
+ }
399
+ return res;
400
+ } catch (e) {
401
+ return "";
402
+ }
403
+ }
404
+
405
+ // Props: value {source: "local"|"entra", values: [...], tenants?: [tid | {tid, domain, fixed}]},
406
+ // onSave({source, tenants: [{tid, domain}]}) -> Promise, tenants (show the tenant list), consentUrl (button «Tenant
407
+ // hinzufügen», e.g. "/api/auth/scalio/consent"), manual (default true: enter a tenant ID by hand), fixed (one fixed
408
+ // tenant, no add or remove: Nexus), extraTenants [{tid, domain, label, href}] and extraTitle (view only: Rosetta's
409
+ // customer tenants), lang, labels, confirm, notify, formatError, disabled, testId.
344
410
  function RoleSourceSettings(props) {
345
411
  const lang = props.lang === "en" ? "en" : "de";
346
412
  const L = rolesText(lang, props.labels);
347
413
  const v = props.value || { source: "local", values: [] };
414
+ const list = (v.tenants || []).map(tenantOf).filter((t) => t.tid);
348
415
  const [source, setSource] = React.useState(v.source || "local");
349
- const [tenants, setTenants] = React.useState((v.tenants || []).join(", "));
416
+ const [manual, setManual] = React.useState(null); // null = closed, else the typed id
350
417
  const [busy, setBusy] = React.useState(false);
351
- React.useEffect(() => { setSource(v.source || "local"); setTenants((v.tenants || []).join(", ")); }, [v.source, (v.tenants || []).join(",")]);
352
- const dirty = source !== (v.source || "local") || (props.tenants && tenants !== (v.tenants || []).join(", "));
353
- const save = async () => {
354
- if (source === "entra" && v.source !== "entra" && !(await ask(props.confirm, L.entraConfirm, L.entraConfirmText, L.entraAction, false))) return;
418
+ const [error, setError] = React.useState("");
419
+ const say = (text, tone) => props.notify && props.notify(text, tone || "success");
420
+ const fmt = props.formatError || errorText;
421
+ React.useEffect(() => { setSource(v.source || "local"); }, [v.source]);
422
+ React.useEffect(() => {
423
+ const res = consentResult();
424
+ if (res === "added") say(L.tenantAdded);
425
+ else if (res === "cancelled") say(L.consentCancelled, "error");
426
+ else if (res === "forbidden") say(L.consentForbidden, "error");
427
+ else if (res) say(L.consentFailed, "error");
428
+ }, []);
429
+ const dirty = source !== (v.source || "local");
430
+ const store = async (src, tenants, okText) => {
355
431
  setBusy(true);
432
+ setError("");
356
433
  try {
357
- await props.onSave({ source, tenants: tenants.split(/[\s,;]+/).filter(Boolean) });
358
- if (props.notify) props.notify(L.saved, "success");
434
+ await props.onSave({ source: src, tenants: tenants.map((t) => ({ tid: t.tid, domain: t.domain || "" })) });
435
+ say(okText || L.saved);
436
+ return true;
359
437
  } catch (e) {
360
- if (props.notify) props.notify((props.formatError || errorText)(e), "error");
438
+ say(fmt(e), "error");
439
+ return false;
361
440
  } finally {
362
441
  setBusy(false);
363
442
  }
364
443
  };
444
+ const save = async () => {
445
+ if (source === "entra" && v.source !== "entra" && !(await ask(props.confirm, L.entraConfirm, L.entraConfirmText, L.entraAction, false))) return;
446
+ store(source, list.filter((t) => !t.fixed));
447
+ };
448
+ const remove = async (t) => {
449
+ if (!(await ask(props.confirm, fill(L.tenantRemoveTitle, { name: t.domain || t.tid }), L.tenantRemoveText, L.tenantRemove))) return;
450
+ store(v.source || "local", list.filter((x) => !x.fixed && x.tid !== t.tid), L.tenantRemoved);
451
+ };
452
+ const addManual = async (e) => {
453
+ e.preventDefault();
454
+ const tid = (manual || "").trim().toLowerCase();
455
+ if (!GUID.test(tid)) { setError(L.tenantInvalid); return; }
456
+ if (list.some((t) => t.tid === tid)) { setManual(null); return; }
457
+ if (await store(v.source || "local", [...list.filter((t) => !t.fixed), { tid, domain: "" }], L.tenantAdded)) setManual(null);
458
+ };
365
459
  const values = (v.values || []).map((x) => (typeof x === "string" ? x : x.value || x.name)).filter(Boolean);
460
+ const off = props.disabled || busy;
461
+ const row = (t, extra) => h("li", { key: t.tid, className: "scalio-tenant-row", "data-testid": `tenant-${t.tid}` },
462
+ h("div", { className: "scalio-tenant-who" },
463
+ h("div", { className: "scalio-tenant-domain" }, t.href ? h("a", { href: t.href }, t.domain || t.label || t.tid) : (t.domain || t.label || t.tid),
464
+ t.label && t.domain ? h("span", { className: "scalio-badge" }, t.label) : null),
465
+ t.domain || t.label ? h("div", { className: "scalio-tenant-id" }, t.tid) : null),
466
+ extra ? null : t.fixed || props.fixed
467
+ ? h("span", { className: "scalio-badge" }, L.tenantFixed)
468
+ : h("button", { type: "button", className: "scalio-btn is-ghost scalio-roles-tool is-danger", onClick: () => remove(t), disabled: off, "data-testid": `tenant-remove-${t.tid}` }, L.tenantRemove));
469
+ const tenantBlock = props.tenants ? h("div", { className: "scalio-users-field", "data-testid": "role-source-tenants" },
470
+ h("div", { className: "scalio-field-label" }, L.tenants),
471
+ list.length
472
+ ? h("ul", { className: "scalio-tenant-list" }, list.map((t) => row(t, false)))
473
+ : h("p", { className: "scalio-users-hint", "data-testid": "tenants-empty" }, L.tenantsEmpty),
474
+ h("p", { className: "scalio-users-hint" }, L.tenantsHint),
475
+ props.fixed ? null : h("div", { className: "scalio-tenant-actions" },
476
+ props.consentUrl ? h("a", { className: cx("scalio-btn is-primary", off && "is-disabled"), href: props.consentUrl, title: L.tenantAddHint, "data-testid": "tenant-add" },
477
+ h(Icons.Plus, { className: "scalio-icon" }), L.tenantAdd) : null,
478
+ props.manual !== false && manual === null ? h("button", { type: "button", className: "scalio-btn", onClick: () => { setError(""); setManual(""); }, disabled: off, "data-testid": "tenant-manual" }, L.tenantManual) : null),
479
+ manual !== null ? h("form", { className: "scalio-tenant-manual", onSubmit: addManual },
480
+ h("label", { className: "scalio-field-label", htmlFor: "tenant-manual-id" }, L.tenantId),
481
+ h("input", { id: "tenant-manual-id", className: "scalio-input", value: manual, autoFocus: true, placeholder: "00000000-0000-0000-0000-000000000000",
482
+ onChange: (e) => setManual(e.target.value), disabled: off, "data-testid": "tenant-manual-id" }),
483
+ h("p", { className: "scalio-users-hint" }, L.tenantManualHint),
484
+ error ? h("p", { className: "scalio-notice is-error", role: "alert", "data-testid": "tenant-manual-error" }, error) : null,
485
+ h("div", { className: "scalio-tenant-actions" },
486
+ h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setManual(null) }, L.cancel),
487
+ h("button", { type: "submit", className: "scalio-btn is-primary", disabled: off || !manual.trim(), "data-testid": "tenant-manual-save" }, L.tenantAddManual))) : null,
488
+ props.extraTenants && props.extraTenants.length ? h("div", { className: "scalio-users-field", "data-testid": "role-source-extra" },
489
+ props.extraTitle ? h("div", { className: "scalio-field-label" }, props.extraTitle) : null,
490
+ h("ul", { className: "scalio-tenant-list" }, props.extraTenants.map(tenantOf).map((t) => row(t, true)))) : null) : null;
366
491
  return h("div", { className: "scalio-roles-source", "data-testid": props.testId || "role-source" },
367
492
  h("div", { className: "scalio-users-field" },
368
493
  h("label", { className: "scalio-field-label", htmlFor: "role-source-select" }, L.source),
369
- h("select", { id: "role-source-select", className: "scalio-input scalio-users-select", value: source, disabled: props.disabled || busy,
370
- onChange: (e) => setSource(e.target.value), "data-testid": "role-source-select" },
371
- h("option", { value: "local" }, L.sourceLocal),
372
- h("option", { value: "entra" }, L.sourceEntra))),
373
- props.tenants ? h("div", { className: "scalio-users-field" },
374
- h("label", { className: "scalio-field-label", htmlFor: "role-source-tenants" }, L.tenants),
375
- h("input", { id: "role-source-tenants", className: "scalio-input", value: tenants, disabled: props.disabled || busy,
376
- onChange: (e) => setTenants(e.target.value), "data-testid": "role-source-tenants" }),
377
- h("p", { className: "scalio-users-hint" }, L.tenantsHint)) : null,
494
+ h("div", { className: "scalio-tenant-actions" },
495
+ h("select", { id: "role-source-select", className: "scalio-input scalio-users-select", value: source, disabled: off,
496
+ onChange: (e) => setSource(e.target.value), "data-testid": "role-source-select" },
497
+ h("option", { value: "local" }, L.sourceLocal),
498
+ h("option", { value: "entra" }, L.sourceEntra)),
499
+ h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: off || !dirty, "data-testid": "role-source-save" }, L.save))),
378
500
  h("p", { className: "scalio-users-hint", "data-testid": "role-source-hint" }, source === "entra" ? L.hintEntra : L.hintLocal, " ",
379
501
  values.map((x, i) => h(React.Fragment, { key: x }, i ? ", " : "", h("code", null, x)))),
380
- h("div", null,
381
- h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: props.disabled || busy || !dirty, "data-testid": "role-source-save" }, L.save)));
502
+ tenantBlock);
382
503
  }
383
504
 
384
505
  module.exports = { RoleAdmin, RoleSourceSettings, rolesApi, rolesText };
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 };