@scalio-cloud/ui 0.25.1 → 0.26.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/nav.css CHANGED
@@ -484,6 +484,19 @@
484
484
  .dark .scalio-roles-yes { color: #4ade80; }
485
485
  .scalio-roles-no { color: hsl(var(--muted-foreground)); }
486
486
  .scalio-roles-lic { margin-left: 0.375rem; }
487
+ .scalio-roles-cell > svg, .scalio-roles-cell > input, .scalio-roles-cell > .scalio-roles-no { display: block; margin-left: auto; margin-right: auto; }
488
+ .scalio-roles-table th.scalio-roles-col.is-mine, .scalio-roles-cell.is-mine { background: hsl(var(--primary) / 0.08); }
489
+ .scalio-roles-table th.scalio-roles-col.is-mine .scalio-roles-name { color: hsl(var(--primary)); }
490
+ .scalio-roles-mine { margin-bottom: 0.25rem; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: hsl(var(--primary)); }
491
+ .scalio-tenant-list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; border: 1px solid hsl(var(--border)); border-radius: 2px; }
492
+ .scalio-tenant-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.625rem 0.875rem; border-bottom: 1px solid hsl(var(--border)); }
493
+ .scalio-tenant-row:last-child { border-bottom: 0; }
494
+ .scalio-tenant-who { min-width: 0; }
495
+ .scalio-tenant-domain { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-weight: 600; overflow-wrap: anywhere; }
496
+ .scalio-tenant-id { margin-top: 0.125rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
497
+ .scalio-tenant-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
498
+ .scalio-tenant-manual { display: flex; flex-direction: column; gap: 0.5rem; max-width: 28rem; }
499
+ .scalio-btn.is-disabled { pointer-events: none; opacity: 0.5; }
487
500
  .scalio-roles-source { display: flex; flex-direction: column; gap: 0.875rem; padding: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); font-size: 0.875rem; }
488
501
 
489
502
  /* Setup wizard (SetupWizard, ADR 0060): design of the Nexus wizard, first built in Kontor */
@@ -493,7 +506,7 @@
493
506
  padding: 1.5rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--foreground));
494
507
  box-shadow: 0 16px 40px rgb(0 0 0 / 0.25); font-size: 0.875rem;
495
508
  }
496
- .scalio-wizard.is-wide { max-width: 56rem; }
509
+ .scalio-wizard.is-wide { max-width: 72rem; }
497
510
  .scalio-wizard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
498
511
  .scalio-wizard-title { margin: 0; font-family: 'Chivo', sans-serif; font-size: 1.5rem; font-weight: 900; line-height: 1.2; text-transform: uppercase; letter-spacing: -0.025em; }
