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