@scalio-cloud/ui 0.34.0 → 0.35.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/README.md CHANGED
@@ -356,6 +356,29 @@ to `accountTo` (the profile page) instead of allowing the reset.
356
356
  `SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
357
357
  keep the fields inside a `SettingsSection` the same in every app.
358
358
 
359
+ ## Gruppe System: Sicherung (React)
360
+
361
+ Since 0.35.0 (ADR 0069 W5) Einstellungen → System → Sicherung is the same in every app. Backend: scalio-backup 0.2.0
362
+ `web.backup_router` (`/api/backup/status`, `/api/backup/export…`, `/api/backup/import/…`), the lock for the import is
363
+ scalio-platform 0.14.0 `maintenance`.
364
+
365
+ ```jsx
366
+ import { BackupPanel, MaintenanceNotice, backupApi, isMaintenance } from "@scalio-cloud/ui/react";
367
+
368
+ const backup = backupApi(api); // once, outside the component
369
+ <BackupPanel client={backup} lang={lang} accountTo="/profile" link={Link} onSignIn={() => logout()}
370
+ labels={{ backup: { withDataOn: "Konfiguration plus Scans, Abweichungen und Protokoll." } }} />
371
+
372
+ // while an import runs the API answers 423: show this instead of the page, it reloads once the lock is gone
373
+ {maintenance && <MaintenanceNotice client={backup} lang={lang} />} // maintenance = isMaintenance(axiosError)
374
+ ```
375
+
376
+ Two areas: «Verschlüsselter Export» (data and secrets switches, file password twice, note, password and code of the
377
+ admin, one download) and «Import» (upload in chunks, file password, preview with the warnings of the backend and the
378
+ app's own warnings as `{code, text}`, confirmation word, other tenant tick, password and code, progress, checklist
379
+ after the import, «Import rückgängig machen» for 7 days). Like the reset, an account without a second factor cannot
380
+ start either and is sent to `accountTo`. `BackupExport` and `BackupImport` are the two areas on their own.
381
+
359
382
  ## Updates im SaaS-Betrieb: Banner und Einstellungen → Updates (React)
360
383
 
361
384
  Since 0.30.0 (scalio-docs ADR 0074) a SaaS instance shows the update Compass offers it. Server side: scalio-connect
package/nav.css CHANGED
@@ -814,3 +814,6 @@
814
814
  .scalio-facts dt { color: hsl(var(--muted-foreground)); }
815
815
  .scalio-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
816
816
  .scalio-page-title .scalio-badge { vertical-align: middle; margin-left: 0.25rem; }
817
+ /* Einstellungen -> Sicherung (BackupPanel, ADR 0069 W5) */
818
+ .scalio-notice > ul, .scalio-notice-body > ul { margin: 0; padding-left: 1.25rem; }
819
+ ul.scalio-notice { flex-direction: column; gap: 0.25rem; padding-left: 2rem; list-style: disc; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
@@ -0,0 +1,551 @@
1
+ "use strict";
2
+ // Einstellungen -> System -> Sicherung (ADR 0069 wartung/W5; after Nexus BackupSection and ImportSection).
3
+ // Backend: scalio-backup `web.backup_router` (GET /api/backup/status, POST /api/backup/export, /api/backup/import/...).
4
+ const React = require("react");
5
+ const h = React.createElement;
6
+ const Icons = require("./icons");
7
+ const { labelsFor } = require("./labels");
8
+ const { SettingsSection, SettingsField: Field } = require("./settings");
9
+
10
+ const SMALL_CHUNK = 768 * 1024; // for reverse proxies that keep the 1 MB default body limit
11
+ const CHECKLIST_KEY = "scalio.backupImportChecklist";
12
+
13
+ function fill(text, vars) {
14
+ return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m));
15
+ }
16
+
17
+ function fmtSize(bytes) {
18
+ if (!bytes) return "0 B";
19
+ const u = ["B", "KB", "MB", "GB"];
20
+ const i = Math.min(u.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
21
+ return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
22
+ }
23
+
24
+ function fmtDate(iso, lang) {
25
+ if (!iso) return "–";
26
+ const d = new Date(iso);
27
+ if (Number.isNaN(d.getTime())) return iso;
28
+ return d.toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "medium", timeStyle: "short" });
29
+ }
30
+
31
+ // A backend code (string or {code}) as text; anything unknown is shown as sent.
32
+ function codeText(L, code, fallback) {
33
+ const E = L.errors || {};
34
+ if (typeof code !== "string" || !code) return fallback || "";
35
+ if (E[code]) return E[code];
36
+ if (code.startsWith("incompatible")) return fill(E.incompatible, { error: code.replace(/^incompatible:\s*/, "") });
37
+ if (code.startsWith("damaged")) return E.damaged;
38
+ return fallback || code;
39
+ }
40
+
41
+ function errText(L, err) {
42
+ const d = err && err.response && err.response.data && err.response.data.detail;
43
+ const code = typeof d === "string" ? d : d && d.code;
44
+ if (code) return codeText(L, code, code);
45
+ return (err && err.message) || String(err);
46
+ }
47
+
48
+ // api: axios instance with the app's base URL (/api).
49
+ function backupApi(api) {
50
+ return {
51
+ status: () => api.get("/backup/status").then((r) => r.data),
52
+ startExport: (body) => api.post("/backup/export", body).then((r) => r.data),
53
+ exportJob: (id) => api.get(`/backup/export/${id}`).then((r) => r.data),
54
+ download: (id) => api.get(`/backup/export/${id}/download`, { responseType: "blob" }).then((r) => r.data),
55
+ progress: () => api.get("/backup/import/progress").then((r) => r.data),
56
+ importStatus: () => api.get("/backup/import/status").then((r) => r.data),
57
+ newUpload: (body) => api.post("/backup/import/uploads", body).then((r) => r.data),
58
+ chunk: (id, offset, part) => api.put(`/backup/import/uploads/${id}`, part, {
59
+ headers: { "Content-Type": "application/octet-stream", "X-Upload-Offset": String(offset) },
60
+ }).then((r) => r.data),
61
+ open: (id, password) => api.post(`/backup/import/uploads/${id}/open`, { password }).then((r) => r.data),
62
+ upload: (id) => api.get(`/backup/import/uploads/${id}`).then((r) => r.data),
63
+ discard: (id) => api.delete(`/backup/import/uploads/${id}`).then((r) => r.data),
64
+ apply: (id, body) => api.post(`/backup/import/uploads/${id}/apply`, body).then((r) => r.data),
65
+ rollback: (body) => api.post("/backup/import/rollback", body).then((r) => r.data),
66
+ };
67
+ }
68
+
69
+ function readChecklist() {
70
+ try { return JSON.parse(window.localStorage.getItem(CHECKLIST_KEY) || "null"); } catch (e) { return null; }
71
+ }
72
+ function writeChecklist(v) {
73
+ try {
74
+ if (v) window.localStorage.setItem(CHECKLIST_KEY, JSON.stringify(v));
75
+ else window.localStorage.removeItem(CHECKLIST_KEY);
76
+ } catch (e) { /* private mode */ }
77
+ }
78
+
79
+ function checklistFor(pv) {
80
+ const codes = new Set(((pv && pv.warnings) || []).map((w) => w.code));
81
+ const items = ["signIn", "license"];
82
+ if (codes.has("otherTenant") || codes.has("modeChange")) items.push("connection");
83
+ if (codes.has("noSecrets")) items.push("secrets");
84
+ if (codes.has("hostChange")) items.push("address");
85
+ return items;
86
+ }
87
+
88
+ // Password (when the account has one) and the second factor, like Zurücksetzen (N21). st: GET /backup/status.
89
+ function reauthReady(st, cur, code) {
90
+ if (!st || st.secondFactor === false) return false;
91
+ return (!st.password || !!cur) && !!String(code || "").trim();
92
+ }
93
+
94
+ function Reauth(props) {
95
+ const { st, L, prefix, cur, setCur, code, setCode, disabled } = props;
96
+ if (!st) return null;
97
+ if (st.secondFactor === false) {
98
+ return h("div", { className: "scalio-notice is-warn", "data-testid": `${prefix}-no-factor` },
99
+ h("span", null, L.noFactor),
100
+ props.accountTo && st.password
101
+ ? h(props.link || "a", Object.assign({ className: "scalio-btn" },
102
+ props.link ? { to: props.accountTo } : { href: props.accountTo }), L.setUpFactor)
103
+ : null);
104
+ }
105
+ return h("div", { className: "scalio-settings-form" },
106
+ h("p", { className: "scalio-field-label" }, h(Icons.KeyRound, { className: "scalio-icon" }), " ", L.reauthTitle),
107
+ st.password
108
+ ? h(Field, { id: `${prefix}-current-password`, label: L.currentPassword },
109
+ h("input", { id: `${prefix}-current-password`, type: "password", className: "scalio-input", value: cur, disabled,
110
+ autoComplete: "current-password", "data-testid": `${prefix}-current-password`, onChange: (e) => setCur(e.target.value) }))
111
+ : null,
112
+ h(Field, { id: `${prefix}-code`, label: L.code },
113
+ h("input", { id: `${prefix}-code`, className: "scalio-input", value: code, disabled, inputMode: "numeric",
114
+ autoComplete: "one-time-code", "data-testid": `${prefix}-code`, onChange: (e) => setCode(e.target.value) })));
115
+ }
116
+
117
+ function useNotify(props) {
118
+ const [error, setError] = React.useState("");
119
+ const [note, setNote] = React.useState("");
120
+ const notify = (text, tone) => {
121
+ if (props.notify) { props.notify(text, tone); return; }
122
+ if (tone === "error") { setError(text); setNote(""); } else { setNote(text); setError(""); }
123
+ };
124
+ const box = h(React.Fragment, null,
125
+ error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": `${props.prefix}-error` }, error) : null,
126
+ note ? h("div", { className: "scalio-notice is-ok", "data-testid": `${props.prefix}-note` }, note) : null);
127
+ return { notify, box, clear: () => { setError(""); setNote(""); } };
128
+ }
129
+
130
+ // ------------------------------------------------------------------------------------------------- export ---
131
+ function BackupExport(props) {
132
+ const { client, lang } = props;
133
+ const L = labelsFor(lang, props.labels).backup;
134
+ const n = useNotify({ notify: props.notify, prefix: "backup-export" });
135
+ const [st, setSt] = React.useState(null);
136
+ const [pw, setPw] = React.useState("");
137
+ const [pw2, setPw2] = React.useState("");
138
+ const [cur, setCur] = React.useState("");
139
+ const [code, setCode] = React.useState("");
140
+ const [withData, setWithData] = React.useState(true);
141
+ const [withSecrets, setWithSecrets] = React.useState(true);
142
+ const [ack, setAck] = React.useState(false);
143
+ const [busy, setBusy] = React.useState(false);
144
+ const [job, setJob] = React.useState(null);
145
+ const timer = React.useRef(null);
146
+ const poll = props.pollMs || 1500;
147
+
148
+ const watch = (id) => {
149
+ clearTimeout(timer.current);
150
+ timer.current = setTimeout(async () => {
151
+ try {
152
+ const data = await client.exportJob(id);
153
+ setJob(data);
154
+ if (data.status === "running") watch(id);
155
+ else if (data.status === "failed") n.notify(fill(L.failed, { error: data.error || "" }), "error");
156
+ } catch (err) { n.notify(errText(L, err), "error"); }
157
+ }, poll);
158
+ };
159
+
160
+ React.useEffect(() => {
161
+ let alive = true;
162
+ client.status().then((data) => {
163
+ if (!alive) return;
164
+ setSt(data);
165
+ if (props.onStatus) props.onStatus(data);
166
+ const jobs = data.jobs || [];
167
+ const running = jobs.find((j) => j.status === "running");
168
+ const ready = jobs.find((j) => j.status === "done");
169
+ if (running) { setJob(running); watch(running.id); } else if (ready) setJob(ready);
170
+ }).catch((err) => alive && n.notify(errText(L, err), "error"));
171
+ return () => { alive = false; clearTimeout(timer.current); };
172
+ }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
173
+
174
+ const minLen = (st && st.minPasswordLength) || 12;
175
+ const pwOk = pw.length >= minLen && pw === pw2;
176
+ const running = job && job.status === "running";
177
+ const disabledReason = st && st.disabled;
178
+ const ready = !!st && !disabledReason && pwOk && ack && reauthReady(st, cur, code) && !busy && !running;
179
+
180
+ const start = async (e) => {
181
+ e.preventDefault();
182
+ if (!ready) return;
183
+ setBusy(true);
184
+ n.clear();
185
+ try {
186
+ const data = await client.startExport({ password: pw, includeData: withData, includeSecrets: withSecrets,
187
+ currentPassword: cur, mfaCode: code });
188
+ setJob(data);
189
+ setPw(""); setPw2(""); setCur(""); setCode(""); setAck(false);
190
+ watch(data.id);
191
+ } catch (err) {
192
+ n.notify(errText(L, err), "error");
193
+ } finally {
194
+ setBusy(false);
195
+ }
196
+ };
197
+
198
+ const download = async () => {
199
+ try {
200
+ const blob = await client.download(job.id);
201
+ const url = URL.createObjectURL(blob);
202
+ const a = document.createElement("a");
203
+ a.href = url;
204
+ a.download = job.filename || "backup.scaliobak";
205
+ document.body.appendChild(a);
206
+ a.click();
207
+ a.remove();
208
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
209
+ setJob(null);
210
+ n.notify(L.downloaded, "ok");
211
+ } catch (err) { n.notify(errText(L, err), "error"); }
212
+ };
213
+
214
+ const check = (testId, checked, set, title, on, off) => h("label", { className: "scalio-settings-check" },
215
+ h("input", { type: "checkbox", checked, disabled: busy, "data-testid": testId, onChange: (e) => set(e.target.checked) }),
216
+ h("span", null, title, h("span", { className: "scalio-settings-help" }, checked ? on : off)));
217
+
218
+ return h(SettingsSection, { id: "backup-export", title: L.exportTitle, description: L.exportHint, testId: "backup-export" },
219
+ h("form", { className: "scalio-settings-form", onSubmit: start },
220
+ h("p", { className: "scalio-settings-text" }, L.exportIntro),
221
+ disabledReason ? h("div", { className: "scalio-notice is-info", "data-testid": "backup-disabled" }, disabledReason) : null,
222
+ check("backup-with-data", withData, setWithData, L.withData, L.withDataOn, L.withDataOff),
223
+ check("backup-with-secrets", withSecrets, setWithSecrets, L.withSecrets, L.withSecretsOn, L.withSecretsOff),
224
+ h("p", { className: "scalio-settings-help" }, L.notIncluded),
225
+ h(Field, { id: "backup-password", label: L.password, hint: fill(L.passwordHint, { n: minLen }) },
226
+ h("input", { id: "backup-password", type: "password", className: "scalio-input", value: pw, disabled: busy,
227
+ autoComplete: "new-password", "data-testid": "backup-password", onChange: (e) => setPw(e.target.value) })),
228
+ h(Field, { id: "backup-password2", label: L.password2 },
229
+ h("input", { id: "backup-password2", type: "password", className: "scalio-input", value: pw2, disabled: busy,
230
+ autoComplete: "new-password", "data-testid": "backup-password2", onChange: (e) => setPw2(e.target.value) }),
231
+ pw2 && pw !== pw2 ? h("p", { className: "scalio-settings-help is-error", "data-testid": "backup-mismatch" }, L.mismatch) : null),
232
+ h("div", { className: "scalio-notice is-warn" },
233
+ h("label", { className: "scalio-settings-check" },
234
+ h("input", { type: "checkbox", checked: ack, disabled: busy, "data-testid": "backup-ack", onChange: (e) => setAck(e.target.checked) }),
235
+ h("span", null, L.warning))),
236
+ h(Reauth, { st, L, prefix: "backup", cur, setCur, code, setCode, disabled: busy, accountTo: props.accountTo, link: props.link }),
237
+ n.box,
238
+ job && job.status === "done"
239
+ ? h("div", { className: "scalio-notice is-ok", "data-testid": "backup-ready" },
240
+ h("div", { className: "scalio-notice-body" },
241
+ h("div", { className: "scalio-notice-title" }, L.ready),
242
+ h("div", { className: "scalio-mono" }, `${job.filename} · ${fmtSize(job.size)} · ${fmtDate(job.finishedAt, lang)}`),
243
+ h("div", null, L.readyHint)),
244
+ h("div", { className: "scalio-notice-actions" },
245
+ h("button", { type: "button", className: "scalio-btn is-solid", onClick: download, "data-testid": "backup-download" },
246
+ h(Icons.Download, { className: "scalio-icon" }), L.download)))
247
+ : null,
248
+ h("div", { className: "scalio-settings-section-footer" },
249
+ h("button", { type: "submit", className: "scalio-btn is-solid", disabled: !ready, "data-testid": "backup-export-start" },
250
+ h(Icons.FileStack, { className: "scalio-icon" }), running ? L.running : L.start))));
251
+ }
252
+
253
+ // ------------------------------------------------------------------------------------------------- import ---
254
+ function warningText(L, w) {
255
+ const map = { otherTenant: L.wOtherTenant, modeChange: L.wModeChange, hostChange: L.wHostChange, noSecrets: L.wNoSecrets,
256
+ configOnly: L.wConfigOnly };
257
+ return w.text || (map[w.code] ? fill(map[w.code], w) : w.code);
258
+ }
259
+
260
+ function BackupImport(props) {
261
+ const { client, lang } = props;
262
+ const L = labelsFor(lang, props.labels).backup;
263
+ const n = useNotify({ notify: props.notify, prefix: "import" });
264
+ const [st, setSt] = React.useState(props.status || null);
265
+ const [status, setStatus] = React.useState(null);
266
+ const [up, setUp] = React.useState(null);
267
+ const [sending, setSending] = React.useState(false);
268
+ const [filePw, setFilePw] = React.useState("");
269
+ const [confirm, setConfirm] = React.useState("");
270
+ const [otherOk, setOtherOk] = React.useState(false);
271
+ const [cur, setCur] = React.useState("");
272
+ const [code, setCode] = React.useState("");
273
+ const [busy, setBusy] = React.useState(false);
274
+ const [progress, setProgress] = React.useState(null);
275
+ const [checklist, setChecklist] = React.useState(readChecklist());
276
+ const fileRef = React.useRef(null);
277
+ const timer = React.useRef(null);
278
+ const poll = props.pollMs || 1500;
279
+
280
+ React.useEffect(() => { if (props.status) setSt(props.status); }, [props.status]);
281
+
282
+ const load = async () => {
283
+ try {
284
+ if (!props.status) setSt(await client.status());
285
+ const data = await client.importStatus();
286
+ setStatus(data);
287
+ const open = (data.uploads || []).find((u) => ["ready", "opening", "uploaded"].includes(u.status));
288
+ if (open) setUp(open);
289
+ if (open && open.status === "opening") watchUpload(open.id);
290
+ } catch (err) { n.notify(errText(L, err), "error"); }
291
+ };
292
+ React.useEffect(() => { load(); return () => clearTimeout(timer.current); }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
293
+
294
+ const watchUpload = (id) => {
295
+ clearTimeout(timer.current);
296
+ timer.current = setTimeout(async () => {
297
+ try {
298
+ const data = await client.upload(id);
299
+ setUp(data);
300
+ if (data.status === "opening" || data.status === "applying") watchUpload(id);
301
+ else if (data.status === "ready" && data.error && progress) setProgress(null);
302
+ } catch (err) { n.notify(errText(L, err), "error"); }
303
+ }, poll);
304
+ };
305
+
306
+ const watchProgress = (kind, pv, id) => {
307
+ clearTimeout(timer.current);
308
+ timer.current = setTimeout(async () => {
309
+ try {
310
+ const data = await client.progress();
311
+ if (data.active) { setProgress(data); watchProgress(kind, pv, id); return; }
312
+ if (data.result === "done" || data.result === "rolled_back") {
313
+ setProgress(null);
314
+ const list = { kind: data.result, items: kind === "import" ? checklistFor(pv) : ["signIn"],
315
+ secrets: (((pv && pv.warnings) || []).find((w) => w.code === "noSecrets") || {}).fields || [], at: data.finishedAt };
316
+ writeChecklist(list);
317
+ setChecklist(list);
318
+ setUp(null);
319
+ return;
320
+ }
321
+ // not started (another import, jobs still running) or failed: the upload says why
322
+ if (id) {
323
+ const u = await client.upload(id).catch(() => null);
324
+ if (u && u.status === "applying") { watchProgress(kind, pv, id); return; }
325
+ setProgress(null);
326
+ if (u) setUp(u);
327
+ if (u && u.status === "ready" && u.error) { n.notify(codeText(L, u.error, L.importFailed), "error"); return; }
328
+ }
329
+ setProgress(null);
330
+ n.notify(kind === "import" ? L.importFailed : L.rollbackFailed, "error");
331
+ } catch (err) { watchProgress(kind, pv, id); } // 423 or a restart: keep asking
332
+ }, poll);
333
+ };
334
+
335
+ const sendFile = async (file) => {
336
+ if (!file) return;
337
+ if (status && status.maxBytes && file.size > status.maxBytes) { n.notify(L.errors.file_too_large, "error"); return; }
338
+ setSending(true);
339
+ n.clear();
340
+ try {
341
+ const data = await client.newUpload({ size: file.size, filename: file.name });
342
+ let current = data;
343
+ setUp(current);
344
+ let size = Math.min((status && status.chunkBytes) || 8 * 1024 * 1024, 8 * 1024 * 1024);
345
+ let offset = 0;
346
+ while (offset < file.size) {
347
+ const part = file.slice(offset, offset + size);
348
+ try {
349
+ const r = await client.chunk(data.id, offset, part);
350
+ offset = r.received;
351
+ current = Object.assign({}, current, { received: offset, status: r.status });
352
+ setUp(current);
353
+ } catch (err) {
354
+ const s = err && err.response && err.response.status;
355
+ const det = err && err.response && err.response.data && err.response.data.detail;
356
+ if (s === 413 && size > SMALL_CHUNK) { size = SMALL_CHUNK; continue; }
357
+ if (s === 409 && det && det.code === "bad_offset") { offset = det.received; continue; }
358
+ throw err;
359
+ }
360
+ }
361
+ } catch (err) {
362
+ n.notify(errText(L, err), "error");
363
+ setUp(null);
364
+ } finally {
365
+ setSending(false);
366
+ if (fileRef.current) fileRef.current.value = "";
367
+ }
368
+ };
369
+
370
+ const openFile = async (e) => {
371
+ if (e) e.preventDefault();
372
+ setBusy(true);
373
+ n.clear();
374
+ try {
375
+ const data = await client.open(up.id, filePw);
376
+ setUp(data);
377
+ setFilePw("");
378
+ watchUpload(data.id);
379
+ } catch (err) { n.notify(errText(L, err), "error"); } finally { setBusy(false); }
380
+ };
381
+
382
+ const discard = async () => {
383
+ try { if (up && up.id) await client.discard(up.id); } catch (err) { /* gone already */ }
384
+ setUp(null); setConfirm(""); setOtherOk(false);
385
+ };
386
+
387
+ const apply = async (e) => {
388
+ e.preventDefault();
389
+ setBusy(true);
390
+ n.clear();
391
+ try {
392
+ const pv = up.preview;
393
+ await client.apply(up.id, { confirm, confirmOtherTenant: otherOk, currentPassword: cur, mfaCode: code });
394
+ setCur(""); setCode("");
395
+ setUp(Object.assign({}, up, { status: "applying", error: null }));
396
+ setProgress({ active: true, kind: "import", phase: "waiting" });
397
+ watchProgress("import", pv, up.id);
398
+ } catch (err) { n.notify(errText(L, err), "error"); } finally { setBusy(false); }
399
+ };
400
+
401
+ const undo = async () => {
402
+ if (!window.confirm(L.rollbackConfirm)) return;
403
+ setBusy(true);
404
+ n.clear();
405
+ try {
406
+ await client.rollback({ currentPassword: cur, mfaCode: code });
407
+ setCur(""); setCode("");
408
+ setProgress({ active: true, kind: "rollback", phase: "waiting" });
409
+ watchProgress("rollback", null, null);
410
+ } catch (err) { n.notify(errText(L, err), "error"); } finally { setBusy(false); }
411
+ };
412
+
413
+ const pv = up && up.preview;
414
+ const last = status && status.last;
415
+ const running = progress && progress.active;
416
+ const reauthOk = reauthReady(st, cur, code);
417
+ const reauth = (prefix) => h(Reauth, { st, L, prefix, cur, setCur, code, setCode, disabled: busy, accountTo: props.accountTo, link: props.link });
418
+ const row = (label, value, mono) => h(React.Fragment, null, h("dt", null, label), h("dd", { className: mono ? "scalio-mono" : undefined }, value || "–"));
419
+ const confirmOk = pv && confirm.trim().toLowerCase() === String(pv.confirmWord).toLowerCase();
420
+
421
+ return h(SettingsSection, { id: "backup-import", title: L.importTitle, description: L.importHint, testId: "backup-import" },
422
+ h("div", { className: "scalio-settings-form" },
423
+ h("p", { className: "scalio-settings-text" }, L.importIntro),
424
+ running
425
+ ? h("div", { className: "scalio-notice is-info", "data-testid": "import-running" },
426
+ h("span", null, `${progress.kind === "rollback" ? L.rollbackRunning : L.importRunning} · ${(L.phase || {})[progress.phase || "waiting"] || ""}`))
427
+ : null,
428
+ checklist && !running
429
+ ? h("div", { className: "scalio-notice is-ok", "data-testid": "import-checklist" },
430
+ h("div", { className: "scalio-notice-body" },
431
+ h("div", { className: "scalio-notice-title" }, checklist.kind === "rolled_back" ? L.rollbackDone : L.importDone),
432
+ h("ul", null, checklist.items.map((k) => h("li", { key: k }, fill((L.check || {})[k], { fields: (checklist.secrets || []).join(", ") }))))),
433
+ h("div", { className: "scalio-notice-actions" },
434
+ h("button", { type: "button", className: "scalio-btn is-solid", "data-testid": "import-sign-in",
435
+ onClick: () => { writeChecklist(null); if (props.onSignIn) props.onSignIn(); else window.location.assign("/"); } }, L.signInAgain),
436
+ h("button", { type: "button", className: "scalio-btn", onClick: () => { writeChecklist(null); setChecklist(null); } }, L.checklistDismiss)))
437
+ : null,
438
+ !running && !up
439
+ ? h("div", { className: "scalio-settings-section-footer" },
440
+ h("input", { ref: fileRef, type: "file", accept: ".scaliobak", hidden: true, "data-testid": "import-file",
441
+ onChange: (e) => sendFile(e.target.files && e.target.files[0]) }),
442
+ h("span", { className: "scalio-settings-help" }, fill(L.maxSize, { n: Math.round(((status && status.maxBytes) || 2 * 1024 ** 3) / 1024 ** 3) })),
443
+ h("button", { type: "button", className: "scalio-btn", disabled: sending, "data-testid": "import-choose",
444
+ onClick: () => fileRef.current && fileRef.current.click() }, h(Icons.Upload, { className: "scalio-icon" }), L.chooseFile))
445
+ : null,
446
+ !running && up && up.status === "uploading"
447
+ ? h("div", { "data-testid": "import-uploading" },
448
+ h("p", { className: "scalio-settings-text" }, fill(L.uploading, { name: up.filename })),
449
+ h("progress", { max: up.size || 1, value: up.received || 0, style: { width: "100%" } }))
450
+ : null,
451
+ !running && up && (up.status === "uploaded" || up.status === "opening")
452
+ ? h("form", { className: "scalio-settings-form", onSubmit: openFile, "data-testid": "import-password" },
453
+ h("p", { className: "scalio-settings-text" }, fill(L.uploaded, { name: up.filename })),
454
+ up.error ? h("div", { className: "scalio-notice is-error", "data-testid": "import-upload-error" }, codeText(L, up.error, L.importFailed)) : null,
455
+ h(Field, { id: "import-file-password", label: L.filePassword },
456
+ h("input", { id: "import-file-password", type: "password", className: "scalio-input", value: filePw, autoComplete: "off",
457
+ disabled: busy || up.status === "opening", "data-testid": "import-file-password", onChange: (e) => setFilePw(e.target.value) })),
458
+ h("div", { className: "scalio-settings-section-footer" },
459
+ h("button", { type: "button", className: "scalio-btn", onClick: discard }, L.discard),
460
+ h("button", { type: "submit", className: "scalio-btn is-solid", disabled: busy || !filePw || up.status === "opening", "data-testid": "import-open" },
461
+ up.status === "opening" ? L.checking : L.openFile)))
462
+ : null,
463
+ !running && up && up.status === "failed"
464
+ ? h("div", { className: "scalio-notice is-error", "data-testid": "import-failed" },
465
+ h("span", { className: "scalio-notice-body" }, codeText(L, up.error, L.importFailed)),
466
+ h("div", { className: "scalio-notice-actions" }, h("button", { type: "button", className: "scalio-btn", onClick: discard }, L.discard)))
467
+ : null,
468
+ !running && up && up.status === "ready" && pv
469
+ ? h("form", { className: "scalio-settings-form", onSubmit: apply, "data-testid": "import-preview" },
470
+ up.error ? h("div", { className: "scalio-notice is-error", "data-testid": "import-apply-error" }, codeText(L, up.error, L.importFailed)) : null,
471
+ h("dl", { className: "scalio-audit-dl" },
472
+ row(L.pvSource, `${(pv.source && pv.source.host) || "–"} (${(pv.source && pv.source.mode) || "–"})`),
473
+ row(L.pvTenant, pv.source && pv.source.tenant, true),
474
+ row(L.pvCreated, `${fmtDate(pv.createdAt, lang)} · ${pv.createdBy || "–"}`),
475
+ row(L.pvVersion, pv.appVersion),
476
+ row(L.pvContent, pv.includeData ? L.withDataOn : L.withDataOff)),
477
+ pv.warnings && pv.warnings.length
478
+ ? h("ul", { className: "scalio-notice is-warn", "data-testid": "import-warnings" },
479
+ pv.warnings.map((w, i) => h("li", { key: `${w.code}-${i}` }, warningText(L, w))))
480
+ : null,
481
+ pv.otherTenant
482
+ ? h("label", { className: "scalio-settings-check" },
483
+ h("input", { type: "checkbox", checked: otherOk, "data-testid": "import-other-tenant", onChange: (e) => setOtherOk(e.target.checked) }),
484
+ h("span", null, L.confirmOtherTenant))
485
+ : null,
486
+ h("div", { className: "scalio-notice is-error" }, L.overwriteWarning),
487
+ h(Field, { id: "import-confirm", label: fill(L.typeToConfirm, { word: pv.confirmWord }) },
488
+ h("input", { id: "import-confirm", className: "scalio-input scalio-mono", value: confirm, autoComplete: "off",
489
+ placeholder: pv.confirmWord, "data-testid": "import-confirm", onChange: (e) => setConfirm(e.target.value) })),
490
+ reauth("import"),
491
+ h("div", { className: "scalio-settings-section-footer" },
492
+ h("button", { type: "button", className: "scalio-btn", onClick: discard }, L.discard),
493
+ h("button", { type: "submit", className: "scalio-btn is-danger is-solid", "data-testid": "import-apply",
494
+ disabled: busy || !reauthOk || !confirmOk || (pv.otherTenant && !otherOk) }, L.applyImport)))
495
+ : null,
496
+ !running && !up && last && last.canRollback
497
+ ? h("div", { className: "scalio-settings-form", "data-testid": "import-rollback" },
498
+ h("p", { className: "scalio-settings-text" },
499
+ fill(L.lastImport, { at: fmtDate(last.finished_at, lang), by: last.imported_by || "–", host: (last.source && last.source.host) || "–" }),
500
+ " ", fill(L.rollbackUntil, { at: fmtDate(last.rollbackUntil, lang) })),
501
+ reauth("rollback"),
502
+ h("div", { className: "scalio-settings-section-footer" },
503
+ h("button", { type: "button", className: "scalio-btn", disabled: busy || !reauthOk, onClick: undo, "data-testid": "import-undo" },
504
+ h(Icons.RotateCcw, { className: "scalio-icon" }), L.rollback)))
505
+ : null,
506
+ n.box,
507
+ h("p", { className: "scalio-settings-help" }, L.ageHint)));
508
+ }
509
+
510
+ // Both areas of Einstellungen -> System -> Sicherung. Props: client (backupApi), lang, labels (backup: {...}
511
+ // overrides, e.g. the app's own withDataOn), notify(text, tone), accountTo + link (where to set up the authenticator
512
+ // app), onSignIn() after an import (default: go to /), pollMs (tests).
513
+ function BackupPanel(props) {
514
+ const [st, setSt] = React.useState(null);
515
+ return h(React.Fragment, null,
516
+ h(BackupExport, Object.assign({}, props, { onStatus: setSt })),
517
+ st ? h(BackupImport, Object.assign({}, props, { status: st })) : null);
518
+ }
519
+
520
+ // 423 from the API while an import or rollback runs (scalio-platform maintenance). The app shows this instead of its
521
+ // pages; it asks for the progress and reloads once the lock is gone. Props: client (backupApi), lang, labels, pollMs.
522
+ function isMaintenance(err) {
523
+ const r = err && err.response;
524
+ return !!(r && r.status === 423);
525
+ }
526
+
527
+ function MaintenanceNotice(props) {
528
+ const { client, lang } = props;
529
+ const L = labelsFor(lang, props.labels).backup;
530
+ const [p, setP] = React.useState(props.progress || { active: true, kind: "import", phase: "waiting" });
531
+ React.useEffect(() => {
532
+ let alive = true;
533
+ let t = null;
534
+ const tick = async () => {
535
+ try {
536
+ const data = await client.progress();
537
+ if (!alive) return;
538
+ setP(data);
539
+ if (!data.active) { if (props.onDone) props.onDone(data); else window.location.reload(); return; }
540
+ } catch (e) { /* the server restarts: keep asking */ }
541
+ if (alive) t = setTimeout(tick, props.pollMs || 3000);
542
+ };
543
+ t = setTimeout(tick, props.pollMs || 3000);
544
+ return () => { alive = false; clearTimeout(t); };
545
+ }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
546
+ const what = p.kind === "rollback" ? L.maintenanceRollback : L.maintenanceImport;
547
+ return h("div", { className: "scalio-notice is-info", role: "status", "data-testid": "maintenance-notice" },
548
+ h("span", null, fill(L.maintenance, { what, phase: p.phase && L.phase && L.phase[p.phase] ? ` (${L.phase[p.phase]})` : "" })));
549
+ }
550
+
551
+ module.exports = { BackupPanel, BackupExport, BackupImport, MaintenanceNotice, backupApi, isMaintenance };
package/react/icons.js CHANGED
@@ -132,8 +132,10 @@ const Globe = icon([
132
132
  ["path", { d: "M2 12h20" }],
133
133
  ]);
134
134
  const Clock = icon([["circle", { cx: 12, cy: 12, r: 10 }], ["path", { d: "M12 6v6l4 2" }]]);
135
+ const Upload = icon([["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }], ["path", { d: "m17 8-5-5-5 5" }], ["path", { d: "M12 3v12" }]]);
136
+ const RotateCcw = icon([["path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }], ["path", { d: "M3 3v5h5" }]]);
135
137
  const Download = icon([["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }], ["path", { d: "m7 10 5 5 5-5" }], ["path", { d: "M12 15V3" }]]);
136
138
 
137
139
  module.exports = { Sun, Moon, Monitor, LifeBuoy, BookOpen, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
138
140
  Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Pencil, Link2, Alert, History, CornerDownLeft,
139
- Users, Settings, ScrollText, Info, RadioTower, Globe, Clock, Download };
141
+ Users, Settings, ScrollText, Info, RadioTower, Globe, Clock, Download, Upload, RotateCcw };
package/react/index.js CHANGED
@@ -378,6 +378,6 @@ module.exports = {
378
378
  TEST_IDS,
379
379
  initials,
380
380
  };
381
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"), require("./mail"), require("./saas_updates"), require("./pages"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./backup"), require("./audit"), require("./mail"), require("./saas_updates"), require("./pages"));
382
382
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
383
383
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -325,6 +325,104 @@ const LABELS = {
325
325
  continue: "Weiter",
326
326
  cancel: "Abbrechen",
327
327
  },
328
+ backup: {
329
+ exportTitle: "Verschlüsselter Export",
330
+ exportHint: "Eine Datei mit allem, was diese Instanz enthält, geschützt mit Ihrem Passwort.",
331
+ exportIntro: "Exportieren Sie alles, was in dieser Instanz eingestellt ist, in eine Datei, die mit einem Passwort Ihrer Wahl geschützt ist. Nutzen Sie sie als Sicherung oder für den Umzug zwischen Self-hosted und SaaS. Ein Import überschreibt die Zielinstanz vollständig.",
332
+ withData: "Daten einschliessen",
333
+ withDataOn: "Konfiguration plus Daten wie Protokoll, Berichte und erfasste Einträge.",
334
+ withDataOff: "Nur Konfiguration (Einstellungen, Vorlagen, Rollen, Konten).",
335
+ withSecrets: "Geheimnisse einschliessen",
336
+ withSecretsOn: "Passwörter, Client-Secrets und Zertifikate kommen in die verschlüsselte Datei; das Ziel funktioniert sofort.",
337
+ withSecretsOff: "Geheimnisse bleiben leer; Sie geben sie nach dem Import erneut ein.",
338
+ notIncluded: "Nie enthalten: Lizenzschlüssel, Installations-ID, Sitzungen und zwischengespeicherte Daten (die Instanz baut sie neu auf).",
339
+ password: "Datei-Passwort",
340
+ password2: "Passwort wiederholen",
341
+ passwordHint: "Mindestens {n} Zeichen. Ohne dieses Passwort lässt sich die Datei nicht öffnen, auch nicht durch Scalio.",
342
+ mismatch: "Die Passwörter stimmen nicht überein.",
343
+ warning: "Mir ist bewusst, dass die Datei alle Einstellungen dieser Instanz enthält und ohne das Passwort nicht wiederhergestellt werden kann. Ich bewahre das Passwort sicher auf.",
344
+ reauthTitle: "Identität bestätigen",
345
+ currentPassword: "Ihr Passwort",
346
+ code: "Code aus der Authenticator-App oder Backup-Code",
347
+ noFactor: "Für Sicherung und Import braucht Ihr Konto eine Authenticator-App; richten Sie sie zuerst im Profil ein. Ein Konto, das sich nur mit Microsoft anmeldet, kann das nicht: Das macht ein lokaler Admin mit Authenticator-App.",
348
+ setUpFactor: "Zum Profil",
349
+ start: "Export erstellen",
350
+ running: "Export läuft …",
351
+ failed: "Export fehlgeschlagen: {error}",
352
+ ready: "Export bereit",
353
+ readyHint: "Die Datei kann einmal heruntergeladen werden und wird danach vom Server gelöscht, spätestens nach einer Stunde.",
354
+ download: "Herunterladen",
355
+ downloaded: "Datei heruntergeladen und vom Server gelöscht.",
356
+ importTitle: "Import",
357
+ importHint: "Diese Instanz aus einer Sicherung wiederherstellen oder eine andere Instanz hierher umziehen.",
358
+ importIntro: "Stellen Sie diese Instanz aus einer .scaliobak-Datei wieder her oder ziehen Sie eine andere Instanz hierher um. Der Import ersetzt die gesamte Konfiguration und alle Daten dieser Instanz; Sie können ihn 7 Tage lang rückgängig machen.",
359
+ chooseFile: "Sicherungsdatei wählen",
360
+ maxSize: "Bis {n} GB",
361
+ uploading: "{name} wird hochgeladen …",
362
+ uploaded: "{name} hochgeladen. Geben Sie das Passwort der Datei ein.",
363
+ filePassword: "Passwort der Datei",
364
+ openFile: "Datei prüfen",
365
+ checking: "Wird entschlüsselt und geprüft …",
366
+ discard: "Verwerfen",
367
+ pvSource: "Von",
368
+ pvTenant: "Tenant",
369
+ pvCreated: "Erstellt",
370
+ pvVersion: "Version",
371
+ pvContent: "Inhalt",
372
+ wOtherTenant: "Die Datei stammt aus einem anderen Tenant ({source}, diese Instanz: {target}). Zwischengespeicherte Daten werden neu aufgebaut.",
373
+ wModeChange: "Die Datei stammt aus einer {source}-Instanz, diese ist {target}. Prüfen Sie danach die Tenant-Verbindung.",
374
+ wHostChange: "Die Datei stammt von {source}, diese Instanz läuft unter {target}.",
375
+ wNoSecrets: "Die Datei wurde ohne Geheimnisse exportiert. Geben Sie sie nach dem Import erneut ein.",
376
+ wConfigOnly: "Die Datei enthält nur die Konfiguration. Die Daten dieser Instanz bleiben erhalten.",
377
+ confirmOtherTenant: "Ich bestätige, dass die Konfiguration eines anderen Tenants diese Instanz ersetzt.",
378
+ overwriteWarning: "Der Import überschreibt alles, was in dieser Instanz eingerichtet ist. Während des Imports ist die Anwendung einige Minuten nicht verfügbar, und alle Sitzungen werden beendet.",
379
+ typeToConfirm: "Geben Sie zur Bestätigung {word} ein",
380
+ applyImport: "Importieren und überschreiben",
381
+ importRunning: "Import läuft, die Anwendung ist im Wartungsmodus",
382
+ rollbackRunning: "Import wird rückgängig gemacht, die Anwendung ist im Wartungsmodus",
383
+ phase: { waiting: "laufende Aufgaben werden abgeschlossen", stopping: "Hintergrundaufgaben werden angehalten", importing: "Daten werden ersetzt", rolling_back: "vorheriger Stand wird wiederhergestellt", finishing: "wird abgeschlossen" },
384
+ importDone: "Import abgeschlossen",
385
+ rollbackDone: "Import rückgängig gemacht, der vorherige Stand ist zurück",
386
+ importFailed: "Der Import ist fehlgeschlagen. Der vorherige Stand wurde wiederhergestellt.",
387
+ rollbackFailed: "Das Rückgängigmachen ist fehlgeschlagen. Prüfen Sie das Protokoll des Servers.",
388
+ check: {
389
+ signIn: "Alle Sitzungen wurden beendet: Melden Sie sich erneut an.",
390
+ license: "Der Lizenzschlüssel bleibt bei dieser Instanz: Prüfen Sie ihn unter Lizenz.",
391
+ connection: "Prüfen Sie die Tenant-Verbindung.",
392
+ secrets: "Geben Sie die ausgelassenen Geheimnisse erneut ein: {fields}.",
393
+ address: "Die Instanz läuft unter einer neuen Adresse: Passkeys registrieren Sie erneut.",
394
+ },
395
+ signInAgain: "Erneut anmelden",
396
+ checklistDismiss: "Erledigt",
397
+ lastImport: "Letzter Import am {at} durch {by} von {host}.",
398
+ rollbackUntil: "Er kann bis {at} rückgängig gemacht werden.",
399
+ rollback: "Import rückgängig machen",
400
+ rollbackConfirm: "Den letzten Import rückgängig machen? Der Stand vor dem Import kommt zurück, und alle Sitzungen werden beendet.",
401
+ ageHint: "Die Datei liegt im offenen age-Format vor: Sie können sie mit dem Werkzeug age und Ihrem Passwort auch selbst entschlüsseln.",
402
+ maintenance: "Die Anwendung ist im Wartungsmodus: {what}{phase}. Die Seite lädt neu, sobald sie fertig ist.",
403
+ maintenanceImport: "eine Sicherung wird eingespielt",
404
+ maintenanceRollback: "ein Import wird rückgängig gemacht",
405
+ errors: {
406
+ password_too_short: "Das Passwort der Datei ist zu kurz.",
407
+ password_too_simple: "Das Passwort der Datei ist zu einfach. Verwenden Sie eine längere Passphrase.",
408
+ wrong_current_password: "Ihr Passwort ist falsch.",
409
+ wrong_mfa_code: "Der Code ist falsch oder wurde bereits verwendet.",
410
+ second_factor_missing: "Ihr Konto braucht zuerst eine Authenticator-App.",
411
+ too_many_attempts: "Zu viele Fehlversuche. Bitte warten Sie einige Minuten.",
412
+ export_running: "Es läuft bereits ein Export.",
413
+ import_running: "Es läuft bereits ein Import, ein Rückgängigmachen oder ein Export.",
414
+ wrong_password: "Das Passwort der Datei ist falsch.",
415
+ wrong_confirmation: "Die Bestätigung stimmt nicht überein.",
416
+ confirm_other_tenant: "Bestätigen Sie, dass die Datei aus einem anderen Tenant stammt.",
417
+ file_too_large: "Die Datei ist grösser als 2 GB.",
418
+ not_ready: "Die Datei ist noch nicht bereit.",
419
+ nothing_to_roll_back: "Es gibt keinen Import, der rückgängig gemacht werden kann.",
420
+ jobs_running: "Eine Hintergrundaufgabe läuft noch länger als 10 Minuten. Es wurde nichts geändert; versuchen Sie es später erneut.",
421
+ maintenance_running: "Es läuft bereits ein Import oder ein Rückgängigmachen.",
422
+ incompatible: "Diese Datei kann hier nicht importiert werden: {error}",
423
+ damaged: "Die Datei ist beschädigt oder keine Sicherung dieser Anwendung.",
424
+ },
425
+ },
328
426
  reset: {
329
427
  title: "Auf Werkseinstellungen zurücksetzen",
330
428
  hint: "Alle Daten löschen und die Instanz neu einrichten.",
@@ -737,6 +835,104 @@ const LABELS = {
737
835
  continue: "Continue",
738
836
  cancel: "Cancel",
739
837
  },
838
+ backup: {
839
+ exportTitle: "Encrypted export",
840
+ exportHint: "One file with everything this instance holds, protected by your password.",
841
+ exportIntro: "Export everything configured in this instance into one file protected by a password of your choice. Use it as a backup or to move between self-hosted and SaaS. An import overwrites the target instance completely.",
842
+ withData: "Include data",
843
+ withDataOn: "Configuration plus data such as the log, reports and the records you entered.",
844
+ withDataOff: "Configuration only (settings, templates, roles, accounts).",
845
+ withSecrets: "Include secrets",
846
+ withSecretsOn: "Passwords, client secrets and certificates go into the encrypted file; the target works right away.",
847
+ withSecretsOff: "Secrets are left empty; you enter them again after the import.",
848
+ notIncluded: "Never included: license key, installation ID, sessions and cached data (the instance builds it again).",
849
+ password: "File password",
850
+ password2: "Repeat password",
851
+ passwordHint: "At least {n} characters. Without this password the file cannot be opened, not even by Scalio.",
852
+ mismatch: "The passwords do not match.",
853
+ warning: "I understand that the file contains all settings of this instance and cannot be restored without the password. I will keep the password in a safe place.",
854
+ reauthTitle: "Confirm your identity",
855
+ currentPassword: "Your password",
856
+ code: "Code from the authenticator app or a backup code",
857
+ noFactor: "Backup and import need an authenticator app on your account; set it up in your profile first. An account that signs in only with Microsoft cannot do it: a local admin with an authenticator app does it.",
858
+ setUpFactor: "Go to profile",
859
+ start: "Create export",
860
+ running: "Export is running …",
861
+ failed: "Export failed: {error}",
862
+ ready: "Export ready",
863
+ readyHint: "The file can be downloaded once and is deleted from the server afterwards, at the latest after one hour.",
864
+ download: "Download",
865
+ downloaded: "File downloaded and deleted from the server.",
866
+ importTitle: "Import",
867
+ importHint: "Restore this instance from a backup or move another instance here.",
868
+ importIntro: "Restore this instance from a .scaliobak file or move another instance here. The import replaces all configuration and data of this instance; you can undo it for 7 days.",
869
+ chooseFile: "Choose backup file",
870
+ maxSize: "Up to {n} GB",
871
+ uploading: "Uploading {name} …",
872
+ uploaded: "{name} uploaded. Enter the password of the file.",
873
+ filePassword: "Password of the file",
874
+ openFile: "Check file",
875
+ checking: "Decrypting and checking …",
876
+ discard: "Discard",
877
+ pvSource: "From",
878
+ pvTenant: "Tenant",
879
+ pvCreated: "Created",
880
+ pvVersion: "Version",
881
+ pvContent: "Content",
882
+ wOtherTenant: "The file comes from another tenant ({source}, this instance: {target}). Cached data is built again.",
883
+ wModeChange: "The file comes from a {source} instance, this one is {target}. Check the tenant connection afterwards.",
884
+ wHostChange: "The file comes from {source}, this instance runs on {target}.",
885
+ wNoSecrets: "The file was exported without secrets. Enter them again after the import.",
886
+ wConfigOnly: "The file contains configuration only. The data of this instance is kept.",
887
+ confirmOtherTenant: "I confirm that the configuration of another tenant replaces this instance.",
888
+ overwriteWarning: "The import overwrites everything that is set up in this instance. During the import the application is unavailable for a few minutes and every session ends.",
889
+ typeToConfirm: "Type {word} to confirm",
890
+ applyImport: "Import and overwrite",
891
+ importRunning: "Import is running, the application is in maintenance",
892
+ rollbackRunning: "Undoing the import, the application is in maintenance",
893
+ phase: { waiting: "letting running tasks finish", stopping: "stopping background tasks", importing: "replacing data", rolling_back: "restoring the previous state", finishing: "finishing" },
894
+ importDone: "Import complete",
895
+ rollbackDone: "Import undone, the previous state is back",
896
+ importFailed: "The import failed. The previous state was restored.",
897
+ rollbackFailed: "Undoing the import failed. Check the server log.",
898
+ check: {
899
+ signIn: "Every session was ended: sign in again.",
900
+ license: "The license key stays with this instance: check it under License.",
901
+ connection: "Check the tenant connection.",
902
+ secrets: "Enter the secrets that were left out again: {fields}.",
903
+ address: "The instance runs on a new address: register passkeys again.",
904
+ },
905
+ signInAgain: "Sign in again",
906
+ checklistDismiss: "Done",
907
+ lastImport: "Last import on {at} by {by} from {host}.",
908
+ rollbackUntil: "It can be undone until {at}.",
909
+ rollback: "Undo import",
910
+ rollbackConfirm: "Undo the last import? The state from before the import comes back and every session ends.",
911
+ ageHint: "The file is in the open age format: you can also decrypt it yourself with the age tool and your password.",
912
+ maintenance: "The application is in maintenance: {what}{phase}. The page reloads once it is done.",
913
+ maintenanceImport: "a backup is being imported",
914
+ maintenanceRollback: "an import is being undone",
915
+ errors: {
916
+ password_too_short: "The file password is too short.",
917
+ password_too_simple: "The file password is too simple. Use a longer passphrase.",
918
+ wrong_current_password: "Your password is wrong.",
919
+ wrong_mfa_code: "The code is wrong or already used.",
920
+ second_factor_missing: "Your account needs an authenticator app first.",
921
+ too_many_attempts: "Too many failed attempts. Please wait a few minutes.",
922
+ export_running: "An export is already running.",
923
+ import_running: "An import, rollback or export is already running.",
924
+ wrong_password: "The file password is wrong.",
925
+ wrong_confirmation: "The confirmation does not match.",
926
+ confirm_other_tenant: "Confirm that the file comes from another tenant.",
927
+ file_too_large: "The file is larger than 2 GB.",
928
+ not_ready: "The file is not ready yet.",
929
+ nothing_to_roll_back: "There is no import to undo.",
930
+ jobs_running: "A background task is still running after 10 minutes. Nothing was changed; try again later.",
931
+ maintenance_running: "An import or rollback is already running.",
932
+ incompatible: "This file cannot be imported here: {error}",
933
+ damaged: "The file is damaged or not a backup of this application.",
934
+ },
935
+ },
740
936
  reset: {
741
937
  title: "Reset to factory settings",
742
938
  hint: "Wipe all data and set the instance up again.",
@@ -847,6 +1043,7 @@ function labelsFor(lang, overrides) {
847
1043
  passwordPolicy: Object.assign({}, base.passwordPolicy, o.passwordPolicy),
848
1044
  profile_: Object.assign({}, base.profile_, o.profile_),
849
1045
  wizard: Object.assign({}, base.wizard, o.wizard),
1046
+ backup: Object.assign({}, base.backup, o.backup),
850
1047
  });
851
1048
  }
852
1049