@scalio-cloud/ui 0.11.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 CHANGED
@@ -1,5 +1,25 @@
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
+
17
+ ## 0.12.0
18
+ - New manual button in `AppHeader` (prop `docsUrl`): a book icon between theme and support that opens the product
19
+ manual in a new tab; `DocsButton` on its own, `manualUrl(product, { stage, lang })` builds the address
20
+ (`devdocs.<domain>` on dev and test, `docs.<domain>` on production, demo and self-hosted, German under `/de/`).
21
+ **Visible change** only in apps that pass `docsUrl`; no token changes.
22
+
3
23
  ## 0.11.0
4
24
  - Self-hosted support (ADR 0022, T11; needs scalio-connect 0.7.0 in the backend): `supportApi` gets `status()` and
5
25
  `setButtonHidden(hidden)`. The support drawer asks `status()` first; a self-hosted installation without a
package/README.md CHANGED
@@ -45,7 +45,7 @@ support, avatar.
45
45
  A part only appears when the app passes its handler (Compass has no dark mode, so no `onThemeChange`).
46
46
 
47
47
  ```jsx
48
- import { AppHeader, PageHeader, VersionTag } from "@scalio-cloud/ui/react";
48
+ import { AppHeader, PageHeader, VersionTag, manualUrl } from "@scalio-cloud/ui/react";
49
49
 
50
50
  <AppHeader
51
51
  brand={{ mark: "N", name: "Scalio Nexus", sub: "Dev" }} // or your own element
@@ -55,6 +55,7 @@ import { AppHeader, PageHeader, VersionTag } from "@scalio-cloud/ui/react";
55
55
  status={<ApiStatus />} // hidden below 768px
56
56
  lang={lang} onLangChange={setLang} // one click, shows the other language
57
57
  theme={theme} themeModes={["light", "dark", "system"]} onThemeChange={setTheme}
58
+ docsUrl={manualUrl("nexus", { stage, lang })} // book icon, opens the manual in a new tab
58
59
  onSupport={() => setSupportOpen(true)}
59
60
  user={{ name, email, role }} onLogout={logout}
60
61
  menuItems={[{ label: "Profil", icon: User, onSelect: () => nav("/profile"), testId: "profile-link" }]}
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.11.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
- 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
@@ -32,6 +32,7 @@ const LifeBuoy = icon([
32
32
  ["circle", { cx: 12, cy: 12, r: 4 }],
33
33
  ["path", { d: "m4.93 4.93 4.24 4.24M14.83 9.17l4.24-4.24M14.83 14.83l4.24 4.24M9.17 14.83l-4.24 4.24" }],
34
34
  ]);
35
+ const BookOpen = icon([["path", { d: "M12 7v14" }], ["path", { d: "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" }]]);
35
36
  const LogOut = icon([["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" }]]);
36
37
  const Menu = icon([["path", { d: "M4 6h16M4 12h16M4 18h16" }]]);
37
38
  const Check = icon([["path", { d: "M20 6 9 17l-5-5" }]]);
@@ -99,5 +100,21 @@ const History = icon([
99
100
  ]);
100
101
  const CornerDownLeft = icon([["path", { d: "M20 4v7a4 4 0 0 1-4 4H4" }], ["path", { d: "m9 10-5 5 5 5" }]]);
101
102
 
102
- module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
103
- Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft };
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
+
118
+ module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
119
+ Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft,
120
+ Users, Settings, ScrollText, Info };
package/react/index.js CHANGED
@@ -10,6 +10,7 @@ const TEST_IDS = {
10
10
  menu: "mobile-menu-btn",
11
11
  language: "language-toggle-btn",
12
12
  theme: "theme-toggle-btn",
13
+ docs: "docs-link-btn",
13
14
  support: "support-ticket-trigger",
14
15
  avatar: "user-avatar-btn",
15
16
  userName: "user-name",
@@ -233,6 +234,43 @@ function SupportButton({ onClick, lang, labels, testId }) {
233
234
  );
234
235
  }
235
236
 
237
+ // The customer manual of a product: devdocs.<domain> on our dev and test instances, docs.<domain> everywhere
238
+ // else (production, demo, self-hosted). German readers get the German version under /de/.
239
+ const DOCS_DOMAINS = {
240
+ nexus: "scalionexus.com",
241
+ citadel: "scaliocitadel.com",
242
+ foundry: "scaliofoundry.com",
243
+ rosetta: "scaliorosetta.com",
244
+ };
245
+
246
+ function manualUrl(product, opts) {
247
+ const o = opts || {};
248
+ const domain = DOCS_DOMAINS[product] || product;
249
+ if (!domain || domain.indexOf(".") < 0) return null;
250
+ const stage = String(o.stage || "").toLowerCase();
251
+ const host = (stage === "dev" || stage === "test" ? "devdocs." : "docs.") + domain;
252
+ return `https://${host}/${o.lang === "de" ? "de/" : ""}`;
253
+ }
254
+
255
+ // Opens the manual in a new tab.
256
+ function DocsButton({ href, lang, labels, testId }) {
257
+ if (!href) return null;
258
+ const L = labelsFor(lang, labels);
259
+ return h(
260
+ "a",
261
+ {
262
+ className: "scalio-btn scalio-btn-icon",
263
+ href,
264
+ target: "_blank",
265
+ rel: "noopener noreferrer",
266
+ title: L.docs,
267
+ "aria-label": L.docs,
268
+ "data-testid": testId || TEST_IDS.docs,
269
+ },
270
+ h(Icons.BookOpen, { className: "scalio-icon" })
271
+ );
272
+ }
273
+
236
274
  function Brand({ brand }) {
237
275
  if (!brand) return null;
238
276
  if (React.isValidElement(brand)) return brand;
@@ -241,7 +279,7 @@ function Brand({ brand }) {
241
279
  h(
242
280
  "span",
243
281
  { key: "t", className: "scalio-brand-text" },
244
- 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),
245
283
  brand.sub ? h("span", { className: "scalio-brand-sub" }, brand.sub) : null
246
284
  ),
247
285
  ];
