@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.
- package/CHANGELOG.md +14 -0
- package/README.md +34 -2
- package/nav.css +119 -0
- package/package.json +1 -1
- package/react/icons.js +26 -1
- package/react/index.js +1 -1
- package/react/labels.js +223 -0
- package/react/license.js +287 -0
- package/react/nav.js +8 -4
- package/react/security.js +327 -0
- package/react/support.js +336 -0
package/react/license.js
ADDED
|
@@ -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
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
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
|
-
|
|
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 };
|