@scalio-cloud/ui 0.7.0 → 0.8.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1
4
+ - `SupportConnection` fills in the portal address of the instance's stage (`default_portal` from scalio-connect
5
+ 0.5.0: dev -> portaldev, test -> portaltest, others -> portal.scaliocloud.com). **Visible change:** the address
6
+ field is no longer empty on the settings card. No change with older backends.
7
+
8
+ ## 0.8.0
9
+ - New `SecurityFactors` (profile: authenticator app, backup codes, passkeys) with `securityApi(api, webauthn)`, and
10
+ `LicensePanel` (status, usage, add-ons, key) with `licenseApi(api)` and `mapScalioLicense`. They replace the copies
11
+ in Citadel, Foundry, Rosetta and Nexus. `LABELS` get `mfa` and `license` texts, `Icons` get Smartphone, KeyRound,
12
+ Trash, Copy, Puzzle, Plus, Alert, and `.scalio-btn` gets the variants `is-primary`, `is-ghost`, `is-danger`.
13
+ **No visible change** until an app uses the new blocks; then they look the same in every app.
14
+
3
15
  ## 0.7.0
4
16
  - `SupportDrawer` takes `client` (from the new `supportApi(api)`) and `page`: tabs "Neue Anfrage" (category,
5
17
  subject, description, technical details) and "Meine Tickets" (list, answers from support, reply, rating) for the
package/README.md CHANGED
@@ -189,6 +189,33 @@ import { AuthLayout, AuthHeader, MicrosoftButton, AuthDivider, AuthMessage, Prof
189
189
 
190
190
  Form fields and buttons stay the app's own (shadcn), so they match the rest of the app.
191
191
 
192
+ ## Two-factor card and license panel (React)
193
+
194
+ The same backend routes in every app, so the apps pass only their axios instance and a few helpers.
195
+
196
+ ```jsx
197
+ import { SecurityFactors, securityApi, LicensePanel, licenseApi } from "@scalio-cloud/ui/react";
198
+ import { QRCodeSVG } from "qrcode.react";
199
+ import { startRegistration, browserSupportsWebAuthn } from "@simplewebauthn/browser";
200
+
201
+ const security = securityApi(api, { startRegistration, browserSupportsWebAuthn }); // once, outside the component
202
+ const license = licenseApi(api);
203
+
204
+ // Profile -> Sicherheit: authenticator app, backup codes, passkeys. Hidden for accounts without a password.
205
+ <SecurityFactors client={security} lang={lang} product="Scalio Citadel" email={user.email}
206
+ qr={(uri) => <QRCodeSVG value={uri} size={160} />} notify={(text, tone) => toast[tone](text)}
207
+ confirm={confirm} onMfaChange={(on) => setUser({ ...user, mfa_enabled: on })} />
208
+
209
+ // Einstellungen -> System -> Lizenz (scalio-license backend: /license, /license/addons)
210
+ <LicensePanel client={license} lang={lang} product="Citadel" kindLabel={licenseKindLabel}
211
+ featureLabel={licenseFeatureLabel} noFeatures="Nur Drift-Erkennung" notify={...} confirm={confirm} />
212
+ ```
213
+
214
+ `securityApi` reads snake_case and camelCase answers. `notify(text, tone)` gets `"success"` or `"error"`; without it
215
+ the blocks show the message themselves. `confirm` is the app's dialog (`{title, action, danger}` -> Promise<boolean>),
216
+ `window.confirm` otherwise. `formatError(err)` lets the app phrase API errors (for example license limit codes). A license backend with other routes passes its own `client` and a `map(license, {L})`
217
+ that returns the view model described in `react/license.js`.
218
+
192
219
  ## Installing
193
220
 
194
221
  Public on npmjs.com, no token needed: `yarn add @scalio-cloud/ui` (pin the exact version).
package/nav.css CHANGED
@@ -270,3 +270,95 @@
270
270
  .scalio-settings-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
271
271
  .scalio-settings-body { display: flex; flex-direction: column; }
272
272
  .scalio-settings-section-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.25rem; }
