@scalio-cloud/ui 0.23.0 → 0.25.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 +29 -0
- package/README.md +43 -2
- package/nav.css +61 -4
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/labels.js +41 -0
- package/react/roles.js +384 -0
- package/react/users.js +27 -12
- package/react/wizard.js +239 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
Rollen, Microsoft Entra und eigene Rollen in allen Apps angleichen (scalio-docs `concepts/app-roles.md`, `roles/R1`–`R6`).
|
|
5
|
+
- New `RoleAdmin` for Konten und Rollen → Rollen: table «welche Rolle darf was» (permissions as rows, grouped; roles as
|
|
6
|
+
columns with number of accounts and a license mark), built-in roles read-only, own roles with checkboxes, «Rolle
|
|
7
|
+
anlegen» (empty or with the permissions of another role), copy, rename, delete (the app refuses roles that are still
|
|
8
|
+
given). A row «Nur für Administratoren» shows what only the administrator may do.
|
|
9
|
+
- `rolesApi(api, {base, permissions})` for the shared role API of scalio-platform 0.3.0 `roles_router`
|
|
10
|
+
(`GET /roles/permissions`, `GET/POST /roles`, `PATCH/DELETE /roles/{id}`; Nexus passes `permissions: "/permissions"`).
|
|
11
|
+
Texts of permissions and roles may be strings or `{de, en}`.
|
|
12
|
+
- New `RoleSourceSettings` for Konten und Rollen → Microsoft Entra: role source (this app or Entra app roles), the
|
|
13
|
+
Entra values, optional tenant IDs, confirmation before switching to Entra.
|
|
14
|
+
- Styles `.scalio-roles*` in nav.css. No token changes, nothing changes until an app uses the new components.
|
|
15
|
+
- `UserAdmin` (follow-up to 0.23.0, built then but not released): badge «Eingeladen» for open invitations (`invited`
|
|
16
|
+
on an account, `invite_only` for a row that is only an invitation; such a row offers only «Einladung zurückziehen»,
|
|
17
|
+
which calls `DELETE /users/{id}`); when the invitation mail could not be sent, the app returns `invite_link` and
|
|
18
|
+
`UserAdmin` shows the link once; badges below the address, actions wrap, last sign-in only on wide screens,
|
|
19
|
+
disabled fields greyed.
|
|
20
|
+
|
|
21
|
+
## 0.24.0
|
|
22
|
+
Einrichtungsassistent als Standard für alle Apps (scalio-docs ADR 0060, contract `interfaces/setup-wizard.md`).
|
|
23
|
+
- New `SetupWizard`: the first-step dialog of Nexus and Kontor as a shared shell. Steps come as configuration
|
|
24
|
+
(`id`, `label`, `title`, `description`, `content`, `onNext`, `canNext`, `optional`); stepper on top, «Schritt n von
|
|
25
|
+
m», Zurück/Weiter, «Überspringen» for optional steps, errors of `onNext` keep the step open, short success message
|
|
26
|
+
at the end. Without `onClose` it cannot be closed; `wide` for steps with tables. Texts DE/EN in `labels.wizard`.
|
|
27
|
+
- `setupWizardApi(api, {base})` for `GET/PUT /api/setup/wizard` (reads snake_case and camelCase), `useSetupWizard()`
|
|
28
|
+
(loads the state, opens once by itself at the saved step while the setup is needed, saves each step, completes),
|
|
29
|
+
`openSetupWizard()` and `SetupWizardButton` for Einstellungen.
|
|
30
|
+
- Styles `.scalio-wizard*` in nav.css. No token changes, nothing changes until an app uses the wizard.
|
|
31
|
+
|
|
3
32
|
## 0.23.0
|
|
4
33
|
Benutzerverwaltung in allen Apps angleichen (scalio-docs ADR 0059, `users/B1`–`B6`, contract `interfaces/app-users.md`).
|
|
5
34
|
- New `UserAdmin` for Konten und Rollen → Benutzer, the same in every app: list with search and badges (Microsoft,
|
package/README.md
CHANGED
|
@@ -236,6 +236,46 @@ const users = usersApi(api); // once, outside the component
|
|
|
236
236
|
<MicrosoftConnection linked={user.microsoft} canUnlink={user.has_password} onLink={...} onUnlink={...} lang={lang} />
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
+
### Rollen und Microsoft Entra (`RoleAdmin`, `RoleSourceSettings`)
|
|
240
|
+
|
|
241
|
+
```jsx
|
|
242
|
+
import { RoleAdmin, rolesApi, RoleSourceSettings } from "@scalio-cloud/ui/react";
|
|
243
|
+
const roles = rolesApi(api); // Nexus: rolesApi(api, { permissions: "/permissions" })
|
|
244
|
+
<RoleAdmin client={roles} lang={lang} notify={toast} confirm={confirm} onChange={reloadUsers} />
|
|
245
|
+
<RoleSourceSettings value={source} tenants onSave={({ source, tenants }) => api.put("/users/role-source", { source, tenant_ids: tenants })} lang={lang} />
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The backend is scalio-platform `roles_router` (0.3.0). Built-in roles are read-only; own roles never get the
|
|
249
|
+
permissions the app keeps for administrators.
|
|
250
|
+
|
|
251
|
+
## Setup wizard (React)
|
|
252
|
+
|
|
253
|
+
Einrichtungsassistent after the first sign-in, the same in every app (ADR 0060, UISPEC section 3). The app writes
|
|
254
|
+
only its steps; the backend follows scalio-docs `interfaces/setup-wizard.md` (`GET/PUT /api/setup/wizard`).
|
|
255
|
+
|
|
256
|
+
```jsx
|
|
257
|
+
import { SetupWizard, SetupWizardButton, useSetupWizard, setupWizardApi } from "@scalio-cloud/ui/react";
|
|
258
|
+
|
|
259
|
+
const wizardClient = setupWizardApi(api); // once, outside the component ({ base: "/wizard" } for another route)
|
|
260
|
+
|
|
261
|
+
function AppWizard() {
|
|
262
|
+
const w = useSetupWizard({ client: wizardClient, enabled: isAdmin }); // opens by itself while needed
|
|
263
|
+
const steps = [
|
|
264
|
+
{ id: "tenant", label: t("Tenant"), title: t("Connect Microsoft"), description: "...", content: <TenantsSection />,
|
|
265
|
+
canNext: connected, onNext: async () => { /* save; a rejected promise keeps the step open */ } },
|
|
266
|
+
{ id: "mail", label: t("Email"), title: t("Email (SMTP)"), content: <SmtpForm />, optional: true },
|
|
267
|
+
];
|
|
268
|
+
return <SetupWizard {...w.props} lang={lang} title={t("Set up Foundry")} subtitle={t("...")} steps={steps}
|
|
269
|
+
success={{ title: t("Setup complete"), text: t("...") }} />;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Einstellungen: open it again (admins only)
|
|
273
|
+
<SetupWizardButton lang={lang} />
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Test IDs: `setup-wizard`, `wizard-stepper(-item)`, `wizard-step-{id}`, `wizard-next`, `wizard-back`, `wizard-skip`,
|
|
277
|
+
`wizard-close`, `wizard-error`, `wizard-success`, `wizard-done`, `settings-open-wizard`.
|
|
278
|
+
|
|
239
279
|
## Two-factor card and license panel (React)
|
|
240
280
|
|
|
241
281
|
The same backend routes in every app, so the apps pass only their axios instance and a few helpers.
|
|
@@ -295,12 +335,13 @@ see `fonts/licenses/`. Icons in `react/icons.js`: Lucide, ISC license, see `lice
|
|
|
295
335
|
|
|
296
336
|
## Release
|
|
297
337
|
|
|
298
|
-
1. Change the files, add a
|
|
338
|
+
1. Change the files, add a file `changelog.d/<name>.md` (never edit `CHANGELOG.md` directly), bump `version` in `package.json` (semver; tag without `v`).
|
|
299
339
|
2. Merge to `main`, then release it: in the Scalio Dev cockpit (row Scalio UI, "Freigeben" with the version) or
|
|
300
340
|
in GitHub (Actions -> publish -> Run workflow on main, or create the tag/release yourself). The `publish`
|
|
301
341
|
workflow tests and publishes that version to npmjs.com via trusted publishing (no token; npm org
|
|
302
342
|
`scalio-cloud`, trusted publisher Scalio-Cloud / scalio-ui / publish.yml) and, when started by hand or from
|
|
303
|
-
the cockpit, creates the tag and GitHub release with the CHANGELOG lines.
|
|
343
|
+
the cockpit, creates the tag and GitHub release with the CHANGELOG lines. Before merging the version bump, run
|
|
344
|
+
`python3 tools/changelog.py release <version>` to move `changelog.d/` into `CHANGELOG.md`.
|
|
304
345
|
3. Apps update with `yarn upgrade @scalio-cloud/ui@<version>` in their own PR.
|
|
305
346
|
|
|
306
347
|
`npm test` checks: light/dark variables match, the preset only uses defined variables, all font files exist,
|
package/nav.css
CHANGED
|
@@ -410,8 +410,9 @@
|
|
|
410
410
|
.scalio-users-table tbody tr:hover { background: hsl(var(--muted) / 0.4); }
|
|
411
411
|
.scalio-users-table tr.is-pending { background: rgb(217 119 6 / 0.05); }
|
|
412
412
|
.scalio-users-table tr.is-locked .scalio-users-who { opacity: 0.6; }
|
|
413
|
-
.scalio-users-hide-sm { display: none; }
|
|
413
|
+
.scalio-users-hide-sm, .scalio-users-hide-md { display: none; }
|
|
414
414
|
@media (min-width: 768px) { .scalio-users-hide-sm { display: table-cell; } }
|
|
415
|
+
@media (min-width: 1536px) { .scalio-users-hide-md { display: table-cell; } }
|
|
415
416
|
.scalio-users-who { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
|
416
417
|
.scalio-users-avatar {
|
|
417
418
|
display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; flex-shrink: 0;
|
|
@@ -419,16 +420,20 @@
|
|
|
419
420
|
}
|
|
420
421
|
.scalio-users-who-text { min-width: 0; }
|
|
421
422
|
.scalio-users-name { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; font-weight: 600; }
|
|
422
|
-
.scalio-users-mail { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: hsl(var(--muted-foreground));
|
|
423
|
+
.scalio-users-mail { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: hsl(var(--muted-foreground)); white-space: nowrap; }
|
|
424
|
+
.scalio-users-badges { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; }
|
|
423
425
|
.scalio-users-you { font-size: 10px; font-weight: 700; text-transform: uppercase; color: hsl(var(--primary)); }
|
|
424
426
|
.scalio-users-role { font-size: 0.8125rem; }
|
|
425
427
|
.scalio-users-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
|
|
426
|
-
.scalio-users-actions { text-align: right;
|
|
427
|
-
.scalio-users-actions
|
|
428
|
+
.scalio-users-actions { text-align: right; }
|
|
429
|
+
.scalio-users-actions-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.375rem; }
|
|
430
|
+
.scalio-users-actions .scalio-btn { height: 2rem; }
|
|
431
|
+
.scalio-users-actions .scalio-btn-icon { width: 2rem; }
|
|
428
432
|
.scalio-users-select { height: 2rem; padding: 0 0.5rem; font-size: 0.8125rem; width: auto; }
|
|
429
433
|
.scalio-users-empty, .scalio-users-loading { margin: 0; padding: 2rem 1rem; text-align: center; color: hsl(var(--muted-foreground)); border: 1px dashed hsl(var(--border)); border-radius: 2px; }
|
|
430
434
|
.scalio-users-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
431
435
|
.scalio-users-field .scalio-field-label { margin-top: 0; }
|
|
436
|
+
.scalio-dialog .scalio-input:disabled, .scalio-users .scalio-input:disabled { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); cursor: not-allowed; }
|
|
432
437
|
.scalio-users-hint { margin: 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
433
438
|
.scalio-users-modes { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
434
439
|
.scalio-users-modes legend { margin-bottom: 0.25rem; }
|
|
@@ -464,6 +469,58 @@
|
|
|
464
469
|
.scalio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
|
465
470
|
.scalio-dialog-left { margin-right: auto; }
|
|
466
471
|
|
|
472
|
+
/* Roles (RoleAdmin, RoleSourceSettings; scalio-docs concepts/app-roles.md) */
|
|
473
|
+
.scalio-roles-table th.scalio-roles-col, .scalio-roles-cell { text-align: center; }
|
|
474
|
+
.scalio-roles-table th.scalio-roles-col { min-width: 7.5rem; vertical-align: top; text-transform: none; letter-spacing: 0; }
|
|
475
|
+
.scalio-roles-table th.scalio-roles-col.is-own { background: hsl(var(--primary) / 0.05); }
|
|
476
|
+
.scalio-roles-perm { text-align: left; min-width: 14rem; }
|
|
477
|
+
.scalio-roles-name { font-size: 0.8125rem; font-weight: 700; color: hsl(var(--foreground)); }
|
|
478
|
+
.scalio-roles-meta { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.375rem; margin-top: 0.25rem; font-size: 10px; font-weight: 500; color: hsl(var(--muted-foreground)); }
|
|
479
|
+
.scalio-roles-tools { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.125rem; margin-top: 0.375rem; }
|
|
480
|
+
.scalio-roles-tool { height: 1.5rem; padding: 0 0.375rem; font-size: 10px; }
|
|
481
|
+
.scalio-roles-tool.is-danger { color: hsl(var(--destructive)); }
|
|
482
|
+
.scalio-roles-group td { padding-top: 1rem; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.4); }
|
|
483
|
+
.scalio-roles-yes { color: #15803d; }
|
|
484
|
+
.dark .scalio-roles-yes { color: #4ade80; }
|
|
485
|
+
.scalio-roles-no { color: hsl(var(--muted-foreground)); }
|
|
486
|
+
.scalio-roles-lic { margin-left: 0.375rem; }
|
|
487
|
+
.scalio-roles-source { display: flex; flex-direction: column; gap: 0.875rem; padding: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); font-size: 0.875rem; }
|
|
488
|
+
|
|
489
|
+
/* Setup wizard (SetupWizard, ADR 0060): design of the Nexus wizard, first built in Kontor */
|
|
490
|
+
.scalio-wizard-root { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgb(0 0 0 / 0.5); }
|
|
491
|
+
.scalio-wizard {
|
|
492
|
+
display: flex; flex-direction: column; gap: 1rem; width: calc(100vw - 2rem); max-width: 42rem; max-height: 90vh; overflow-y: auto;
|
|
493
|
+
padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
|
|
494
|
+
box-shadow: 0 16px 40px rgb(0 0 0 / 0.25); font-size: 0.875rem;
|
|
495
|
+
}
|
|
496
|
+
.scalio-wizard.is-wide { max-width: 56rem; }
|
|
497
|
+
.scalio-wizard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
|
498
|
+
.scalio-wizard-title { margin: 0; font-family: 'Chivo', sans-serif; font-size: 1.5rem; font-weight: 900; line-height: 1.2; text-transform: uppercase; letter-spacing: -0.025em; }
|
|
499
|
+
.scalio-wizard-subtitle { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
|
|
500
|
+
.scalio-wizard-stepper { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
|
|
501
|
+
.scalio-wizard-stepper-item { display: flex; flex: 1 1 0; min-width: 0; align-items: center; gap: 0.5rem; }
|
|
502
|
+
.scalio-wizard-dot {
|
|
503
|
+
display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 999px;
|
|
504
|
+
background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); font-size: 0.75rem; font-weight: 700;
|
|
505
|
+
}
|
|
506
|
+
.scalio-wizard-dot.is-current { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
|
|
507
|
+
.scalio-wizard-dot.is-done, .scalio-wizard-success-icon { background: #059669; color: #fff; }
|
|
508
|
+
.scalio-wizard-label { display: none; white-space: nowrap; font-size: 0.75rem; font-weight: 500; color: hsl(var(--muted-foreground)); }
|
|
509
|
+
.scalio-wizard-label.is-current { color: hsl(var(--foreground)); }
|
|
510
|
+
@media (min-width: 1024px) { .scalio-wizard-label { display: block; } }
|
|
511
|
+
.scalio-wizard-line { flex: 1 1 0; min-width: 0.5rem; height: 1px; background: hsl(var(--border)); }
|
|
512
|
+
.scalio-wizard-count { margin: 0; font-size: 0.75rem; font-weight: 500; color: hsl(var(--muted-foreground)); }
|
|
513
|
+
.scalio-wizard-step { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
514
|
+
.scalio-wizard-step-title { margin: 0; font-size: 0.875rem; font-weight: 700; }
|
|
515
|
+
.scalio-wizard-text { margin: 0; color: hsl(var(--muted-foreground)); }
|
|
516
|
+
.scalio-wizard-actions { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-top: 0.5rem; }
|
|
517
|
+
.scalio-wizard-actions-right { display: flex; align-items: center; gap: 0.5rem; }
|
|
518
|
+
.scalio-wizard-success { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 1.5rem 0; text-align: center; }
|
|
519
|
+
.scalio-wizard-success .scalio-wizard-step-title { font-size: 1.125rem; }
|
|
520
|
+
.scalio-wizard-success .scalio-wizard-text { max-width: 28rem; }
|
|
521
|
+
.scalio-wizard-success .scalio-btn { margin-top: 0.5rem; }
|
|
522
|
+
.scalio-wizard-success-icon { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 999px; }
|
|
523
|
+
|
|
467
524
|
/* License panel (LicensePanel) */
|
|
468
525
|
.scalio-license { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
|
|
469
526
|
.scalio-license-part { padding: 1.25rem; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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/index.js
CHANGED
|
@@ -378,6 +378,6 @@ module.exports = {
|
|
|
378
378
|
TEST_IDS,
|
|
379
379
|
initials,
|
|
380
380
|
};
|
|
381
|
-
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"));
|
|
381
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"));
|
|
382
382
|
// After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
|
|
383
383
|
Object.assign(module.exports, require("./auth"));
|
package/react/labels.js
CHANGED
|
@@ -13,6 +13,19 @@ const LABELS = {
|
|
|
13
13
|
userMenu: "Benutzermenü",
|
|
14
14
|
menu: "Menü",
|
|
15
15
|
version: "Version",
|
|
16
|
+
wizard: {
|
|
17
|
+
step: "Schritt {n} von {total}",
|
|
18
|
+
back: "Zurück",
|
|
19
|
+
next: "Weiter",
|
|
20
|
+
finish: "Einrichtung abschliessen",
|
|
21
|
+
skip: "Überspringen",
|
|
22
|
+
close: "Später",
|
|
23
|
+
done: "Los geht's",
|
|
24
|
+
successTitle: "Einrichtung abgeschlossen",
|
|
25
|
+
successText: "Alles ist bereit. Sie können jede Einstellung später unter Einstellungen ändern.",
|
|
26
|
+
open: "Einrichtungsassistent",
|
|
27
|
+
openHint: "Die Fragen der Ersteinrichtung noch einmal durchgehen.",
|
|
28
|
+
},
|
|
16
29
|
// Sign-in and profile (same words in every app)
|
|
17
30
|
signIn: "Anmelden",
|
|
18
31
|
microsoft: "Mit Microsoft anmelden",
|
|
@@ -195,6 +208,13 @@ const LABELS = {
|
|
|
195
208
|
emergency: "Notfall-Administrator",
|
|
196
209
|
locked: "Gesperrt",
|
|
197
210
|
pending: "Wartet auf Freigabe",
|
|
211
|
+
invitedBadge: "Eingeladen",
|
|
212
|
+
inviteLinkTitle: "Einladung erstellt",
|
|
213
|
+
inviteLinkText: "Die Mail an {email} konnte nicht gesendet werden. Geben Sie diesen Link weiter; er gilt einmal.",
|
|
214
|
+
revokeInvite: "Einladung zurückziehen",
|
|
215
|
+
revokeTitle: "Einladung an {email} zurückziehen?",
|
|
216
|
+
revokeText: "Der Link in der Mail funktioniert danach nicht mehr.",
|
|
217
|
+
revoked: "Einladung zurückgezogen.",
|
|
198
218
|
approve: "Freischalten",
|
|
199
219
|
merge: "Zusammenführen",
|
|
200
220
|
edit: "Bearbeiten",
|
|
@@ -391,6 +411,19 @@ const LABELS = {
|
|
|
391
411
|
userMenu: "User menu",
|
|
392
412
|
menu: "Menu",
|
|
393
413
|
version: "Version",
|
|
414
|
+
wizard: {
|
|
415
|
+
step: "Step {n} of {total}",
|
|
416
|
+
back: "Back",
|
|
417
|
+
next: "Continue",
|
|
418
|
+
finish: "Finish setup",
|
|
419
|
+
skip: "Skip",
|
|
420
|
+
close: "Later",
|
|
421
|
+
done: "Let's go",
|
|
422
|
+
successTitle: "Setup complete",
|
|
423
|
+
successText: "Everything is ready. You can change any setting later under Settings.",
|
|
424
|
+
open: "Setup wizard",
|
|
425
|
+
openHint: "Go through the first-time setup questions again.",
|
|
426
|
+
},
|
|
394
427
|
signIn: "Sign in",
|
|
395
428
|
microsoft: "Sign in with Microsoft",
|
|
396
429
|
redirecting: "Redirecting …",
|
|
@@ -567,6 +600,13 @@ const LABELS = {
|
|
|
567
600
|
emergency: "Emergency administrator",
|
|
568
601
|
locked: "Locked",
|
|
569
602
|
pending: "Waiting for approval",
|
|
603
|
+
invitedBadge: "Invited",
|
|
604
|
+
inviteLinkTitle: "Invitation created",
|
|
605
|
+
inviteLinkText: "The mail to {email} could not be sent. Pass on this link; it works once.",
|
|
606
|
+
revokeInvite: "Withdraw invitation",
|
|
607
|
+
revokeTitle: "Withdraw the invitation to {email}?",
|
|
608
|
+
revokeText: "The link in the mail stops working.",
|
|
609
|
+
revoked: "Invitation withdrawn.",
|
|
570
610
|
approve: "Approve",
|
|
571
611
|
merge: "Merge",
|
|
572
612
|
edit: "Edit",
|
|
@@ -766,6 +806,7 @@ function labelsFor(lang, overrides) {
|
|
|
766
806
|
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
|
767
807
|
passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
|
|
768
808
|
profile_: Object.assign({}, base.profile_, o.profile_),
|
|
809
|
+
wizard: Object.assign({}, base.wizard, o.wizard),
|
|
769
810
|
});
|
|
770
811
|
}
|
|
771
812
|
|
package/react/roles.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Konten und Rollen -> Rollen and -> Microsoft Entra, the same in every Scalio app (scalio-docs concepts/app-roles.md,
|
|
3
|
+
// roles/R1-R6). rolesApi(api, opts) maps the shared role API of scalio-platform `roles_router` (GET /roles/permissions,
|
|
4
|
+
// GET/POST /roles, PATCH/DELETE /roles/{id}); RoleAdmin shows "welche Rolle darf was" (permissions as rows, roles as
|
|
5
|
+
// columns), built-in roles read-only, own roles with checkboxes, add / copy / rename / delete. RoleSourceSettings is
|
|
6
|
+
// the role source (Scalio app or Entra app roles) with the Entra values and, where the app has them, the tenant IDs.
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const Icons = require("./icons");
|
|
10
|
+
|
|
11
|
+
const TEXT = {
|
|
12
|
+
de: {
|
|
13
|
+
loading: "Wird geladen …",
|
|
14
|
+
permission: "Recht",
|
|
15
|
+
add: "Rolle anlegen",
|
|
16
|
+
addTitle: "Rolle anlegen",
|
|
17
|
+
name: "Name",
|
|
18
|
+
description: "Beschreibung (optional)",
|
|
19
|
+
copyFrom: "Rechte übernehmen von",
|
|
20
|
+
none: "keiner Rolle (leer beginnen)",
|
|
21
|
+
create: "Anlegen",
|
|
22
|
+
cancel: "Abbrechen",
|
|
23
|
+
save: "Speichern",
|
|
24
|
+
rename: "Umbenennen",
|
|
25
|
+
renameTitle: "Rolle umbenennen",
|
|
26
|
+
copy: "Kopieren",
|
|
27
|
+
copyOf: "Kopie von {name}",
|
|
28
|
+
remove: "Löschen",
|
|
29
|
+
removeTitle: "Rolle «{name}» löschen?",
|
|
30
|
+
removeText: "Konten mit dieser Rolle müssen vorher eine andere Rolle bekommen.",
|
|
31
|
+
builtin: "Eingebaut",
|
|
32
|
+
own: "Eigene Rolle",
|
|
33
|
+
users: "{n} Konten",
|
|
34
|
+
oneUser: "1 Konto",
|
|
35
|
+
license: "Zählt für die Lizenz",
|
|
36
|
+
licenseHint: "Rechte mit diesem Zeichen machen eine Rolle lizenzpflichtig.",
|
|
37
|
+
adminAll: "Alle Rechte, auch Einstellungen, Benutzer und Lizenz",
|
|
38
|
+
adminOnly: "Nur für Administratoren",
|
|
39
|
+
created: "Rolle «{name}» angelegt.",
|
|
40
|
+
saved: "Gespeichert.",
|
|
41
|
+
removed: "Rolle gelöscht.",
|
|
42
|
+
empty: "Noch keine eigenen Rollen. Mit «Rolle anlegen» stellen Sie eine Rolle aus einzelnen Rechten zusammen.",
|
|
43
|
+
readOnly: "Eingebaute Rollen sind fest. Für andere Rechte legen Sie eine eigene Rolle an, z. B. als Kopie.",
|
|
44
|
+
// RoleSourceSettings
|
|
45
|
+
source: "Rollen kommen aus",
|
|
46
|
+
sourceLocal: "dieser App",
|
|
47
|
+
sourceEntra: "Microsoft Entra ID (App-Rollen)",
|
|
48
|
+
hintLocal: "Sie vergeben die Rollen unter Benutzer. Werte für Entra ID, falls Sie später umstellen:",
|
|
49
|
+
hintEntra: "Ab der nächsten Anmeldung entscheiden die App-Rollen der Unternehmensanwendung «Scalio» für jedes Microsoft-Konto. Wer keine Rolle hat, kann sich nicht mehr anmelden. Eigene Rollen und lokale Konten vergeben Sie weiter hier. Werte:",
|
|
50
|
+
tenants: "Microsoft-Tenant-IDs",
|
|
51
|
+
tenantsHint: "Nur App-Rollen aus diesen Tenants zählen, durch Komma getrennt.",
|
|
52
|
+
entraConfirm: "Rollen aus Entra ID übernehmen?",
|
|
53
|
+
entraConfirmText: "Ab der nächsten Anmeldung entscheiden die App-Rollen in Entra ID für jedes Microsoft-Konto. Personen ohne Rolle können sich nicht mehr anmelden.",
|
|
54
|
+
entraAction: "Entra ID verwenden",
|
|
55
|
+
},
|
|
56
|
+
en: {
|
|
57
|
+
loading: "Loading …",
|
|
58
|
+
permission: "Permission",
|
|
59
|
+
add: "Add role",
|
|
60
|
+
addTitle: "Add role",
|
|
61
|
+
name: "Name",
|
|
62
|
+
description: "Description (optional)",
|
|
63
|
+
copyFrom: "Take permissions from",
|
|
64
|
+
none: "no role (start empty)",
|
|
65
|
+
create: "Add",
|
|
66
|
+
cancel: "Cancel",
|
|
67
|
+
save: "Save",
|
|
68
|
+
rename: "Rename",
|
|
69
|
+
renameTitle: "Rename role",
|
|
70
|
+
copy: "Copy",
|
|
71
|
+
copyOf: "Copy of {name}",
|
|
72
|
+
remove: "Delete",
|
|
73
|
+
removeTitle: "Delete the role “{name}”?",
|
|
74
|
+
removeText: "Accounts with this role need another role first.",
|
|
75
|
+
builtin: "Built-in",
|
|
76
|
+
own: "Own role",
|
|
77
|
+
users: "{n} accounts",
|
|
78
|
+
oneUser: "1 account",
|
|
79
|
+
license: "Counts for the license",
|
|
80
|
+
licenseHint: "Permissions with this mark make a role count for the license.",
|
|
81
|
+
adminAll: "Every permission, including settings, users and license",
|
|
82
|
+
adminOnly: "Administrators only",
|
|
83
|
+
created: "Role “{name}” added.",
|
|
84
|
+
saved: "Saved.",
|
|
85
|
+
removed: "Role deleted.",
|
|
86
|
+
empty: "No own roles yet. With “Add role” you put a role together from single permissions.",
|
|
87
|
+
readOnly: "Built-in roles are fixed. For other permissions add an own role, for example as a copy.",
|
|
88
|
+
source: "Roles come from",
|
|
89
|
+
sourceLocal: "this app",
|
|
90
|
+
sourceEntra: "Microsoft Entra ID (app roles)",
|
|
91
|
+
hintLocal: "You give roles under Users. Values for Entra ID, in case you switch later:",
|
|
92
|
+
hintEntra: "From the next sign-in, the app roles of the enterprise application “Scalio” decide for every Microsoft account. People without a role can no longer sign in. Own roles and local accounts are still given here. Values:",
|
|
93
|
+
tenants: "Microsoft tenant IDs",
|
|
94
|
+
tenantsHint: "Only app roles from these tenants count, separated by commas.",
|
|
95
|
+
entraConfirm: "Take roles from Entra ID?",
|
|
96
|
+
entraConfirmText: "From the next sign-in, the app roles in Entra ID decide for every Microsoft account. People without a role can no longer sign in.",
|
|
97
|
+
entraAction: "Use Entra ID",
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function rolesText(lang, overrides) {
|
|
102
|
+
return Object.assign({}, TEXT[lang === "en" ? "en" : "de"], overrides || {});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cx() {
|
|
106
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fill(text, values) {
|
|
110
|
+
return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A text that is either a string or {de, en}.
|
|
114
|
+
function tr(value, lang) {
|
|
115
|
+
if (value && typeof value === "object") return value[lang === "en" ? "en" : "de"] || value.en || value.de || "";
|
|
116
|
+
return value || "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function errorText(err) {
|
|
120
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
121
|
+
if (typeof d === "string") return d;
|
|
122
|
+
if (d && typeof d.message === "string") return d.message;
|
|
123
|
+
if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
|
|
124
|
+
return (err && err.message) || String(err);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeRole(d, builtin) {
|
|
128
|
+
return {
|
|
129
|
+
id: String(d.id),
|
|
130
|
+
name: d.name,
|
|
131
|
+
description: d.description || "",
|
|
132
|
+
builtin: d.builtin !== undefined ? !!d.builtin : !!builtin,
|
|
133
|
+
admin: !!(d.admin || d.isAdmin),
|
|
134
|
+
permissions: Array.isArray(d.permissions) ? d.permissions : [],
|
|
135
|
+
license: !!d.license,
|
|
136
|
+
users: typeof d.users === "number" ? d.users : null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// api: axios instance with the app's base URL (/api). opts.base ("/roles"), opts.permissions (Nexus "/permissions").
|
|
141
|
+
function rolesApi(api, opts) {
|
|
142
|
+
const base = (opts && opts.base) || "/roles";
|
|
143
|
+
const permsUrl = (opts && opts.permissions) || `${base}/permissions`;
|
|
144
|
+
const one = (id) => `${base}/${encodeURIComponent(id)}`;
|
|
145
|
+
return {
|
|
146
|
+
permissions: () => api.get(permsUrl).then((r) => ({ permissions: r.data.permissions || [], groups: r.data.groups || [] })),
|
|
147
|
+
list: () => api.get(base).then((r) => [
|
|
148
|
+
...(r.data.builtin || []).map((d) => normalizeRole(d, true)),
|
|
149
|
+
...(r.data.custom || []).map((d) => normalizeRole(d, false)),
|
|
150
|
+
]),
|
|
151
|
+
create: (body) => api.post(base, body).then((r) => normalizeRole(r.data, false)),
|
|
152
|
+
update: (id, patch) => api.patch(one(id), patch).then((r) => normalizeRole(r.data, false)),
|
|
153
|
+
remove: (id) => api.delete(one(id)).then(() => true),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function ask(confirm, title, text, action, danger) {
|
|
158
|
+
if (confirm) return !!(await confirm({ title, text, action, danger: danger !== false }));
|
|
159
|
+
return typeof window !== "undefined" && window.confirm ? window.confirm(title) : true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Dialog({ title, onClose, children, testId }) {
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
const key = (e) => { if (e.key === "Escape") onClose(); };
|
|
165
|
+
document.addEventListener("keydown", key);
|
|
166
|
+
return () => document.removeEventListener("keydown", key);
|
|
167
|
+
}, [onClose]);
|
|
168
|
+
return h(
|
|
169
|
+
"div",
|
|
170
|
+
{ className: "scalio-dialog-root" },
|
|
171
|
+
h("div", { className: "scalio-dialog-backdrop", onClick: onClose }),
|
|
172
|
+
h(
|
|
173
|
+
"div",
|
|
174
|
+
{ className: "scalio-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, "data-testid": testId },
|
|
175
|
+
h(
|
|
176
|
+
"div",
|
|
177
|
+
{ className: "scalio-dialog-head" },
|
|
178
|
+
h("div", { className: "scalio-dialog-title" }, title),
|
|
179
|
+
h("button", { type: "button", className: "scalio-btn is-ghost scalio-btn-icon", onClick: onClose, "aria-label": "×", "data-testid": "dialog-close" },
|
|
180
|
+
h(Icons.X, { className: "scalio-icon" }))
|
|
181
|
+
),
|
|
182
|
+
children
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Props: client (rolesApi), lang, labels (overrides), notify(message, tone), confirm({title, text, action, danger}),
|
|
188
|
+
// formatError(err), canEdit (default true), onChange() after every change, testId.
|
|
189
|
+
function RoleAdmin(props) {
|
|
190
|
+
const lang = props.lang === "en" ? "en" : "de";
|
|
191
|
+
const L = rolesText(lang, props.labels);
|
|
192
|
+
const client = props.client;
|
|
193
|
+
const canEdit = props.canEdit !== false;
|
|
194
|
+
const [catalog, setCatalog] = React.useState(null);
|
|
195
|
+
const [roles, setRoles] = React.useState(null);
|
|
196
|
+
const [dialog, setDialog] = React.useState(null); // {mode: "add"|"rename", role}
|
|
197
|
+
const [form, setForm] = React.useState({ name: "", description: "", from: "" });
|
|
198
|
+
const [error, setError] = React.useState("");
|
|
199
|
+
const [busy, setBusy] = React.useState(false);
|
|
200
|
+
const fmt = props.formatError || errorText;
|
|
201
|
+
const say = (text, tone) => props.notify && props.notify(text, tone || "success");
|
|
202
|
+
|
|
203
|
+
const load = React.useCallback(() => {
|
|
204
|
+
return Promise.all([client.permissions(), client.list()])
|
|
205
|
+
.then(([c, r]) => { setCatalog(c); setRoles(r); })
|
|
206
|
+
.catch((e) => { setCatalog({ permissions: [], groups: [] }); setRoles([]); say(fmt(e), "error"); });
|
|
207
|
+
}, [client]);
|
|
208
|
+
React.useEffect(() => { load(); }, [load]);
|
|
209
|
+
|
|
210
|
+
const changed = () => { if (props.onChange) props.onChange(); return load(); };
|
|
211
|
+
const run = async (fn, okText, after) => {
|
|
212
|
+
setBusy(true);
|
|
213
|
+
setError("");
|
|
214
|
+
try {
|
|
215
|
+
const res = await fn();
|
|
216
|
+
if (okText) say(okText);
|
|
217
|
+
if (after) after(res);
|
|
218
|
+
await changed();
|
|
219
|
+
return true;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (dialog) setError(fmt(e)); else say(fmt(e), "error");
|
|
222
|
+
return false;
|
|
223
|
+
} finally {
|
|
224
|
+
setBusy(false);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (!catalog || !roles) return h("div", { className: "scalio-users-loading" }, L.loading);
|
|
229
|
+
|
|
230
|
+
const own = roles.filter((r) => !r.builtin);
|
|
231
|
+
const toggle = (role, key) => {
|
|
232
|
+
const has = role.permissions.includes(key);
|
|
233
|
+
const permissions = has ? role.permissions.filter((p) => p !== key) : [...role.permissions, key];
|
|
234
|
+
setRoles(roles.map((r) => (r.id === role.id ? Object.assign({}, r, { permissions }) : r)));
|
|
235
|
+
run(() => client.update(role.id, { permissions }), null);
|
|
236
|
+
};
|
|
237
|
+
const openAdd = (from) => {
|
|
238
|
+
setError("");
|
|
239
|
+
setForm({ name: from ? fill(L.copyOf, { name: tr(from.name, lang) }) : "", description: "", from: from ? from.id : "" });
|
|
240
|
+
setDialog({ mode: "add" });
|
|
241
|
+
};
|
|
242
|
+
const openRename = (role) => {
|
|
243
|
+
setError("");
|
|
244
|
+
setForm({ name: tr(role.name, lang), description: role.description || "", from: "" });
|
|
245
|
+
setDialog({ mode: "rename", role });
|
|
246
|
+
};
|
|
247
|
+
const close = () => { setDialog(null); setError(""); };
|
|
248
|
+
const submit = (e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
const name = form.name.trim();
|
|
251
|
+
if (dialog.mode === "add") {
|
|
252
|
+
const src = roles.find((r) => r.id === form.from);
|
|
253
|
+
const permissions = src ? src.permissions : [];
|
|
254
|
+
run(() => client.create({ name, description: form.description.trim(), permissions }), fill(L.created, { name }), () => setDialog(null));
|
|
255
|
+
} else {
|
|
256
|
+
run(() => client.update(dialog.role.id, { name, description: form.description.trim() }), L.saved, () => setDialog(null));
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const remove = async (role) => {
|
|
260
|
+
if (!(await ask(props.confirm, fill(L.removeTitle, { name: tr(role.name, lang) }), L.removeText, L.remove))) return;
|
|
261
|
+
run(() => client.remove(role.id), L.removed);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const groups = catalog.groups && catalog.groups.length ? catalog.groups : [""];
|
|
265
|
+
const byGroup = (g) => catalog.permissions.filter((p) => (p.group || "") === g || (!groups.includes(p.group || "") && g === groups[0]));
|
|
266
|
+
const usersText = (r) => (r.users === null ? "" : r.users === 1 ? L.oneUser : fill(L.users, { n: r.users }));
|
|
267
|
+
|
|
268
|
+
const head = h("tr", null,
|
|
269
|
+
h("th", { className: "scalio-roles-perm" }, L.permission),
|
|
270
|
+
roles.map((r) => h("th", { key: r.id, className: cx("scalio-roles-col", r.builtin ? "is-builtin" : "is-own"), "data-testid": `role-col-${r.id}` },
|
|
271
|
+
h("div", { className: "scalio-roles-name" }, tr(r.name, lang)),
|
|
272
|
+
h("div", { className: "scalio-roles-meta" },
|
|
273
|
+
h("span", null, r.builtin ? L.builtin : L.own),
|
|
274
|
+
usersText(r) ? h("span", null, usersText(r)) : null,
|
|
275
|
+
r.license ? h("span", { className: "scalio-badge is-warn", title: L.license, "data-testid": `role-license-${r.id}` }, "€") : null),
|
|
276
|
+
canEdit ? h("div", { className: "scalio-roles-tools" },
|
|
277
|
+
h("button", { type: "button", className: "scalio-btn is-ghost scalio-roles-tool", onClick: () => openAdd(r), disabled: busy, title: L.copy, "data-testid": `role-copy-${r.id}` }, L.copy),
|
|
278
|
+
!r.builtin ? h("button", { type: "button", className: "scalio-btn is-ghost scalio-roles-tool", onClick: () => openRename(r), disabled: busy, title: L.rename, "data-testid": `role-rename-${r.id}` }, L.rename) : null,
|
|
279
|
+
!r.builtin ? h("button", { type: "button", className: "scalio-btn is-ghost scalio-roles-tool is-danger", onClick: () => remove(r), disabled: busy, title: L.remove, "data-testid": `role-delete-${r.id}` }, L.remove) : null
|
|
280
|
+
) : null)));
|
|
281
|
+
|
|
282
|
+
const cell = (r, p) => {
|
|
283
|
+
const has = r.admin || r.permissions.includes(p.key);
|
|
284
|
+
if (r.builtin || !canEdit) {
|
|
285
|
+
return h("td", { key: r.id, className: "scalio-roles-cell", "data-testid": `role-cell-${r.id}-${p.key}` },
|
|
286
|
+
has ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no", "aria-label": "–" }, "–"));
|
|
287
|
+
}
|
|
288
|
+
return h("td", { key: r.id, className: "scalio-roles-cell" },
|
|
289
|
+
h("input", { type: "checkbox", checked: has, disabled: busy, onChange: () => toggle(r, p.key),
|
|
290
|
+
"aria-label": `${tr(r.name, lang)}: ${tr(p.label, lang)}`, "data-testid": `role-check-${r.id}-${p.key}` }));
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const body = [];
|
|
294
|
+
groups.forEach((g) => {
|
|
295
|
+
const rows = byGroup(g);
|
|
296
|
+
if (!rows.length) return;
|
|
297
|
+
if (g) body.push(h("tr", { key: `g-${g}`, className: "scalio-roles-group" }, h("td", { colSpan: roles.length + 1 }, g)));
|
|
298
|
+
rows.forEach((p) => body.push(h("tr", { key: p.key, "data-testid": `role-row-${p.key}` },
|
|
299
|
+
h("td", { className: "scalio-roles-perm" }, tr(p.label, lang),
|
|
300
|
+
p.license ? h("span", { className: "scalio-badge is-warn scalio-roles-lic", title: L.license }, "€") : null),
|
|
301
|
+
roles.map((r) => cell(r, p)))));
|
|
302
|
+
});
|
|
303
|
+
body.push(h("tr", { key: "admin-only", className: "scalio-roles-group" }, h("td", { colSpan: roles.length + 1 }, L.adminOnly)));
|
|
304
|
+
body.push(h("tr", { key: "admin-row" },
|
|
305
|
+
h("td", { className: "scalio-roles-perm" }, L.adminAll),
|
|
306
|
+
roles.map((r) => h("td", { key: r.id, className: "scalio-roles-cell" },
|
|
307
|
+
r.admin ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no" }, "–")))));
|
|
308
|
+
|
|
309
|
+
const dialogEl = dialog ? h(Dialog, { title: dialog.mode === "add" ? L.addTitle : L.renameTitle, onClose: close, testId: "role-dialog" },
|
|
310
|
+
h("form", { className: "scalio-dialog-body", onSubmit: submit },
|
|
311
|
+
h("div", { className: "scalio-users-field" },
|
|
312
|
+
h("label", { className: "scalio-field-label", htmlFor: "role-name" }, L.name),
|
|
313
|
+
h("input", { id: "role-name", className: "scalio-input", value: form.name, required: true, maxLength: 60, autoFocus: true,
|
|
314
|
+
onChange: (e) => setForm(Object.assign({}, form, { name: e.target.value })), "data-testid": "role-name" })),
|
|
315
|
+
h("div", { className: "scalio-users-field" },
|
|
316
|
+
h("label", { className: "scalio-field-label", htmlFor: "role-description" }, L.description),
|
|
317
|
+
h("input", { id: "role-description", className: "scalio-input", value: form.description, maxLength: 300,
|
|
318
|
+
onChange: (e) => setForm(Object.assign({}, form, { description: e.target.value })), "data-testid": "role-description" })),
|
|
319
|
+
dialog.mode === "add" ? h("div", { className: "scalio-users-field" },
|
|
320
|
+
h("label", { className: "scalio-field-label", htmlFor: "role-from" }, L.copyFrom),
|
|
321
|
+
h("select", { id: "role-from", className: "scalio-input", value: form.from, onChange: (e) => setForm(Object.assign({}, form, { from: e.target.value })), "data-testid": "role-from" },
|
|
322
|
+
h("option", { value: "" }, L.none),
|
|
323
|
+
roles.filter((r) => !r.admin).map((r) => h("option", { key: r.id, value: r.id }, tr(r.name, lang))))) : null,
|
|
324
|
+
error ? h("p", { className: "scalio-notice is-error scalio-users-error", role: "alert", "data-testid": "role-dialog-error" }, error) : null,
|
|
325
|
+
h("div", { className: "scalio-dialog-actions" },
|
|
326
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: close }, L.cancel),
|
|
327
|
+
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || !form.name.trim(), "data-testid": "role-save" },
|
|
328
|
+
dialog.mode === "add" ? L.create : L.save)))) : null;
|
|
329
|
+
|
|
330
|
+
return h("div", { className: "scalio-users scalio-roles", "data-testid": props.testId || "role-admin" },
|
|
331
|
+
h("div", { className: "scalio-users-bar" },
|
|
332
|
+
h("p", { className: "scalio-users-hint" }, own.length ? L.readOnly : L.empty),
|
|
333
|
+
canEdit ? h("button", { type: "button", className: "scalio-btn is-primary", onClick: () => openAdd(null), disabled: busy, "data-testid": "role-add" },
|
|
334
|
+
h(Icons.Plus, { className: "scalio-icon" }), L.add) : null),
|
|
335
|
+
h("div", { className: "scalio-users-table-wrap" },
|
|
336
|
+
h("table", { className: "scalio-users-table scalio-roles-table", "data-testid": "roles-table" },
|
|
337
|
+
h("thead", null, head), h("tbody", null, body))),
|
|
338
|
+
catalog.permissions.some((p) => p.license) ? h("p", { className: "scalio-users-hint" }, h("span", { className: "scalio-badge is-warn" }, "€"), " ", L.licenseHint) : null,
|
|
339
|
+
dialogEl);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Props: value {source: "local"|"entra", values: [...], tenants?: [...]}, onSave({source, tenants}) -> Promise,
|
|
343
|
+
// tenants (show the tenant field), lang, labels, confirm, notify, formatError, disabled, testId.
|
|
344
|
+
function RoleSourceSettings(props) {
|
|
345
|
+
const lang = props.lang === "en" ? "en" : "de";
|
|
346
|
+
const L = rolesText(lang, props.labels);
|
|
347
|
+
const v = props.value || { source: "local", values: [] };
|
|
348
|
+
const [source, setSource] = React.useState(v.source || "local");
|
|
349
|
+
const [tenants, setTenants] = React.useState((v.tenants || []).join(", "));
|
|
350
|
+
const [busy, setBusy] = React.useState(false);
|
|
351
|
+
React.useEffect(() => { setSource(v.source || "local"); setTenants((v.tenants || []).join(", ")); }, [v.source, (v.tenants || []).join(",")]);
|
|
352
|
+
const dirty = source !== (v.source || "local") || (props.tenants && tenants !== (v.tenants || []).join(", "));
|
|
353
|
+
const save = async () => {
|
|
354
|
+
if (source === "entra" && v.source !== "entra" && !(await ask(props.confirm, L.entraConfirm, L.entraConfirmText, L.entraAction, false))) return;
|
|
355
|
+
setBusy(true);
|
|
356
|
+
try {
|
|
357
|
+
await props.onSave({ source, tenants: tenants.split(/[\s,;]+/).filter(Boolean) });
|
|
358
|
+
if (props.notify) props.notify(L.saved, "success");
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (props.notify) props.notify((props.formatError || errorText)(e), "error");
|
|
361
|
+
} finally {
|
|
362
|
+
setBusy(false);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const values = (v.values || []).map((x) => (typeof x === "string" ? x : x.value || x.name)).filter(Boolean);
|
|
366
|
+
return h("div", { className: "scalio-roles-source", "data-testid": props.testId || "role-source" },
|
|
367
|
+
h("div", { className: "scalio-users-field" },
|
|
368
|
+
h("label", { className: "scalio-field-label", htmlFor: "role-source-select" }, L.source),
|
|
369
|
+
h("select", { id: "role-source-select", className: "scalio-input scalio-users-select", value: source, disabled: props.disabled || busy,
|
|
370
|
+
onChange: (e) => setSource(e.target.value), "data-testid": "role-source-select" },
|
|
371
|
+
h("option", { value: "local" }, L.sourceLocal),
|
|
372
|
+
h("option", { value: "entra" }, L.sourceEntra))),
|
|
373
|
+
props.tenants ? h("div", { className: "scalio-users-field" },
|
|
374
|
+
h("label", { className: "scalio-field-label", htmlFor: "role-source-tenants" }, L.tenants),
|
|
375
|
+
h("input", { id: "role-source-tenants", className: "scalio-input", value: tenants, disabled: props.disabled || busy,
|
|
376
|
+
onChange: (e) => setTenants(e.target.value), "data-testid": "role-source-tenants" }),
|
|
377
|
+
h("p", { className: "scalio-users-hint" }, L.tenantsHint)) : null,
|
|
378
|
+
h("p", { className: "scalio-users-hint", "data-testid": "role-source-hint" }, source === "entra" ? L.hintEntra : L.hintLocal, " ",
|
|
379
|
+
values.map((x, i) => h(React.Fragment, { key: x }, i ? ", " : "", h("code", null, x)))),
|
|
380
|
+
h("div", null,
|
|
381
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: props.disabled || busy || !dirty, "data-testid": "role-source-save" }, L.save)));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
module.exports = { RoleAdmin, RoleSourceSettings, rolesApi, rolesText };
|
package/react/users.js
CHANGED
|
@@ -52,6 +52,10 @@ function normalizeUser(d) {
|
|
|
52
52
|
name: pick(d, ["name"]) || "",
|
|
53
53
|
role: pick(d, ["role"]) || "",
|
|
54
54
|
pending: !!pick(d, ["access_pending", "accessPending", "pendingAccess"]),
|
|
55
|
+
// invited: the invitation mail is out, the person has not chosen a password yet. inviteOnly: the row is only an
|
|
56
|
+
// invitation (no account yet, Foundry): it can only be withdrawn.
|
|
57
|
+
invited: !!pick(d, ["invited", "invitePending"]),
|
|
58
|
+
inviteOnly: !!pick(d, ["invite_only", "inviteOnly"]),
|
|
55
59
|
microsoft: !!pick(d, ["microsoft", "entra_linked", "external"]),
|
|
56
60
|
managedBy: pick(d, ["managed_by", "roleManagedBy"]) || "",
|
|
57
61
|
hasPassword: !!pick(d, ["has_password", "hasPassword"]),
|
|
@@ -72,7 +76,9 @@ function usersApi(api, opts) {
|
|
|
72
76
|
const secret = (r) => pick(r.data, ["start_password", "startPassword"]) || "";
|
|
73
77
|
return {
|
|
74
78
|
list: () => api.get(base).then((r) => (Array.isArray(r.data) ? r.data : r.data.users || []).map(normalizeUser)),
|
|
75
|
-
|
|
79
|
+
// inviteLink: only when the invitation mail could not be sent (the admin passes the link on).
|
|
80
|
+
create: (body) => api.post(base, body).then((r) => ({ user: normalizeUser(r.data.user || r.data), startPassword: secret(r),
|
|
81
|
+
inviteLink: pick(r.data, ["invite_link", "inviteLink"]) || "" })),
|
|
76
82
|
update: (id, patch) => api.patch(one(id), patch).then((r) => normalizeUser(r.data.user || r.data)),
|
|
77
83
|
approve: (id, body) => api.post(`${one(id)}/approve`, body).then(() => true),
|
|
78
84
|
merge: (id, into) => api.post(`${one(id)}/merge`, { into }).then(() => true),
|
|
@@ -191,6 +197,7 @@ function UserAdmin(props) {
|
|
|
191
197
|
if (form.access !== null && form.access !== undefined) body.access = form.access;
|
|
192
198
|
run(() => client.create(body), null, (res) => {
|
|
193
199
|
if (res.startPassword) showSecret(res.user, res.startPassword, true);
|
|
200
|
+
else if (res.inviteLink) setDialog({ mode: "secret", user: res.user, password: res.inviteLink, link: true });
|
|
194
201
|
else {
|
|
195
202
|
say(form.signIn === "invite" ? fill(L.invited, { email: body.email }) : fill(L.created, { email: body.email }));
|
|
196
203
|
close();
|
|
@@ -216,12 +223,14 @@ function UserAdmin(props) {
|
|
|
216
223
|
removePassword: [L.removePasswordTitle, L.removePasswordText, L.removePasswordAction],
|
|
217
224
|
mfa: [L.resetMfaTitle, L.resetMfaText, L.resetMfaAction],
|
|
218
225
|
unlink: [L.unlinkTitle, L.unlinkText, L.unlinkAction],
|
|
219
|
-
remove: [L.removeTitle, L.removeText, L.removeAction]
|
|
226
|
+
remove: [L.removeTitle, L.removeText, L.removeAction],
|
|
227
|
+
revoke: [L.revokeTitle, L.revokeText, L.revokeInvite] }[kind];
|
|
220
228
|
if (!(await ask(confirm, fill(t[0], { email: u.email }), t[1], t[2], t[3]))) return;
|
|
221
229
|
if (kind === "password") return run(() => client.newPassword(u.id), null, (pw) => showSecret(u, pw, false));
|
|
222
230
|
if (kind === "removePassword") return run(() => client.removePassword(u.id), L.passwordRemoved);
|
|
223
231
|
if (kind === "mfa") return run(() => client.resetMfa(u.id), L.mfaReset);
|
|
224
232
|
if (kind === "unlink") return run(() => client.unlinkMicrosoft(u.id), L.unlinked);
|
|
233
|
+
if (kind === "revoke") return run(() => client.remove(u.id), L.revoked);
|
|
225
234
|
return run(() => client.remove(u.id), L.removed);
|
|
226
235
|
};
|
|
227
236
|
|
|
@@ -249,7 +258,7 @@ function UserAdmin(props) {
|
|
|
249
258
|
h("select", Object.assign({ className: "scalio-input scalio-users-select", value, onChange: (e) => onChange(e.target.value) }, attrs),
|
|
250
259
|
roles.map((r) => h("option", { key: r.value, value: r.value }, r.label)));
|
|
251
260
|
const errorBox = error ? h("p", { className: "scalio-notice is-error scalio-users-error", role: "alert", "data-testid": "user-dialog-error" }, error) : null;
|
|
252
|
-
const mergeTargets = (u) => rows.filter((r) => r.id !== u.id && !r.pending && !r.microsoft);
|
|
261
|
+
const mergeTargets = (u) => rows.filter((r) => r.id !== u.id && !r.pending && !r.microsoft && !r.inviteOnly);
|
|
253
262
|
|
|
254
263
|
const header = h(
|
|
255
264
|
"div",
|
|
@@ -270,7 +279,7 @@ function UserAdmin(props) {
|
|
|
270
279
|
h("thead", null, h("tr", null,
|
|
271
280
|
h("th", null, L.user), h("th", null, L.role),
|
|
272
281
|
props.accessColumn ? h("th", { className: "scalio-users-hide-sm" }, props.accessColumn.label) : null,
|
|
273
|
-
h("th", { className: "scalio-users-hide-
|
|
282
|
+
h("th", { className: "scalio-users-hide-md" }, L.lastLogin), h("th", null))),
|
|
274
283
|
h("tbody", null, shown.map((u) => h(
|
|
275
284
|
"tr",
|
|
276
285
|
{ key: u.id, "data-testid": `user-row-${u.email}`, className: cx(u.pending && "is-pending", !u.active && "is-locked") },
|
|
@@ -281,14 +290,16 @@ function UserAdmin(props) {
|
|
|
281
290
|
h("div", { className: "scalio-users-who-text" },
|
|
282
291
|
h("div", { className: "scalio-users-name" },
|
|
283
292
|
u.name || u.email,
|
|
284
|
-
u.id === me ? h("span", { className: "scalio-users-you" }, L.you) : null,
|
|
293
|
+
u.id === me ? h("span", { className: "scalio-users-you" }, L.you) : null),
|
|
294
|
+
h("div", { className: "scalio-users-mail" }, u.email),
|
|
295
|
+
h("div", { className: "scalio-users-badges" },
|
|
285
296
|
u.microsoft ? badge("running", "Microsoft", `user-ms-${u.email}`) : null,
|
|
286
297
|
u.hasPassword ? badge("neutral", L.password, `user-pw-${u.email}`) : null,
|
|
287
298
|
u.managedBy === "entra" ? badge("running", L.managedByEntra, `user-entra-${u.email}`) : null,
|
|
288
299
|
u.emergency && u.role === adminRole ? badge("error", L.emergency, `user-emergency-${u.email}`) : null,
|
|
289
300
|
!u.active && !u.pending ? badge("neutral", L.locked, `user-locked-${u.email}`) : null,
|
|
290
|
-
u.
|
|
291
|
-
|
|
301
|
+
u.invited || u.inviteOnly ? badge("warn", L.invitedBadge, `user-invited-${u.email}`) : null,
|
|
302
|
+
u.pending ? badge("warn", L.pending, `user-pending-${u.email}`) : null))
|
|
292
303
|
)),
|
|
293
304
|
h("td", null, u.pending && u.managedBy !== "entra"
|
|
294
305
|
? roleSelect(pendingRole[u.id] || props.defaultRole || (roles[0] && roles[0].value), (v) => setPendingRole(Object.assign({}, pendingRole, { [u.id]: v })),
|
|
@@ -297,9 +308,12 @@ function UserAdmin(props) {
|
|
|
297
308
|
props.accessColumn ? h("td", { className: "scalio-users-hide-sm" }, u.pending && props.accessField
|
|
298
309
|
? props.accessField(pendingAccess[u.id], (v) => setPendingAccess(Object.assign({}, pendingAccess, { [u.id]: v })), pendingRole[u.id] || props.defaultRole, u)
|
|
299
310
|
: props.accessColumn.render(u)) : null,
|
|
300
|
-
h("td", { className: "scalio-users-hide-
|
|
301
|
-
h("td", { className: "scalio-users-actions" }, u.
|
|
302
|
-
? h(
|
|
311
|
+
h("td", { className: "scalio-users-hide-md scalio-users-date" }, u.lastLogin ? fmtDate(u.lastLogin, lang) : "–"),
|
|
312
|
+
h("td", { className: "scalio-users-actions" }, u.inviteOnly
|
|
313
|
+
? h("button", { type: "button", className: "scalio-btn is-ghost is-danger", onClick: () => action(u, "revoke"), disabled: busy,
|
|
314
|
+
"data-testid": `user-revoke-${u.email}` }, L.revokeInvite)
|
|
315
|
+
: u.pending
|
|
316
|
+
? h("div", { className: "scalio-users-actions-row" },
|
|
303
317
|
h("button", { type: "button", className: "scalio-btn is-primary", onClick: () => approve(u), disabled: busy, "data-testid": `user-approve-${u.email}` }, L.approve),
|
|
304
318
|
mergeTargets(u).length ? h("button", { type: "button", className: "scalio-btn", onClick: () => openMerge(u), "data-testid": `user-merge-${u.email}` }, L.merge) : null,
|
|
305
319
|
h("button", { type: "button", className: "scalio-btn is-ghost is-danger scalio-btn-icon", onClick: () => action(u, "remove"), title: L.removeAction,
|
|
@@ -407,9 +421,10 @@ function UserAdmin(props) {
|
|
|
407
421
|
const c = typeof navigator !== "undefined" && navigator.clipboard;
|
|
408
422
|
if (c) c.writeText(dialog.password).then(() => say(L.copied), () => null);
|
|
409
423
|
};
|
|
410
|
-
|
|
424
|
+
const title = dialog.link ? L.inviteLinkTitle : dialog.created ? fill(L.createdTitle, { email: dialog.user.email }) : L.secretTitle;
|
|
425
|
+
body = h(Dialog, { title, onClose: close, testId: "user-secret-dialog" },
|
|
411
426
|
h("div", { className: "scalio-dialog-body" },
|
|
412
|
-
h("p", { className: "scalio-users-hint" }, fill(L.secretText, { email: dialog.user.email })),
|
|
427
|
+
h("p", { className: "scalio-users-hint" }, fill(dialog.link ? L.inviteLinkText : L.secretText, { email: dialog.user.email })),
|
|
413
428
|
h("div", { className: "scalio-users-secret", "data-testid": "user-start-password" }, dialog.password),
|
|
414
429
|
h("div", { className: "scalio-dialog-actions" },
|
|
415
430
|
h("button", { type: "button", className: "scalio-btn", onClick: copy, "data-testid": "user-copy-password" }, h(Icons.Copy, { className: "scalio-icon" }), L.copy),
|
package/react/wizard.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Setup wizard ("Einrichtungsassistent", ADR 0060, UISPEC section 3): the same first-step dialog in every app.
|
|
3
|
+
// Design of the Nexus SetupWizard, first built in Kontor (#59): dialog, stepper on top, Zurück/Weiter, Überspringen
|
|
4
|
+
// for optional steps, a short success message at the end. The shell knows no app: steps come as configuration.
|
|
5
|
+
// Progress lives in the app's backend (GET/PUT /api/setup/wizard, scalio-docs interfaces/setup-wizard.md);
|
|
6
|
+
// setupWizardApi() and useSetupWizard() do the wiring, so an app only writes its steps.
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const Icons = require("./icons");
|
|
10
|
+
const { labelsFor } = require("./labels");
|
|
11
|
+
const { Notice } = require("./blocks");
|
|
12
|
+
|
|
13
|
+
const OPEN_SETUP_WIZARD = "scalio:open-setup-wizard";
|
|
14
|
+
|
|
15
|
+
// Opens the wizard from anywhere (Einstellungen button, dashboard card). useSetupWizard listens for it.
|
|
16
|
+
function openSetupWizard(step) {
|
|
17
|
+
if (typeof window === "undefined") return;
|
|
18
|
+
window.dispatchEvent(new window.CustomEvent(OPEN_SETUP_WIZARD, { detail: { step: step || 0 } }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeWizardState(d) {
|
|
22
|
+
const o = d || {};
|
|
23
|
+
const pick = (a, b) => (o[a] !== undefined ? o[a] : o[b]);
|
|
24
|
+
return {
|
|
25
|
+
needed: !!o.needed,
|
|
26
|
+
completed: !!o.completed,
|
|
27
|
+
lastStep: Number(pick("last_step", "lastStep")) || 0,
|
|
28
|
+
canEdit: pick("can_edit", "canEdit") === undefined ? true : !!pick("can_edit", "canEdit"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The shared route. `api` is the app's axios-like client (get/put returning { data }).
|
|
33
|
+
function setupWizardApi(api, opts) {
|
|
34
|
+
const base = (opts && opts.base) || "/setup/wizard";
|
|
35
|
+
return {
|
|
36
|
+
get: () => api.get(base).then((r) => normalizeWizardState(r.data)),
|
|
37
|
+
save: (step) => api.put(base, { last_step: step }).then(() => true),
|
|
38
|
+
complete: () => api.put(base, { completed: true }).then(() => true),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// State, auto-open and saving for SetupWizard. Spread `props` into <SetupWizard> and add title and steps.
|
|
43
|
+
// Opens by itself once per mount while the setup is needed and the person may edit it, at the saved step;
|
|
44
|
+
// openSetupWizard() opens it again at the beginning. `closable: false` hides «Später» (e.g. nothing exists yet).
|
|
45
|
+
function useSetupWizard(opts) {
|
|
46
|
+
const client = opts && opts.client;
|
|
47
|
+
const enabled = !opts || opts.enabled !== false;
|
|
48
|
+
const [state, setState] = React.useState(null);
|
|
49
|
+
const [open, setOpen] = React.useState(false);
|
|
50
|
+
const [step, setStep] = React.useState(0);
|
|
51
|
+
const auto = React.useRef(false);
|
|
52
|
+
const stateRef = React.useRef(null);
|
|
53
|
+
stateRef.current = state;
|
|
54
|
+
|
|
55
|
+
const reload = React.useCallback(() => {
|
|
56
|
+
if (!client) return Promise.resolve(null);
|
|
57
|
+
return client.get().then((s) => { setState(s); return s; }, () => null);
|
|
58
|
+
}, [client]);
|
|
59
|
+
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (enabled) reload();
|
|
62
|
+
}, [enabled, reload]);
|
|
63
|
+
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (state && state.needed && state.canEdit && !auto.current) {
|
|
66
|
+
auto.current = true;
|
|
67
|
+
setStep(state.lastStep);
|
|
68
|
+
setOpen(true);
|
|
69
|
+
}
|
|
70
|
+
}, [state]);
|
|
71
|
+
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
if (typeof window === "undefined") return undefined;
|
|
74
|
+
const onOpen = (e) => {
|
|
75
|
+
const s = stateRef.current;
|
|
76
|
+
if (s && !s.canEdit) return;
|
|
77
|
+
setStep((e && e.detail && e.detail.step) || 0);
|
|
78
|
+
setOpen(true);
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener(OPEN_SETUP_WIZARD, onOpen);
|
|
81
|
+
return () => window.removeEventListener(OPEN_SETUP_WIZARD, onOpen);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const close = () => setOpen(false);
|
|
85
|
+
const props = {
|
|
86
|
+
open,
|
|
87
|
+
step,
|
|
88
|
+
onStepChange: (n) => {
|
|
89
|
+
setStep(n);
|
|
90
|
+
if (client) client.save(n).catch(() => {});
|
|
91
|
+
},
|
|
92
|
+
onFinish: async () => {
|
|
93
|
+
if (client) await client.complete();
|
|
94
|
+
setState((s) => Object.assign({}, s, { needed: false, completed: true }));
|
|
95
|
+
if (opts && opts.onCompleted) opts.onCompleted();
|
|
96
|
+
},
|
|
97
|
+
onClose: opts && opts.closable === false ? undefined : close,
|
|
98
|
+
onDone: close,
|
|
99
|
+
};
|
|
100
|
+
return { state, open, setOpen, step, setStep, reload, props };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function StepDot({ index, current }) {
|
|
104
|
+
const cls = index < current ? "is-done" : index === current ? "is-current" : "";
|
|
105
|
+
return h("span", { className: `scalio-wizard-dot ${cls}`.trim() }, index < current ? h(Icons.Check, { width: 14, height: 14 }) : index + 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function SetupWizard(props) {
|
|
109
|
+
const { open, title, subtitle, steps, step, onStepChange, onFinish, onClose, firstStepAction, onDone, testId, wide } = props;
|
|
110
|
+
const L = Object.assign({}, labelsFor(props.lang).wizard, props.labels);
|
|
111
|
+
const success = props.success || { title: L.successTitle, text: L.successText };
|
|
112
|
+
const [busy, setBusy] = React.useState(false);
|
|
113
|
+
const [error, setError] = React.useState(null);
|
|
114
|
+
const [finished, setFinished] = React.useState(false);
|
|
115
|
+
const list = steps || [];
|
|
116
|
+
const index = Math.min(Math.max(step || 0, 0), list.length - 1);
|
|
117
|
+
const current = list[index];
|
|
118
|
+
const last = index === list.length - 1;
|
|
119
|
+
|
|
120
|
+
React.useEffect(() => setError(null), [index]);
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
if (!open) setFinished(false);
|
|
123
|
+
}, [open]);
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
if (!open || !onClose) return undefined;
|
|
126
|
+
const onKey = (e) => {
|
|
127
|
+
if (e.key === "Escape" && !busy) onClose();
|
|
128
|
+
};
|
|
129
|
+
window.addEventListener("keydown", onKey);
|
|
130
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
131
|
+
}, [open, onClose, busy]);
|
|
132
|
+
|
|
133
|
+
if (!open || !current) return null;
|
|
134
|
+
|
|
135
|
+
async function next(save) {
|
|
136
|
+
setBusy(true);
|
|
137
|
+
setError(null);
|
|
138
|
+
try {
|
|
139
|
+
if (save && current.onNext) await current.onNext();
|
|
140
|
+
if (last) {
|
|
141
|
+
await onFinish();
|
|
142
|
+
setFinished(true);
|
|
143
|
+
} else {
|
|
144
|
+
onStepChange(index + 1);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
setError((err && err.message) || String(err));
|
|
148
|
+
} finally {
|
|
149
|
+
setBusy(false);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const left = index > 0
|
|
154
|
+
? { label: L.back, onClick: () => onStepChange(index - 1) }
|
|
155
|
+
: firstStepAction || (onClose ? { label: L.close, onClick: onClose } : null);
|
|
156
|
+
|
|
157
|
+
const head = h(
|
|
158
|
+
"div",
|
|
159
|
+
{ className: "scalio-wizard-head" },
|
|
160
|
+
h("div", null,
|
|
161
|
+
h("h2", { className: "scalio-wizard-title" }, title),
|
|
162
|
+
subtitle && !finished ? h("p", { className: "scalio-wizard-subtitle" }, subtitle) : null),
|
|
163
|
+
onClose
|
|
164
|
+
? h("button", { type: "button", className: "scalio-btn scalio-btn-icon is-ghost", onClick: onClose, "aria-label": L.close, "data-testid": "wizard-close" },
|
|
165
|
+
h(Icons.X, null))
|
|
166
|
+
: null
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const body = finished
|
|
170
|
+
? h(
|
|
171
|
+
"div",
|
|
172
|
+
{ className: "scalio-wizard-success", "data-testid": "wizard-success" },
|
|
173
|
+
h("span", { className: "scalio-wizard-success-icon" }, h(Icons.Check, { width: 24, height: 24 })),
|
|
174
|
+
h("h3", { className: "scalio-wizard-step-title" }, success.title),
|
|
175
|
+
h("p", { className: "scalio-wizard-text" }, success.text),
|
|
176
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: onDone || onClose, "data-testid": "wizard-done", autoFocus: true }, L.done)
|
|
177
|
+
)
|
|
178
|
+
: h(
|
|
179
|
+
React.Fragment,
|
|
180
|
+
null,
|
|
181
|
+
h(
|
|
182
|
+
"ol",
|
|
183
|
+
{ className: "scalio-wizard-stepper", "data-testid": "wizard-stepper" },
|
|
184
|
+
list.map((s, i) =>
|
|
185
|
+
h(
|
|
186
|
+
"li",
|
|
187
|
+
{ key: s.id, className: "scalio-wizard-stepper-item", "data-testid": "wizard-stepper-item", "aria-current": i === index ? "step" : undefined },
|
|
188
|
+
h(StepDot, { index: i, current: index }),
|
|
189
|
+
h("span", { className: `scalio-wizard-label${i === index ? " is-current" : ""}` }, s.label),
|
|
190
|
+
i < list.length - 1 ? h("span", { className: "scalio-wizard-line" }) : null
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
),
|
|
194
|
+
h("p", { className: "scalio-wizard-count" }, L.step.replace("{n}", String(index + 1)).replace("{total}", String(list.length))),
|
|
195
|
+
h(
|
|
196
|
+
"section",
|
|
197
|
+
{ className: "scalio-wizard-step", "data-testid": `wizard-step-${current.id}` },
|
|
198
|
+
h("div", null,
|
|
199
|
+
h("h3", { className: "scalio-wizard-step-title" }, current.title),
|
|
200
|
+
current.description ? h("p", { className: "scalio-wizard-text" }, current.description) : null),
|
|
201
|
+
current.content
|
|
202
|
+
),
|
|
203
|
+
error ? h(Notice, { kind: "error", icon: false, testId: "wizard-error", lang: props.lang }, error) : null,
|
|
204
|
+
h(
|
|
205
|
+
"div",
|
|
206
|
+
{ className: "scalio-wizard-actions" },
|
|
207
|
+
left
|
|
208
|
+
? h("button", { type: "button", className: "scalio-btn", onClick: left.onClick, disabled: busy, "data-testid": "wizard-back" }, left.label)
|
|
209
|
+
: h("span", null),
|
|
210
|
+
h(
|
|
211
|
+
"div",
|
|
212
|
+
{ className: "scalio-wizard-actions-right" },
|
|
213
|
+
current.optional
|
|
214
|
+
? h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => next(false), disabled: busy, "data-testid": "wizard-skip" }, L.skip)
|
|
215
|
+
: null,
|
|
216
|
+
h(
|
|
217
|
+
"button",
|
|
218
|
+
{ type: "button", className: "scalio-btn is-primary", onClick: () => next(true), disabled: busy || current.canNext === false, "data-testid": "wizard-next" },
|
|
219
|
+
busy ? h("span", { className: "scalio-spinner", "aria-hidden": "true" }) : null,
|
|
220
|
+
last ? L.finish : L.next
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return h(
|
|
227
|
+
"div",
|
|
228
|
+
{ className: "scalio-wizard-root", onMouseDown: (e) => { if (e.target === e.currentTarget && !busy && onClose) onClose(); } },
|
|
229
|
+
h("div", { role: "dialog", "aria-modal": "true", "aria-label": title, className: wide ? "scalio-wizard is-wide" : "scalio-wizard", "data-testid": testId || "setup-wizard" }, head, body)
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Button for Einstellungen (UISPEC: the wizard can always be opened again, by people who may edit the setup).
|
|
234
|
+
function SetupWizardButton({ lang, labels, testId, className }) {
|
|
235
|
+
const L = Object.assign({}, labelsFor(lang).wizard, labels);
|
|
236
|
+
return h("button", { type: "button", className: className || "scalio-btn", onClick: () => openSetupWizard(0), "data-testid": testId || "settings-open-wizard", title: L.openHint }, L.open);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { SetupWizard, SetupWizardButton, useSetupWizard, setupWizardApi, normalizeWizardState, openSetupWizard, OPEN_SETUP_WIZARD };
|