@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 +21 -1
- package/nav.css +31 -3
- package/package.json +1 -1
- package/react/icons.js +2 -1
- package/react/index.js +1 -1
- package/react/labels.js +34 -2
- package/react/license.js +104 -104
- package/react/maintenance.js +106 -0
- package/react/roles.js +153 -32
- package/react/settings.js +30 -2
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="
|
|
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:
|
|
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 {
|
|
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;
|
|
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.
|
|
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: "
|
|
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: "
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
{ className: cx("scalio-
|
|
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-
|
|
193
|
+
{ key: "status", className: "scalio-license-status" },
|
|
199
194
|
h(
|
|
200
195
|
"div",
|
|
201
|
-
{ className: "scalio-license-
|
|
202
|
-
h(
|
|
203
|
-
|
|
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.
|
|
223
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
{
|
|
230
|
+
{ style: { minWidth: 0 } },
|
|
238
231
|
h(
|
|
239
232
|
"div",
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{ className: "scalio-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
288
|
-
? h("div", {
|
|
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-
|
|
294
|
-
h("
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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 ->
|
|
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
|
|
6
|
-
// the role source (Scalio app or Entra app roles) with the Entra values
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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 [
|
|
416
|
+
const [manual, setManual] = React.useState(null); // null = closed, else the typed id
|
|
350
417
|
const [busy, setBusy] = React.useState(false);
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
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.
|
|
358
|
-
|
|
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
|
-
|
|
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("
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|