@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 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.12.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
- function Notice({ kind, title, children, testId }) {
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}`), role: k === "error" ? "alert" : "status", "data-testid": testId },
59
- title ? h("div", { className: "scalio-notice-title" }, title) : null,
60
- children
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
- function SubNav({ items, link, activePath, label, testId }) {
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": L.settings, "data-testid": "settings-nav" },
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) || L.settings;
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 ? `${L.settings} › ${current.group.label}` : L.settings
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));