@scalio-cloud/ui 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +46 -0
- package/nav.css +104 -0
- package/package.json +1 -1
- package/react/icons.js +3 -1
- package/react/index.js +1 -1
- package/react/labels.js +208 -0
- package/react/users.js +446 -0
- package/react/wizard.js +239 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0
|
|
4
|
+
Einrichtungsassistent als Standard für alle Apps (scalio-docs ADR 0060, contract `interfaces/setup-wizard.md`).
|
|
5
|
+
- New `SetupWizard`: the first-step dialog of Nexus and Kontor as a shared shell. Steps come as configuration
|
|
6
|
+
(`id`, `label`, `title`, `description`, `content`, `onNext`, `canNext`, `optional`); stepper on top, «Schritt n von
|
|
7
|
+
m», Zurück/Weiter, «Überspringen» for optional steps, errors of `onNext` keep the step open, short success message
|
|
8
|
+
at the end. Without `onClose` it cannot be closed; `wide` for steps with tables. Texts DE/EN in `labels.wizard`.
|
|
9
|
+
- `setupWizardApi(api, {base})` for `GET/PUT /api/setup/wizard` (reads snake_case and camelCase), `useSetupWizard()`
|
|
10
|
+
(loads the state, opens once by itself at the saved step while the setup is needed, saves each step, completes),
|
|
11
|
+
`openSetupWizard()` and `SetupWizardButton` for Einstellungen.
|
|
12
|
+
- Styles `.scalio-wizard*` in nav.css. No token changes, nothing changes until an app uses the wizard.
|
|
13
|
+
|
|
14
|
+
## 0.23.0
|
|
15
|
+
Benutzerverwaltung in allen Apps angleichen (scalio-docs ADR 0059, `users/B1`–`B6`, contract `interfaces/app-users.md`).
|
|
16
|
+
- New `UserAdmin` for Konten und Rollen → Benutzer, the same in every app: list with search and badges (Microsoft,
|
|
17
|
+
Passwort, Rolle aus Entra, Notfall-Administrator, Gesperrt, Wartet auf Freigabe), «Benutzer hinzufügen» (Microsoft,
|
|
18
|
+
Passwort with a start password shown once, or invitation by e-mail), «Bearbeiten» (name and e-mail only for accounts
|
|
19
|
+
without Microsoft, role, access, lock, emergency administrator) with the actions new start password, remove password,
|
|
20
|
+
reset second factor, unlink Microsoft and delete; waiting accounts: role, access, «Freischalten», «Zusammenführen».
|
|
21
|
+
The app passes roles and an optional access column/field (customers, organisation).
|
|
22
|
+
- `usersApi(api, {base})` for the shared routes (`/users`, Nexus `/admins`), reads snake_case and camelCase.
|
|
23
|
+
- `MicrosoftConnection` for the profile section «Verknüpfungen» (link or unlink one's own Microsoft account).
|
|
24
|
+
- New icons `Pencil`, `Link2`; modal dialog styles (`.scalio-dialog*`). No token changes, nothing changes until an app
|
|
25
|
+
uses the new components.
|
|
26
|
+
|
|
3
27
|
## 0.22.0
|
|
4
28
|
Login, Navigation und Banner vereinheitlichen (scalio-docs UISPEC section 3, ADR 0042 Nachtrag 2026-11-01).
|
|
5
29
|
- **Notice in the look of the test mode banner:** thin frame in the tone, 3px bar on the left, tinted background,
|
package/README.md
CHANGED
|
@@ -218,6 +218,52 @@ for light, dark and system.
|
|
|
218
218
|
|
|
219
219
|
Other form fields and buttons (e.g. the name field) stay the app's own (shadcn), so they match the rest of the app.
|
|
220
220
|
|
|
221
|
+
## User administration (React)
|
|
222
|
+
|
|
223
|
+
Konten und Rollen → Benutzer, the same in every app (ADR 0059). The backend follows scalio-docs
|
|
224
|
+
`interfaces/app-users.md`; `usersApi(api, {base: "/admins"})` for Nexus.
|
|
225
|
+
|
|
226
|
+
```jsx
|
|
227
|
+
import { UserAdmin, usersApi, MicrosoftConnection } from "@scalio-cloud/ui/react";
|
|
228
|
+
|
|
229
|
+
const users = usersApi(api); // once, outside the component
|
|
230
|
+
<UserAdmin client={users} me={user.id} lang={lang} roles={[{ value: "admin", label: t("Administrator") }, ...]}
|
|
231
|
+
defaultRole="operator" signInModes={["microsoft", "password"]} microsoftEnabled={ssoOn} canLock
|
|
232
|
+
accessColumn={{ label: t("Customers"), render: (u) => ... }} accessField={(value, onChange, role, u) => ...}
|
|
233
|
+
notify={(text, tone) => toast[tone](text)} confirm={confirm} formatError={fmt} />
|
|
234
|
+
|
|
235
|
+
// Profile -> Verknüpfungen
|
|
236
|
+
<MicrosoftConnection linked={user.microsoft} canUnlink={user.has_password} onLink={...} onUnlink={...} lang={lang} />
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Setup wizard (React)
|
|
240
|
+
|
|
241
|
+
Einrichtungsassistent after the first sign-in, the same in every app (ADR 0060, UISPEC section 3). The app writes
|
|
242
|
+
only its steps; the backend follows scalio-docs `interfaces/setup-wizard.md` (`GET/PUT /api/setup/wizard`).
|
|
243
|
+
|
|
244
|
+
```jsx
|
|
245
|
+
import { SetupWizard, SetupWizardButton, useSetupWizard, setupWizardApi } from "@scalio-cloud/ui/react";
|
|
246
|
+
|
|
247
|
+
const wizardClient = setupWizardApi(api); // once, outside the component ({ base: "/wizard" } for another route)
|
|
248
|
+
|
|
249
|
+
function AppWizard() {
|
|
250
|
+
const w = useSetupWizard({ client: wizardClient, enabled: isAdmin }); // opens by itself while needed
|
|
251
|
+
const steps = [
|
|
252
|
+
{ id: "tenant", label: t("Tenant"), title: t("Connect Microsoft"), description: "...", content: <TenantsSection />,
|
|
253
|
+
canNext: connected, onNext: async () => { /* save; a rejected promise keeps the step open */ } },
|
|
254
|
+
{ id: "mail", label: t("Email"), title: t("Email (SMTP)"), content: <SmtpForm />, optional: true },
|
|
255
|
+
];
|
|
256
|
+
return <SetupWizard {...w.props} lang={lang} title={t("Set up Foundry")} subtitle={t("...")} steps={steps}
|
|
257
|
+
success={{ title: t("Setup complete"), text: t("...") }} />;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Einstellungen: open it again (admins only)
|
|
261
|
+
<SetupWizardButton lang={lang} />
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Test IDs: `setup-wizard`, `wizard-stepper(-item)`, `wizard-step-{id}`, `wizard-next`, `wizard-back`, `wizard-skip`,
|
|
265
|
+
`wizard-close`, `wizard-error`, `wizard-success`, `wizard-done`, `settings-open-wizard`.
|
|
266
|
+
|
|
221
267
|
## Two-factor card and license panel (React)
|
|
222
268
|
|
|
223
269
|
The same backend routes in every app, so the apps pass only their axios instance and a few helpers.
|
package/nav.css
CHANGED
|
@@ -395,6 +395,110 @@
|
|
|
395
395
|
.scalio-security-key-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: hsl(var(--muted-foreground)); }
|
|
396
396
|
.scalio-security-keys .scalio-btn { width: 2rem; height: 2rem; padding: 0; }
|
|
397
397
|
|
|
398
|
+
/* User administration (UserAdmin, MicrosoftConnection; ADR 0059) */
|
|
399
|
+
.scalio-users { display: flex; flex-direction: column; gap: 1rem; font-size: 0.875rem; }
|
|
400
|
+
.scalio-users-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
|
401
|
+
.scalio-users-search { max-width: 20rem; flex: 1 1 12rem; }
|
|
402
|
+
.scalio-users-table-wrap { overflow-x: auto; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); }
|
|
403
|
+
.scalio-users-table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
404
|
+
.scalio-users-table th {
|
|
405
|
+
padding: 0.75rem 1rem; border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.5);
|
|
406
|
+
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: hsl(var(--muted-foreground));
|
|
407
|
+
}
|
|
408
|
+
.scalio-users-table td { padding: 0.75rem 1rem; border-bottom: 1px solid hsl(var(--border)); vertical-align: middle; }
|
|
409
|
+
.scalio-users-table tr:last-child td { border-bottom: 0; }
|
|
410
|
+
.scalio-users-table tbody tr:hover { background: hsl(var(--muted) / 0.4); }
|
|
411
|
+
.scalio-users-table tr.is-pending { background: rgb(217 119 6 / 0.05); }
|
|
412
|
+
.scalio-users-table tr.is-locked .scalio-users-who { opacity: 0.6; }
|
|
413
|
+
.scalio-users-hide-sm { display: none; }
|
|
414
|
+
@media (min-width: 768px) { .scalio-users-hide-sm { display: table-cell; } }
|
|
415
|
+
.scalio-users-who { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
|
416
|
+
.scalio-users-avatar {
|
|
417
|
+
display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; flex-shrink: 0;
|
|
418
|
+
border-radius: 2px; background: hsl(var(--foreground)); color: hsl(var(--background)); font-family: 'Chivo', sans-serif; font-weight: 700; font-size: 0.75rem;
|
|
419
|
+
}
|
|
420
|
+
.scalio-users-who-text { min-width: 0; }
|
|
421
|
+
.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)); overflow-wrap: anywhere; }
|
|
423
|
+
.scalio-users-you { font-size: 10px; font-weight: 700; text-transform: uppercase; color: hsl(var(--primary)); }
|
|
424
|
+
.scalio-users-role { font-size: 0.8125rem; }
|
|
425
|
+
.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; white-space: nowrap; }
|
|
427
|
+
.scalio-users-actions > * + * { margin-left: 0.375rem; }
|
|
428
|
+
.scalio-users-select { height: 2rem; padding: 0 0.5rem; font-size: 0.8125rem; width: auto; }
|
|
429
|
+
.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
|
+
.scalio-users-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
431
|
+
.scalio-users-field .scalio-field-label { margin-top: 0; }
|
|
432
|
+
.scalio-users-hint { margin: 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
433
|
+
.scalio-users-modes { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
434
|
+
.scalio-users-modes legend { margin-bottom: 0.25rem; }
|
|
435
|
+
.scalio-users-mode { display: flex; gap: 0.625rem; align-items: flex-start; padding: 0.625rem 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; cursor: pointer; }
|
|
436
|
+
.scalio-users-mode.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.05); }
|
|
437
|
+
.scalio-users-mode input { margin-top: 0.2rem; }
|
|
438
|
+
.scalio-users-mode > span { display: flex; flex-direction: column; gap: 0.125rem; }
|
|
439
|
+
.scalio-users-mode-label { font-weight: 600; }
|
|
440
|
+
.scalio-users-check { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; }
|
|
441
|
+
.scalio-users-status { display: flex; flex-direction: column; gap: 0.375rem; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted) / 0.4); }
|
|
442
|
+
.scalio-users-status-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: hsl(var(--muted-foreground)); }
|
|
443
|
+
.scalio-users-status-row { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.8125rem; }
|
|
444
|
+
.scalio-users-status-row > span:first-child { color: hsl(var(--muted-foreground)); }
|
|
445
|
+
.scalio-users-status-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.375rem; }
|
|
446
|
+
.scalio-users-secret {
|
|
447
|
+
padding: 0.75rem 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted));
|
|
448
|
+
font-family: 'JetBrains Mono', monospace; font-size: 1rem; letter-spacing: 0.05em; text-align: center; user-select: all;
|
|
449
|
+
}
|
|
450
|
+
.scalio-users-error { margin: 0; }
|
|
451
|
+
.scalio-users-connection { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
|
452
|
+
.scalio-users-connection-text { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
453
|
+
|
|
454
|
+
/* Modal dialog */
|
|
455
|
+
.scalio-dialog-root { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 4rem 1rem 1rem; overflow-y: auto; }
|
|
456
|
+
.scalio-dialog-backdrop { position: fixed; inset: 0; background: rgb(9 9 11 / 0.5); }
|
|
457
|
+
.scalio-dialog {
|
|
458
|
+
position: relative; width: 100%; max-width: 32rem; border: 1px solid hsl(var(--border)); border-radius: 2px;
|
|
459
|
+
background: hsl(var(--card)); color: hsl(var(--card-foreground)); box-shadow: 0 16px 40px rgb(0 0 0 / 0.25); font-size: 0.875rem;
|
|
460
|
+
}
|
|
461
|
+
.scalio-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem 0.75rem 0.75rem 1.25rem; border-bottom: 1px solid hsl(var(--border)); }
|
|
462
|
+
.scalio-dialog-title { font-family: 'Chivo', sans-serif; font-weight: 700; font-size: 1rem; }
|
|
463
|
+
.scalio-dialog-body { display: flex; flex-direction: column; gap: 1rem; padding: 1.25rem; }
|
|
464
|
+
.scalio-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
|
465
|
+
.scalio-dialog-left { margin-right: auto; }
|
|
466
|
+
|
|
467
|
+
/* Setup wizard (SetupWizard, ADR 0060): design of the Nexus wizard, first built in Kontor */
|
|
468
|
+
.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); }
|
|
469
|
+
.scalio-wizard {
|
|
470
|
+
display: flex; flex-direction: column; gap: 1rem; width: calc(100vw - 2rem); max-width: 42rem; max-height: 90vh; overflow-y: auto;
|
|
471
|
+
padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
|
|
472
|
+
box-shadow: 0 16px 40px rgb(0 0 0 / 0.25); font-size: 0.875rem;
|
|
473
|
+
}
|
|
474
|
+
.scalio-wizard.is-wide { max-width: 56rem; }
|
|
475
|
+
.scalio-wizard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
|
476
|
+
.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; }
|
|
477
|
+
.scalio-wizard-subtitle { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
|
|
478
|
+
.scalio-wizard-stepper { display: flex; align-items: center; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
|
|
479
|
+
.scalio-wizard-stepper-item { display: flex; flex: 1 1 0; min-width: 0; align-items: center; gap: 0.5rem; }
|
|
480
|
+
.scalio-wizard-dot {
|
|
481
|
+
display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 999px;
|
|
482
|
+
background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); font-size: 0.75rem; font-weight: 700;
|
|
483
|
+
}
|
|
484
|
+
.scalio-wizard-dot.is-current { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
|
|
485
|
+
.scalio-wizard-dot.is-done, .scalio-wizard-success-icon { background: #059669; color: #fff; }
|
|
486
|
+
.scalio-wizard-label { display: none; white-space: nowrap; font-size: 0.75rem; font-weight: 500; color: hsl(var(--muted-foreground)); }
|
|
487
|
+
.scalio-wizard-label.is-current { color: hsl(var(--foreground)); }
|
|
488
|
+
@media (min-width: 1024px) { .scalio-wizard-label { display: block; } }
|
|
489
|
+
.scalio-wizard-line { flex: 1 1 0; min-width: 0.5rem; height: 1px; background: hsl(var(--border)); }
|
|
490
|
+
.scalio-wizard-count { margin: 0; font-size: 0.75rem; font-weight: 500; color: hsl(var(--muted-foreground)); }
|
|
491
|
+
.scalio-wizard-step { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
492
|
+
.scalio-wizard-step-title { margin: 0; font-size: 0.875rem; font-weight: 700; }
|
|
493
|
+
.scalio-wizard-text { margin: 0; color: hsl(var(--muted-foreground)); }
|
|
494
|
+
.scalio-wizard-actions { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-top: 0.5rem; }
|
|
495
|
+
.scalio-wizard-actions-right { display: flex; align-items: center; gap: 0.5rem; }
|
|
496
|
+
.scalio-wizard-success { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 1.5rem 0; text-align: center; }
|
|
497
|
+
.scalio-wizard-success .scalio-wizard-step-title { font-size: 1.125rem; }
|
|
498
|
+
.scalio-wizard-success .scalio-wizard-text { max-width: 28rem; }
|
|
499
|
+
.scalio-wizard-success .scalio-btn { margin-top: 0.5rem; }
|
|
500
|
+
.scalio-wizard-success-icon { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 999px; }
|
|
501
|
+
|
|
398
502
|
/* License panel (LicensePanel) */
|
|
399
503
|
.scalio-license { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
|
|
400
504
|
.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.24.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/icons.js
CHANGED
|
@@ -90,6 +90,8 @@ const Puzzle = icon([
|
|
|
90
90
|
["path", { d: "M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z" }],
|
|
91
91
|
]);
|
|
92
92
|
const Plus = icon([["path", { d: "M5 12h14M12 5v14" }]]);
|
|
93
|
+
const Pencil = icon([["path", { d: "M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5zM15 5l4 4" }]]);
|
|
94
|
+
const Link2 = icon([["path", { d: "M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8" }]]);
|
|
93
95
|
const Alert = icon([
|
|
94
96
|
["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
|
|
95
97
|
["path", { d: "M12 9v4M12 17h.01" }],
|
|
@@ -132,5 +134,5 @@ const Globe = icon([
|
|
|
132
134
|
const Clock = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 6v6l4 2" }]]);
|
|
133
135
|
|
|
134
136
|
module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
|
|
135
|
-
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft,
|
|
137
|
+
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Pencil, Link2, Alert, History, CornerDownLeft,
|
|
136
138
|
Users, Settings, ScrollText, Info, RadioTower, Globe, Clock };
|
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"));
|
|
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("./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",
|
|
@@ -180,6 +193,96 @@ const LABELS = {
|
|
|
180
193
|
beaconCheckFailed: "Keine Verbindung: {detail}",
|
|
181
194
|
beaconRemoveConfirm: "Verbindung zu Beacon trennen? Befunde gehen dann nicht mehr an Beacon; die Tickets dort bleiben.",
|
|
182
195
|
// Two-factor card (SecurityFactors) and license panel (LicensePanel)
|
|
196
|
+
users: {
|
|
197
|
+
loading: "Wird geladen …",
|
|
198
|
+
search: "Benutzer suchen",
|
|
199
|
+
add: "Benutzer hinzufügen",
|
|
200
|
+
addTitle: "Benutzer hinzufügen",
|
|
201
|
+
user: "Benutzer",
|
|
202
|
+
role: "Rolle",
|
|
203
|
+
access: "Zugriff",
|
|
204
|
+
lastLogin: "Letzte Anmeldung",
|
|
205
|
+
you: "Sie",
|
|
206
|
+
password: "Passwort",
|
|
207
|
+
managedByEntra: "Rolle aus Entra",
|
|
208
|
+
emergency: "Notfall-Administrator",
|
|
209
|
+
locked: "Gesperrt",
|
|
210
|
+
pending: "Wartet auf Freigabe",
|
|
211
|
+
approve: "Freischalten",
|
|
212
|
+
merge: "Zusammenführen",
|
|
213
|
+
edit: "Bearbeiten",
|
|
214
|
+
none: "Noch keine Benutzer.",
|
|
215
|
+
noMatch: "Kein Benutzer passt zur Suche.",
|
|
216
|
+
name: "Name",
|
|
217
|
+
email: "E-Mail",
|
|
218
|
+
signIn: "Anmeldung",
|
|
219
|
+
modeMicrosoft: "Mit Microsoft",
|
|
220
|
+
modeMicrosoftHint: "Empfohlen. Das Konto wird bei der ersten Microsoft-Anmeldung mit genau dieser Adresse verknüpft. Keine Freigabe nötig.",
|
|
221
|
+
modePassword: "Mit Passwort",
|
|
222
|
+
modePasswordHint: "Sie erhalten ein Startpasswort. Bei der ersten Anmeldung muss die Person es ändern.",
|
|
223
|
+
modeInvite: "Einladung per E-Mail",
|
|
224
|
+
modeInviteHint: "Die Person erhält einen Link und legt ihr Passwort selbst fest.",
|
|
225
|
+
sendInvite: "Einladung senden",
|
|
226
|
+
cancel: "Abbrechen",
|
|
227
|
+
save: "Speichern",
|
|
228
|
+
saved: "Gespeichert.",
|
|
229
|
+
created: "{email} wurde angelegt.",
|
|
230
|
+
invited: "Einladung an {email} gesendet.",
|
|
231
|
+
editTitle: "{name} bearbeiten",
|
|
232
|
+
fromEntra: "Name und E-Mail kommen aus Microsoft Entra und werden bei jeder Anmeldung nachgeführt.",
|
|
233
|
+
emailIsLogin: "Die E-Mail ist auch der Anmeldename für das Passwort.",
|
|
234
|
+
roleFromEntra: "Die Rolle wird in Entra ID vergeben.",
|
|
235
|
+
ownRole: "Ihre eigene Rolle kann ein anderer Administrator ändern.",
|
|
236
|
+
lockAccount: "Konto sperren (keine Anmeldung möglich)",
|
|
237
|
+
signInSecurity: "Anmeldung und Sicherheit",
|
|
238
|
+
linked: "verknüpft",
|
|
239
|
+
notLinked: "nicht verknüpft",
|
|
240
|
+
set: "gesetzt",
|
|
241
|
+
startPasswordSet: "Startpasswort, noch nicht geändert",
|
|
242
|
+
none_: "keines",
|
|
243
|
+
secondFactor: "Zweiter Faktor",
|
|
244
|
+
on: "aktiv",
|
|
245
|
+
off: "aus",
|
|
246
|
+
newPassword: "Neues Startpasswort",
|
|
247
|
+
givePassword: "Passwort vergeben",
|
|
248
|
+
newPasswordTitle: "Neues Startpasswort für {email}?",
|
|
249
|
+
givePasswordTitle: "Passwort für {email} vergeben?",
|
|
250
|
+
newPasswordText: "Das bisherige Passwort gilt nicht mehr und alle Sitzungen werden beendet. Die Person muss das Startpasswort bei der ersten Anmeldung ändern.",
|
|
251
|
+
newPasswordAction: "Startpasswort erzeugen",
|
|
252
|
+
removePasswordTitle: "Passwort von {email} entfernen?",
|
|
253
|
+
removePasswordText: "Die Person meldet sich danach nur noch mit Microsoft an.",
|
|
254
|
+
removePasswordAction: "Passwort entfernen",
|
|
255
|
+
passwordRemoved: "Passwort entfernt.",
|
|
256
|
+
resetMfaTitle: "Zweiten Faktor von {email} zurücksetzen?",
|
|
257
|
+
resetMfaText: "Authenticator-App, Backup-Codes und Passkeys werden entfernt, zum Beispiel bei einem verlorenen Telefon.",
|
|
258
|
+
resetMfaAction: "Zweiten Faktor zurücksetzen",
|
|
259
|
+
mfaReset: "Zweiter Faktor zurückgesetzt.",
|
|
260
|
+
unlinkTitle: "Microsoft-Verknüpfung von {email} lösen?",
|
|
261
|
+
unlinkText: "Das Konto bleibt bestehen. Bei der nächsten Microsoft-Anmeldung mit derselben Adresse wird es wieder verknüpft.",
|
|
262
|
+
unlinkAction: "Verknüpfung lösen",
|
|
263
|
+
unlinked: "Verknüpfung gelöst.",
|
|
264
|
+
removeTitle: "Konto von {email} löschen?",
|
|
265
|
+
removeText: "Die Person kann sich danach nicht mehr anmelden.",
|
|
266
|
+
removeAction: "Löschen",
|
|
267
|
+
removed: "Konto gelöscht.",
|
|
268
|
+
approved: "Zugang für {email} freigeschaltet.",
|
|
269
|
+
mergeTitle: "{email} zusammenführen",
|
|
270
|
+
mergeText: "Die Microsoft-Anmeldung dieses wartenden Kontos wird auf ein bestehendes Konto übertragen. Das wartende Konto verschwindet, Rolle und Zugriff des bestehenden Kontos bleiben.",
|
|
271
|
+
mergeInto: "Bestehendes Konto",
|
|
272
|
+
choose: "Bitte wählen",
|
|
273
|
+
mergeAction: "Zusammenführen",
|
|
274
|
+
merged: "Microsoft-Anmeldung auf {email} übertragen.",
|
|
275
|
+
createdTitle: "{email} angelegt",
|
|
276
|
+
secretTitle: "Neues Startpasswort",
|
|
277
|
+
secretText: "Geben Sie dieses Startpasswort an {email} weiter. Es wird nur jetzt angezeigt. Bei der ersten Anmeldung muss es geändert werden.",
|
|
278
|
+
copy: "Kopieren",
|
|
279
|
+
copied: "Kopiert.",
|
|
280
|
+
done: "Fertig",
|
|
281
|
+
linkMicrosoft: "Mit Microsoft verknüpfen",
|
|
282
|
+
connectionHint: "Verknüpfen Sie Ihr Microsoft-Konto, dann können Sie sich auch mit Microsoft anmelden.",
|
|
283
|
+
connectionLinked: "Sie können sich mit Microsoft und mit Ihrem Passwort anmelden.",
|
|
284
|
+
connectionOnly: "Sie melden sich mit Microsoft an.",
|
|
285
|
+
},
|
|
183
286
|
mfa: {
|
|
184
287
|
backupTitle: "Ihre Backup-Codes",
|
|
185
288
|
backupHint: "Jeder Code funktioniert einmal, falls Sie Ihr Telefon nicht zur Hand haben. Bewahren Sie die Codes sicher auf, zum Beispiel im Passwort-Manager. Sie werden nur jetzt angezeigt.",
|
|
@@ -301,6 +404,19 @@ const LABELS = {
|
|
|
301
404
|
userMenu: "User menu",
|
|
302
405
|
menu: "Menu",
|
|
303
406
|
version: "Version",
|
|
407
|
+
wizard: {
|
|
408
|
+
step: "Step {n} of {total}",
|
|
409
|
+
back: "Back",
|
|
410
|
+
next: "Continue",
|
|
411
|
+
finish: "Finish setup",
|
|
412
|
+
skip: "Skip",
|
|
413
|
+
close: "Later",
|
|
414
|
+
done: "Let's go",
|
|
415
|
+
successTitle: "Setup complete",
|
|
416
|
+
successText: "Everything is ready. You can change any setting later under Settings.",
|
|
417
|
+
open: "Setup wizard",
|
|
418
|
+
openHint: "Go through the first-time setup questions again.",
|
|
419
|
+
},
|
|
304
420
|
signIn: "Sign in",
|
|
305
421
|
microsoft: "Sign in with Microsoft",
|
|
306
422
|
redirecting: "Redirecting …",
|
|
@@ -462,6 +578,96 @@ const LABELS = {
|
|
|
462
578
|
beaconChecked: "Connection works (Beacon {version}).",
|
|
463
579
|
beaconCheckFailed: "No connection: {detail}",
|
|
464
580
|
beaconRemoveConfirm: "Disconnect from Beacon? Findings no longer go to Beacon; the tickets there stay.",
|
|
581
|
+
users: {
|
|
582
|
+
loading: "Loading …",
|
|
583
|
+
search: "Search users",
|
|
584
|
+
add: "Add user",
|
|
585
|
+
addTitle: "Add user",
|
|
586
|
+
user: "User",
|
|
587
|
+
role: "Role",
|
|
588
|
+
access: "Access",
|
|
589
|
+
lastLogin: "Last sign-in",
|
|
590
|
+
you: "you",
|
|
591
|
+
password: "Password",
|
|
592
|
+
managedByEntra: "Role from Entra",
|
|
593
|
+
emergency: "Emergency administrator",
|
|
594
|
+
locked: "Locked",
|
|
595
|
+
pending: "Waiting for approval",
|
|
596
|
+
approve: "Approve",
|
|
597
|
+
merge: "Merge",
|
|
598
|
+
edit: "Edit",
|
|
599
|
+
none: "No users yet.",
|
|
600
|
+
noMatch: "No user matches the search.",
|
|
601
|
+
name: "Name",
|
|
602
|
+
email: "Email",
|
|
603
|
+
signIn: "Sign-in",
|
|
604
|
+
modeMicrosoft: "With Microsoft",
|
|
605
|
+
modeMicrosoftHint: "Recommended. The account is linked at the first Microsoft sign-in with exactly this address. No approval needed.",
|
|
606
|
+
modePassword: "With a password",
|
|
607
|
+
modePasswordHint: "You get a start password. The person has to change it at the first sign-in.",
|
|
608
|
+
modeInvite: "Invitation by email",
|
|
609
|
+
modeInviteHint: "The person gets a link and sets their own password.",
|
|
610
|
+
sendInvite: "Send invitation",
|
|
611
|
+
cancel: "Cancel",
|
|
612
|
+
save: "Save",
|
|
613
|
+
saved: "Saved.",
|
|
614
|
+
created: "{email} was added.",
|
|
615
|
+
invited: "Invitation sent to {email}.",
|
|
616
|
+
editTitle: "Edit {name}",
|
|
617
|
+
fromEntra: "Name and email come from Microsoft Entra and are updated at every sign-in.",
|
|
618
|
+
emailIsLogin: "The email is also the sign-in name for the password.",
|
|
619
|
+
roleFromEntra: "The role is assigned in Entra ID.",
|
|
620
|
+
ownRole: "Another administrator can change your own role.",
|
|
621
|
+
lockAccount: "Lock account (no sign-in)",
|
|
622
|
+
signInSecurity: "Sign-in and security",
|
|
623
|
+
linked: "linked",
|
|
624
|
+
notLinked: "not linked",
|
|
625
|
+
set: "set",
|
|
626
|
+
startPasswordSet: "start password, not changed yet",
|
|
627
|
+
none_: "none",
|
|
628
|
+
secondFactor: "Second factor",
|
|
629
|
+
on: "on",
|
|
630
|
+
off: "off",
|
|
631
|
+
newPassword: "New start password",
|
|
632
|
+
givePassword: "Give a password",
|
|
633
|
+
newPasswordTitle: "New start password for {email}?",
|
|
634
|
+
givePasswordTitle: "Give {email} a password?",
|
|
635
|
+
newPasswordText: "The old password stops working and every session is signed out. The person has to change the start password at the first sign-in.",
|
|
636
|
+
newPasswordAction: "Create start password",
|
|
637
|
+
removePasswordTitle: "Remove the password of {email}?",
|
|
638
|
+
removePasswordText: "The person then signs in with Microsoft only.",
|
|
639
|
+
removePasswordAction: "Remove password",
|
|
640
|
+
passwordRemoved: "Password removed.",
|
|
641
|
+
resetMfaTitle: "Reset the second factor of {email}?",
|
|
642
|
+
resetMfaText: "Removes the authenticator app, backup codes and passkeys, for example after a lost phone.",
|
|
643
|
+
resetMfaAction: "Reset second factor",
|
|
644
|
+
mfaReset: "Second factor reset.",
|
|
645
|
+
unlinkTitle: "Unlink the Microsoft account of {email}?",
|
|
646
|
+
unlinkText: "The account stays. The next Microsoft sign-in with the same address links it again.",
|
|
647
|
+
unlinkAction: "Unlink",
|
|
648
|
+
unlinked: "Unlinked.",
|
|
649
|
+
removeTitle: "Delete the account of {email}?",
|
|
650
|
+
removeText: "The person can no longer sign in.",
|
|
651
|
+
removeAction: "Delete",
|
|
652
|
+
removed: "Account deleted.",
|
|
653
|
+
approved: "Access approved for {email}.",
|
|
654
|
+
mergeTitle: "Merge {email}",
|
|
655
|
+
mergeText: "The Microsoft sign-in of this waiting account moves to an existing account. The waiting account goes away; role and access of the existing account stay.",
|
|
656
|
+
mergeInto: "Existing account",
|
|
657
|
+
choose: "Please choose",
|
|
658
|
+
mergeAction: "Merge",
|
|
659
|
+
merged: "Microsoft sign-in moved to {email}.",
|
|
660
|
+
createdTitle: "{email} added",
|
|
661
|
+
secretTitle: "New start password",
|
|
662
|
+
secretText: "Give this start password to {email}. It is shown only now. It has to be changed at the first sign-in.",
|
|
663
|
+
copy: "Copy",
|
|
664
|
+
copied: "Copied.",
|
|
665
|
+
done: "Done",
|
|
666
|
+
linkMicrosoft: "Link with Microsoft",
|
|
667
|
+
connectionHint: "Link your Microsoft account to sign in with Microsoft as well.",
|
|
668
|
+
connectionLinked: "You can sign in with Microsoft and with your password.",
|
|
669
|
+
connectionOnly: "You sign in with Microsoft.",
|
|
670
|
+
},
|
|
465
671
|
mfa: {
|
|
466
672
|
backupTitle: "Your backup codes",
|
|
467
673
|
backupHint: "Each code works once, if you do not have your phone. Keep them in a safe place, for example your password manager. They are shown only now.",
|
|
@@ -580,11 +786,13 @@ function labelsFor(lang, overrides) {
|
|
|
580
786
|
sectionHints: Object.assign({}, base.sectionHints, o.sectionHints),
|
|
581
787
|
settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
|
|
582
788
|
mfa: Object.assign({}, base.mfa, o.mfa),
|
|
789
|
+
users: Object.assign({}, base.users, o.users),
|
|
583
790
|
license: Object.assign({}, base.license, o.license),
|
|
584
791
|
edition: Object.assign({}, base.edition, o.edition),
|
|
585
792
|
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
|
586
793
|
passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
|
|
587
794
|
profile_: Object.assign({}, base.profile_, o.profile_),
|
|
795
|
+
wizard: Object.assign({}, base.wizard, o.wizard),
|
|
588
796
|
});
|
|
589
797
|
}
|
|
590
798
|
|
package/react/users.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// User administration under Konten und Rollen -> Benutzer, the same in every Scalio app (scalio-docs ADR 0059,
|
|
3
|
+
// users/B1-B6, contract interfaces/app-users.md). usersApi(api, opts) maps the shared routes for an axios-like
|
|
4
|
+
// client and accepts snake_case and camelCase answers; UserAdmin shows the list, the dialogs to add and edit an
|
|
5
|
+
// account, the actions (start password, second factor, Microsoft link, lock, delete) and approving or merging a
|
|
6
|
+
// waiting Microsoft account. MicrosoftConnection is the profile card to link one's own Microsoft account.
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const Icons = require("./icons");
|
|
10
|
+
const { labelsFor } = require("./labels");
|
|
11
|
+
|
|
12
|
+
function cx() {
|
|
13
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pick(obj, keys) {
|
|
17
|
+
for (const k of keys) if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fill(text, values) {
|
|
22
|
+
return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function errorText(err) {
|
|
26
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
27
|
+
if (typeof d === "string") return d;
|
|
28
|
+
if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
|
|
29
|
+
if (d && typeof d.message === "string") return d.message;
|
|
30
|
+
return (err && err.message) || String(err);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtDate(value, lang) {
|
|
34
|
+
if (!value) return "";
|
|
35
|
+
const d = new Date(value);
|
|
36
|
+
if (isNaN(d.getTime())) return String(value);
|
|
37
|
+
return d.toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "medium", timeStyle: "short" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function initials(u) {
|
|
41
|
+
const src = String((u && (u.name || u.email)) || "?").trim();
|
|
42
|
+
const parts = src.includes("@") ? [src] : src.split(/\s+/);
|
|
43
|
+
return parts.map((p) => p[0] || "").join("").slice(0, 2).toUpperCase() || "?";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// One account in the shape the component uses. raw keeps the app's own fields (for access columns).
|
|
47
|
+
function normalizeUser(d) {
|
|
48
|
+
const active = pick(d, ["active"]);
|
|
49
|
+
return {
|
|
50
|
+
id: String(pick(d, ["id", "_id"]) || ""),
|
|
51
|
+
email: pick(d, ["email"]) || "",
|
|
52
|
+
name: pick(d, ["name"]) || "",
|
|
53
|
+
role: pick(d, ["role"]) || "",
|
|
54
|
+
pending: !!pick(d, ["access_pending", "accessPending", "pendingAccess"]),
|
|
55
|
+
microsoft: !!pick(d, ["microsoft", "entra_linked", "external"]),
|
|
56
|
+
managedBy: pick(d, ["managed_by", "roleManagedBy"]) || "",
|
|
57
|
+
hasPassword: !!pick(d, ["has_password", "hasPassword"]),
|
|
58
|
+
emergency: !!pick(d, ["emergency_admin", "emergencyAdmin"]),
|
|
59
|
+
mfa: !!pick(d, ["mfa_enabled", "mfaEnabled"]),
|
|
60
|
+
active: active === undefined ? true : !!active,
|
|
61
|
+
mustChange: !!pick(d, ["must_change_password", "mustChangePassword"]),
|
|
62
|
+
lastLogin: pick(d, ["last_login", "lastLoginAt", "last_login_at"]) || "",
|
|
63
|
+
createdAt: pick(d, ["created_at", "createdAt", "created"]) || "",
|
|
64
|
+
raw: d,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// api: axios instance with the app's base URL (/api). opts.base: route of the list ("/users", Nexus "/admins").
|
|
69
|
+
function usersApi(api, opts) {
|
|
70
|
+
const base = (opts && opts.base) || "/users";
|
|
71
|
+
const one = (id) => `${base}/${encodeURIComponent(id)}`;
|
|
72
|
+
const secret = (r) => pick(r.data, ["start_password", "startPassword"]) || "";
|
|
73
|
+
return {
|
|
74
|
+
list: () => api.get(base).then((r) => (Array.isArray(r.data) ? r.data : r.data.users || []).map(normalizeUser)),
|
|
75
|
+
create: (body) => api.post(base, body).then((r) => ({ user: normalizeUser(r.data.user || r.data), startPassword: secret(r) })),
|
|
76
|
+
update: (id, patch) => api.patch(one(id), patch).then((r) => normalizeUser(r.data.user || r.data)),
|
|
77
|
+
approve: (id, body) => api.post(`${one(id)}/approve`, body).then(() => true),
|
|
78
|
+
merge: (id, into) => api.post(`${one(id)}/merge`, { into }).then(() => true),
|
|
79
|
+
newPassword: (id) => api.post(`${one(id)}/password`, {}).then(secret),
|
|
80
|
+
removePassword: (id) => api.delete(`${one(id)}/password`).then(() => true),
|
|
81
|
+
resetMfa: (id) => api.post(`${one(id)}/reset-mfa`, {}).then(() => true),
|
|
82
|
+
unlinkMicrosoft: (id) => api.delete(`${one(id)}/microsoft`).then(() => true),
|
|
83
|
+
setEmergency: (id, value) => api.put(`${one(id)}/emergency`, { value }).then(() => true),
|
|
84
|
+
remove: (id) => api.delete(one(id)).then(() => true),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function ask(confirm, title, text, action, danger) {
|
|
89
|
+
if (confirm) return !!(await confirm({ title, text, action, danger: danger !== false }));
|
|
90
|
+
return typeof window !== "undefined" && window.confirm ? window.confirm(title) : true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Field({ id, label, hint, children }) {
|
|
94
|
+
return h(
|
|
95
|
+
"div",
|
|
96
|
+
{ className: "scalio-users-field" },
|
|
97
|
+
h("label", { className: "scalio-field-label", htmlFor: id }, label),
|
|
98
|
+
children,
|
|
99
|
+
hint ? h("p", { className: "scalio-users-hint" }, hint) : null
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Dialog({ title, onClose, children, testId }) {
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
const key = (e) => { if (e.key === "Escape") onClose(); };
|
|
106
|
+
document.addEventListener("keydown", key);
|
|
107
|
+
return () => document.removeEventListener("keydown", key);
|
|
108
|
+
}, [onClose]);
|
|
109
|
+
return h(
|
|
110
|
+
"div",
|
|
111
|
+
{ className: "scalio-dialog-root" },
|
|
112
|
+
h("div", { className: "scalio-dialog-backdrop", onClick: onClose }),
|
|
113
|
+
h(
|
|
114
|
+
"div",
|
|
115
|
+
{ className: "scalio-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, "data-testid": testId },
|
|
116
|
+
h(
|
|
117
|
+
"div",
|
|
118
|
+
{ className: "scalio-dialog-head" },
|
|
119
|
+
h("div", { className: "scalio-dialog-title" }, title),
|
|
120
|
+
h("button", { type: "button", className: "scalio-btn is-ghost scalio-btn-icon", onClick: onClose, "aria-label": "×", "data-testid": "dialog-close" },
|
|
121
|
+
h(Icons.X, { className: "scalio-icon" }))
|
|
122
|
+
),
|
|
123
|
+
children
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Props:
|
|
129
|
+
// client (usersApi), me (id of the signed-in person), lang, labels, notify(message, tone), confirm({title, text, action, danger}),
|
|
130
|
+
// formatError(err), roles [{value, label}], defaultRole, adminRole ("admin"; emergency only for it),
|
|
131
|
+
// signInModes (subset of ["microsoft", "password", "invite"], first is the default), microsoftEnabled,
|
|
132
|
+
// canLock (accounts can be locked), accessColumn {label, render(user)}, accessField(value, onChange, role, user) -> element
|
|
133
|
+
// (create, edit and approve; value is passed as `access` in the body), onChange() after every change, testId.
|
|
134
|
+
function UserAdmin(props) {
|
|
135
|
+
const { client, me, lang, notify, confirm } = props;
|
|
136
|
+
const L = labelsFor(lang, props.labels).users;
|
|
137
|
+
const fmtErr = props.formatError || errorText;
|
|
138
|
+
const roles = props.roles || [];
|
|
139
|
+
const roleLabel = (r) => (roles.find((x) => x.value === r) || { label: r }).label;
|
|
140
|
+
const modes = (props.signInModes || ["microsoft", "password"]).filter((m) => m !== "microsoft" || props.microsoftEnabled !== false);
|
|
141
|
+
const adminRole = props.adminRole || "admin";
|
|
142
|
+
const [rows, setRows] = React.useState(null);
|
|
143
|
+
const [dialog, setDialog] = React.useState(null); // {mode: create|edit|merge|secret, user, ...}
|
|
144
|
+
const [form, setForm] = React.useState({});
|
|
145
|
+
const [pendingRole, setPendingRole] = React.useState({});
|
|
146
|
+
const [pendingAccess, setPendingAccess] = React.useState({});
|
|
147
|
+
const [error, setError] = React.useState("");
|
|
148
|
+
const [busy, setBusy] = React.useState(false);
|
|
149
|
+
const [query, setQuery] = React.useState("");
|
|
150
|
+
|
|
151
|
+
const say = (msg, tone) => { if (notify) notify(msg, tone || "success"); };
|
|
152
|
+
const load = React.useCallback(() => client.list().then(setRows).catch((e) => { setRows([]); say(fmtErr(e), "error"); }), [client]);
|
|
153
|
+
React.useEffect(() => { load(); }, [load]);
|
|
154
|
+
const changed = () => { load(); if (props.onChange) props.onChange(); };
|
|
155
|
+
const close = () => { setDialog(null); setError(""); setBusy(false); };
|
|
156
|
+
|
|
157
|
+
const run = async (fn, okMsg, after) => {
|
|
158
|
+
setError("");
|
|
159
|
+
setBusy(true);
|
|
160
|
+
try {
|
|
161
|
+
const res = await fn();
|
|
162
|
+
if (okMsg) say(okMsg);
|
|
163
|
+
changed();
|
|
164
|
+
if (after) after(res);
|
|
165
|
+
else close();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (!(err && err.licenseShown)) {
|
|
168
|
+
if (dialog) setError(fmtErr(err));
|
|
169
|
+
else say(fmtErr(err), "error");
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
setBusy(false);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const openCreate = () => {
|
|
177
|
+
setError("");
|
|
178
|
+
setForm({ name: "", email: "", role: props.defaultRole || (roles[0] && roles[0].value) || "", signIn: modes[0], access: null });
|
|
179
|
+
setDialog({ mode: "create" });
|
|
180
|
+
};
|
|
181
|
+
const openEdit = (u) => {
|
|
182
|
+
setError("");
|
|
183
|
+
setForm({ name: u.name, email: u.email, role: u.role, active: u.active, access: null });
|
|
184
|
+
setDialog({ mode: "edit", user: u });
|
|
185
|
+
};
|
|
186
|
+
const showSecret = (u, password, created) => setDialog({ mode: "secret", user: u, password, created });
|
|
187
|
+
|
|
188
|
+
const create = (e) => {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
const body = { name: form.name.trim(), email: form.email.trim(), role: form.role, sign_in: form.signIn };
|
|
191
|
+
if (form.access !== null && form.access !== undefined) body.access = form.access;
|
|
192
|
+
run(() => client.create(body), null, (res) => {
|
|
193
|
+
if (res.startPassword) showSecret(res.user, res.startPassword, true);
|
|
194
|
+
else {
|
|
195
|
+
say(form.signIn === "invite" ? fill(L.invited, { email: body.email }) : fill(L.created, { email: body.email }));
|
|
196
|
+
close();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const save = (e) => {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
const u = dialog.user;
|
|
204
|
+
const patch = {};
|
|
205
|
+
if (!u.microsoft && form.name.trim() !== u.name) patch.name = form.name.trim();
|
|
206
|
+
if (!u.microsoft && form.email.trim().toLowerCase() !== u.email.toLowerCase()) patch.email = form.email.trim();
|
|
207
|
+
if (form.role !== u.role) patch.role = form.role;
|
|
208
|
+
if (props.canLock && form.active !== u.active) patch.active = form.active;
|
|
209
|
+
if (form.access !== null && form.access !== undefined) patch.access = form.access;
|
|
210
|
+
if (!Object.keys(patch).length) return close();
|
|
211
|
+
run(() => client.update(u.id, patch), L.saved);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const action = async (u, kind) => {
|
|
215
|
+
const t = { password: [u.hasPassword ? L.newPasswordTitle : L.givePasswordTitle, L.newPasswordText, L.newPasswordAction, false],
|
|
216
|
+
removePassword: [L.removePasswordTitle, L.removePasswordText, L.removePasswordAction],
|
|
217
|
+
mfa: [L.resetMfaTitle, L.resetMfaText, L.resetMfaAction],
|
|
218
|
+
unlink: [L.unlinkTitle, L.unlinkText, L.unlinkAction],
|
|
219
|
+
remove: [L.removeTitle, L.removeText, L.removeAction] }[kind];
|
|
220
|
+
if (!(await ask(confirm, fill(t[0], { email: u.email }), t[1], t[2], t[3]))) return;
|
|
221
|
+
if (kind === "password") return run(() => client.newPassword(u.id), null, (pw) => showSecret(u, pw, false));
|
|
222
|
+
if (kind === "removePassword") return run(() => client.removePassword(u.id), L.passwordRemoved);
|
|
223
|
+
if (kind === "mfa") return run(() => client.resetMfa(u.id), L.mfaReset);
|
|
224
|
+
if (kind === "unlink") return run(() => client.unlinkMicrosoft(u.id), L.unlinked);
|
|
225
|
+
return run(() => client.remove(u.id), L.removed);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const approve = (u) => {
|
|
229
|
+
const body = { role: pendingRole[u.id] || props.defaultRole || (roles[0] && roles[0].value) };
|
|
230
|
+
if (pendingAccess[u.id] !== undefined) body.access = pendingAccess[u.id];
|
|
231
|
+
run(() => client.approve(u.id, body), fill(L.approved, { email: u.email }));
|
|
232
|
+
};
|
|
233
|
+
const openMerge = (u) => { setError(""); setForm({ into: "" }); setDialog({ mode: "merge", user: u }); };
|
|
234
|
+
const merge = (e) => {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
const target = (rows || []).find((r) => r.id === form.into);
|
|
237
|
+
run(() => client.merge(dialog.user.id, form.into), fill(L.merged, { email: target ? target.email : "" }));
|
|
238
|
+
};
|
|
239
|
+
const setEmergency = (u, value) => run(() => client.setEmergency(u.id, value), null, () => {
|
|
240
|
+
setDialog({ mode: "edit", user: Object.assign({}, u, { emergency: value }) });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (rows === null) return h("div", { className: "scalio-users-loading" }, L.loading);
|
|
244
|
+
|
|
245
|
+
const q = query.trim().toLowerCase();
|
|
246
|
+
const shown = rows.filter((u) => !q || `${u.name} ${u.email}`.toLowerCase().includes(q));
|
|
247
|
+
const badge = (tone, text, id) => h("span", { className: cx("scalio-badge", `is-${tone}`), "data-testid": id }, text);
|
|
248
|
+
const roleSelect = (value, onChange, attrs) =>
|
|
249
|
+
h("select", Object.assign({ className: "scalio-input scalio-users-select", value, onChange: (e) => onChange(e.target.value) }, attrs),
|
|
250
|
+
roles.map((r) => h("option", { key: r.value, value: r.value }, r.label)));
|
|
251
|
+
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);
|
|
253
|
+
|
|
254
|
+
const header = h(
|
|
255
|
+
"div",
|
|
256
|
+
{ className: "scalio-users-bar" },
|
|
257
|
+
h("input", { className: "scalio-input scalio-users-search", type: "search", value: query, placeholder: L.search, "aria-label": L.search,
|
|
258
|
+
onChange: (e) => setQuery(e.target.value), "data-testid": "users-search" }),
|
|
259
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: openCreate, "data-testid": "user-add" },
|
|
260
|
+
h(Icons.Plus, { className: "scalio-icon" }), L.add)
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const table = shown.length
|
|
264
|
+
? h(
|
|
265
|
+
"div",
|
|
266
|
+
{ className: "scalio-users-table-wrap" },
|
|
267
|
+
h(
|
|
268
|
+
"table",
|
|
269
|
+
{ className: "scalio-users-table", "data-testid": "users-table" },
|
|
270
|
+
h("thead", null, h("tr", null,
|
|
271
|
+
h("th", null, L.user), h("th", null, L.role),
|
|
272
|
+
props.accessColumn ? h("th", { className: "scalio-users-hide-sm" }, props.accessColumn.label) : null,
|
|
273
|
+
h("th", { className: "scalio-users-hide-sm" }, L.lastLogin), h("th", null))),
|
|
274
|
+
h("tbody", null, shown.map((u) => h(
|
|
275
|
+
"tr",
|
|
276
|
+
{ key: u.id, "data-testid": `user-row-${u.email}`, className: cx(u.pending && "is-pending", !u.active && "is-locked") },
|
|
277
|
+
h("td", null, h(
|
|
278
|
+
"div",
|
|
279
|
+
{ className: "scalio-users-who" },
|
|
280
|
+
h("span", { className: "scalio-users-avatar", "aria-hidden": "true" }, initials(u)),
|
|
281
|
+
h("div", { className: "scalio-users-who-text" },
|
|
282
|
+
h("div", { className: "scalio-users-name" },
|
|
283
|
+
u.name || u.email,
|
|
284
|
+
u.id === me ? h("span", { className: "scalio-users-you" }, L.you) : null,
|
|
285
|
+
u.microsoft ? badge("running", "Microsoft", `user-ms-${u.email}`) : null,
|
|
286
|
+
u.hasPassword ? badge("neutral", L.password, `user-pw-${u.email}`) : null,
|
|
287
|
+
u.managedBy === "entra" ? badge("running", L.managedByEntra, `user-entra-${u.email}`) : null,
|
|
288
|
+
u.emergency && u.role === adminRole ? badge("error", L.emergency, `user-emergency-${u.email}`) : null,
|
|
289
|
+
!u.active && !u.pending ? badge("neutral", L.locked, `user-locked-${u.email}`) : null,
|
|
290
|
+
u.pending ? badge("warn", L.pending, `user-pending-${u.email}`) : null),
|
|
291
|
+
h("div", { className: "scalio-users-mail" }, u.email))
|
|
292
|
+
)),
|
|
293
|
+
h("td", null, u.pending && u.managedBy !== "entra"
|
|
294
|
+
? roleSelect(pendingRole[u.id] || props.defaultRole || (roles[0] && roles[0].value), (v) => setPendingRole(Object.assign({}, pendingRole, { [u.id]: v })),
|
|
295
|
+
{ "aria-label": L.role, "data-testid": `user-pending-role-${u.email}` })
|
|
296
|
+
: h("span", { className: "scalio-users-role" }, roleLabel(u.role))),
|
|
297
|
+
props.accessColumn ? h("td", { className: "scalio-users-hide-sm" }, u.pending && props.accessField
|
|
298
|
+
? props.accessField(pendingAccess[u.id], (v) => setPendingAccess(Object.assign({}, pendingAccess, { [u.id]: v })), pendingRole[u.id] || props.defaultRole, u)
|
|
299
|
+
: props.accessColumn.render(u)) : null,
|
|
300
|
+
h("td", { className: "scalio-users-hide-sm scalio-users-date" }, u.lastLogin ? fmtDate(u.lastLogin, lang) : "–"),
|
|
301
|
+
h("td", { className: "scalio-users-actions" }, u.pending
|
|
302
|
+
? h(React.Fragment, null,
|
|
303
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: () => approve(u), disabled: busy, "data-testid": `user-approve-${u.email}` }, L.approve),
|
|
304
|
+
mergeTargets(u).length ? h("button", { type: "button", className: "scalio-btn", onClick: () => openMerge(u), "data-testid": `user-merge-${u.email}` }, L.merge) : null,
|
|
305
|
+
h("button", { type: "button", className: "scalio-btn is-ghost is-danger scalio-btn-icon", onClick: () => action(u, "remove"), title: L.removeAction,
|
|
306
|
+
"aria-label": L.removeAction, "data-testid": `user-remove-${u.email}` }, h(Icons.Trash, { className: "scalio-icon" })))
|
|
307
|
+
: h("button", { type: "button", className: "scalio-btn", onClick: () => openEdit(u), "data-testid": `user-edit-${u.email}` },
|
|
308
|
+
h(Icons.Pencil, { className: "scalio-icon" }), h("span", { className: "scalio-hide-sm" }, L.edit)))
|
|
309
|
+
)))
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
: h("p", { className: "scalio-users-empty", "data-testid": "users-empty" }, q ? L.noMatch : L.none);
|
|
313
|
+
|
|
314
|
+
let body = null;
|
|
315
|
+
if (dialog && dialog.mode === "create") {
|
|
316
|
+
const modeHint = { microsoft: L.modeMicrosoftHint, password: L.modePasswordHint, invite: L.modeInviteHint };
|
|
317
|
+
const modeLabel = { microsoft: L.modeMicrosoft, password: L.modePassword, invite: L.modeInvite };
|
|
318
|
+
body = h(Dialog, { title: L.addTitle, onClose: close, testId: "user-create-dialog" }, h(
|
|
319
|
+
"form",
|
|
320
|
+
{ className: "scalio-dialog-body", onSubmit: create },
|
|
321
|
+
h(Field, { id: "user-name", label: L.name }, h("input", { id: "user-name", className: "scalio-input", value: form.name, required: true, maxLength: 120,
|
|
322
|
+
autoFocus: true, "data-testid": "user-name-input", onChange: (e) => setForm(Object.assign({}, form, { name: e.target.value })) })),
|
|
323
|
+
h(Field, { id: "user-email", label: L.email }, h("input", { id: "user-email", type: "email", className: "scalio-input", value: form.email, required: true,
|
|
324
|
+
"data-testid": "user-email-input", onChange: (e) => setForm(Object.assign({}, form, { email: e.target.value })) })),
|
|
325
|
+
h(Field, { id: "user-role", label: L.role }, roleSelect(form.role, (v) => setForm(Object.assign({}, form, { role: v })), { id: "user-role", "data-testid": "user-role-select" })),
|
|
326
|
+
props.accessField ? h(Field, { id: "user-access", label: (props.accessColumn && props.accessColumn.label) || L.access },
|
|
327
|
+
props.accessField(form.access, (v) => setForm(Object.assign({}, form, { access: v })), form.role, null)) : null,
|
|
328
|
+
modes.length > 1 || modes[0] !== "password"
|
|
329
|
+
? h("fieldset", { className: "scalio-users-modes", "data-testid": "user-signin-modes" },
|
|
330
|
+
h("legend", { className: "scalio-field-label" }, L.signIn),
|
|
331
|
+
modes.map((m) => h("label", { key: m, className: cx("scalio-users-mode", form.signIn === m && "is-active") },
|
|
332
|
+
h("input", { type: "radio", name: "sign-in", value: m, checked: form.signIn === m, "data-testid": `user-signin-${m}`,
|
|
333
|
+
onChange: () => setForm(Object.assign({}, form, { signIn: m })) }),
|
|
334
|
+
h("span", null, h("span", { className: "scalio-users-mode-label" }, modeLabel[m]), h("span", { className: "scalio-users-hint" }, modeHint[m])))))
|
|
335
|
+
: h("p", { className: "scalio-users-hint" }, L.modePasswordHint),
|
|
336
|
+
errorBox,
|
|
337
|
+
h("div", { className: "scalio-dialog-actions" },
|
|
338
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: close }, L.cancel),
|
|
339
|
+
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy, "data-testid": "user-create-submit" }, form.signIn === "invite" ? L.sendInvite : L.add))
|
|
340
|
+
));
|
|
341
|
+
} else if (dialog && dialog.mode === "edit") {
|
|
342
|
+
const u = dialog.user;
|
|
343
|
+
const self = u.id === me;
|
|
344
|
+
const roleLocked = u.managedBy === "entra" || self;
|
|
345
|
+
const line = (label, value, id) => h("div", { className: "scalio-users-status-row", "data-testid": id }, h("span", null, label), h("span", null, value));
|
|
346
|
+
body = h(Dialog, { title: fill(L.editTitle, { name: u.name || u.email }), onClose: close, testId: "user-edit-dialog" }, h(
|
|
347
|
+
"form",
|
|
348
|
+
{ className: "scalio-dialog-body", onSubmit: save },
|
|
349
|
+
h(Field, { id: "edit-name", label: L.name, hint: u.microsoft ? L.fromEntra : null },
|
|
350
|
+
h("input", { id: "edit-name", className: "scalio-input", value: form.name, required: true, maxLength: 120, disabled: u.microsoft,
|
|
351
|
+
"data-testid": "user-name-input", onChange: (e) => setForm(Object.assign({}, form, { name: e.target.value })) })),
|
|
352
|
+
h(Field, { id: "edit-email", label: L.email, hint: u.microsoft ? null : u.hasPassword ? L.emailIsLogin : null },
|
|
353
|
+
h("input", { id: "edit-email", type: "email", className: "scalio-input", value: form.email, required: true, disabled: u.microsoft,
|
|
354
|
+
"data-testid": "user-email-input", onChange: (e) => setForm(Object.assign({}, form, { email: e.target.value })) })),
|
|
355
|
+
h(Field, { id: "edit-role", label: L.role, hint: u.managedBy === "entra" ? L.roleFromEntra : self ? L.ownRole : null },
|
|
356
|
+
roleSelect(form.role, (v) => setForm(Object.assign({}, form, { role: v })), { id: "edit-role", disabled: roleLocked, "data-testid": "user-role-select" })),
|
|
357
|
+
props.accessField ? h(Field, { id: "edit-access", label: (props.accessColumn && props.accessColumn.label) || L.access },
|
|
358
|
+
props.accessField(form.access === null ? undefined : form.access, (v) => setForm(Object.assign({}, form, { access: v })), form.role, u)) : null,
|
|
359
|
+
props.canLock && !self
|
|
360
|
+
? h("label", { className: "scalio-users-check" }, h("input", { type: "checkbox", checked: !form.active, "data-testid": "user-locked-toggle",
|
|
361
|
+
onChange: (e) => setForm(Object.assign({}, form, { active: !e.target.checked })) }), L.lockAccount)
|
|
362
|
+
: null,
|
|
363
|
+
u.role === adminRole && u.hasPassword
|
|
364
|
+
? h("label", { className: "scalio-users-check" }, h("input", { type: "checkbox", checked: u.emergency, disabled: busy, "data-testid": "user-emergency-toggle",
|
|
365
|
+
onChange: (e) => setEmergency(u, e.target.checked) }), L.emergency)
|
|
366
|
+
: null,
|
|
367
|
+
h("div", { className: "scalio-users-status" },
|
|
368
|
+
h("div", { className: "scalio-users-status-title" }, L.signInSecurity),
|
|
369
|
+
line("Microsoft", u.microsoft ? L.linked : L.notLinked, "user-status-ms"),
|
|
370
|
+
line(L.password, u.hasPassword ? (u.mustChange ? L.startPasswordSet : L.set) : L.none_, "user-status-pw"),
|
|
371
|
+
line(L.secondFactor, u.mfa ? L.on : L.off, "user-status-mfa"),
|
|
372
|
+
u.lastLogin ? line(L.lastLogin, fmtDate(u.lastLogin, lang), "user-status-last") : null,
|
|
373
|
+
h("div", { className: "scalio-users-status-actions" },
|
|
374
|
+
!self ? h("button", { type: "button", className: "scalio-btn", onClick: () => action(u, "password"), disabled: busy, "data-testid": "user-new-password" },
|
|
375
|
+
h(Icons.KeyRound, { className: "scalio-icon" }), u.hasPassword ? L.newPassword : L.givePassword) : null,
|
|
376
|
+
!self && u.hasPassword && u.microsoft ? h("button", { type: "button", className: "scalio-btn", onClick: () => action(u, "removePassword"), disabled: busy,
|
|
377
|
+
"data-testid": "user-remove-password" }, L.removePasswordAction) : null,
|
|
378
|
+
!self && u.mfa ? h("button", { type: "button", className: "scalio-btn", onClick: () => action(u, "mfa"), disabled: busy, "data-testid": "user-reset-mfa" },
|
|
379
|
+
h(Icons.Smartphone, { className: "scalio-icon" }), L.resetMfaAction) : null,
|
|
380
|
+
!self && u.microsoft ? h("button", { type: "button", className: "scalio-btn", onClick: () => action(u, "unlink"), disabled: busy, "data-testid": "user-unlink" },
|
|
381
|
+
h(Icons.Link2, { className: "scalio-icon" }), L.unlinkAction) : null)),
|
|
382
|
+
errorBox,
|
|
383
|
+
h("div", { className: "scalio-dialog-actions" },
|
|
384
|
+
!self ? h("button", { type: "button", className: "scalio-btn is-ghost is-danger scalio-dialog-left", onClick: () => action(u, "remove"), disabled: busy,
|
|
385
|
+
"data-testid": "user-remove" }, h(Icons.Trash, { className: "scalio-icon" }), L.removeAction) : null,
|
|
386
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: close }, L.cancel),
|
|
387
|
+
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy, "data-testid": "user-save" }, L.save))
|
|
388
|
+
));
|
|
389
|
+
} else if (dialog && dialog.mode === "merge") {
|
|
390
|
+
const u = dialog.user;
|
|
391
|
+
body = h(Dialog, { title: fill(L.mergeTitle, { email: u.email }), onClose: close, testId: "user-merge-dialog" }, h(
|
|
392
|
+
"form",
|
|
393
|
+
{ className: "scalio-dialog-body", onSubmit: merge },
|
|
394
|
+
h("p", { className: "scalio-users-hint" }, L.mergeText),
|
|
395
|
+
h(Field, { id: "merge-into", label: L.mergeInto },
|
|
396
|
+
h("select", { id: "merge-into", className: "scalio-input", value: form.into, required: true, "data-testid": "user-merge-select",
|
|
397
|
+
onChange: (e) => setForm({ into: e.target.value }) },
|
|
398
|
+
h("option", { value: "" }, L.choose),
|
|
399
|
+
mergeTargets(u).map((r) => h("option", { key: r.id, value: r.id }, `${r.name || r.email} · ${r.email}`)))),
|
|
400
|
+
errorBox,
|
|
401
|
+
h("div", { className: "scalio-dialog-actions" },
|
|
402
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: close }, L.cancel),
|
|
403
|
+
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || !form.into, "data-testid": "user-merge-submit" }, L.mergeAction))
|
|
404
|
+
));
|
|
405
|
+
} else if (dialog && dialog.mode === "secret") {
|
|
406
|
+
const copy = () => {
|
|
407
|
+
const c = typeof navigator !== "undefined" && navigator.clipboard;
|
|
408
|
+
if (c) c.writeText(dialog.password).then(() => say(L.copied), () => null);
|
|
409
|
+
};
|
|
410
|
+
body = h(Dialog, { title: dialog.created ? fill(L.createdTitle, { email: dialog.user.email }) : L.secretTitle, onClose: close, testId: "user-secret-dialog" },
|
|
411
|
+
h("div", { className: "scalio-dialog-body" },
|
|
412
|
+
h("p", { className: "scalio-users-hint" }, fill(L.secretText, { email: dialog.user.email })),
|
|
413
|
+
h("div", { className: "scalio-users-secret", "data-testid": "user-start-password" }, dialog.password),
|
|
414
|
+
h("div", { className: "scalio-dialog-actions" },
|
|
415
|
+
h("button", { type: "button", className: "scalio-btn", onClick: copy, "data-testid": "user-copy-password" }, h(Icons.Copy, { className: "scalio-icon" }), L.copy),
|
|
416
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: close, "data-testid": "user-secret-done" }, L.done))));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return h("div", { className: cx("scalio-users", props.className), "data-testid": props.testId || "user-admin" }, header, table, body);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Profile card: link one's own Microsoft account (sign in once with Microsoft) or remove the link.
|
|
423
|
+
// Props: linked, email (of the Microsoft account, optional), canUnlink (has a password), onLink(), onUnlink(), lang, labels, disabled.
|
|
424
|
+
function MicrosoftConnection(props) {
|
|
425
|
+
const L = labelsFor(props.lang, props.labels).users;
|
|
426
|
+
const [busy, setBusy] = React.useState(false);
|
|
427
|
+
const go = (fn) => async () => {
|
|
428
|
+
setBusy(true);
|
|
429
|
+
try { await fn(); } finally { setBusy(false); }
|
|
430
|
+
};
|
|
431
|
+
return h(
|
|
432
|
+
"div",
|
|
433
|
+
{ className: "scalio-users-connection", "data-testid": props.testId || "microsoft-connection" },
|
|
434
|
+
h("div", { className: "scalio-users-connection-text" },
|
|
435
|
+
h("div", { className: "scalio-users-name" }, "Microsoft", props.linked ? h("span", { className: "scalio-badge is-ok" }, L.linked) : null),
|
|
436
|
+
h("p", { className: "scalio-users-hint" }, props.linked ? (props.canUnlink ? L.connectionLinked : L.connectionOnly) : L.connectionHint)),
|
|
437
|
+
props.linked
|
|
438
|
+
? props.canUnlink && props.onUnlink
|
|
439
|
+
? h("button", { type: "button", className: "scalio-btn", disabled: busy || props.disabled, onClick: go(props.onUnlink), "data-testid": "ms-unlink" }, L.unlinkAction)
|
|
440
|
+
: null
|
|
441
|
+
: h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || props.disabled, onClick: go(props.onLink), "data-testid": "ms-link" },
|
|
442
|
+
h(Icons.Link2, { className: "scalio-icon" }), L.linkMicrosoft)
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = { UserAdmin, usersApi, normalizeUser, MicrosoftConnection };
|
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 };
|