@scalio-cloud/ui 0.12.0 → 0.14.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 +24 -0
- package/nav.css +36 -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 +63 -0
- package/react/nav.js +10 -3
- package/react/settings.js +6 -4
- package/react/unify.js +221 -0
- package/shell.css +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
UI alignment round 1, follow-up (scalio-docs ADR 0042, Markus 2026-10-30).
|
|
5
|
+
- `accountGroups({ lang, base, show })` and `accountArea(part)`: Konten und Rollen in the structure of the settings
|
|
6
|
+
page (groups Personen, Rechte, Sicherheit; one address per area, `/accounts/<area>`).
|
|
7
|
+
- `SettingsLayout` takes `root` (e.g. "Konten und Rollen") for the crumb, title fallback and nav label.
|
|
8
|
+
- `SubNav` with `value` and `onSelect` renders in-page tabs (buttons, `role="tablist"`).
|
|
9
|
+
- New CSS classes `.scalio-tabs` / `.scalio-tab` in `nav.css`: in-page tabs (shadcn Tabs) look like the sub
|
|
10
|
+
navigation (underline, like Nexus "Gruppen").
|
|
11
|
+
- **Visible change:** none until an app uses the new parts. No token changes.
|
|
12
|
+
|
|
13
|
+
## 0.13.0
|
|
14
|
+
UI alignment round 1 (scalio-docs ADR 0041).
|
|
15
|
+
- `editionBadges(license, { admin, demo, lang })` and `EditionBadges`: edition badge next to the product name from the
|
|
16
|
+
scalio-license state (Free, plan, trial with days left, demo; read-only or grace for administrators). `Brand`
|
|
17
|
+
takes `brand.badges` and shows them on the name line.
|
|
18
|
+
- `adminSection({ audit, accounts, settings })`: the sidebar section "Verwaltung" with fixed order, words and icons
|
|
19
|
+
(new inline icons `ScrollText`, `Users`, `Settings`, `Info`).
|
|
20
|
+
- `ApiStatus` / `useApiStatus(check)`: nothing while the API answers, a red badge "API nicht erreichbar" when not.
|
|
21
|
+
- `Notice` gets an icon, `action`, `onDismiss` ("Später") and `banner` (full width above the page).
|
|
22
|
+
- `accountTabs()` (Benutzer, Rollen, Microsoft Entra, Anmeldung, Organisationen), `PasswordPolicySelect` and
|
|
23
|
+
`policyAllowed()`: "nur Notfall-Administrator" and "aus" stay disabled until an emergency administrator exists.
|
|
24
|
+
- **Visible change:** existing `Notice` boxes get an icon in front and lay out as a row. Everything else only where
|
|
25
|
+
an app uses the new parts. No token changes.
|
|
26
|
+
|
|
3
27
|
## 0.12.0
|
|
4
28
|
- New manual button in `AppHeader` (prop `docsUrl`): a book icon between theme and support that opens the product
|
|
5
29
|
manual in a new tab; `DocsButton` on its own, `manualUrl(product, { stage, lang })` builds the address
|
package/nav.css
CHANGED
|
@@ -48,6 +48,17 @@
|
|
|
48
48
|
.scalio-subnav-item:hover { color: hsl(var(--foreground)); }
|
|
49
49
|
.scalio-subnav-item.is-active { border-bottom-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; }
|
|
50
50
|
.scalio-subnav-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
|
|
51
|
+
/* In-page tabs (shadcn/Radix Tabs in the apps: TabsList = .scalio-tabs, TabsTrigger = .scalio-tab) look like the sub navigation. */
|
|
52
|
+
.scalio-tabs { display: flex; align-items: flex-end; gap: 0.25rem; border-bottom: 1px solid hsl(var(--border)); overflow-x: auto; background: transparent; }
|
|
53
|
+
.scalio-tab {
|
|
54
|
+
display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; margin-bottom: -1px; white-space: nowrap; flex-shrink: 0;
|
|
55
|
+
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; cursor: pointer;
|
|
56
|
+
font: inherit; font-size: 0.875rem; color: hsl(var(--muted-foreground));
|
|
57
|
+
}
|
|
58
|
+
.scalio-tab:hover { color: hsl(var(--foreground)); }
|
|
59
|
+
.scalio-tab[data-state="active"], .scalio-tab.is-active { border-bottom-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; }
|
|
60
|
+
.scalio-tab:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
|
|
61
|
+
.scalio-tab:disabled { opacity: 0.5; pointer-events: none; }
|
|
51
62
|
.scalio-nav-icon { width: 1rem; height: 1rem; flex-shrink: 0; }
|
|
52
63
|
.scalio-nav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
53
64
|
.scalio-nav-badge {
|
|
@@ -213,8 +224,32 @@
|
|
|
213
224
|
.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
225
|
@keyframes scalio-spin { to { transform: rotate(360deg); } }
|
|
215
226
|
@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; }
|
|
227
|
+
.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; }
|
|
228
|
+
.scalio-notice-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
|
229
|
+
.scalio-notice-body { flex: 1; min-width: 0; }
|
|
217
230
|
.scalio-notice-title { font-weight: 600; margin-bottom: 0.125rem; }
|
|
231
|
+
.scalio-notice-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
232
|
+
.scalio-notice-action {
|
|
233
|
+
display: inline-flex; align-items: center; height: 2rem; padding: 0 0.75rem; border: 1px solid transparent; border-radius: 2px;
|
|
234
|
+
background: #002FA7; color: #fff; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
|
235
|
+
text-decoration: none; cursor: pointer; white-space: nowrap;
|
|
236
|
+
}
|
|
237
|
+
.scalio-notice-action:hover { background: #002480; }
|
|
238
|
+
.scalio-notice-dismiss {
|
|
239
|
+
height: 2rem; padding: 0 0.5rem; border: 0; border-radius: 2px; background: none; color: inherit; font-size: 0.75rem; font-weight: 700;
|
|
240
|
+
text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; opacity: 0.75;
|
|
241
|
+
}
|
|
242
|
+
.scalio-notice-dismiss:hover { opacity: 1; }
|
|
243
|
+
.scalio-notice-action:focus-visible, .scalio-notice-dismiss:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
244
|
+
.scalio-notice.is-banner { align-items: center; border-width: 0 0 1px 3px; padding: 0.5rem 1rem; }
|
|
245
|
+
@media (max-width: 639px) { .scalio-notice.is-banner { flex-wrap: wrap; } }
|
|
246
|
+
/* Password sign-in choice (Konten und Rollen -> Anmeldung). */
|
|
247
|
+
.scalio-policy { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
248
|
+
.scalio-policy-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
|
249
|
+
.scalio-policy-select { width: auto; min-width: 14rem; }
|
|
250
|
+
.scalio-policy-help { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
251
|
+
.scalio-policy-help.is-warn { color: #b45309; }
|
|
252
|
+
.dark .scalio-policy-help.is-warn { color: #fbbf24; }
|
|
218
253
|
.scalio-notice.is-info { border-color: hsl(var(--primary) / 0.35); background: hsl(var(--primary) / 0.06); }
|
|
219
254
|
.scalio-notice.is-ok { border-color: rgb(22 163 74 / 0.4); background: rgb(22 163 74 / 0.08); }
|
|
220
255
|
.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.14.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,36 @@ 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
|
+
accountGroups: { people: "Personen", access: "Rechte" },
|
|
54
|
+
accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Microsoft Entra", signIn: "Anmeldung", organizations: "Organisationen" },
|
|
55
|
+
passwordPolicy: {
|
|
56
|
+
label: "Anmeldung mit Passwort",
|
|
57
|
+
local: "Erlaubt",
|
|
58
|
+
emergency: "Nur Notfall-Administrator",
|
|
59
|
+
off: "Aus (nur Microsoft)",
|
|
60
|
+
help: "Die Anmeldung mit Microsoft funktioniert immer. Ein Notfall-Administrator mit Passwort ist der Weg hinein, wenn Microsoft nicht erreichbar ist.",
|
|
61
|
+
needEmergency: "Markieren Sie zuerst unter «Benutzer» mindestens einen Administrator mit Passwort als Notfall-Administrator. Vorher bleibt nur «Erlaubt» wählbar.",
|
|
62
|
+
emergencyCount: "Notfall-Administratoren: {n}",
|
|
63
|
+
forced: "FORCE_LOCAL_LOGIN ist in der Umgebung gesetzt: Passwörter sind für alle erlaubt, bis die Variable entfernt wird.",
|
|
64
|
+
},
|
|
35
65
|
administration: "Verwaltung",
|
|
36
66
|
auditLog: "Protokoll",
|
|
37
67
|
accounts: "Konten und Rollen",
|
|
@@ -231,6 +261,36 @@ const LABELS = {
|
|
|
231
261
|
twoFactor: "Two-factor sign-in",
|
|
232
262
|
mfaCode: "Code from the authenticator app",
|
|
233
263
|
overview: "Overview",
|
|
264
|
+
// UI alignment round 1 (ADR 0041)
|
|
265
|
+
later: "Later",
|
|
266
|
+
apiOffline: "API unreachable",
|
|
267
|
+
apiOfflineTip: "The app cannot reach its backend. Changes are not saved.",
|
|
268
|
+
edition: {
|
|
269
|
+
free: "Free",
|
|
270
|
+
freeTip: "Community edition with limits. A license key unlocks more.",
|
|
271
|
+
trial: "Trial",
|
|
272
|
+
trialDays: "Trial · {n} d",
|
|
273
|
+
trialTip: "Trial of the {plan} plan",
|
|
274
|
+
demo: "Demo",
|
|
275
|
+
demoTip: "Demo mode",
|
|
276
|
+
blocked: "Read-only",
|
|
277
|
+
blockedTip: "This version is newer than your license permits. The app is read-only.",
|
|
278
|
+
grace: "Grace",
|
|
279
|
+
graceDays: "Grace {n} d",
|
|
280
|
+
graceTip: "This version is past your license date and turns read-only after the grace period.",
|
|
281
|
+
},
|
|
282
|
+
accountGroups: { people: "People", access: "Permissions" },
|
|
283
|
+
accountTabs: { users: "Users", roles: "Roles", entra: "Microsoft Entra", signIn: "Sign-in", organizations: "Organizations" },
|
|
284
|
+
passwordPolicy: {
|
|
285
|
+
label: "Password sign-in",
|
|
286
|
+
local: "Allowed",
|
|
287
|
+
emergency: "Emergency administrator only",
|
|
288
|
+
off: "Off (Microsoft only)",
|
|
289
|
+
help: "Signing in with Microsoft always works. An emergency administrator with a password is the way in when Microsoft is unreachable.",
|
|
290
|
+
needEmergency: "First mark at least one administrator with a password as emergency administrator under Users. Until then only Allowed can be chosen.",
|
|
291
|
+
emergencyCount: "Emergency administrators: {n}",
|
|
292
|
+
forced: "FORCE_LOCAL_LOGIN is set in the environment: passwords are allowed for everybody until it is removed.",
|
|
293
|
+
},
|
|
234
294
|
administration: "Administration",
|
|
235
295
|
auditLog: "Activity log",
|
|
236
296
|
accounts: "Accounts and roles",
|
|
@@ -407,6 +467,9 @@ function labelsFor(lang, overrides) {
|
|
|
407
467
|
settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
|
|
408
468
|
mfa: Object.assign({}, base.mfa, o.mfa),
|
|
409
469
|
license: Object.assign({}, base.license, o.license),
|
|
470
|
+
edition: Object.assign({}, base.edition, o.edition),
|
|
471
|
+
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
|
472
|
+
passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
|
|
410
473
|
});
|
|
411
474
|
}
|
|
412
475
|
|
package/react/nav.js
CHANGED
|
@@ -144,16 +144,23 @@ function Sidebar(props) {
|
|
|
144
144
|
|
|
145
145
|
// Tabs as links on top of a page, for pages that belong together under one menu item
|
|
146
146
|
// (e.g. Gruppen: Sicherheit, Verteiler, Microsoft 365, Teams). Each tab keeps its own address.
|
|
147
|
-
|
|
147
|
+
// Tabs: as links (items with `to`, the router decides what is shown) or, with `onSelect`, as in-page tabs
|
|
148
|
+
// (items with `id`, `value` is the active one). Both look the same (UISPEC: tabs like Nexus "Gruppen").
|
|
149
|
+
function SubNav({ items, link, activePath, label, testId, value, onSelect }) {
|
|
148
150
|
const visible = (items || []).filter((i) => i && !i.hidden);
|
|
149
151
|
if (visible.length < 2) return null;
|
|
152
|
+
const buttons = typeof onSelect === "function";
|
|
150
153
|
return h(
|
|
151
154
|
"nav",
|
|
152
|
-
{ className: "scalio-subnav", "aria-label": label || undefined, "data-testid": testId || "subnav" },
|
|
155
|
+
{ className: "scalio-subnav", "aria-label": label || undefined, role: buttons ? "tablist" : undefined, "data-testid": testId || "subnav" },
|
|
153
156
|
visible.map((item) => {
|
|
154
|
-
const tid = item.testId || `subnav-${slug(item.to)}`;
|
|
157
|
+
const tid = item.testId || `subnav-${buttons ? item.id : slug(item.to)}`;
|
|
155
158
|
const cls = (active) => cx("scalio-subnav-item", active && "is-active");
|
|
156
159
|
const content = [item.label, item.badge != null && item.badge !== false ? h("span", { key: "b", className: "scalio-nav-badge" }, item.badge) : null];
|
|
160
|
+
if (buttons) {
|
|
161
|
+
const active = item.id === value;
|
|
162
|
+
return h("button", { key: item.id, type: "button", role: "tab", "aria-selected": active, className: cls(active), onClick: () => onSelect(item.id), "data-testid": tid }, content);
|
|
163
|
+
}
|
|
157
164
|
if (link) {
|
|
158
165
|
return h(link, { key: item.to, to: item.to, end: item.end, className: ({ isActive }) => cls(isActive), "data-testid": tid }, content);
|
|
159
166
|
}
|
package/react/settings.js
CHANGED
|
@@ -57,9 +57,11 @@ function findSetting(groups, id) {
|
|
|
57
57
|
// link: the router's NavLink for the list (optional, without it plain links + onSelect).
|
|
58
58
|
// minItems: below this many areas the sub-navigation is left out (Citadel, Foundry), default 3.
|
|
59
59
|
// search: true (default, filters the list), false, or the app's own search element.
|
|
60
|
+
// root: label of the page when it is not the settings page (e.g. "Konten und Rollen"): crumb and nav label.
|
|
60
61
|
function SettingsLayout(props) {
|
|
61
|
-
const { groups, active, onSelect, link, lang, labels, title, hint, actions, children, minItems, search, testId } = props;
|
|
62
|
+
const { groups, active, onSelect, link, lang, labels, title, hint, actions, children, minItems, search, testId, root } = props;
|
|
62
63
|
const L = labelsFor(lang, labels);
|
|
64
|
+
const rootLabel = root || L.settings;
|
|
63
65
|
const visible = settingsGroups(groups, L);
|
|
64
66
|
const [query, setQuery] = React.useState("");
|
|
65
67
|
const current = findSetting(visible, active);
|
|
@@ -126,7 +128,7 @@ function SettingsLayout(props) {
|
|
|
126
128
|
const nav = showNav
|
|
127
129
|
? h(
|
|
128
130
|
"nav",
|
|
129
|
-
{ className: "scalio-settings-nav", "aria-label":
|
|
131
|
+
{ className: "scalio-settings-nav", "aria-label": rootLabel, "data-testid": "settings-nav" },
|
|
130
132
|
searchBox,
|
|
131
133
|
// Phones: one select instead of the list.
|
|
132
134
|
h(
|
|
@@ -162,7 +164,7 @@ function SettingsLayout(props) {
|
|
|
162
164
|
)
|
|
163
165
|
: null;
|
|
164
166
|
|
|
165
|
-
const heading = title || (current && current.item.label) ||
|
|
167
|
+
const heading = title || (current && current.item.label) || rootLabel;
|
|
166
168
|
const sub = hint !== undefined ? hint : current && current.item.hint;
|
|
167
169
|
return h(
|
|
168
170
|
"div",
|
|
@@ -180,7 +182,7 @@ function SettingsLayout(props) {
|
|
|
180
182
|
h(
|
|
181
183
|
"div",
|
|
182
184
|
{ className: "scalio-eyebrow", "data-testid": "settings-crumb" },
|
|
183
|
-
current && showNav ? `${
|
|
185
|
+
current && showNav ? `${rootLabel} › ${current.group.label}` : rootLabel
|
|
184
186
|
),
|
|
185
187
|
h("h1", { className: "scalio-page-title", "data-testid": "settings-title" }, heading),
|
|
186
188
|
sub ? h("p", { className: "scalio-page-subtitle" }, sub) : null
|
package/react/unify.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
// Konten und Rollen in the structure of the settings page (SettingsLayout, UISPEC): areas on the left in groups,
|
|
143
|
+
// one address per area (<base>/users, /organizations, /roles, /entra, /sign-in).
|
|
144
|
+
const ACCOUNT_AREAS = { users: "users", organizations: "organizations", roles: "roles", entra: "entra", signIn: "sign-in" };
|
|
145
|
+
|
|
146
|
+
function accountGroups(opts) {
|
|
147
|
+
const o = opts || {};
|
|
148
|
+
const all = labelsFor(o.lang, o.labels);
|
|
149
|
+
const L = all.accountTabs;
|
|
150
|
+
const base = (o.base || "/accounts").replace(/\/$/, "");
|
|
151
|
+
const show = Object.assign({ users: true, roles: true, entra: true, signIn: true, organizations: false }, o.show);
|
|
152
|
+
const item = (id) => ({ id, to: `${base}/${ACCOUNT_AREAS[id]}`, label: L[id], hidden: !show[id] });
|
|
153
|
+
return [
|
|
154
|
+
{ id: "people", label: all.accountGroups.people, items: [item("users"), item("organizations")] },
|
|
155
|
+
{ id: "access", label: all.accountGroups.access, items: [item("roles"), item("entra")] },
|
|
156
|
+
{ id: "security", label: all.settingsGroups.security, items: [item("signIn")] },
|
|
157
|
+
].map((g) => Object.assign({}, g, { items: g.items.filter((i) => !i.hidden) })).filter((g) => g.items.length);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Area id from the address part (/accounts/sign-in -> signIn); unknown -> users.
|
|
161
|
+
function accountArea(part) {
|
|
162
|
+
for (const id of Object.keys(ACCOUNT_AREAS)) if (part === id || part === ACCOUNT_AREAS[id]) return id;
|
|
163
|
+
return "users";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Password sign-in: "local" (everybody), "emergency" (only emergency administrators), "off" (Microsoft only).
|
|
167
|
+
// "emergency" and "off" need at least one emergency administrator; without one they stay disabled.
|
|
168
|
+
const PASSWORD_POLICIES = ["local", "emergency", "off"];
|
|
169
|
+
|
|
170
|
+
function policyAllowed(value, emergencyCount) {
|
|
171
|
+
return value === "local" || (emergencyCount || 0) > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function PasswordPolicySelect({ value, emergencyCount, onChange, disabled, forced, lang, labels, testId }) {
|
|
175
|
+
const L = labelsFor(lang, labels).passwordPolicy;
|
|
176
|
+
const options = PASSWORD_POLICIES.map((p) =>
|
|
177
|
+
h("option", { key: p, value: p, disabled: !policyAllowed(p, emergencyCount) && p !== value }, L[p])
|
|
178
|
+
);
|
|
179
|
+
return h(
|
|
180
|
+
"div",
|
|
181
|
+
{ className: "scalio-policy", "data-testid": testId || "password-policy" },
|
|
182
|
+
h(
|
|
183
|
+
"label",
|
|
184
|
+
{ className: "scalio-policy-row" },
|
|
185
|
+
h("span", { className: "scalio-eyebrow" }, L.label),
|
|
186
|
+
h(
|
|
187
|
+
"select",
|
|
188
|
+
{
|
|
189
|
+
className: "scalio-input scalio-policy-select",
|
|
190
|
+
value: value || "local",
|
|
191
|
+
disabled: disabled || forced,
|
|
192
|
+
onChange: (e) => onChange && onChange(e.target.value),
|
|
193
|
+
"data-testid": `${testId || "password-policy"}-select`,
|
|
194
|
+
},
|
|
195
|
+
options
|
|
196
|
+
)
|
|
197
|
+
),
|
|
198
|
+
h("p", { className: "scalio-policy-help" }, L.help),
|
|
199
|
+
forced ? h("p", { className: "scalio-policy-help is-warn", "data-testid": `${testId || "password-policy"}-forced` }, L.forced) : null,
|
|
200
|
+
!forced && !(emergencyCount > 0)
|
|
201
|
+
? h("p", { className: "scalio-policy-help is-warn", "data-testid": `${testId || "password-policy"}-need-emergency` }, L.needEmergency)
|
|
202
|
+
: null,
|
|
203
|
+
!forced && emergencyCount > 0
|
|
204
|
+
? h("p", { className: "scalio-policy-help", "data-testid": `${testId || "password-policy"}-count` }, fill(L.emergencyCount, { n: emergencyCount }))
|
|
205
|
+
: null
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
editionBadges,
|
|
211
|
+
EditionBadges,
|
|
212
|
+
adminSection,
|
|
213
|
+
useApiStatus,
|
|
214
|
+
ApiStatus,
|
|
215
|
+
accountTabs,
|
|
216
|
+
accountGroups,
|
|
217
|
+
accountArea,
|
|
218
|
+
PASSWORD_POLICIES,
|
|
219
|
+
policyAllowed,
|
|
220
|
+
PasswordPolicySelect,
|
|
221
|
+
};
|
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));
|