273
+
274
+ /* Button variants for page blocks (base: .scalio-btn in shell.css) */
275
+ .scalio-btn.is-primary { border-color: hsl(var(--primary)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
276
+ .scalio-btn.is-primary:hover { background: hsl(var(--primary) / 0.9); }
277
+ .scalio-btn.is-ghost { border-color: transparent; background: transparent; color: hsl(var(--muted-foreground)); }
278
+ .scalio-btn.is-ghost:hover { color: hsl(var(--foreground)); background: transparent; }
279
+ .scalio-btn.is-danger { color: hsl(var(--destructive)); }
280
+ .scalio-btn.is-ghost.is-danger { color: hsl(var(--muted-foreground)); }
281
+ .scalio-btn.is-ghost.is-danger:hover { color: hsl(var(--destructive)); }
282
+ .scalio-btn:disabled { opacity: 0.6; cursor: default; }
283
+
284
+ /* Two-factor card (SecurityFactors) */
285
+ .scalio-security { display: flex; flex-direction: column; gap: 1.5rem; font-size: 0.875rem; }
286
+ .scalio-security-block { display: flex; flex-direction: column; gap: 0.75rem; }
287
+ .scalio-security-head { display: flex; align-items: flex-start; gap: 0.75rem; }
288
+ .scalio-security-head > .scalio-icon { margin-top: 0.125rem; flex-shrink: 0; color: hsl(var(--muted-foreground)); }
289
+ .scalio-security-title { font-weight: 600; font-size: 0.875rem; }
290
+ .scalio-security-hint { margin: 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
291
+ .scalio-security-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
292
+ .scalio-security .is-indented { padding-left: 1.75rem; }
293
+ .scalio-security-form, .scalio-security-confirm { display: flex; flex-direction: column; gap: 0.75rem; }
294
+ .scalio-security-confirm, .scalio-security-codes {
295
+ max-width: 28rem; padding: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card));
296
+ display: flex; flex-direction: column; gap: 0.75rem;
297
+ }
298
+ .scalio-security-field { display: flex; flex-direction: column; gap: 0.25rem; max-width: 20rem; }
299
+ .scalio-security-field .scalio-field-label { margin-top: 0; }
300
+ .scalio-security-confirm .scalio-security-field { max-width: none; }
301
+ .scalio-security-code { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.2em; }
302
+ .scalio-security-qr { display: inline-block; align-self: flex-start; padding: 0.75rem; border-radius: 2px; background: #fff; }
303
+ .scalio-security-secret { font-family: 'JetBrains Mono', monospace; overflow-wrap: anywhere; color: hsl(var(--foreground)); }
304
+ .scalio-security-code-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.25rem 1.5rem; font-family: 'JetBrains Mono', monospace; }
305
+ .scalio-security-keys { margin: 0; list-style: none; border-top: 1px solid hsl(var(--border)); border-bottom: 1px solid hsl(var(--border)); }
306
+ .scalio-security-keys li { display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0; }
307
+ .scalio-security-keys li + li { border-top: 1px solid hsl(var(--border)); }
308
+ .scalio-security-key-name { flex: 1 1 auto; min-width: 0; font-weight: 500; overflow-wrap: anywhere; }
309
+ .scalio-security-key-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: hsl(var(--muted-foreground)); }
310
+ .scalio-security-keys .scalio-btn { width: 2rem; height: 2rem; padding: 0; }
311
+
312
+ /* License panel (LicensePanel) */
313
+ .scalio-license { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
314
+ .scalio-license-part { padding: 1.25rem; }
315
+ .scalio-license-part + .scalio-license-part { border-top: 1px solid hsl(var(--border)); }
316
+ .scalio-license-title { display: flex; align-items: center; gap: 0.5rem; font-family: 'Chivo', sans-serif; font-weight: 700; }
317
+ .scalio-license-title .scalio-icon { color: hsl(var(--primary)); }
318
+ .scalio-license-hint { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
319
+ .scalio-license-messages { display: flex; flex-direction: column; gap: 0.25rem; margin: 1.25rem 1.25rem 0; }
320
+ .scalio-license-messages div { display: flex; gap: 0.5rem; }
321
+ .scalio-license-messages .scalio-icon { flex-shrink: 0; margin-top: 0.125rem; }
322
+ .scalio-license-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
323
+ @media (min-width: 768px) { .scalio-license-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
324
+ .scalio-license-status { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
325
+ .scalio-license-edition { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
326
+ .scalio-license-customer { font-weight: 600; }
327
+ .scalio-license-rows { display: flex; flex-direction: column; gap: 0.375rem; margin: 0; }
328
+ .scalio-license-rows > div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
329
+ .scalio-license-rows dt { color: hsl(var(--muted-foreground)); white-space: nowrap; }
330
+ .scalio-license-rows dd { margin: 0; text-align: right; font-weight: 500; overflow-wrap: anywhere; min-width: 0; }
331
+ .scalio-license-rows dd.is-mono, .scalio-license-copy { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 400; }
332
+ .scalio-license-copy { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0; border: 0; background: none; color: hsl(var(--muted-foreground)); cursor: pointer; text-align: right; }
333
+ .scalio-license-copy:hover { color: hsl(var(--primary)); }
334
+ .scalio-license .is-ok { color: #15803d; }
335
+ .scalio-license .is-warn { color: #b45309; }
336
+ .scalio-license .is-error { color: hsl(var(--destructive)); }
337
+ .dark .scalio-license .is-ok { color: #4ade80; }
338
+ .dark .scalio-license .is-warn { color: #fbbf24; }
339
+ .scalio-license-sub { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: hsl(var(--muted-foreground)); }
340
+ .scalio-license-features { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 0.125rem; }
341
+ .scalio-license-features li { display: flex; align-items: center; gap: 0.375rem; }
342
+ .scalio-license-features .scalio-icon { width: 14px; height: 14px; color: #16a34a; }
343
+ .scalio-license-usage { display: flex; flex-direction: column; gap: 1rem; padding: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted) / 0.4); }
344
+ .scalio-license-usage .scalio-license-sub { margin-bottom: 0; }
345
+ .scalio-license-bar-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; font-size: 0.75rem; }
346
+ .scalio-license-bar-label { font-weight: 600; color: hsl(var(--muted-foreground)); }
347
+ .scalio-license-bar-label::first-letter { text-transform: uppercase; }
348
+ .scalio-license-bar-value { font-family: 'JetBrains Mono', monospace; color: hsl(var(--muted-foreground)); }
349
+ .scalio-license-bar-value.is-error { color: hsl(var(--destructive)); }
350
+ .scalio-license-bar { height: 6px; overflow: hidden; border-radius: 999px; background: hsl(var(--muted)); }
351
+ .scalio-license-bar > div { height: 100%; background: hsl(var(--primary)); }
352
+ .scalio-license-bar > div.is-unlimited { background: #10b981; }
353
+ .scalio-license-bar > div.is-full { background: hsl(var(--destructive)); }
354
+ .scalio-license-addons { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.5rem; margin-bottom: 1rem; }
355
+ @media (min-width: 640px) { .scalio-license-addons { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
356
+ .scalio-license-addon { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 0.625rem 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; }
357
+ .scalio-license-addon-name { font-weight: 600; }
358
+ .scalio-license-addon-detail { margin: 0.125rem 0 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
359
+ .scalio-license-addon .scalio-btn { width: 2rem; height: 2rem; padding: 0; flex-shrink: 0; }
360
+ .scalio-license-empty { margin: 0 0 1rem; color: hsl(var(--muted-foreground)); }
361
+ .scalio-license-form { display: flex; flex-direction: column; gap: 0.5rem; }
362
+ .scalio-license-form textarea { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
363
+ .scalio-license-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
364
+ .scalio-license-addon-error { margin-bottom: 0.5rem; font-size: 0.75rem; color: hsl(var(--destructive)); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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
@@ -70,4 +70,29 @@ const Activity = icon([
70
70
  ["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }],
71
71
  ]);
72
72
 
73
- module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity };
73
+ const Smartphone = icon([["rect", { x: 5, y: 2, width: 14, height: 20, rx: 2, ry: 2 }], ["path", { d: "M12 18h.01" }]]);
74
+ const KeyRound = icon([
75
+ ["path", { d: "M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" }],
76
+ ["circle", { cx: 16.5, cy: 7.5, r: 0.5, fill: "currentColor" }],
77
+ ]);
78
+ const Trash = icon([
79
+ ["path", { d: "M3 6h18" }],
80
+ ["path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }],
81
+ ["path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }],
82
+ ["path", { d: "M10 11v6M14 11v6" }],
83
+ ]);
84
+ const Copy = icon([
85
+ ["rect", { width: 14, height: 14, x: 8, y: 8, rx: 2, ry: 2 }],
86
+ ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }],
87
+ ]);
88
+ const Puzzle = icon([
89
+ ["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" }],
90
+ ]);
91
+ const Plus = icon([["path", { d: "M5 12h14M12 5v14" }]]);
92
+ const Alert = icon([
93
+ ["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" }],
94
+ ["path", { d: "M12 9v4M12 17h.01" }],
95
+ ]);
96
+
97
+ module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
98
+ Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert };
package/react/index.js CHANGED
@@ -333,6 +333,6 @@ module.exports = {
333
333
  TEST_IDS,
334
334
  initials,
335
335
  };
336
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"));
336
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"));
337
337
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
338
338
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -96,6 +96,80 @@ const LABELS = {
96
96
  supportRemove: "Trennen",
97
97
  supportRemoveConfirm: "Verbindung zum Support trennen? Neue Tickets warten dann, bis wieder verbunden ist.",
98
98
  supportRemoved: "Verbindung getrennt.",
99
+ // Two-factor card (SecurityFactors) and license panel (LicensePanel)
100
+ mfa: {
101
+ backupTitle: "Ihre Backup-Codes",
102
+ 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.",
103
+ copy: "Kopieren",
104
+ copied: "Kopiert",
105
+ download: "Herunterladen",
106
+ saved: "Ich habe die Codes gespeichert",
107
+ app: "Authenticator-App",
108
+ on: "Aktiv. Die Anmeldung fragt nach einem Code. Noch {n} Backup-Codes.",
109
+ off: "Aus. Mit einer App wie Microsoft Authenticator fragt die Anmeldung zusätzlich nach einem 6-stelligen Code.",
110
+ newCodes: "Neue Backup-Codes",
111
+ turnOff: "Ausschalten",
112
+ turnOffTitle: "Authenticator-App ausschalten",
113
+ turnedOff: "Authenticator-App ausgeschaltet",
114
+ setUp: "Einrichten",
115
+ setUpTitle: "Authenticator-App einrichten",
116
+ scan: "Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben Sie dann den angezeigten Code ein.",
117
+ key: "Oder geben Sie diesen Schlüssel ein:",
118
+ code: "Code",
119
+ activate: "Aktivieren",
120
+ passkeys: "Passkeys",
121
+ passkeysHint: "Anmelden mit Face ID, Touch ID, Windows Hello oder einem Sicherheitsschlüssel, ohne Passwort.",
122
+ addPasskey: "Passkey hinzufügen",
123
+ passkeyAdded: "Passkey hinzugefügt",
124
+ device: "Name des Geräts",
125
+ deviceExample: "z. B. MacBook",
126
+ used: "genutzt {date}",
127
+ added: "hinzugefügt {date}",
128
+ remove: "Entfernen",
129
+ removePasskey: "Passkey {name} entfernen?",
130
+ currentPassword: "Aktuelles Passwort",
131
+ verifying: "Wird geprüft …",
132
+ continue: "Weiter",
133
+ cancel: "Abbrechen",
134
+ },
135
+ license: {
136
+ title: "Lizenz",
137
+ hint: "Lizenzen kaufen und verlängern Sie in Scalio Compass. Der Schlüssel gilt nur für die Installations-ID unten.",
138
+ managed: "Die Lizenz dieser Instanz wird von Ihrem Anbieter verwaltet.",
139
+ status: "Status",
140
+ active: "Aktiv",
141
+ limited: "Eingeschränkt",
142
+ trialEnds: "Test endet",
143
+ maintenanceUntil: "Wartung bis",
144
+ never: "nie",
145
+ installationId: "Installations-ID",
146
+ idCopied: "Installations-ID kopiert",
147
+ copy: "Kopieren",
148
+ copyFailed: "Kopieren fehlgeschlagen",
149
+ domains: "Domains",
150
+ coverage: "Abdeckung dieser Version",
151
+ covered: "Abgedeckt",
152
+ grace: "Kulanzfrist bis {date}",
153
+ notCovered: "Nicht abgedeckt (schreibgeschützt)",
154
+ buildDate: "Build-Datum",
155
+ features: "Funktionen",
156
+ noFeatures: "Nur die Grundfunktionen",
157
+ usage: "Nutzung",
158
+ addons: "Add-ons",
159
+ included: "Enthalten",
160
+ noAddons: "Keine Add-ons.",
161
+ addonPlaceholder: "Add-on-Schlüssel einfügen",
162
+ add: "Hinzufügen",
163
+ addonActivated: "Add-on aktiviert",
164
+ addonRemoved: "Add-on entfernt",
165
+ key: "Lizenzschlüssel",
166
+ keyPlaceholder: "Lizenzschlüssel einfügen",
167
+ activate: "Aktivieren",
168
+ activated: "Lizenz aktiviert",
169
+ remove: "Entfernen",
170
+ removed: "Lizenzschlüssel entfernt",
171
+ removeConfirm: "Lizenzschlüssel entfernen? {product} läuft danach als Community-Edition.",
172
+ },
99
173
  // States
100
174
  loading: "Wird geladen …",
101
175
  empty: "Noch keine Einträge.",
@@ -204,6 +278,79 @@ const LABELS = {
204
278
  supportRemove: "Disconnect",
205
279
  supportRemoveConfirm: "Disconnect from support? New tickets then wait until the instance is connected again.",
206
280
  supportRemoved: "Disconnected.",
281
+ mfa: {
282
+ backupTitle: "Your backup codes",
283
+ 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.",
284
+ copy: "Copy",
285
+ copied: "Copied",
286
+ download: "Download",
287
+ saved: "I have saved the codes",
288
+ app: "Authenticator app",
289
+ on: "On. Sign-in asks for a code. {n} backup codes left.",
290
+ off: "Off. With an app such as Microsoft Authenticator, sign-in also asks for a 6-digit code.",
291
+ newCodes: "New backup codes",
292
+ turnOff: "Turn off",
293
+ turnOffTitle: "Turn off authenticator app",
294
+ turnedOff: "Authenticator app turned off",
295
+ setUp: "Set up",
296
+ setUpTitle: "Set up authenticator app",
297
+ scan: "Scan the QR code with your authenticator app, then enter the code it shows.",
298
+ key: "Or enter this key:",
299
+ code: "Code",
300
+ activate: "Activate",
301
+ passkeys: "Passkeys",
302
+ passkeysHint: "Sign in with Face ID, Touch ID, Windows Hello or a security key, without a password.",
303
+ addPasskey: "Add passkey",
304
+ passkeyAdded: "Passkey added",
305
+ device: "Name of the device",
306
+ deviceExample: "e.g. MacBook",
307
+ used: "used {date}",
308
+ added: "added {date}",
309
+ remove: "Remove",
310
+ removePasskey: "Remove passkey {name}?",
311
+ currentPassword: "Current password",
312
+ verifying: "Verifying …",
313
+ continue: "Continue",
314
+ cancel: "Cancel",
315
+ },
316
+ license: {
317
+ title: "License",
318
+ hint: "Buy or extend a license in Scalio Compass. The key is bound to the installation ID below.",
319
+ managed: "The license of this instance is managed by your provider.",
320
+ status: "Status",
321
+ active: "Active",
322
+ limited: "Limited",
323
+ trialEnds: "Trial ends",
324
+ maintenanceUntil: "Maintenance until",
325
+ never: "never",
326
+ installationId: "Installation ID",
327
+ idCopied: "Installation ID copied",
328
+ copy: "Copy",
329
+ copyFailed: "Copy failed",
330
+ domains: "Domains",
331
+ coverage: "Build coverage",
332
+ covered: "Covered",
333
+ grace: "Grace period until {date}",
334
+ notCovered: "Not covered (read-only)",
335
+ buildDate: "Build date",
336
+ features: "Features",
337
+ noFeatures: "Basic features only",
338
+ usage: "Usage",
339
+ addons: "Add-ons",
340
+ included: "Included",
341
+ noAddons: "No add-ons.",
342
+ addonPlaceholder: "Paste an add-on key",
343
+ add: "Add",
344
+ addonActivated: "Add-on activated",
345
+ addonRemoved: "Add-on removed",
346
+ key: "License key",
347
+ keyPlaceholder: "Paste the license key",
348
+ activate: "Activate",
349
+ activated: "License activated",
350
+ remove: "Remove",
351
+ removed: "License key removed",
352
+ removeConfirm: "Remove the license key? {product} then runs as Community edition.",
353
+ },
207
354
  loading: "Loading …",
208
355
  empty: "No entries yet.",
209
356
  sections: {
@@ -230,6 +377,8 @@ function labelsFor(lang, overrides) {
230
377
  sections: Object.assign({}, base.sections, o.sections),
231
378
  sectionHints: Object.assign({}, base.sectionHints, o.sectionHints),
232
379
  settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
380
+ mfa: Object.assign({}, base.mfa, o.mfa),
381
+ license: Object.assign({}, base.license, o.license),
233
382
  });
234
383
  }
235
384
 
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ // License panel for Einstellungen -> System -> Lizenz: status and usage side by side, then add-ons, then the key.
3
+ // licenseApi(api) maps the routes of the scalio-license backend (Citadel, Foundry, Rosetta); mapScalioLicense turns its
4
+ // answer into the view model the panel shows. An app with another backend passes its own `client` and `map`.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+ const { labelsFor } = require("./labels");
9
+
10
+ function cx() {
11
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
12
+ }
13
+
14
+ function fill(text, values) {
15
+ return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
16
+ }
17
+
18
+ function errorText(err) {
19
+ const d = err && err.response && err.response.data && err.response.data.detail;
20
+ if (typeof d === "string") return d;
21
+ if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
22
+ if (d && typeof d.message === "string") return d.message;
23
+ return (err && err.message) || String(err);
24
+ }
25
+
26
+ // api: axios instance with the app's base URL (/api). Every method resolves to the license as the backend sends it.
27
+ function licenseApi(api) {
28
+ const data = (p) => p.then((r) => r.data);
29
+ return {
30
+ get: () => data(api.get("/license")),
31
+ activate: (key) => data(api.put("/license", { key })),
32
+ removeKey: () => data(api.delete("/license")),
33
+ addAddon: (key) => data(api.post("/license/addons", { key })),
34
+ removeAddon: (addon) => data(api.delete(`/license/addons/${encodeURIComponent(addon.id)}`)),
35
+ };
36
+ }
37
+
38
+ // View model:
39
+ // { edition, licensed, customer, managed, messages[], rows[{label, value, tone, mono, copy, testId}],
40
+ // features[{key, label}] | null, usage[{key, label, used, max}], addons[{id, name, detail, included}], addonErrors[{key, error}], keyPresent }
41
+ function mapScalioLicense(lic, opts) {
42
+ const o = opts || {};
43
+ const L = o.L || labelsFor(o.lang).license;
44
+ const kind = o.kindLabel || ((k) => k);
45
+ const feature = o.featureLabel || ((f) => f);
46
+ const managed = !!lic.saas;
47
+ const licensed = lic.edition !== "community" && !lic.error;
48
+ const rows = [
49
+ { label: L.status, value: licensed ? L.active : L.limited, tone: licensed ? "ok" : "warn", testId: "license-status" },
50
+ { label: lic.trial ? L.trialEnds : L.maintenanceUntil, value: lic.exp || L.never },
51
+ ];
52
+ if (!managed && lic.installation_id) rows.push({ label: L.installationId, value: lic.installation_id, copy: true, testId: "license-installation-id" });
53
+ if ((lic.domains || []).length) rows.push({ label: L.domains, value: lic.domains.join(", "), mono: true });
54
+ if (licensed && !lic.trial) {
55
+ rows.push({
56
+ label: L.coverage,
57
+ value: lic.build_blocked ? L.notCovered : lic.build_grace ? fill(L.grace, { date: lic.build_grace_until }) : L.covered,
58
+ tone: lic.build_blocked ? "error" : lic.build_grace ? "warn" : "ok",
59
+ testId: "license-build-coverage",
60
+ });
61
+ }
62
+ if (lic.build_date) rows.push({ label: L.buildDate, value: lic.build_date, mono: true });
63
+ return {
64
+ edition: lic.edition_name || lic.edition,
65
+ licensed,
66
+ customer: lic.customer,
67
+ managed,
68
+ messages: [lic.error, lic.restricted_reason].concat(lic.warnings || []).filter(Boolean),
69
+ rows,
70
+ features: (lic.features || []).map((f) => ({ key: f, label: feature(f) })),
71
+ usage: Object.keys(lic.limits || {}).map((k) => ({ key: k, label: kind(k), used: (lic.usage || {})[k], max: lic.limits[k] })),
72
+ addons: (lic.addons || []).map((a) => ({
73
+ id: a.guid,
74
+ name: a.name,
75
+ seats: a.seats || 0,
76
+ exp: a.exp,
77
+ })),
78
+ addonErrors: lic.addon_errors || [],
79
+ keyPresent: !!lic.key_present,
80
+ };
81
+ }
82
+
83
+ function UsageBar({ item }) {
84
+ const unlimited = item.max === null || item.max === undefined;
85
+ const used = item.used === undefined || item.used === null ? null : item.used;
86
+ const full = !unlimited && used !== null && used >= item.max;
87
+ const pct = unlimited ? 100 : Math.min(100, Math.round(((used || 0) / Math.max(1, item.max)) * 100));
88
+ return h(
89
+ "div",
90
+ { "data-testid": `license-limit-${item.key}` },
91
+ h(
92
+ "div",
93
+ { className: "scalio-license-bar-head" },
94
+ h("span", { className: "scalio-license-bar-label" }, item.label),
95
+ h("span", { className: cx("scalio-license-bar-value", full && "is-error") }, used === null ? "-" : used, " / ", unlimited ? "∞" : item.max)
96
+ ),
97
+ h("div", { className: "scalio-license-bar" }, h("div", { className: cx(full && "is-full", unlimited && "is-unlimited"), style: { width: `${pct}%` } }))
98
+ );
99
+ }
100
+
101
+ // Props: client (licenseApi or the same shape), map(license, {L, lang, kindLabel, featureLabel}) -> view model,
102
+ // lang, labels, product ("Citadel", for the remove question), kindLabel(k), featureLabel(f), noFeatures (text when
103
+ // the license has no features), notify(message, tone), confirm({title, action, danger}), onChange(license), children
104
+ // (extra parts at the end, e.g. a policy form), formatError(err) -> text, className.
105
+ function LicensePanel(props) {
106
+ const { client, lang, notify, confirm, onChange } = props;
107
+ const L = labelsFor(lang, props.labels).license;
108
+ const fmtErr = props.formatError || errorText;
109
+ const [lic, setLic] = React.useState(null);
110
+ const [loadError, setLoadError] = React.useState("");
111
+ const [key, setKey] = React.useState("");
112
+ const [addon, setAddon] = React.useState("");
113
+ const [busy, setBusy] = React.useState(false);
114
+ const [note, setNote] = React.useState(null); // {text, tone} when no notify is given
115
+
116
+ React.useEffect(() => {
117
+ let alive = true;
118
+ client.get().then((d) => alive && setLic(d)).catch((err) => alive && setLoadError(fmtErr(err)));
119
+ return () => { alive = false; };
120
+ }, [client]);
121
+
122
+ const say = (text, tone) => {
123
+ if (notify) notify(text, tone || "success");
124
+ else setNote({ text, tone: tone || "success" });
125
+ };
126
+ if (!lic) return loadError ? h("div", { className: "scalio-notice is-error", role: "alert" }, loadError) : null;
127
+
128
+ const map = props.map || mapScalioLicense;
129
+ const m = map(lic, { L, lang, kindLabel: props.kindLabel, featureLabel: props.featureLabel });
130
+
131
+ const run = async (fn, ok) => {
132
+ setBusy(true);
133
+ setNote(null);
134
+ try {
135
+ const d = await fn();
136
+ setLic(d);
137
+ if (onChange) onChange(d);
138
+ say(ok);
139
+ return true;
140
+ } catch (err) {
141
+ say(fmtErr(err), "error");
142
+ return false;
143
+ } finally {
144
+ setBusy(false);
145
+ }
146
+ };
147
+ const activate = async () => {
148
+ if (await run(() => client.activate(key.trim()), L.activated)) setKey("");
149
+ };
150
+ const remove = async () => {
151
+ const title = fill(L.removeConfirm, { product: props.product || "Scalio" });
152
+ const yes = confirm ? await confirm({ title, action: L.remove, danger: true }) : window.confirm(title);
153
+ if (yes) run(() => client.removeKey(), L.removed);
154
+ };
155
+ const addAddon = async () => {
156
+ if (await run(() => client.addAddon(addon.trim()), L.addonActivated)) setAddon("");
157
+ };
158
+ const copy = async (value) => {
159
+ try {
160
+ await navigator.clipboard.writeText(value);
161
+ say(L.idCopied);
162
+ } catch (e) {
163
+ say(L.copyFailed, "error");
164
+ }
165
+ };
166
+
167
+ const row = (r, i) =>
168
+ h(
169
+ "div",
170
+ { key: r.label + i },
171
+ h("dt", null, r.label),
172
+ r.copy
173
+ ? h("dd", null, h("button", { type: "button", className: "scalio-license-copy", title: L.copy, onClick: () => copy(r.value), "data-testid": r.testId },
174
+ r.value, h(Icons.Copy, { className: "scalio-icon", width: 12, height: 12 })))
175
+ : h("dd", { className: cx(r.mono && "is-mono"), "data-testid": r.testId }, r.tone ? h("span", { className: `is-${r.tone}` }, r.value) : r.value)
176
+ );
177
+
178
+ return h(
179
+ "section",
180
+ { className: cx("scalio-license", props.className), "data-testid": props.testId || "license-panel" },
181
+ h(
182
+ "div",
183
+ { className: "scalio-license-part" },
184
+ h("div", { className: "scalio-license-title" }, h(Icons.ShieldCheck, { className: "scalio-icon" }), L.title),
185
+ h("p", { className: "scalio-license-hint" }, m.managed ? L.managed : L.hint)
186
+ ),
187
+ note ? h("div", { className: cx("scalio-notice", note.tone === "error" ? "is-error" : "is-ok"), style: { margin: "1.25rem 1.25rem 0" }, role: "status" }, note.text) : null,
188
+ m.messages.length
189
+ ? h("div", { className: "scalio-notice is-warn scalio-license-messages", "data-testid": "license-messages" },
190
+ m.messages.map((t) => h("div", { key: t }, h(Icons.Alert, { className: "scalio-icon" }), h("span", null, t))))
191
+ : null,
192
+ h(
193
+ "div",
194
+ { className: "scalio-license-part scalio-license-grid" },
195
+ h(
196
+ "div",
197
+ { className: "scalio-license-status" },
198
+ h(
199
+ "div",
200
+ { className: "scalio-license-edition" },
201
+ h("span", { className: cx("scalio-badge", m.licensed ? "is-ok" : "is-warn"), "data-testid": "license-edition" }, m.edition),
202
+ m.customer ? h("span", { className: "scalio-license-customer" }, m.customer) : null
203
+ ),
204
+ h("dl", { className: "scalio-license-rows" }, m.rows.map(row)),
205
+ m.features
206
+ ? h(
207
+ "div",
208
+ null,
209
+ h("div", { className: "scalio-license-hint", style: { marginBottom: "0.25rem" } }, L.features),
210
+ m.features.length
211
+ ? h("ul", { className: "scalio-license-features", "data-testid": "license-features" },
212
+ m.features.map((f) => h("li", { key: f.key }, h(Icons.Check, { className: "scalio-icon" }), f.label)))
213
+ : h("p", { className: "scalio-license-hint" }, props.noFeatures || L.noFeatures)
214
+ )
215
+ : null,
216
+ props.statusExtra || null
217
+ ),
218
+ m.usage.length
219
+ ? h("div", { className: "scalio-license-usage" }, h("div", { className: "scalio-license-sub" }, L.usage), m.usage.map((u) => h(UsageBar, { key: u.key, item: u })))
220
+ : null
221
+ ),
222
+ h(
223
+ "div",
224
+ { className: "scalio-license-part", "data-testid": "license-addons" },
225
+ h("div", { className: "scalio-license-sub" }, h(Icons.Puzzle, { className: "scalio-icon", width: 14, height: 14 }), L.addons),
226
+ m.addons.length
227
+ ? h(
228
+ "div",
229
+ { className: "scalio-license-addons" },
230
+ m.addons.map((a) =>
231
+ h(
232
+ "div",
233
+ { key: a.id, className: "scalio-license-addon", "data-testid": `addon-card-${a.id}` },
234
+ h(
235
+ "div",
236
+ { style: { minWidth: 0 } },
237
+ h("div", { className: "scalio-license-addon-name" }, a.name),
238
+ h("p", { className: "scalio-license-addon-detail" },
239
+ a.seats ? `+${a.seats}` : h("span", { className: "is-ok" }, L.included),
240
+ a.exp ? ` · ${L.maintenanceUntil} ${a.exp}` : "")
241
+ ),
242
+ m.managed
243
+ ? null
244
+ : h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
245
+ "data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a), L.addonRemoved) },
246
+ h(Icons.Trash, { className: "scalio-icon" }))
247
+ )
248
+ )
249
+ )
250
+ : h("p", { className: "scalio-license-empty" }, L.noAddons),
251
+ m.addonErrors.map((e) => h("div", { key: e.key, className: "scalio-license-addon-error" }, e.error)),
252
+ m.managed
253
+ ? null
254
+ : h(
255
+ "div",
256
+ { className: "scalio-license-form" },
257
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 2, value: addon, placeholder: L.addonPlaceholder, disabled: busy,
258
+ "aria-label": L.addonPlaceholder, "data-testid": "license-addon-input", onChange: (e) => setAddon(e.target.value) }),
259
+ h("div", { className: "scalio-license-actions" },
260
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !addon.trim(), onClick: addAddon, "data-testid": "license-addon-add" },
261
+ h(Icons.Plus, { className: "scalio-icon" }), L.add))
262
+ )
263
+ ),
264
+ m.managed
265
+ ? null
266
+ : h(
267
+ "div",
268
+ { className: "scalio-license-part scalio-license-form" },
269
+ h("div", { className: "scalio-license-sub" }, h(Icons.KeyRound, { className: "scalio-icon", width: 14, height: 14 }), L.key),
270
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: key, placeholder: L.keyPlaceholder, disabled: busy,
271
+ "aria-label": L.keyPlaceholder, "data-testid": "license-key-input", onChange: (e) => setKey(e.target.value) }),
272
+ h(
273
+ "div",
274
+ { className: "scalio-license-actions" },
275
+ h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy || !key.trim(), onClick: activate, "data-testid": "license-activate" },
276
+ h(Icons.ShieldCheck, { className: "scalio-icon" }), L.activate),
277
+ m.keyPresent
278
+ ? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, onClick: remove, "data-testid": "license-remove" },
279
+ h(Icons.Trash, { className: "scalio-icon" }), L.remove)
280
+ : null
281
+ )
282
+ ),
283
+ props.children ? h("div", { className: "scalio-license-part" }, props.children) : null
284
+ );
285
+ }
286
+
287
+ module.exports = { LicensePanel, licenseApi, mapScalioLicense };
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ // Two-factor card for the profile page: authenticator app (TOTP), backup codes and passkeys.
3
+ // Same backend routes in every app (/auth/security, /auth/mfa/*, /auth/passkeys/*); securityApi(api, webauthn)
4
+ // maps them for an axios-like client and accepts snake_case (Citadel, Foundry, Rosetta) and camelCase (Nexus)
5
+ // answers. Every change asks for the current password inline. No runtime dependencies: the app passes the QR
6
+ // code renderer (`qr`) and the WebAuthn helpers from @simplewebauthn/browser.
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, a, b) {
17
+ if (!obj) return undefined;
18
+ return obj[a] !== undefined ? obj[a] : obj[b];
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
+ // The browser's own messages when the person closes the passkey dialog are not helpful.
41
+ function passkeyCancelled(err) {
42
+ return !!err && (err.name === "NotAllowedError" || err.name === "AbortError");
43
+ }
44
+
45
+ function normalizeSecurity(d) {
46
+ return {
47
+ hasPassword: !!pick(d, "has_password", "hasPassword"),
48
+ mfaEnabled: !!pick(d, "mfa_enabled", "mfaEnabled"),
49
+ backupCodesLeft: pick(d, "backup_codes_left", "backupCodesLeft") || 0,
50
+ passkeys: ((d && d.passkeys) || []).map((p) => ({
51
+ id: p.id,
52
+ name: p.name,
53
+ lastUsedAt: pick(p, "last_used_at", "lastUsedAt"),
54
+ createdAt: pick(p, "created_at", "createdAt"),
55
+ })),
56
+ };
57
+ }
58
+
59
+ // api: axios instance with the app's base URL (/api).
60
+ // webauthn: { startRegistration, browserSupportsWebAuthn } from @simplewebauthn/browser (optional: no passkeys without it).
61
+ function securityApi(api, webauthn) {
62
+ const w = webauthn || {};
63
+ const codes = (r) => pick(r.data, "backup_codes", "backupCodes") || [];
64
+ return {
65
+ security: () => api.get("/auth/security").then((r) => normalizeSecurity(r.data)),
66
+ setup: (password) => api.post("/auth/mfa/setup", { password }).then((r) => ({ secret: r.data.secret, uri: r.data.uri })),
67
+ enable: (code) => api.post("/auth/mfa/enable", { code: String(code).trim() }).then(codes),
68
+ backupCodes: (password) => api.post("/auth/mfa/backup-codes", { password }).then(codes),
69
+ disable: (password) => api.post("/auth/mfa/disable", { password }).then(() => true),
70
+ removePasskey: (id) => api.delete(`/auth/passkeys/${encodeURIComponent(id)}`).then(() => true),
71
+ passkeysSupported: () => !!(w.startRegistration && w.browserSupportsWebAuthn && w.browserSupportsWebAuthn()),
72
+ addPasskey: async (password, name) => {
73
+ const { data } = await api.post("/auth/passkeys/register/options", { password });
74
+ const response = await w.startRegistration({ optionsJSON: data.options });
75
+ // The apps name the challenge id differently; send it back under the name it came with.
76
+ const idKey = data.challengeId !== undefined ? "challengeId" : "challenge_id";
77
+ const body = { response, name };
78
+ body[idKey] = data[idKey];
79
+ return (await api.post("/auth/passkeys/register/verify", body)).data;
80
+ },
81
+ };
82
+ }
83
+
84
+ async function ask(confirm, title, action) {
85
+ if (confirm) return !!(await confirm({ title, action, danger: true }));
86
+ return typeof window !== "undefined" && window.confirm ? window.confirm(title) : true;
87
+ }
88
+
89
+ function Field({ id, label, children }) {
90
+ return h("div", { className: "scalio-security-field" }, h("label", { className: "scalio-field-label", htmlFor: id }, label), children);
91
+ }
92
+
93
+ // Props: client (securityApi), lang, labels, qr(uri) -> element, notify(message, tone), confirm({title, action, danger}),
94
+ // product ("Scalio Citadel", for the backup code file), email, onMfaChange(on), formatError(err) -> text, className.
95
+ function SecurityFactors(props) {
96
+ const { client, lang, qr, notify, confirm, product, email, onMfaChange } = props;
97
+ const L = labelsFor(lang, props.labels).mfa;
98
+ const fmtErr = props.formatError || errorText;
99
+ const [sec, setSec] = React.useState(null);
100
+ const [prompt, setPrompt] = React.useState(null); // {action, title}
101
+ const [password, setPassword] = React.useState("");
102
+ const [keyName, setKeyName] = React.useState("");
103
+ const [setup, setSetup] = React.useState(null); // {secret, uri}
104
+ const [code, setCode] = React.useState("");
105
+ const [codes, setCodes] = React.useState(null);
106
+ const [busy, setBusy] = React.useState(false);
107
+ const [error, setError] = React.useState("");
108
+ const [note, setNote] = React.useState("");
109
+
110
+ const say = (msg, tone) => {
111
+ if (notify) notify(msg, tone || "success");
112
+ else setNote(msg);
113
+ };
114
+ const load = React.useCallback(() => client.security().then(setSec).catch(() => setSec(false)), [client]);
115
+ React.useEffect(() => { load(); }, [load]);
116
+ if (!sec || !sec.hasPassword) return null;
117
+
118
+ const close = () => { setPrompt(null); setPassword(""); setError(""); };
119
+ const open = (action, title) => () => { setError(""); setNote(""); setPassword(""); setPrompt({ action, title }); };
120
+
121
+ const run = async (e) => {
122
+ e.preventDefault();
123
+ setError("");
124
+ setBusy(true);
125
+ try {
126
+ if (prompt.action === "setup") setSetup(await client.setup(password));
127
+ if (prompt.action === "codes") setCodes(await client.backupCodes(password));
128
+ if (prompt.action === "disable") {
129
+ await client.disable(password);
130
+ if (onMfaChange) onMfaChange(false);
131
+ say(L.turnedOff);
132
+ }
133
+ if (prompt.action === "passkey") {
134
+ await client.addPasskey(password, keyName.trim());
135
+ setKeyName("");
136
+ say(L.passkeyAdded);
137
+ }
138
+ close();
139
+ load();
140
+ } catch (err) {
141
+ if (!passkeyCancelled(err)) setError(fmtErr(err));
142
+ } finally {
143
+ setBusy(false);
144
+ }
145
+ };
146
+
147
+ const enable = async (e) => {
148
+ e.preventDefault();
149
+ setError("");
150
+ setBusy(true);
151
+ try {
152
+ setCodes(await client.enable(code));
153
+ setSetup(null);
154
+ setCode("");
155
+ if (onMfaChange) onMfaChange(true);
156
+ load();
157
+ } catch (err) {
158
+ setError(fmtErr(err));
159
+ } finally {
160
+ setBusy(false);
161
+ }
162
+ };
163
+
164
+ const removeKey = async (p) => {
165
+ if (!(await ask(confirm, fill(L.removePasskey, { name: p.name }), L.remove))) return;
166
+ try {
167
+ await client.removePasskey(p.id);
168
+ load();
169
+ } catch (err) {
170
+ say(fmtErr(err), "error");
171
+ }
172
+ };
173
+
174
+ const copyCodes = () => {
175
+ const c = typeof navigator !== "undefined" && navigator.clipboard;
176
+ if (c) c.writeText(codes.join("\n")).then(() => say(L.copied), () => null);
177
+ };
178
+ const download = () => {
179
+ const name = product || "Scalio";
180
+ const text = `${name}${email ? ` · ${email}` : ""}\n\n${codes.join("\n")}\n`;
181
+ const a = document.createElement("a");
182
+ a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
183
+ a.download = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-backup-codes.txt`;
184
+ a.click();
185
+ URL.revokeObjectURL(a.href);
186
+ };
187
+
188
+ const btn = (label, onClick, opts) =>
189
+ h("button", Object.assign({ type: "button", className: cx("scalio-btn", opts && opts.primary && "is-primary"), onClick }, opts && opts.attrs), label);
190
+ const errorBox = (id) => (error ? h("p", { className: "scalio-notice is-error", role: "alert", "data-testid": id }, error) : null);
191
+
192
+ const passkeys = sec.passkeys;
193
+ return h(
194
+ "div",
195
+ { className: cx("scalio-security", props.className), "data-testid": props.testId || "security-factors" },
196
+ note ? h("div", { className: "scalio-notice is-ok", role: "status" }, note) : null,
197
+ codes
198
+ ? h(
199
+ "div",
200
+ { className: "scalio-security-codes", "data-testid": "backup-codes-panel" },
201
+ h("div", { className: "scalio-security-title" }, L.backupTitle),
202
+ h("p", { className: "scalio-security-hint" }, L.backupHint),
203
+ h("div", { className: "scalio-security-code-list", "data-testid": "backup-codes" }, codes.map((c) => h("span", { key: c }, c))),
204
+ h(
205
+ "div",
206
+ { className: "scalio-security-actions" },
207
+ btn(L.copy, copyCodes),
208
+ btn(L.download, download),
209
+ btn(L.saved, () => setCodes(null), { primary: true, attrs: { "data-testid": "backup-codes-done-btn" } })
210
+ )
211
+ )
212
+ : null,
213
+ h(
214
+ "div",
215
+ { className: "scalio-security-block", "data-testid": "totp-block" },
216
+ h(
217
+ "div",
218
+ { className: "scalio-security-head" },
219
+ h(Icons.Smartphone, { className: "scalio-icon" }),
220
+ h(
221
+ "div",
222
+ null,
223
+ h("div", { className: "scalio-security-title" }, L.app),
224
+ h("p", { className: "scalio-security-hint", "data-testid": "totp-status" }, sec.mfaEnabled ? fill(L.on, { n: sec.backupCodesLeft }) : L.off)
225
+ )
226
+ ),
227
+ !setup && !prompt
228
+ ? h(
229
+ "div",
230
+ { className: "scalio-security-actions is-indented" },
231
+ sec.mfaEnabled
232
+ ? [
233
+ h(React.Fragment, { key: "c" }, btn(L.newCodes, open("codes", L.newCodes), { attrs: { "data-testid": "backup-codes-btn" } })),
234
+ h(React.Fragment, { key: "d" }, btn(L.turnOff, open("disable", L.turnOffTitle), { attrs: { "data-testid": "totp-disable-btn" } })),
235
+ ]
236
+ : btn(
237
+ h(React.Fragment, null, h(Icons.ShieldCheck, { className: "scalio-icon" }), " ", L.setUp),
238
+ open("setup", L.setUpTitle),
239
+ { primary: true, attrs: { "data-testid": "totp-setup-btn" } }
240
+ )
241
+ )
242
+ : null,
243
+ setup
244
+ ? h(
245
+ "form",
246
+ { onSubmit: enable, className: "scalio-security-form is-indented", "data-testid": "totp-enable-form" },
247
+ h("p", { className: "scalio-security-hint" }, L.scan),
248
+ qr ? h("div", { className: "scalio-security-qr", "data-testid": "totp-qr" }, qr(setup.uri)) : null,
249
+ h("div", { className: "scalio-security-hint" }, L.key, " ", h("span", { className: "scalio-security-secret", "data-testid": "totp-secret" }, setup.secret)),
250
+ h(
251
+ Field,
252
+ { id: "totp-code", label: L.code },
253
+ h("input", {
254
+ id: "totp-code", className: "scalio-input scalio-security-code", value: code, required: true, inputMode: "numeric",
255
+ autoComplete: "one-time-code", maxLength: 8, "data-testid": "totp-code-input", onChange: (e) => setCode(e.target.value),
256
+ })
257
+ ),
258
+ errorBox("totp-error"),
259
+ h(
260
+ "div",
261
+ { className: "scalio-security-actions" },
262
+ h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || !code.trim(), "data-testid": "totp-enable-btn" }, L.activate),
263
+ btn(L.cancel, () => { setSetup(null); setCode(""); setError(""); }, { attrs: { className: "scalio-btn is-ghost" } })
264
+ )
265
+ )
266
+ : null
267
+ ),
268
+ h(
269
+ "div",
270
+ { className: "scalio-security-block", "data-testid": "passkeys-block" },
271
+ h(
272
+ "div",
273
+ { className: "scalio-security-head" },
274
+ h(Icons.KeyRound, { className: "scalio-icon" }),
275
+ h("div", null, h("div", { className: "scalio-security-title" }, L.passkeys), h("p", { className: "scalio-security-hint" }, L.passkeysHint))
276
+ ),
277
+ passkeys.length
278
+ ? h(
279
+ "ul",
280
+ { className: "scalio-security-keys is-indented", "data-testid": "passkeys-list" },
281
+ passkeys.map((p) =>
282
+ h(
283
+ "li",
284
+ { key: p.id },
285
+ h("span", { className: "scalio-security-key-name" }, p.name),
286
+ h("span", { className: "scalio-security-key-date" },
287
+ p.lastUsedAt ? fill(L.used, { date: fmtDate(p.lastUsedAt, lang) }) : fill(L.added, { date: fmtDate(p.createdAt, lang) })),
288
+ h(
289
+ "button",
290
+ { type: "button", className: "scalio-btn is-ghost is-danger", onClick: () => removeKey(p), "aria-label": L.remove, title: L.remove,
291
+ "data-testid": `passkey-remove-${p.name}` },
292
+ h(Icons.Trash, { className: "scalio-icon" })
293
+ )
294
+ )
295
+ )
296
+ )
297
+ : null,
298
+ !prompt && client.passkeysSupported()
299
+ ? h("div", { className: "scalio-security-actions is-indented" }, btn(L.addPasskey, open("passkey", L.addPasskey), { attrs: { "data-testid": "passkey-add-btn" } }))
300
+ : null
301
+ ),
302
+ prompt
303
+ ? h(
304
+ "form",
305
+ { onSubmit: run, className: "scalio-security-confirm", "data-testid": "password-confirm-form" },
306
+ h("div", { className: "scalio-security-title" }, prompt.title),
307
+ prompt.action === "passkey"
308
+ ? h(Field, { id: "passkey-name", label: L.device },
309
+ h("input", { id: "passkey-name", className: "scalio-input", value: keyName, maxLength: 60, placeholder: L.deviceExample,
310
+ "data-testid": "passkey-name-input", onChange: (e) => setKeyName(e.target.value) }))
311
+ : null,
312
+ h(Field, { id: "confirm-password", label: L.currentPassword },
313
+ h("input", { id: "confirm-password", type: "password", className: "scalio-input", value: password, required: true, autoFocus: true,
314
+ autoComplete: "current-password", "data-testid": "confirm-password-input", onChange: (e) => setPassword(e.target.value) })),
315
+ errorBox("security-error"),
316
+ h(
317
+ "div",
318
+ { className: "scalio-security-actions" },
319
+ h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy, "data-testid": "password-confirm-btn" }, busy ? L.verifying : L.continue),
320
+ btn(L.cancel, close, { attrs: { className: "scalio-btn is-ghost" } })
321
+ )
322
+ )
323
+ : null
324
+ );
325
+ }
326
+
327
+ module.exports = { SecurityFactors, securityApi, passkeyCancelled };
package/react/support.js CHANGED
@@ -257,7 +257,11 @@ function SupportConnection({ client, lang, labels, onChange }) {
257
257
  const [note, setNote] = React.useState("");
258
258
  React.useEffect(() => {
259
259
  let alive = true;
260
- client.connection().then((d) => alive && setSt(d)).catch((err) => alive && (setError(errorText(err)), setSt(false)));
260
+ client.connection().then((d) => {
261
+ if (!alive) return;
262
+ setSt(d);
263
+ if (d && d.default_portal) setPortal((p) => p || d.default_portal); // the portal of this instance's stage
264
+ }).catch((err) => alive && (setError(errorText(err)), setSt(false)));
261
265
  return () => { alive = false; };
262
266
  }, [client]);
263
267
  const run = async (fn, ok) => {