@scalio-cloud/ui 0.22.0 → 0.23.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 +13 -0
- package/README.md +18 -0
- package/nav.css +69 -0
- package/package.json +1 -1
- package/react/icons.js +3 -1
- package/react/index.js +1 -1
- package/react/labels.js +181 -0
- package/react/users.js +446 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
Benutzerverwaltung in allen Apps angleichen (scalio-docs ADR 0059, `users/B1`–`B6`, contract `interfaces/app-users.md`).
|
|
5
|
+
- New `UserAdmin` for Konten und Rollen → Benutzer, the same in every app: list with search and badges (Microsoft,
|
|
6
|
+
Passwort, Rolle aus Entra, Notfall-Administrator, Gesperrt, Wartet auf Freigabe), «Benutzer hinzufügen» (Microsoft,
|
|
7
|
+
Passwort with a start password shown once, or invitation by e-mail), «Bearbeiten» (name and e-mail only for accounts
|
|
8
|
+
without Microsoft, role, access, lock, emergency administrator) with the actions new start password, remove password,
|
|
9
|
+
reset second factor, unlink Microsoft and delete; waiting accounts: role, access, «Freischalten», «Zusammenführen».
|
|
10
|
+
The app passes roles and an optional access column/field (customers, organisation).
|
|
11
|
+
- `usersApi(api, {base})` for the shared routes (`/users`, Nexus `/admins`), reads snake_case and camelCase.
|
|
12
|
+
- `MicrosoftConnection` for the profile section «Verknüpfungen» (link or unlink one's own Microsoft account).
|
|
13
|
+
- New icons `Pencil`, `Link2`; modal dialog styles (`.scalio-dialog*`). No token changes, nothing changes until an app
|
|
14
|
+
uses the new components.
|
|
15
|
+
|
|
3
16
|
## 0.22.0
|
|
4
17
|
Login, Navigation und Banner vereinheitlichen (scalio-docs UISPEC section 3, ADR 0042 Nachtrag 2026-11-01).
|
|
5
18
|
- **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,24 @@ 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
|
+
|
|
221
239
|
## Two-factor card and license panel (React)
|
|
222
240
|
|
|
223
241
|
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,75 @@
|
|
|
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
|
+
|
|
398
467
|
/* License panel (LicensePanel) */
|
|
399
468
|
.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
469
|
.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.23.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"));
|
|
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
|
@@ -180,6 +180,96 @@ const LABELS = {
|
|
|
180
180
|
beaconCheckFailed: "Keine Verbindung: {detail}",
|
|
181
181
|
beaconRemoveConfirm: "Verbindung zu Beacon trennen? Befunde gehen dann nicht mehr an Beacon; die Tickets dort bleiben.",
|
|
182
182
|
// Two-factor card (SecurityFactors) and license panel (LicensePanel)
|
|
183
|
+
users: {
|
|
184
|
+
loading: "Wird geladen …",
|
|
185
|
+
search: "Benutzer suchen",
|
|
186
|
+
add: "Benutzer hinzufügen",
|
|
187
|
+
addTitle: "Benutzer hinzufügen",
|
|
188
|
+
user: "Benutzer",
|
|
189
|
+
role: "Rolle",
|
|
190
|
+
access: "Zugriff",
|
|
191
|
+
lastLogin: "Letzte Anmeldung",
|
|
192
|
+
you: "Sie",
|
|
193
|
+
password: "Passwort",
|
|
194
|
+
managedByEntra: "Rolle aus Entra",
|
|
195
|
+
emergency: "Notfall-Administrator",
|
|
196
|
+
locked: "Gesperrt",
|
|
197
|
+
pending: "Wartet auf Freigabe",
|
|
198
|
+
approve: "Freischalten",
|
|
199
|
+
merge: "Zusammenführen",
|
|
200
|
+
edit: "Bearbeiten",
|
|
201
|
+
none: "Noch keine Benutzer.",
|
|
202
|
+
noMatch: "Kein Benutzer passt zur Suche.",
|
|
203
|
+
name: "Name",
|
|
204
|
+
email: "E-Mail",
|
|
205
|
+
signIn: "Anmeldung",
|
|
206
|
+
modeMicrosoft: "Mit Microsoft",
|
|
207
|
+
modeMicrosoftHint: "Empfohlen. Das Konto wird bei der ersten Microsoft-Anmeldung mit genau dieser Adresse verknüpft. Keine Freigabe nötig.",
|
|
208
|
+
modePassword: "Mit Passwort",
|
|
209
|
+
modePasswordHint: "Sie erhalten ein Startpasswort. Bei der ersten Anmeldung muss die Person es ändern.",
|
|
210
|
+
modeInvite: "Einladung per E-Mail",
|
|
211
|
+
modeInviteHint: "Die Person erhält einen Link und legt ihr Passwort selbst fest.",
|
|
212
|
+
sendInvite: "Einladung senden",
|
|
213
|
+
cancel: "Abbrechen",
|
|
214
|
+
save: "Speichern",
|
|
215
|
+
saved: "Gespeichert.",
|
|
216
|
+
created: "{email} wurde angelegt.",
|
|
217
|
+
invited: "Einladung an {email} gesendet.",
|
|
218
|
+
editTitle: "{name} bearbeiten",
|
|
219
|
+
fromEntra: "Name und E-Mail kommen aus Microsoft Entra und werden bei jeder Anmeldung nachgeführt.",
|
|
220
|
+
emailIsLogin: "Die E-Mail ist auch der Anmeldename für das Passwort.",
|
|
221
|
+
roleFromEntra: "Die Rolle wird in Entra ID vergeben.",
|
|
222
|
+
ownRole: "Ihre eigene Rolle kann ein anderer Administrator ändern.",
|
|
223
|
+
lockAccount: "Konto sperren (keine Anmeldung möglich)",
|
|
224
|
+
signInSecurity: "Anmeldung und Sicherheit",
|
|
225
|
+
linked: "verknüpft",
|
|
226
|
+
notLinked: "nicht verknüpft",
|
|
227
|
+
set: "gesetzt",
|
|
228
|
+
startPasswordSet: "Startpasswort, noch nicht geändert",
|
|
229
|
+
none_: "keines",
|
|
230
|
+
secondFactor: "Zweiter Faktor",
|
|
231
|
+
on: "aktiv",
|
|
232
|
+
off: "aus",
|
|
233
|
+
newPassword: "Neues Startpasswort",
|
|
234
|
+
givePassword: "Passwort vergeben",
|
|
235
|
+
newPasswordTitle: "Neues Startpasswort für {email}?",
|
|
236
|
+
givePasswordTitle: "Passwort für {email} vergeben?",
|
|
237
|
+
newPasswordText: "Das bisherige Passwort gilt nicht mehr und alle Sitzungen werden beendet. Die Person muss das Startpasswort bei der ersten Anmeldung ändern.",
|
|
238
|
+
newPasswordAction: "Startpasswort erzeugen",
|
|
239
|
+
removePasswordTitle: "Passwort von {email} entfernen?",
|
|
240
|
+
removePasswordText: "Die Person meldet sich danach nur noch mit Microsoft an.",
|
|
241
|
+
removePasswordAction: "Passwort entfernen",
|
|
242
|
+
passwordRemoved: "Passwort entfernt.",
|
|
243
|
+
resetMfaTitle: "Zweiten Faktor von {email} zurücksetzen?",
|
|
244
|
+
resetMfaText: "Authenticator-App, Backup-Codes und Passkeys werden entfernt, zum Beispiel bei einem verlorenen Telefon.",
|
|
245
|
+
resetMfaAction: "Zweiten Faktor zurücksetzen",
|
|
246
|
+
mfaReset: "Zweiter Faktor zurückgesetzt.",
|
|
247
|
+
unlinkTitle: "Microsoft-Verknüpfung von {email} lösen?",
|
|
248
|
+
unlinkText: "Das Konto bleibt bestehen. Bei der nächsten Microsoft-Anmeldung mit derselben Adresse wird es wieder verknüpft.",
|
|
249
|
+
unlinkAction: "Verknüpfung lösen",
|
|
250
|
+
unlinked: "Verknüpfung gelöst.",
|
|
251
|
+
removeTitle: "Konto von {email} löschen?",
|
|
252
|
+
removeText: "Die Person kann sich danach nicht mehr anmelden.",
|
|
253
|
+
removeAction: "Löschen",
|
|
254
|
+
removed: "Konto gelöscht.",
|
|
255
|
+
approved: "Zugang für {email} freigeschaltet.",
|
|
256
|
+
mergeTitle: "{email} zusammenführen",
|
|
257
|
+
mergeText: "Die Microsoft-Anmeldung dieses wartenden Kontos wird auf ein bestehendes Konto übertragen. Das wartende Konto verschwindet, Rolle und Zugriff des bestehenden Kontos bleiben.",
|
|
258
|
+
mergeInto: "Bestehendes Konto",
|
|
259
|
+
choose: "Bitte wählen",
|
|
260
|
+
mergeAction: "Zusammenführen",
|
|
261
|
+
merged: "Microsoft-Anmeldung auf {email} übertragen.",
|
|
262
|
+
createdTitle: "{email} angelegt",
|
|
263
|
+
secretTitle: "Neues Startpasswort",
|
|
264
|
+
secretText: "Geben Sie dieses Startpasswort an {email} weiter. Es wird nur jetzt angezeigt. Bei der ersten Anmeldung muss es geändert werden.",
|
|
265
|
+
copy: "Kopieren",
|
|
266
|
+
copied: "Kopiert.",
|
|
267
|
+
done: "Fertig",
|
|
268
|
+
linkMicrosoft: "Mit Microsoft verknüpfen",
|
|
269
|
+
connectionHint: "Verknüpfen Sie Ihr Microsoft-Konto, dann können Sie sich auch mit Microsoft anmelden.",
|
|
270
|
+
connectionLinked: "Sie können sich mit Microsoft und mit Ihrem Passwort anmelden.",
|
|
271
|
+
connectionOnly: "Sie melden sich mit Microsoft an.",
|
|
272
|
+
},
|
|
183
273
|
mfa: {
|
|
184
274
|
backupTitle: "Ihre Backup-Codes",
|
|
185
275
|
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.",
|
|
@@ -462,6 +552,96 @@ const LABELS = {
|
|
|
462
552
|
beaconChecked: "Connection works (Beacon {version}).",
|
|
463
553
|
beaconCheckFailed: "No connection: {detail}",
|
|
464
554
|
beaconRemoveConfirm: "Disconnect from Beacon? Findings no longer go to Beacon; the tickets there stay.",
|
|
555
|
+
users: {
|
|
556
|
+
loading: "Loading …",
|
|
557
|
+
search: "Search users",
|
|
558
|
+
add: "Add user",
|
|
559
|
+
addTitle: "Add user",
|
|
560
|
+
user: "User",
|
|
561
|
+
role: "Role",
|
|
562
|
+
access: "Access",
|
|
563
|
+
lastLogin: "Last sign-in",
|
|
564
|
+
you: "you",
|
|
565
|
+
password: "Password",
|
|
566
|
+
managedByEntra: "Role from Entra",
|
|
567
|
+
emergency: "Emergency administrator",
|
|
568
|
+
locked: "Locked",
|
|
569
|
+
pending: "Waiting for approval",
|
|
570
|
+
approve: "Approve",
|
|
571
|
+
merge: "Merge",
|
|
572
|
+
edit: "Edit",
|
|
573
|
+
none: "No users yet.",
|
|
574
|
+
noMatch: "No user matches the search.",
|
|
575
|
+
name: "Name",
|
|
576
|
+
email: "Email",
|
|
577
|
+
signIn: "Sign-in",
|
|
578
|
+
modeMicrosoft: "With Microsoft",
|
|
579
|
+
modeMicrosoftHint: "Recommended. The account is linked at the first Microsoft sign-in with exactly this address. No approval needed.",
|
|
580
|
+
modePassword: "With a password",
|
|
581
|
+
modePasswordHint: "You get a start password. The person has to change it at the first sign-in.",
|
|
582
|
+
modeInvite: "Invitation by email",
|
|
583
|
+
modeInviteHint: "The person gets a link and sets their own password.",
|
|
584
|
+
sendInvite: "Send invitation",
|
|
585
|
+
cancel: "Cancel",
|
|
586
|
+
save: "Save",
|
|
587
|
+
saved: "Saved.",
|
|
588
|
+
created: "{email} was added.",
|
|
589
|
+
invited: "Invitation sent to {email}.",
|
|
590
|
+
editTitle: "Edit {name}",
|
|
591
|
+
fromEntra: "Name and email come from Microsoft Entra and are updated at every sign-in.",
|
|
592
|
+
emailIsLogin: "The email is also the sign-in name for the password.",
|
|
593
|
+
roleFromEntra: "The role is assigned in Entra ID.",
|
|
594
|
+
ownRole: "Another administrator can change your own role.",
|
|
595
|
+
lockAccount: "Lock account (no sign-in)",
|
|
596
|
+
signInSecurity: "Sign-in and security",
|
|
597
|
+
linked: "linked",
|
|
598
|
+
notLinked: "not linked",
|
|
599
|
+
set: "set",
|
|
600
|
+
startPasswordSet: "start password, not changed yet",
|
|
601
|
+
none_: "none",
|
|
602
|
+
secondFactor: "Second factor",
|
|
603
|
+
on: "on",
|
|
604
|
+
off: "off",
|
|
605
|
+
newPassword: "New start password",
|
|
606
|
+
givePassword: "Give a password",
|
|
607
|
+
newPasswordTitle: "New start password for {email}?",
|
|
608
|
+
givePasswordTitle: "Give {email} a password?",
|
|
609
|
+
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.",
|
|
610
|
+
newPasswordAction: "Create start password",
|
|
611
|
+
removePasswordTitle: "Remove the password of {email}?",
|
|
612
|
+
removePasswordText: "The person then signs in with Microsoft only.",
|
|
613
|
+
removePasswordAction: "Remove password",
|
|
614
|
+
passwordRemoved: "Password removed.",
|
|
615
|
+
resetMfaTitle: "Reset the second factor of {email}?",
|
|
616
|
+
resetMfaText: "Removes the authenticator app, backup codes and passkeys, for example after a lost phone.",
|
|
617
|
+
resetMfaAction: "Reset second factor",
|
|
618
|
+
mfaReset: "Second factor reset.",
|
|
619
|
+
unlinkTitle: "Unlink the Microsoft account of {email}?",
|
|
620
|
+
unlinkText: "The account stays. The next Microsoft sign-in with the same address links it again.",
|
|
621
|
+
unlinkAction: "Unlink",
|
|
622
|
+
unlinked: "Unlinked.",
|
|
623
|
+
removeTitle: "Delete the account of {email}?",
|
|
624
|
+
removeText: "The person can no longer sign in.",
|
|
625
|
+
removeAction: "Delete",
|
|
626
|
+
removed: "Account deleted.",
|
|
627
|
+
approved: "Access approved for {email}.",
|
|
628
|
+
mergeTitle: "Merge {email}",
|
|
629
|
+
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.",
|
|
630
|
+
mergeInto: "Existing account",
|
|
631
|
+
choose: "Please choose",
|
|
632
|
+
mergeAction: "Merge",
|
|
633
|
+
merged: "Microsoft sign-in moved to {email}.",
|
|
634
|
+
createdTitle: "{email} added",
|
|
635
|
+
secretTitle: "New start password",
|
|
636
|
+
secretText: "Give this start password to {email}. It is shown only now. It has to be changed at the first sign-in.",
|
|
637
|
+
copy: "Copy",
|
|
638
|
+
copied: "Copied.",
|
|
639
|
+
done: "Done",
|
|
640
|
+
linkMicrosoft: "Link with Microsoft",
|
|
641
|
+
connectionHint: "Link your Microsoft account to sign in with Microsoft as well.",
|
|
642
|
+
connectionLinked: "You can sign in with Microsoft and with your password.",
|
|
643
|
+
connectionOnly: "You sign in with Microsoft.",
|
|
644
|
+
},
|
|
465
645
|
mfa: {
|
|
466
646
|
backupTitle: "Your backup codes",
|
|
467
647
|
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,6 +760,7 @@ function labelsFor(lang, overrides) {
|
|
|
580
760
|
sectionHints: Object.assign({}, base.sectionHints, o.sectionHints),
|
|
581
761
|
settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
|
|
582
762
|
mfa: Object.assign({}, base.mfa, o.mfa),
|
|
763
|
+
users: Object.assign({}, base.users, o.users),
|
|
583
764
|
license: Object.assign({}, base.license, o.license),
|
|
584
765
|
edition: Object.assign({}, base.edition, o.edition),
|
|
585
766
|
accountTabs: Object.assign({}, base.accountTabs, o.accountTabs),
|
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 };
|