@scalio-cloud/ui 0.12.0 → 0.13.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/CHANGELOG.md +14 -0
- package/nav.css +25 -1
- package/package.json +1 -1
- package/react/blocks.js +32 -5
- package/react/icons.js +17 -1
- package/react/index.js +2 -2
- package/react/labels.js +61 -0
- package/react/unify.js +195 -0
- package/shell.css +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
UI alignment round 1 (scalio-docs ADR 0041).
|
|
5
|
+
- `editionBadges(license, { admin, demo, lang })` and `EditionBadges`: edition badge next to the product name from the
|
|
6
|
+
scalio-license state (Free, plan, trial with days left, demo; read-only or grace for administrators). `Brand`
|
|
7
|
+
takes `brand.badges` and shows them on the name line.
|
|
8
|
+
- `adminSection({ audit, accounts, settings })`: the sidebar section "Verwaltung" with fixed order, words and icons
|
|
9
|
+
(new inline icons `ScrollText`, `Users`, `Settings`, `Info`).
|
|
10
|
+
- `ApiStatus` / `useApiStatus(check)`: nothing while the API answers, a red badge "API nicht erreichbar" when not.
|
|
11
|
+
- `Notice` gets an icon, `action`, `onDismiss` ("Später") and `banner` (full width above the page).
|
|
12
|
+
- `accountTabs()` (Benutzer, Rollen, Microsoft Entra, Anmeldung, Organisationen), `PasswordPolicySelect` and
|
|
13
|
+
`policyAllowed()`: "nur Notfall-Administrator" and "aus" stay disabled until an emergency administrator exists.
|
|
14
|
+
- **Visible change:** existing `Notice` boxes get an icon in front and lay out as a row. Everything else only where
|
|
15
|
+
an app uses the new parts. No token changes.
|
|
16
|
+
|
|
3
17
|
## 0.12.0
|
|
4
18
|
- New manual button in `AppHeader` (prop `docsUrl`): a book icon between theme and support that opens the product
|
|
5
19
|
manual in a new tab; `DocsButton` on its own, `manualUrl(product, { stage, lang })` builds the address
|
package/nav.css
CHANGED
|
@@ -213,8 +213,32 @@
|
|
|
213
213
|
.scalio-spinner { width: 0.875rem; height: 0.875rem; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 999px; animation: scalio-spin 0.8s linear infinite; }
|
|
214
214
|
@keyframes scalio-spin { to { transform: rotate(360deg); } }
|
|
215
215
|
@media (prefers-reduced-motion: reduce) { .scalio-spinner { animation: none; } }
|
|
216
|
-
.scalio-notice { padding: 0.75rem 1rem; border: 1px solid; border-left-width: 3px; border-radius: 2px; font-size: 0.875rem; }
|
|
216
|
+
.scalio-notice { display: flex; align-items: flex-start; gap: 0.625rem; padding: 0.75rem 1rem; border: 1px solid; border-left-width: 3px; border-radius: 2px; font-size: 0.875rem; }
|
|
217
|
+
.scalio-notice-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
|
218
|
+
.scalio-notice-body { flex: 1; min-width: 0; }
|
|
217
219
|
.scalio-notice-title { font-weight: 600; margin-bottom: 0.125rem; }
|
|
220
|
+
.scalio-notice-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
221
|
+
.scalio-notice-action {
|
|
222
|
+
display: inline-flex; align-items: center; height: 2rem; padding: 0 0.75rem; border: 1px solid transparent; border-radius: 2px;
|
|
223
|
+
background: #002FA7; color: #fff; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
|
224
|
+
text-decoration: none; cursor: pointer; white-space: nowrap;
|
|
225
|
+
}
|
|
226
|
+
.scalio-notice-action:hover { background: #002480; }
|
|
227
|
+
.scalio-notice-dismiss {
|
|
228
|
+
height: 2rem; padding: 0 0.5rem; border: 0; border-radius: 2px; background: none; color: inherit; font-size: 0.75rem; font-weight: 700;
|
|
229
|
+
text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; opacity: 0.75;
|
|
230
|
+
}
|
|
231
|
+
.scalio-notice-dismiss:hover { opacity: 1; }
|
|
232
|
+
.scalio-notice-action:focus-visible, .scalio-notice-dismiss:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
233
|
+
.scalio-notice.is-banner { align-items: center; border-width: 0 0 1px 3px; padding: 0.5rem 1rem; }
|
|
234
|
+
@media (max-width: 639px) { .scalio-notice.is-banner { flex-wrap: wrap; } }
|
|
235
|
+
/* Password sign-in choice (Konten und Rollen -> Anmeldung). */
|
|
236
|
+
.scalio-policy { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
237
|
+
.scalio-policy-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
|
238
|
+
.scalio-policy-select { width: auto; min-width: 14rem; }
|
|
239
|
+
.scalio-policy-help { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
240
|
+
.scalio-policy-help.is-warn { color: #b45309; }
|
|
241
|
+
.dark .scalio-policy-help.is-warn { color: #fbbf24; }
|
|
218
242
|
.scalio-notice.is-info { border-color: hsl(var(--primary) / 0.35); background: hsl(var(--primary) / 0.06); }
|
|
219
243
|
.scalio-notice.is-ok { border-color: rgb(22 163 74 / 0.4); background: rgb(22 163 74 / 0.08); }
|
|
220
244
|
.scalio-notice.is-warn { border-color: rgb(217 119 6 / 0.45); background: rgb(217 119 6 / 0.1); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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/blocks.js
CHANGED
|
@@ -50,14 +50,41 @@ function Loading({ label, lang, labels, testId }) {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// kind: info, ok, warn, error.
|
|
54
|
-
|
|
53
|
+
// kind: info, ok, warn, error. One notice style for every hint in every app (ADR 0041 A6): eckig, icon, bold
|
|
54
|
+
// title, one sentence, at most one main action plus "Später". banner: full width above the page (AppShell
|
|
55
|
+
// prop banner), several stack, the most important first. action: { label, onClick | href, testId }.
|
|
56
|
+
const NOTICE_ICONS = { info: "Info", ok: "Check", warn: "Alert", error: "Alert" };
|
|
57
|
+
|
|
58
|
+
function Notice({ kind, title, children, action, onDismiss, dismissLabel, banner, icon, lang, labels, testId }) {
|
|
55
59
|
const k = ["info", "ok", "warn", "error"].indexOf(kind) >= 0 ? kind : "info";
|
|
60
|
+
const L = labelsFor(lang, labels);
|
|
61
|
+
const Icons = require("./icons");
|
|
62
|
+
const Icon = icon === false ? null : icon || Icons[NOTICE_ICONS[k]];
|
|
63
|
+
const tid = testId || "notice";
|
|
64
|
+
const act = action
|
|
65
|
+
? h(
|
|
66
|
+
action.href ? "a" : "button",
|
|
67
|
+
Object.assign(
|
|
68
|
+
{ className: "scalio-notice-action", "data-testid": action.testId || `${tid}-action` },
|
|
69
|
+
action.href ? { href: action.href } : { type: "button", onClick: action.onClick }
|
|
70
|
+
),
|
|
71
|
+
action.label
|
|
72
|
+
)
|
|
73
|
+
: null;
|
|
74
|
+
const dismiss = onDismiss
|
|
75
|
+
? h("button", { type: "button", className: "scalio-notice-dismiss", onClick: onDismiss, "data-testid": `${tid}-dismiss` }, dismissLabel || L.later)
|
|
76
|
+
: null;
|
|
56
77
|
return h(
|
|
57
78
|
"div",
|
|
58
|
-
{ className: cx("scalio-notice", `is-${k}
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
{ className: cx("scalio-notice", `is-${k}`, banner && "is-banner"), role: k === "error" ? "alert" : "status", "data-testid": testId },
|
|
80
|
+
Icon ? h(Icon, { className: "scalio-icon scalio-notice-icon" }) : null,
|
|
81
|
+
h(
|
|
82
|
+
"div",
|
|
83
|
+
{ className: "scalio-notice-body" },
|
|
84
|
+
title ? h("div", { className: "scalio-notice-title" }, title) : null,
|
|
85
|
+
children ? h("div", { className: "scalio-notice-text" }, children) : null
|
|
86
|
+
),
|
|
87
|
+
act || dismiss ? h("div", { className: "scalio-notice-actions" }, act, dismiss) : null
|
|
61
88
|
);
|
|
62
89
|
}
|
|
63
90
|
|
package/react/icons.js
CHANGED
|
@@ -100,5 +100,21 @@ const History = icon([
|
|
|
100
100
|
]);
|
|
101
101
|
const CornerDownLeft = icon([["path", { d: "M20 4v7a4 4 0 0 1-4 4H4" }], ["path", { d: "m9 10-5 5 5 5" }]]);
|
|
102
102
|
|
|
103
|
+
const Users = icon([
|
|
104
|
+
["path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }],
|
|
105
|
+
["circle", { cx: 9, cy: 7, r: 4 }],
|
|
106
|
+
["path", { d: "M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" }],
|
|
107
|
+
]);
|
|
108
|
+
const Settings = icon([
|
|
109
|
+
["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }],
|
|
110
|
+
["circle", { cx: 12, cy: 12, r: 3 }],
|
|
111
|
+
]);
|
|
112
|
+
const ScrollText = icon([
|
|
113
|
+
["path", { d: "M15 12h-5M15 8h-5M19 17V5a2 2 0 0 0-2-2H4" }],
|
|
114
|
+
["path", { d: "M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" }],
|
|
115
|
+
]);
|
|
116
|
+
const Info = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 16v-4M12 8h.01" }]]);
|
|
117
|
+
|
|
103
118
|
module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
|
|
104
|
-
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft
|
|
119
|
+
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft,
|
|
120
|
+
Users, Settings, ScrollText, Info };
|
package/react/index.js
CHANGED
|
@@ -279,7 +279,7 @@ function Brand({ brand }) {
|
|
|
279
279
|
h(
|
|
280
280
|
"span",
|
|
281
281
|
{ key: "t", className: "scalio-brand-text" },
|
|
282
|
-
h("span", { className: "scalio-brand-name" }, brand.name),
|
|
282
|
+
h("span", { className: "scalio-brand-line" }, h("span", { className: "scalio-brand-name" }, brand.name), brand.badges || null),
|
|
283
283
|
brand.sub ? h("span", { className: "scalio-brand-sub" }, brand.sub) : null
|
|
284
284
|
),
|
|
285
285
|
];
|
|
@@ -375,6 +375,6 @@ module.exports = {
|
|
|
375
375
|
TEST_IDS,
|
|
376
376
|
initials,
|
|
377
377
|
};
|
|
378
|
-
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"));
|
|
378
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"));
|
|
379
379
|
// After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
|
|
380
380
|
Object.assign(module.exports, require("./auth"));
|
package/react/labels.js
CHANGED
|
@@ -32,6 +32,35 @@ const LABELS = {
|
|
|
32
32
|
mfaCode: "Code aus der Authenticator-App",
|
|
33
33
|
// Navigation (same words in every app, see the glossary in UISPEC)
|
|
34
34
|
overview: "Übersicht",
|
|
35
|
+
// UI alignment round 1 (ADR 0041)
|
|
36
|
+
later: "Später",
|
|
37
|
+
apiOffline: "API nicht erreichbar",
|
|
38
|
+
apiOfflineTip: "Die Anwendung erreicht ihr Backend nicht. Änderungen werden nicht gespeichert.",
|
|
39
|
+
edition: {
|
|
40
|
+
free: "Free",
|
|
41
|
+
freeTip: "Community-Edition mit Grenzen. Mit einem Lizenzschlüssel schalten Sie mehr frei.",
|
|
42
|
+
trial: "Test",
|
|
43
|
+
trialDays: "Test · {n} T",
|
|
44
|
+
trialTip: "Testphase für den Plan {plan}",
|
|
45
|
+
demo: "Demo",
|
|
46
|
+
demoTip: "Demo-Modus",
|
|
47
|
+
blocked: "Nur-Lesen",
|
|
48
|
+
blockedTip: "Diese Version ist neuer, als Ihre Lizenz erlaubt. Die App ist schreibgeschützt.",
|
|
49
|
+
grace: "Kulanz",
|
|
50
|
+
graceDays: "Kulanz {n} T",
|
|
51
|
+
graceTip: "Diese Version liegt nach Ihrem Lizenzdatum und wechselt nach der Kulanzzeit in den Nur-Lesen-Modus.",
|
|
52
|
+
},
|
|
53
|
+
accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Microsoft Entra", signIn: "Anmeldung", organizations: "Organisationen" },
|
|
54
|
+
passwordPolicy: {
|
|
55
|
+
label: "Anmeldung mit Passwort",
|
|
56
|
+
local: "Erlaubt",
|
|
57
|
+
emergency: "Nur Notfall-Administrator",
|
|
58
|
+
off: "Aus (nur Microsoft)",
|
|
59
|
+
help: "Die Anmeldung mit Microsoft funktioniert immer. Ein Notfall-Administrator mit Passwort ist der Weg hinein, wenn Microsoft nicht erreichbar ist.",
|
|
60
|
+
needEmergency: "Markieren Sie zuerst unter «Benutzer» mindestens einen Administrator mit Passwort als Notfall-Administrator. Vorher bleibt nur «Erlaubt» wählbar.",
|
|
61
|
+
emergencyCount: "Notfall-Administratoren: {n}",
|
|
62
|
+
forced: "FORCE_LOCAL_LOGIN ist in der Umgebung gesetzt: Passwörter sind für alle erlaubt, bis die Variable entfernt wird.",
|
|
63
|
+
},
|
|
35
64
|
administration: "Verwaltung",
|
|
36
65
|
auditLog: "Protokoll",
|
|
37
66
|
accounts: "Konten und Rollen",
|
|
@@ -231,6 +260,35 @@ const LABELS = {
|
|
|
231
260
|
twoFactor: "Two-factor sign-in",
|
|
232
261
|
mfaCode: "Code from the authenticator app",
|
|
233
262
|
overview: "Overview",
|
|
263
|
+
// UI alignment round 1 (ADR 0041)
|
|
264
|
+
later: "Later",
|
|
265
|
+
apiOffline: "API unreachable",
|
|
266
|
+
apiOfflineTip: "The app cannot reach its backend. Changes are not saved.",
|
|
267
|
+
edition: {
|
|
268
|
+
free: "Free",
|
|
269
|
+
freeTip: "Community edition with limits. A license key unlocks more.",
|
|
270
|
+
trial: "Trial",
|
|
271
|
+
trialDays: "Trial · {n} d",
|
|
272
|
+
trialTip: "Trial of the {plan} plan",
|
|
273
|
+
demo: "Demo",
|
|
274
|
+
demoTip: "Demo mode",
|
|
275
|
+
blocked: "Read-only",
|
|
276
|
+
blockedTip: "This version is newer than your license permits. The app is read-only.",
|
|
277
|
+
grace: "Grace",
|
|
278
|
+
graceDays: "Grace {n} d",
|
|
279
|
+
graceTip: "This version is past your license date and turns read-only after the grace period.",
|
|
280
|
+
},
|
|
281
|
+
accountTabs: { users: "Users", roles: "Roles", entra: "Microsoft Entra", signIn: "Sign-in", organizations: "Organizations" },
|
|
282
|
+
passwordPolicy: {
|
|
283
|
+
label: "Password sign-in",
|
|
284
|
+
local: "Allowed",
|
|
285
|
+
emergency: "Emergency administrator only",
|
|
286
|
+
off: "Off (Microsoft only)",
|
|
287
|
+
help: "Signing in with Microsoft always works. An emergency administrator with a password is the way in when Microsoft is unreachable.",
|
|
288
|
+
needEmergency: "First mark at least one administrator with a password as emergency administrator under Users. Until then only Allowed can be chosen.",
|
|
289
|
+
emergencyCount: "Emergency administrators: {n}",
|
|
290
|
+
forced: "FORCE_LOCAL_LOGIN is set in the environment: passwords are allowed for everybody until it is removed.",
|
|
291
|
+
},
|
|
234
292
|
administration: "Administration",
|
|
235
293
|
auditLog: "Activity log",
|
|
236
294
|
accounts: "Accounts and roles",
|
|
@@ -407,6 +465,9 @@ function labelsFor(lang, overrides) {
|
|
|
407
465
|
settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
|
|
408
466
|
mfa: Object.assign({}, base.mfa, o.mfa),
|
|
409
467
|
license: Object.assign({}, base.license, o.license),
|
|
468
|
+
edition: Object.assign({}, base.edition, o.edition),
|
|
469
|
+
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
|
470
|
+
passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
|
|
410
471
|
});
|
|
411
472
|
}
|
|
412
473
|
|
package/react/unify.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// UI alignment round 1 (scalio-docs ADR 0041): edition badges next to the brand, the fixed "Verwaltung"
|
|
3
|
+
// section, the API status in the header, the tabs of "Konten und Rollen" and the password sign-in choice.
|
|
4
|
+
const React = require("react");
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const Icons = require("./icons");
|
|
7
|
+
const { labelsFor } = require("./labels");
|
|
8
|
+
|
|
9
|
+
function cx() {
|
|
10
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fill(text, values) {
|
|
14
|
+
return String(text).replace(/\{(\w+)\}/g, (m, k) => (values[k] === undefined ? m : values[k]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function daysUntil(date, now) {
|
|
18
|
+
if (!date) return null;
|
|
19
|
+
const end = new Date(String(date).length <= 10 ? `${date}T23:59:59` : date);
|
|
20
|
+
if (isNaN(end.getTime())) return null;
|
|
21
|
+
return Math.max(0, Math.ceil((end.getTime() - (now || new Date()).getTime()) / 86400000));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Badges next to the product name, from the license state of scalio-license (public summary or admin view):
|
|
25
|
+
// { edition, edition_name, trial, exp, build_blocked, build_grace, build_grace_until }.
|
|
26
|
+
// opts: demo (demo instance), admin (build coverage is shown to administrators only), lang, labels, now.
|
|
27
|
+
// Returns [{ key, label, tone, title, testId }] in a fixed order: demo or edition, trial, coverage.
|
|
28
|
+
function editionBadges(lic, opts) {
|
|
29
|
+
const o = opts || {};
|
|
30
|
+
const L = labelsFor(o.lang, o.labels).edition;
|
|
31
|
+
const out = [];
|
|
32
|
+
if (o.demo) {
|
|
33
|
+
out.push({ key: "demo", label: L.demo, tone: "running", title: L.demoTip, testId: "demo-badge" });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
if (!lic || lic.error || !lic.edition || lic.edition === "unenforced") return out;
|
|
37
|
+
if (lic.edition === "community") {
|
|
38
|
+
out.push({ key: "edition", label: L.free, tone: "warn", title: L.freeTip, testId: "edition-badge" });
|
|
39
|
+
} else {
|
|
40
|
+
out.push({ key: "edition", label: String(lic.edition), tone: "ok", title: lic.customer || lic.edition_name || "", testId: "edition-badge" });
|
|
41
|
+
}
|
|
42
|
+
if (lic.trial && lic.edition !== "community") {
|
|
43
|
+
const days = daysUntil(lic.exp, o.now);
|
|
44
|
+
out.push({
|
|
45
|
+
key: "trial",
|
|
46
|
+
label: days === null ? L.trial : fill(L.trialDays, { n: days }),
|
|
47
|
+
tone: "running",
|
|
48
|
+
title: fill(L.trialTip, { plan: lic.edition_name || lic.edition }),
|
|
49
|
+
testId: "trial-badge",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (o.admin && lic.build_blocked) {
|
|
53
|
+
out.push({ key: "coverage", label: L.blocked, tone: "error", title: L.blockedTip, testId: "coverage-badge" });
|
|
54
|
+
} else if (o.admin && lic.build_grace) {
|
|
55
|
+
const days = daysUntil(lic.build_grace_until, o.now);
|
|
56
|
+
out.push({
|
|
57
|
+
key: "coverage",
|
|
58
|
+
label: days === null ? L.grace : fill(L.graceDays, { n: days }),
|
|
59
|
+
tone: "warn",
|
|
60
|
+
title: L.graceTip,
|
|
61
|
+
testId: "coverage-badge",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function EditionBadges(props) {
|
|
68
|
+
const items = props.items || editionBadges(props.license, props);
|
|
69
|
+
if (!items.length) return null;
|
|
70
|
+
return h(
|
|
71
|
+
"span",
|
|
72
|
+
{ className: "scalio-edition-badges", "data-testid": props.testId || "edition-badges" },
|
|
73
|
+
items.map((b) =>
|
|
74
|
+
h("span", { key: b.key, className: cx("scalio-badge", "scalio-edition-badge", `is-${b.tone}`), title: b.title || undefined, "data-testid": b.testId }, b.label)
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The "Verwaltung" section of the sidebar: always Protokoll, Konten und Rollen, Einstellungen, in this order,
|
|
80
|
+
// with the same icons and words in every app. Each entry: true (shown), false (hidden) or an object that
|
|
81
|
+
// overrides fields of the item (to, testId, hidden, badge). Routes default to /audit, /accounts, /settings.
|
|
82
|
+
function adminSection(opts) {
|
|
83
|
+
const o = opts || {};
|
|
84
|
+
const L = labelsFor(o.lang, o.labels);
|
|
85
|
+
const base = [
|
|
86
|
+
["audit", { to: "/audit", label: L.auditLog, icon: Icons.ScrollText, testId: "nav-audit" }],
|
|
87
|
+
["accounts", { to: "/accounts", label: L.accounts, icon: Icons.Users, testId: "nav-accounts" }],
|
|
88
|
+
["settings", { to: "/settings", label: L.settings, icon: Icons.Settings, testId: "nav-settings" }],
|
|
89
|
+
];
|
|
90
|
+
const items = [];
|
|
91
|
+
base.forEach(([key, item]) => {
|
|
92
|
+
const v = o[key];
|
|
93
|
+
if (v === undefined || v === false || v === null) return;
|
|
94
|
+
items.push(Object.assign({}, item, v === true ? {} : v));
|
|
95
|
+
});
|
|
96
|
+
return { id: "admin", label: L.administration, items };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Header status: nothing while the API answers, a red "API nicht erreichbar" when it does not.
|
|
100
|
+
// check() returns a promise (e.g. () => api.get("/health")). Checks every intervalMs (default 30 s).
|
|
101
|
+
function useApiStatus(check, intervalMs) {
|
|
102
|
+
const [online, setOnline] = React.useState(null);
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (!check) return undefined;
|
|
105
|
+
let alive = true;
|
|
106
|
+
const ping = () =>
|
|
107
|
+
Promise.resolve()
|
|
108
|
+
.then(check)
|
|
109
|
+
.then(() => alive && setOnline(true), () => alive && setOnline(false));
|
|
110
|
+
ping();
|
|
111
|
+
const id = setInterval(ping, intervalMs || 30000);
|
|
112
|
+
return () => {
|
|
113
|
+
alive = false;
|
|
114
|
+
clearInterval(id);
|
|
115
|
+
};
|
|
116
|
+
}, [check, intervalMs]);
|
|
117
|
+
return online;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ApiStatus({ online, check, intervalMs, lang, labels, testId }) {
|
|
121
|
+
const own = useApiStatus(online === undefined ? check : null, intervalMs);
|
|
122
|
+
const state = online === undefined ? own : online;
|
|
123
|
+
if (state !== false) return null;
|
|
124
|
+
const L = labelsFor(lang, labels);
|
|
125
|
+
return h(
|
|
126
|
+
"span",
|
|
127
|
+
{ className: "scalio-badge is-error scalio-api-status", role: "status", title: L.apiOfflineTip, "data-testid": testId || "api-status" },
|
|
128
|
+
h(Icons.Alert, { className: "scalio-icon" }),
|
|
129
|
+
L.apiOffline
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fixed tabs of "Konten und Rollen" (same order in every app). show: { organizations: true } adds the
|
|
134
|
+
// Foundry tab for the platform owner. Returns [{ id, label }] for the app's own tab component.
|
|
135
|
+
function accountTabs(opts) {
|
|
136
|
+
const o = opts || {};
|
|
137
|
+
const L = labelsFor(o.lang, o.labels).accountTabs;
|
|
138
|
+
const show = Object.assign({ users: true, roles: true, entra: true, signIn: true, organizations: false }, o.show);
|
|
139
|
+
return ["users", "roles", "entra", "signIn", "organizations"].filter((id) => show[id]).map((id) => ({ id, label: L[id] }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Password sign-in: "local" (everybody), "emergency" (only emergency administrators), "off" (Microsoft only).
|
|
143
|
+
// "emergency" and "off" need at least one emergency administrator; without one they stay disabled.
|
|
144
|
+
const PASSWORD_POLICIES = ["local", "emergency", "off"];
|
|
145
|
+
|
|
146
|
+
function policyAllowed(value, emergencyCount) {
|
|
147
|
+
return value === "local" || (emergencyCount || 0) > 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function PasswordPolicySelect({ value, emergencyCount, onChange, disabled, forced, lang, labels, testId }) {
|
|
151
|
+
const L = labelsFor(lang, labels).passwordPolicy;
|
|
152
|
+
const options = PASSWORD_POLICIES.map((p) =>
|
|
153
|
+
h("option", { key: p, value: p, disabled: !policyAllowed(p, emergencyCount) && p !== value }, L[p])
|
|
154
|
+
);
|
|
155
|
+
return h(
|
|
156
|
+
"div",
|
|
157
|
+
{ className: "scalio-policy", "data-testid": testId || "password-policy" },
|
|
158
|
+
h(
|
|
159
|
+
"label",
|
|
160
|
+
{ className: "scalio-policy-row" },
|
|
161
|
+
h("span", { className: "scalio-eyebrow" }, L.label),
|
|
162
|
+
h(
|
|
163
|
+
"select",
|
|
164
|
+
{
|
|
165
|
+
className: "scalio-input scalio-policy-select",
|
|
166
|
+
value: value || "local",
|
|
167
|
+
disabled: disabled || forced,
|
|
168
|
+
onChange: (e) => onChange && onChange(e.target.value),
|
|
169
|
+
"data-testid": `${testId || "password-policy"}-select`,
|
|
170
|
+
},
|
|
171
|
+
options
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
h("p", { className: "scalio-policy-help" }, L.help),
|
|
175
|
+
forced ? h("p", { className: "scalio-policy-help is-warn", "data-testid": `${testId || "password-policy"}-forced` }, L.forced) : null,
|
|
176
|
+
!forced && !(emergencyCount > 0)
|
|
177
|
+
? h("p", { className: "scalio-policy-help is-warn", "data-testid": `${testId || "password-policy"}-need-emergency` }, L.needEmergency)
|
|
178
|
+
: null,
|
|
179
|
+
!forced && emergencyCount > 0
|
|
180
|
+
? h("p", { className: "scalio-policy-help", "data-testid": `${testId || "password-policy"}-count` }, fill(L.emergencyCount, { n: emergencyCount }))
|
|
181
|
+
: null
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
editionBadges,
|
|
187
|
+
EditionBadges,
|
|
188
|
+
adminSection,
|
|
189
|
+
useApiStatus,
|
|
190
|
+
ApiStatus,
|
|
191
|
+
accountTabs,
|
|
192
|
+
PASSWORD_POLICIES,
|
|
193
|
+
policyAllowed,
|
|
194
|
+
PasswordPolicySelect,
|
|
195
|
+
};
|
package/shell.css
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
}
|
|
30
30
|
.scalio-brand-text { display: none; flex-direction: column; line-height: 1.2; }
|
|
31
31
|
@media (min-width: 1024px) { .scalio-brand-text { display: flex; } }
|
|
32
|
+
/* Product name and its edition badges on one line (ADR 0041 A1). */
|
|
33
|
+
.scalio-brand-line { display: flex; align-items: center; gap: 0.375rem; }
|
|
34
|
+
.scalio-edition-badges { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
35
|
+
.scalio-edition-badge { font-size: 9px; padding: 0.0625rem 0.3125rem; }
|
|
36
|
+
.scalio-api-status { gap: 0.25rem; }
|
|
37
|
+
.scalio-api-status .scalio-icon { width: 12px; height: 12px; }
|
|
32
38
|
.scalio-brand-name { font-family: 'Chivo', sans-serif; font-weight: 900; text-transform: uppercase; letter-spacing: -0.01em; font-size: 0.875rem; }
|
|
33
39
|
.scalio-brand-sub, .scalio-eyebrow, .scalio-menu-role {
|
|
34
40
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: hsl(var(--muted-foreground));
|