@@ -249,7 +287,7 @@ function Brand({ brand }) {
249
287
  return h("div", { className: "scalio-brand" }, inner);
250
288
  }
251
289
 
252
- // Header: [menu] brand, left slot ... app actions, status, language, theme, support, avatar.
290
+ // Header: [menu] brand, left slot ... app actions, status, language, theme, manual, support, avatar.
253
291
  // The part from status to avatar has the same order in every app.
254
292
  function AppHeader(props) {
255
293
  const L = labelsFor(props.lang, props.labels);
@@ -284,6 +322,7 @@ function AppHeader(props) {
284
322
  labels: props.labels,
285
323
  testId: T.theme,
286
324
  }),
325
+ h(DocsButton, { href: props.docsUrl, lang: props.lang, labels: props.labels, testId: T.docs }),
287
326
  h(SupportButton, { onClick: props.onSupport, lang: props.lang, labels: props.labels, testId: T.support }),
288
327
  props.user
289
328
  ? h(UserMenu, {
@@ -326,6 +365,9 @@ module.exports = {
326
365
  LanguageToggle,
327
366
  ThemeToggle,
328
367
  SupportButton,
368
+ DocsButton,
369
+ manualUrl,
370
+ DOCS_DOMAINS,
329
371
  PageHeader,
330
372
  VersionTag,
331
373
  Icons,
@@ -333,6 +375,6 @@ module.exports = {
333
375
  TEST_IDS,
334
376
  initials,
335
377
  };
336
- 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"));
337
379
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
338
380
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -8,6 +8,7 @@ const LABELS = {
8
8
  dark: "Dunkel",
9
9
  system: "System",
10
10
  support: "Support",
11
+ docs: "Handbuch (öffnet in neuem Tab)",
11
12
  logout: "Abmelden",
12
13
  userMenu: "Benutzermenü",
13
14
  menu: "Menü",
@@ -31,6 +32,35 @@ const LABELS = {
31
32
  mfaCode: "Code aus der Authenticator-App",
32
33
  // Navigation (same words in every app, see the glossary in UISPEC)
33
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
+ },
34
64
  administration: "Verwaltung",
35
65
  auditLog: "Protokoll",
36
66
  accounts: "Konten und Rollen",
@@ -208,6 +238,7 @@ const LABELS = {
208
238
  dark: "Dark",
209
239
  system: "System",
210
240
  support: "Support",
241
+ docs: "Manual (opens in a new tab)",
211
242
  logout: "Sign out",
212
243
  userMenu: "User menu",
213
244
  menu: "Menu",
@@ -229,6 +260,35 @@ const LABELS = {
229
260
  twoFactor: "Two-factor sign-in",
230
261
  mfaCode: "Code from the authenticator app",
231
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
+ },
232
292
  administration: "Administration",
233
293
  auditLog: "Activity log",
234
294
  accounts: "Accounts and roles",
@@ -405,6 +465,9 @@ function labelsFor(lang, overrides) {
405
465
  settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
406
466
  mfa: Object.assign({}, base.mfa, o.mfa),
407
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),
408
471
  });
409
472
  }
410
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));
@@ -45,6 +51,7 @@
45
51
  }
46
52
  @media (min-width: 640px) { .scalio-btn { padding: 0 1rem; } }
47
53
  .scalio-btn:hover { background: hsl(var(--secondary)); }
54
+ a.scalio-btn { text-decoration: none; }
48
55
  .scalio-btn:focus-visible, .scalio-avatar:focus-visible, .scalio-menu-item:focus-visible {
49
56
  outline: 2px solid hsl(var(--ring)); outline-offset: 1px;
50
57
  }