@scalio-cloud/ui 0.6.2 → 0.8.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.
@@ -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 };
package/react/nav.js CHANGED
@@ -411,9 +411,11 @@ function resetScalioApps() {
411
411
  }
412
412
 
413
413
  // --- Support drawer ---------------------------------------------------------------------------
414
- // onSubmit({ subject, description }) sends the request (and shows the app's toast); a thrown error is
415
- // shown in the drawer. context: rows like [["URL", "/drifts"], ["Tenant", "Contoso"]].
416
- function SupportDrawer({ open, onClose, onSubmit, context, lang, labels }) {
414
+ // With client (supportApi(api), see support.js): tabs "Neue Anfrage" and "Meine Tickets"; tickets go to the
415
+ // central support desk (ADR 0022), page is the current path. Without client: one form, onSubmit({ subject,
416
+ // description }) sends it (and shows the app's toast); a thrown error is shown in the drawer.
417
+ // context: rows like [["URL", "/drifts"], ["Tenant", "Contoso"]].
418
+ function SupportDrawer({ open, onClose, onSubmit, client, page, context, lang, labels }) {
417
419
  const L = labelsFor(lang, labels);
418
420
  const [subject, setSubject] = React.useState("");
419
421
  const [description, setDescription] = React.useState("");
@@ -467,7 +469,9 @@ function SupportDrawer({ open, onClose, onSubmit, context, lang, labels }) {
467
469
  context.map((row, i) => h("div", { key: i }, `${row[0]}: ${row[1] == null || row[1] === "" ? "—" : row[1]}`))
468
470
  )
469
471
  : null,
470
- h(
472
+ client
473
+ ? h(require("./support").SupportDesk, { client, page, lang, labels })
474
+ : h(
471
475
  "form",
472
476
  { className: "scalio-support-form", onSubmit: submit },
473
477
  h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
@@ -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 };