@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 +20 -0
- package/README.md +2 -1
- package/nav.css +25 -1
- package/package.json +1 -1
- package/react/blocks.js +32 -5
- package/react/icons.js +19 -2
- package/react/index.js +45 -3
- package/react/labels.js +63 -0
- package/react/unify.js +195 -0
- package/shell.css +7 -0
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.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"private": false,
|
package/react/blocks.js
CHANGED
|
@@ -50,14 +50,41 @@ function Loading({ label, lang, labels, testId }) {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// kind: info, ok, warn, error.
|
|
54
|
-
|
|
53
|
+
// kind: info, ok, warn, error. One notice style for every hint in every app (ADR 0041 A6): eckig, icon, bold
|
|
54
|
+
// title, one sentence, at most one main action plus "Später". banner: full width above the page (AppShell
|
|
55
|
+
// prop banner), several stack, the most important first. action: { label, onClick | href, testId }.
|
|
56
|
+
const NOTICE_ICONS = { info: "Info", ok: "Check", warn: "Alert", error: "Alert" };
|
|
57
|
+
|
|
58
|
+
function Notice({ kind, title, children, action, onDismiss, dismissLabel, banner, icon, lang, labels, testId }) {
|
|
55
59
|
const k = ["info", "ok", "warn", "error"].indexOf(kind) >= 0 ? kind : "info";
|
|
60
|
+
const L = labelsFor(lang, labels);
|
|
61
|
+
const Icons = require("./icons");
|
|
62
|
+
const Icon = icon === false ? null : icon || Icons[NOTICE_ICONS[k]];
|
|
63
|
+
const tid = testId || "notice";
|
|
64
|
+
const act = action
|
|
65
|
+
? h(
|
|
66
|
+
action.href ? "a" : "button",
|
|
67
|
+
Object.assign(
|
|
68
|
+
{ className: "scalio-notice-action", "data-testid": action.testId || `${tid}-action` },
|
|
69
|
+
action.href ? { href: action.href } : { type: "button", onClick: action.onClick }
|
|
70
|
+
),
|
|
71
|
+
action.label
|
|
72
|
+
)
|
|
73
|
+
: null;
|
|
74
|
+
const dismiss = onDismiss
|
|
75
|
+
? h("button", { type: "button", className: "scalio-notice-dismiss", onClick: onDismiss, "data-testid": `${tid}-dismiss` }, dismissLabel || L.later)
|
|
76
|
+
: null;
|
|
56
77
|
return h(
|
|
57
78
|
"div",
|
|
58
|
-
{ className: cx("scalio-notice", `is-${k}
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
{ className: cx("scalio-notice", `is-${k}`, banner && "is-banner"), role: k === "error" ? "alert" : "status", "data-testid": testId },
|
|
80
|
+
Icon ? h(Icon, { className: "scalio-icon scalio-notice-icon" }) : null,
|
|
81
|
+
h(
|
|
82
|
+
"div",
|
|
83
|
+
{ className: "scalio-notice-body" },
|
|
84
|
+
title ? h("div", { className: "scalio-notice-title" }, title) : null,
|
|
85
|
+
children ? h("div", { className: "scalio-notice-text" }, children) : null
|
|
86
|
+
),
|
|
87
|
+
act || dismiss ? h("div", { className: "scalio-notice-actions" }, act, dismiss) : null
|
|
61
88
|
);
|
|
62
89
|
}
|
|
63
90
|
|
package/react/icons.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
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
|
}
|