499
512
  .scalio-wizard-subtitle { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.25.1",
3
+ "version": "0.26.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/labels.js CHANGED
@@ -64,7 +64,7 @@ const LABELS = {
64
64
  graceTip: "Diese Version liegt nach Ihrem Lizenzdatum und wechselt nach der Kulanzzeit in den Nur-Lesen-Modus.",
65
65
  },
66
66
  accountGroups: { people: "Personen", access: "Rechte" },
67
- accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Microsoft Entra", signIn: "Anmeldung", organizations: "Organisationen" },
67
+ accountTabs: { users: "Benutzer", roles: "Rollen", entra: "Rollen aus Entra ID", signIn: "Anmeldung", organizations: "Organisationen" },
68
68
  passwordPolicy: {
69
69
  label: "Anmeldung mit Passwort",
70
70
  local: "Erlaubt",
@@ -460,7 +460,7 @@ const LABELS = {
460
460
  graceTip: "This version is past your license date and turns read-only after the grace period.",
461
461
  },
462
462
  accountGroups: { people: "People", access: "Permissions" },
463
- accountTabs: { users: "Users", roles: "Roles", entra: "Microsoft Entra", signIn: "Sign-in", organizations: "Organizations" },
463
+ accountTabs: { users: "Users", roles: "Roles", entra: "Roles from Entra ID", signIn: "Sign-in", organizations: "Organizations" },
464
464
  passwordPolicy: {
465
465
  label: "Password sign-in",
466
466
  local: "Allowed",
package/react/roles.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
- // Konten und Rollen -> Rollen and -> Microsoft Entra, the same in every Scalio app (scalio-docs concepts/app-roles.md,
2
+ // Konten und Rollen -> Rollen and -> Rollen aus Entra ID, the same in every Scalio app (scalio-docs concepts/app-roles.md,
3
3
  // roles/R1-R6). rolesApi(api, opts) maps the shared role API of scalio-platform `roles_router` (GET /roles/permissions,
4
4
  // GET/POST /roles, PATCH/DELETE /roles/{id}); RoleAdmin shows "welche Rolle darf was" (permissions as rows, roles as
5
- // columns), built-in roles read-only, own roles with checkboxes, add / copy / rename / delete. RoleSourceSettings is
6
- // the role source (Scalio app or Entra app roles) with the Entra values and, where the app has them, the tenant IDs.
5
+ // columns), built-in roles read-only, own roles with checkboxes, add / copy / rename / delete; the column of the signed-in
6
+ // person's role is marked. RoleSourceSettings is the role source (Scalio app or Entra app roles) with the Entra values
7
+ // and the list of tenants whose roles count, with «Tenant hinzufügen» by admin consent (ADR 0068).
7
8
  const React = require("react");
8
9
  const h = React.createElement;
9
10
  const Icons = require("./icons");
@@ -47,8 +48,26 @@ const TEXT = {
47
48
  sourceEntra: "Microsoft Entra ID (App-Rollen)",
48
49
  hintLocal: "Sie vergeben die Rollen unter Benutzer. Werte für Entra ID, falls Sie später umstellen:",
49
50
  hintEntra: "Ab der nächsten Anmeldung entscheiden die App-Rollen der Unternehmensanwendung «Scalio» für jedes Microsoft-Konto. Wer keine Rolle hat, kann sich nicht mehr anmelden. Eigene Rollen und lokale Konten vergeben Sie weiter hier. Werte:",
50
- tenants: "Microsoft-Tenant-IDs",
51
- tenantsHint: "Nur App-Rollen aus diesen Tenants zählen, durch Komma getrennt.",
51
+ yourRole: "Ihre Rolle",
52
+ tenants: "Vertraute Tenants",
53
+ tenantsHint: "Nur App-Rollen aus diesen Tenants zählen, und Personen aus diesen Tenants können sich anmelden.",
54
+ tenantsEmpty: "Noch kein Tenant. Mit «Tenant hinzufügen» gibt ein Admin des Tenants die Zustimmung für die App «Scalio».",
55
+ tenantAdd: "Tenant hinzufügen",
56
+ tenantAddHint: "Sie melden sich als Admin des Tenants bei Microsoft an und stimmen der App «Scalio» zu.",
57
+ tenantManual: "Von Hand eintragen",
58
+ tenantManualHint: "Nur wenn ein Admin des Tenants der App «Scalio» schon zugestimmt hat.",
59
+ tenantId: "Tenant-ID",
60
+ tenantAddManual: "Eintragen",
61
+ tenantInvalid: "Das ist keine Tenant-ID (GUID).",
62
+ tenantRemove: "Entfernen",
63
+ tenantRemoveTitle: "Tenant «{name}» entfernen?",
64
+ tenantRemoveText: "App-Rollen aus diesem Tenant zählen dann nicht mehr. Die Unternehmensanwendung im Tenant bleibt; entfernen Sie sie dort bei Bedarf selbst.",
65
+ tenantFixed: "Fest",
66
+ tenantAdded: "Tenant hinzugefügt.",
67
+ tenantRemoved: "Tenant entfernt.",
68
+ consentCancelled: "Die Zustimmung wurde abgebrochen.",
69
+ consentFailed: "Die Zustimmung hat nicht geklappt. Melden Sie sich mit einem Konto an, das in diesem Tenant Admin ist.",
70
+ consentForbidden: "Nur Admins dieser App können einen Tenant hinzufügen.",
52
71
  entraConfirm: "Rollen aus Entra ID übernehmen?",
53
72
  entraConfirmText: "Ab der nächsten Anmeldung entscheiden die App-Rollen in Entra ID für jedes Microsoft-Konto. Personen ohne Rolle können sich nicht mehr anmelden.",
54
73
  entraAction: "Entra ID verwenden",
@@ -90,8 +109,26 @@ const TEXT = {
90
109
  sourceEntra: "Microsoft Entra ID (app roles)",
91
110
  hintLocal: "You give roles under Users. Values for Entra ID, in case you switch later:",
92
111
  hintEntra: "From the next sign-in, the app roles of the enterprise application “Scalio” decide for every Microsoft account. People without a role can no longer sign in. Own roles and local accounts are still given here. Values:",
93
- tenants: "Microsoft tenant IDs",
94
- tenantsHint: "Only app roles from these tenants count, separated by commas.",
112
+ yourRole: "Your role",
113
+ tenants: "Trusted tenants",
114
+ tenantsHint: "Only app roles from these tenants count, and people from these tenants can sign in.",
115
+ tenantsEmpty: "No tenant yet. With “Add tenant” an admin of the tenant consents to the app “Scalio”.",
116
+ tenantAdd: "Add tenant",
117
+ tenantAddHint: "You sign in to Microsoft as an admin of the tenant and consent to the app “Scalio”.",
118
+ tenantManual: "Enter by hand",
119
+ tenantManualHint: "Only if an admin of the tenant already consented to the app “Scalio”.",
120
+ tenantId: "Tenant ID",
121
+ tenantAddManual: "Add",
122
+ tenantInvalid: "This is not a tenant ID (GUID).",
123
+ tenantRemove: "Remove",
124
+ tenantRemoveTitle: "Remove the tenant “{name}”?",
125
+ tenantRemoveText: "App roles from this tenant no longer count. The enterprise application stays in the tenant; remove it there yourself if needed.",
126
+ tenantFixed: "Fixed",
127
+ tenantAdded: "Tenant added.",
128
+ tenantRemoved: "Tenant removed.",
129
+ consentCancelled: "The consent was cancelled.",
130
+ consentFailed: "The consent did not work. Sign in with an account that is an admin in this tenant.",
131
+ consentForbidden: "Only admins of this app can add a tenant.",
95
132
  entraConfirm: "Take roles from Entra ID?",
96
133
  entraConfirmText: "From the next sign-in, the app roles in Entra ID decide for every Microsoft account. People without a role can no longer sign in.",
97
134
  entraAction: "Use Entra ID",
@@ -265,9 +302,11 @@ function RoleAdmin(props) {
265
302
  const byGroup = (g) => catalog.permissions.filter((p) => (p.group || "") === g || (!groups.includes(p.group || "") && g === groups[0]));
266
303
  const usersText = (r) => (r.users === null ? "" : r.users === 1 ? L.oneUser : fill(L.users, { n: r.users }));
267
304
 
305
+ const mine = (r) => props.currentRole !== undefined && props.currentRole !== null && String(props.currentRole) === r.id;
268
306
  const head = h("tr", null,
269
307
  h("th", { className: "scalio-roles-perm" }, L.permission),
270
- roles.map((r) => h("th", { key: r.id, className: cx("scalio-roles-col", r.builtin ? "is-builtin" : "is-own"), "data-testid": `role-col-${r.id}` },
308
+ roles.map((r) => h("th", { key: r.id, className: cx("scalio-roles-col", r.builtin ? "is-builtin" : "is-own", mine(r) && "is-mine"), "data-testid": `role-col-${r.id}` },
309
+ mine(r) ? h("div", { className: "scalio-roles-mine", "data-testid": "role-yours" }, L.yourRole) : null,
271
310
  h("div", { className: "scalio-roles-name" }, tr(r.name, lang)),
272
311
  h("div", { className: "scalio-roles-meta" },
273
312
  h("span", null, r.builtin ? L.builtin : L.own),
@@ -282,10 +321,10 @@ function RoleAdmin(props) {
282
321
  const cell = (r, p) => {
283
322
  const has = r.admin || r.permissions.includes(p.key);
284
323
  if (r.builtin || !canEdit) {
285
- return h("td", { key: r.id, className: "scalio-roles-cell", "data-testid": `role-cell-${r.id}-${p.key}` },
324
+ return h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine"), "data-testid": `role-cell-${r.id}-${p.key}` },
286
325
  has ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no", "aria-label": "–" }, "–"));
287
326
  }
288
- return h("td", { key: r.id, className: "scalio-roles-cell" },
327
+ return h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine") },
289
328
  h("input", { type: "checkbox", checked: has, disabled: busy, onChange: () => toggle(r, p.key),
290
329
  "aria-label": `${tr(r.name, lang)}: ${tr(p.label, lang)}`, "data-testid": `role-check-${r.id}-${p.key}` }));
291
330
  };
@@ -303,7 +342,7 @@ function RoleAdmin(props) {
303
342
  body.push(h("tr", { key: "admin-only", className: "scalio-roles-group" }, h("td", { colSpan: roles.length + 1 }, L.adminOnly)));
304
343
  body.push(h("tr", { key: "admin-row" },
305
344
  h("td", { className: "scalio-roles-perm" }, L.adminAll),
306
- roles.map((r) => h("td", { key: r.id, className: "scalio-roles-cell" },
345
+ roles.map((r) => h("td", { key: r.id, className: cx("scalio-roles-cell", mine(r) && "is-mine") },
307
346
  r.admin ? h(Icons.Check, { className: "scalio-icon scalio-roles-yes", "aria-label": "✓" }) : h("span", { className: "scalio-roles-no" }, "–")))));
308
347
 
309
348
  const dialogEl = dialog ? h(Dialog, { title: dialog.mode === "add" ? L.addTitle : L.renameTitle, onClose: close, testId: "role-dialog" },
@@ -339,46 +378,128 @@ function RoleAdmin(props) {
339
378
  dialogEl);
340
379
  }
341
380
 
342
- // Props: value {source: "local"|"entra", values: [...], tenants?: [...]}, onSave({source, tenants}) -> Promise,
343
- // tenants (show the tenant field), lang, labels, confirm, notify, formatError, disabled, testId.
381
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
382
+
383
+ // A tenant entry: "tid" (old lists) or {tid, domain, fixed, label, href}.
384
+ function tenantOf(x) {
385
+ if (typeof x === "string") return { tid: x.trim().toLowerCase(), domain: "" };
386
+ return Object.assign({}, x, { tid: String(x.tid || x.id || "").trim().toLowerCase(), domain: x.domain || "" });
387
+ }
388
+
389
+ // Back from «Tenant hinzufügen»: ?entra_tenant=added|cancelled|failed|forbidden (scalio-identity 0.5.0).
390
+ function consentResult() {
391
+ if (typeof window === "undefined" || !window.location) return "";
392
+ try {
393
+ const url = new URL(window.location.href);
394
+ const res = url.searchParams.get("entra_tenant") || "";
395
+ if (res && window.history && window.history.replaceState) {
396
+ url.searchParams.delete("entra_tenant");
397
+ window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
398
+ }
399
+ return res;
400
+ } catch (e) {
401
+ return "";
402
+ }
403
+ }
404
+
405
+ // Props: value {source: "local"|"entra", values: [...], tenants?: [tid | {tid, domain, fixed}]},
406
+ // onSave({source, tenants: [{tid, domain}]}) -> Promise, tenants (show the tenant list), consentUrl (button «Tenant
407
+ // hinzufügen», e.g. "/api/auth/scalio/consent"), manual (default true: enter a tenant ID by hand), fixed (one fixed
408
+ // tenant, no add or remove: Nexus), extraTenants [{tid, domain, label, href}] and extraTitle (view only: Rosetta's
409
+ // customer tenants), lang, labels, confirm, notify, formatError, disabled, testId.
344
410
  function RoleSourceSettings(props) {
345
411
  const lang = props.lang === "en" ? "en" : "de";
346
412
  const L = rolesText(lang, props.labels);
347
413
  const v = props.value || { source: "local", values: [] };
414
+ const list = (v.tenants || []).map(tenantOf).filter((t) => t.tid);
348
415
  const [source, setSource] = React.useState(v.source || "local");
349
- const [tenants, setTenants] = React.useState((v.tenants || []).join(", "));
416
+ const [manual, setManual] = React.useState(null); // null = closed, else the typed id
350
417
  const [busy, setBusy] = React.useState(false);
351
- React.useEffect(() => { setSource(v.source || "local"); setTenants((v.tenants || []).join(", ")); }, [v.source, (v.tenants || []).join(",")]);
352
- const dirty = source !== (v.source || "local") || (props.tenants && tenants !== (v.tenants || []).join(", "));
353
- const save = async () => {
354
- if (source === "entra" && v.source !== "entra" && !(await ask(props.confirm, L.entraConfirm, L.entraConfirmText, L.entraAction, false))) return;
418
+ const [error, setError] = React.useState("");
419
+ const say = (text, tone) => props.notify && props.notify(text, tone || "success");
420
+ const fmt = props.formatError || errorText;
421
+ React.useEffect(() => { setSource(v.source || "local"); }, [v.source]);
422
+ React.useEffect(() => {
423
+ const res = consentResult();
424
+ if (res === "added") say(L.tenantAdded);
425
+ else if (res === "cancelled") say(L.consentCancelled, "error");
426
+ else if (res === "forbidden") say(L.consentForbidden, "error");
427
+ else if (res) say(L.consentFailed, "error");
428
+ }, []);
429
+ const dirty = source !== (v.source || "local");
430
+ const store = async (src, tenants, okText) => {
355
431
  setBusy(true);
432
+ setError("");
356
433
  try {
357
- await props.onSave({ source, tenants: tenants.split(/[\s,;]+/).filter(Boolean) });
358
- if (props.notify) props.notify(L.saved, "success");
434
+ await props.onSave({ source: src, tenants: tenants.map((t) => ({ tid: t.tid, domain: t.domain || "" })) });
435
+ say(okText || L.saved);
436
+ return true;
359
437
  } catch (e) {
360
- if (props.notify) props.notify((props.formatError || errorText)(e), "error");
438
+ say(fmt(e), "error");
439
+ return false;
361
440
  } finally {
362
441
  setBusy(false);
363
442
  }
364
443
  };
444
+ const save = async () => {
445
+ if (source === "entra" && v.source !== "entra" && !(await ask(props.confirm, L.entraConfirm, L.entraConfirmText, L.entraAction, false))) return;
446
+ store(source, list.filter((t) => !t.fixed));
447
+ };
448
+ const remove = async (t) => {
449
+ if (!(await ask(props.confirm, fill(L.tenantRemoveTitle, { name: t.domain || t.tid }), L.tenantRemoveText, L.tenantRemove))) return;
450
+ store(v.source || "local", list.filter((x) => !x.fixed && x.tid !== t.tid), L.tenantRemoved);
451
+ };
452
+ const addManual = async (e) => {
453
+ e.preventDefault();
454
+ const tid = (manual || "").trim().toLowerCase();
455
+ if (!GUID.test(tid)) { setError(L.tenantInvalid); return; }
456
+ if (list.some((t) => t.tid === tid)) { setManual(null); return; }
457
+ if (await store(v.source || "local", [...list.filter((t) => !t.fixed), { tid, domain: "" }], L.tenantAdded)) setManual(null);
458
+ };
365
459
  const values = (v.values || []).map((x) => (typeof x === "string" ? x : x.value || x.name)).filter(Boolean);
460
+ const off = props.disabled || busy;
461
+ const row = (t, extra) => h("li", { key: t.tid, className: "scalio-tenant-row", "data-testid": `tenant-${t.tid}` },
462
+ h("div", { className: "scalio-tenant-who" },
463
+ h("div", { className: "scalio-tenant-domain" }, t.href ? h("a", { href: t.href }, t.domain || t.label || t.tid) : (t.domain || t.label || t.tid),
464
+ t.label && t.domain ? h("span", { className: "scalio-badge" }, t.label) : null),
465
+ t.domain || t.label ? h("div", { className: "scalio-tenant-id" }, t.tid) : null),
466
+ extra ? null : t.fixed || props.fixed
467
+ ? h("span", { className: "scalio-badge" }, L.tenantFixed)
468
+ : h("button", { type: "button", className: "scalio-btn is-ghost scalio-roles-tool is-danger", onClick: () => remove(t), disabled: off, "data-testid": `tenant-remove-${t.tid}` }, L.tenantRemove));
469
+ const tenantBlock = props.tenants ? h("div", { className: "scalio-users-field", "data-testid": "role-source-tenants" },
470
+ h("div", { className: "scalio-field-label" }, L.tenants),
471
+ list.length
472
+ ? h("ul", { className: "scalio-tenant-list" }, list.map((t) => row(t, false)))
473
+ : h("p", { className: "scalio-users-hint", "data-testid": "tenants-empty" }, L.tenantsEmpty),
474
+ h("p", { className: "scalio-users-hint" }, L.tenantsHint),
475
+ props.fixed ? null : h("div", { className: "scalio-tenant-actions" },
476
+ props.consentUrl ? h("a", { className: cx("scalio-btn is-primary", off && "is-disabled"), href: props.consentUrl, title: L.tenantAddHint, "data-testid": "tenant-add" },
477
+ h(Icons.Plus, { className: "scalio-icon" }), L.tenantAdd) : null,
478
+ props.manual !== false && manual === null ? h("button", { type: "button", className: "scalio-btn", onClick: () => { setError(""); setManual(""); }, disabled: off, "data-testid": "tenant-manual" }, L.tenantManual) : null),
479
+ manual !== null ? h("form", { className: "scalio-tenant-manual", onSubmit: addManual },
480
+ h("label", { className: "scalio-field-label", htmlFor: "tenant-manual-id" }, L.tenantId),
481
+ h("input", { id: "tenant-manual-id", className: "scalio-input", value: manual, autoFocus: true, placeholder: "00000000-0000-0000-0000-000000000000",
482
+ onChange: (e) => setManual(e.target.value), disabled: off, "data-testid": "tenant-manual-id" }),
483
+ h("p", { className: "scalio-users-hint" }, L.tenantManualHint),
484
+ error ? h("p", { className: "scalio-notice is-error", role: "alert", "data-testid": "tenant-manual-error" }, error) : null,
485
+ h("div", { className: "scalio-tenant-actions" },
486
+ h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setManual(null) }, L.cancel),
487
+ h("button", { type: "submit", className: "scalio-btn is-primary", disabled: off || !manual.trim(), "data-testid": "tenant-manual-save" }, L.tenantAddManual))) : null,
488
+ props.extraTenants && props.extraTenants.length ? h("div", { className: "scalio-users-field", "data-testid": "role-source-extra" },
489
+ props.extraTitle ? h("div", { className: "scalio-field-label" }, props.extraTitle) : null,
490
+ h("ul", { className: "scalio-tenant-list" }, props.extraTenants.map(tenantOf).map((t) => row(t, true)))) : null) : null;
366
491
  return h("div", { className: "scalio-roles-source", "data-testid": props.testId || "role-source" },
367
492
  h("div", { className: "scalio-users-field" },
368
493
  h("label", { className: "scalio-field-label", htmlFor: "role-source-select" }, L.source),
369
- h("select", { id: "role-source-select", className: "scalio-input scalio-users-select", value: source, disabled: props.disabled || busy,
370
- onChange: (e) => setSource(e.target.value), "data-testid": "role-source-select" },
371
- h("option", { value: "local" }, L.sourceLocal),
372
- h("option", { value: "entra" }, L.sourceEntra))),
373
- props.tenants ? h("div", { className: "scalio-users-field" },
374
- h("label", { className: "scalio-field-label", htmlFor: "role-source-tenants" }, L.tenants),
375
- h("input", { id: "role-source-tenants", className: "scalio-input", value: tenants, disabled: props.disabled || busy,
376
- onChange: (e) => setTenants(e.target.value), "data-testid": "role-source-tenants" }),
377
- h("p", { className: "scalio-users-hint" }, L.tenantsHint)) : null,
494
+ h("div", { className: "scalio-tenant-actions" },
495
+ h("select", { id: "role-source-select", className: "scalio-input scalio-users-select", value: source, disabled: off,
496
+ onChange: (e) => setSource(e.target.value), "data-testid": "role-source-select" },
497
+ h("option", { value: "local" }, L.sourceLocal),
498
+ h("option", { value: "entra" }, L.sourceEntra)),
499
+ h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: off || !dirty, "data-testid": "role-source-save" }, L.save))),
378
500
  h("p", { className: "scalio-users-hint", "data-testid": "role-source-hint" }, source === "entra" ? L.hintEntra : L.hintLocal, " ",
379
501
  values.map((x, i) => h(React.Fragment, { key: x }, i ? ", " : "", h("code", null, x)))),
380
- h("div", null,
381
- h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: props.disabled || busy || !dirty, "data-testid": "role-source-save" }, L.save)));
502
+ tenantBlock);
382
503
  }
383
504
 
384
505
  module.exports = { RoleAdmin, RoleSourceSettings, rolesApi, rolesText };