@scalio-cloud/ui 0.29.0 → 0.31.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 +23 -0
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/mail.js +17 -5
- package/react/saas_updates.js +274 -0
package/README.md
CHANGED
|
@@ -325,6 +325,24 @@ const reset = factoryResetApi(api); // once, outside the component
|
|
|
325
325
|
`SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
|
|
326
326
|
keep the fields inside a `SettingsSection` the same in every app.
|
|
327
327
|
|
|
328
|
+
## Updates im SaaS-Betrieb: Banner und Einstellungen → Updates (React)
|
|
329
|
+
|
|
330
|
+
Since 0.30.0 (scalio-docs ADR 0074) a SaaS instance shows the update Compass offers it. Server side: scalio-connect
|
|
331
|
+
0.14.0 `saas_updates_router` (`/api/saas-updates*`). Compass runs the update; the app shows the offer, the deadline
|
|
332
|
+
and the maintenance window and passes on the admin's choice (jetzt, verschieben bis zur Frist, automatisch/nur auf
|
|
333
|
+
Klick, eigenes Fenster). Self-hosted instances answer `saas: false` and both blocks render nothing.
|
|
334
|
+
|
|
335
|
+
```jsx
|
|
336
|
+
import { UpdateBanner, SaasUpdatesPanel, saasUpdatesApi } from "@scalio-cloud/ui/react";
|
|
337
|
+
|
|
338
|
+
const updates = saasUpdatesApi(api); // once, outside the component
|
|
339
|
+
<AppShell banner={<UpdateBanner client={updates} lang={lang} onOpen={() => navigate("/settings/updates")} />} … />
|
|
340
|
+
<SaasUpdatesPanel client={updates} lang={lang} notify={(m, tone) => toast[tone === "error" ? "error" : "success"](m)} />
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The banner is blue (orange for a security update); people without admin role only see when the update is planned.
|
|
344
|
+
«Später» hides it for this version until the next browser session.
|
|
345
|
+
|
|
328
346
|
## Benachrichtigungen: E-Mail-Versand und E-Mail-Vorlagen (React)
|
|
329
347
|
|
|
330
348
|
Since 0.29.0 (ADR 0069 W5) every app sends mail the same way. The server side is scalio-platform 0.8.0 `mail`
|
|
@@ -349,6 +367,11 @@ in a sandboxed frame, and saves, tests («Test an mich») or resets to the built
|
|
|
349
367
|
checkbox «Diese Mail senden». The `editor` gets `{value, onChange, testId}`; without it a text field is used, so this
|
|
350
368
|
package keeps no dependencies. With an editor, plain built-in texts are passed as paragraphs (`textToHtml`).
|
|
351
369
|
|
|
370
|
+
Since 0.31.0 the provider list comes from the backend (`providers` in the settings, scalio-platform 0.10.0). When it
|
|
371
|
+
lists `tenant` («Microsoft 365 über die Tenant-Verbindung», for apps that already have a connection to the customer's
|
|
372
|
+
tenant, like Nexus), the block asks only for the sending mailbox and saves `graph: {sender}`; `tenantHint` replaces
|
|
373
|
+
the default text about Mail.Send. Without `providers` the three providers stay as before.
|
|
374
|
+
|
|
352
375
|
## Protokoll: Aktionen und Jobs (React)
|
|
353
376
|
|
|
354
377
|
One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"private": false,
|
package/react/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"));
|
|
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"));
|
|
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/mail.js
CHANGED
|
@@ -12,7 +12,7 @@ const LABELS = {
|
|
|
12
12
|
transportTitle: "E-Mail-Versand",
|
|
13
13
|
transportHint: "Über welchen Server die App Mails schickt. Passwort und Client-Secret werden verschlüsselt gespeichert und nie angezeigt.",
|
|
14
14
|
provider: "Mails senden mit",
|
|
15
|
-
providers: { off: "Aus", smtp: "SMTP-Server", graph: "Microsoft 365" },
|
|
15
|
+
providers: { off: "Aus", smtp: "SMTP-Server", graph: "Microsoft 365", tenant: "Microsoft 365 über die Tenant-Verbindung" },
|
|
16
16
|
fromName: "Absendername",
|
|
17
17
|
fromAddress: "Absenderadresse",
|
|
18
18
|
language: "Sprache der Mails",
|
|
@@ -28,6 +28,7 @@ const LABELS = {
|
|
|
28
28
|
clientSecret: "Client-Secret",
|
|
29
29
|
sender: "Postfach, das sendet",
|
|
30
30
|
graphHint: "Die App braucht die Anwendungsberechtigung Mail.Send. Beschränken Sie sie in Exchange Online mit «RBAC for Applications» auf das Postfach, das sendet.",
|
|
31
|
+
tenantHint: "Die App sendet mit ihrer bestehenden Verbindung zum Tenant, ohne zweites Secret. Die Verbindung braucht die Anwendungsberechtigung Mail.Send; beschränken Sie sie in Exchange Online mit «RBAC for Applications» auf das Postfach, das sendet.",
|
|
31
32
|
testMailbox: "Testinstanz: Mails gehen über das Testpostfach, die Einstellungen hier gelten nicht.",
|
|
32
33
|
save: "Speichern",
|
|
33
34
|
saved: "Gespeichert",
|
|
@@ -68,7 +69,7 @@ const LABELS = {
|
|
|
68
69
|
transportTitle: "E-mail sending",
|
|
69
70
|
transportHint: "Which server the app sends mail through. Password and client secret are stored encrypted and never shown.",
|
|
70
71
|
provider: "Send mail with",
|
|
71
|
-
providers: { off: "Off", smtp: "SMTP server", graph: "Microsoft 365" },
|
|
72
|
+
providers: { off: "Off", smtp: "SMTP server", graph: "Microsoft 365", tenant: "Microsoft 365 through the tenant connection" },
|
|
72
73
|
fromName: "Sender name",
|
|
73
74
|
fromAddress: "Sender address",
|
|
74
75
|
language: "Language of the mails",
|
|
@@ -84,6 +85,7 @@ const LABELS = {
|
|
|
84
85
|
clientSecret: "Client secret",
|
|
85
86
|
sender: "Mailbox that sends",
|
|
86
87
|
graphHint: "The app needs the application permission Mail.Send. Limit it to the sending mailbox with RBAC for Applications in Exchange Online.",
|
|
88
|
+
tenantHint: "The app sends with its existing connection to the tenant, no second secret. The connection needs the application permission Mail.Send; limit it to the sending mailbox with RBAC for Applications in Exchange Online.",
|
|
87
89
|
testMailbox: "Test instance: mails go through the test mailbox, the settings here do not apply.",
|
|
88
90
|
save: "Save",
|
|
89
91
|
saved: "Saved",
|
|
@@ -126,7 +128,8 @@ const LABELS = {
|
|
|
126
128
|
function textToHtml(text) {
|
|
127
129
|
if (!text || /<[a-zA-Z/][^>]*>/.test(text)) return text || "";
|
|
128
130
|
const esc = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
129
|
-
|
|
131
|
+
// Quill keeps a paragraph per line, so an empty paragraph keeps the blank line between two paragraphs.
|
|
132
|
+
return esc.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("<p><br></p>");
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
function mailLabels(lang, overrides) {
|
|
@@ -197,7 +200,8 @@ function select(id, value, options, onChange, extra) {
|
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
// Props: client (mailApi), lang, labels, notify(message, tone), formatError(err), graphHint (the app's own text for
|
|
200
|
-
// the Graph permissions, e.g. Beacon also needs Mail.ReadWrite),
|
|
203
|
+
// the Graph permissions, e.g. Beacon also needs Mail.ReadWrite), tenantHint (the same for the provider «tenant»,
|
|
204
|
+
// offered when the backend lists it in settings.providers, scalio-platform 0.10.0), children (more blocks after «Testmail», e.g. Beacon's
|
|
201
205
|
// mail intake), outbox (false hides «Letzte Mails»).
|
|
202
206
|
function MailTransport(props) {
|
|
203
207
|
const { client, lang } = props;
|
|
@@ -236,6 +240,7 @@ function MailTransport(props) {
|
|
|
236
240
|
const body = { provider: form.provider, from_name: form.from_name, from_address: form.from_address, language: form.language };
|
|
237
241
|
if (form.provider === "smtp") body.smtp = Object.assign({}, form.smtp, { port: Number(form.smtp.port) || 587 });
|
|
238
242
|
if (form.provider === "graph") body.graph = form.graph;
|
|
243
|
+
if (form.provider === "tenant") body.graph = { sender: form.graph.sender };
|
|
239
244
|
setForm(await client.save(body));
|
|
240
245
|
m.ok(L.saved);
|
|
241
246
|
} catch (err) {
|
|
@@ -260,7 +265,7 @@ function MailTransport(props) {
|
|
|
260
265
|
setTesting(false);
|
|
261
266
|
};
|
|
262
267
|
|
|
263
|
-
const providers = ["off", "smtp", "graph"];
|
|
268
|
+
const providers = (form.providers || ["off", "smtp", "graph"]).filter((id) => L.providers[id]);
|
|
264
269
|
const transport = h(
|
|
265
270
|
SettingsSection,
|
|
266
271
|
{ id: "mail", title: L.transportTitle, description: L.transportHint, testId: "mail-transport" },
|
|
@@ -306,6 +311,13 @@ function MailTransport(props) {
|
|
|
306
311
|
{ type: "email", maxLength: 254, placeholder: "it@example.com", disabled: busy }))),
|
|
307
312
|
h("p", { className: "scalio-settings-help" }, props.graphHint || L.graphHint))
|
|
308
313
|
: null,
|
|
314
|
+
form.provider === "tenant"
|
|
315
|
+
? h("div", { "data-testid": "mail-tenant" },
|
|
316
|
+
h("div", { className: "scalio-mail-grid" },
|
|
317
|
+
h(Field, { id: "mail-tenant-sender", label: L.sender }, input("mail-tenant-sender", form.graph.sender, part("graph", "sender"),
|
|
318
|
+
{ type: "email", maxLength: 254, placeholder: "it@example.com", disabled: busy }))),
|
|
319
|
+
h("p", { className: "scalio-settings-help" }, props.tenantHint || L.tenantHint))
|
|
320
|
+
: null,
|
|
309
321
|
h(Notice, { msg: m.msg, testId: "mail-message" }),
|
|
310
322
|
h("div", { className: "scalio-settings-section-footer" },
|
|
311
323
|
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || locked, "data-testid": "mail-save" },
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SaaS updates from Compass (scalio-docs ADR 0074): UpdateBanner on every page (blue, security orange) and
|
|
3
|
+
// SaasUpdatesPanel in Einstellungen -> Updates for admins. Backend: scalio-connect `saas_updates_router`
|
|
4
|
+
// (GET /saas-updates/banner, GET /saas-updates, POST /saas-updates/{now,postpone,mode,window}). Compass runs the
|
|
5
|
+
// update; the app only shows the offer and passes on the customer's choice.
|
|
6
|
+
const React = require("react");
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
const Icons = require("./icons");
|
|
9
|
+
const { Notice } = require("./blocks");
|
|
10
|
+
const { SettingsSection, SettingsField: Field } = require("./settings");
|
|
11
|
+
|
|
12
|
+
const LABELS = {
|
|
13
|
+
de: {
|
|
14
|
+
title: "Updates",
|
|
15
|
+
hint: "Scalio stellt neue Versionen bereit. Sie wählen, wann Ihre Instanz aktualisiert wird, spätestens bis zur Frist.",
|
|
16
|
+
current: "Aktuelle Version",
|
|
17
|
+
available: "Version {version} ist verfügbar",
|
|
18
|
+
security: "Sicherheitsupdate {version} ist verfügbar",
|
|
19
|
+
bannerText: "Spätestens am {due}.",
|
|
20
|
+
bannerPlanned: "Geplant: {planned}.",
|
|
21
|
+
bannerManual: "Wird erst auf Klick oder zur Frist installiert.",
|
|
22
|
+
bannerAction: "Ansehen",
|
|
23
|
+
plannedTitle: "Update am {planned}",
|
|
24
|
+
plannedText: "Version {version} wird installiert. Die Anwendung ist dabei kurz nicht erreichbar.",
|
|
25
|
+
later: "Später",
|
|
26
|
+
none: "Ihre Instanz ist aktuell.",
|
|
27
|
+
notes: "Was ist neu",
|
|
28
|
+
due: "Frist",
|
|
29
|
+
planned: "Geplant",
|
|
30
|
+
plannedNone: "erst auf Klick, spätestens zur Frist",
|
|
31
|
+
held: "Scalio hält dieses Update für Ihre Instanz noch zurück.",
|
|
32
|
+
forced: "Scalio hat den Termin festgelegt.",
|
|
33
|
+
now: "Jetzt aktualisieren",
|
|
34
|
+
nowConfirm: "Version {version} jetzt installieren? Die Anwendung ist einige Minuten nicht erreichbar.",
|
|
35
|
+
postpone: "Verschieben bis",
|
|
36
|
+
postponeGo: "Verschieben",
|
|
37
|
+
mode: "Verhalten",
|
|
38
|
+
modeAuto: "Automatisch im Wartungsfenster",
|
|
39
|
+
modeManual: "Nur auf Klick (spätestens zur Frist)",
|
|
40
|
+
window: "Wartungsfenster",
|
|
41
|
+
windowHint: "Updates laufen nur in diesem Fenster. Ohne eigenes Fenster gilt der Standard von Scalio.",
|
|
42
|
+
days: "Tage (keiner = jeden Tag)",
|
|
43
|
+
start: "Beginn",
|
|
44
|
+
hours: "Dauer (Stunden)",
|
|
45
|
+
tz: "Zeitzone",
|
|
46
|
+
save: "Fenster speichern",
|
|
47
|
+
useDefault: "Standard verwenden",
|
|
48
|
+
ownWindow: "eigenes Fenster",
|
|
49
|
+
defaultWindow: "Standard",
|
|
50
|
+
last: "Letztes Update",
|
|
51
|
+
lastDone: "{version} installiert am {at}",
|
|
52
|
+
lastError: "{version} fehlgeschlagen am {at}, zurück auf der alten Version",
|
|
53
|
+
unreachable: "Compass ist gerade nicht erreichbar. Updates laufen trotzdem wie geplant.",
|
|
54
|
+
saved: "Gespeichert.",
|
|
55
|
+
weekdays: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
|
|
56
|
+
},
|
|
57
|
+
en: {
|
|
58
|
+
title: "Updates",
|
|
59
|
+
hint: "Scalio provides new versions. You choose when your instance is updated, at the latest by the deadline.",
|
|
60
|
+
current: "Current version",
|
|
61
|
+
available: "Version {version} is available",
|
|
62
|
+
security: "Security update {version} is available",
|
|
63
|
+
bannerText: "At the latest on {due}.",
|
|
64
|
+
bannerPlanned: "Planned: {planned}.",
|
|
65
|
+
bannerManual: "Installed only on a click or at the deadline.",
|
|
66
|
+
bannerAction: "View",
|
|
67
|
+
plannedTitle: "Update on {planned}",
|
|
68
|
+
plannedText: "Version {version} will be installed. The application is briefly unavailable.",
|
|
69
|
+
later: "Later",
|
|
70
|
+
none: "Your instance is up to date.",
|
|
71
|
+
notes: "What's new",
|
|
72
|
+
due: "Deadline",
|
|
73
|
+
planned: "Planned",
|
|
74
|
+
plannedNone: "only on a click, at the latest at the deadline",
|
|
75
|
+
held: "Scalio is still holding this update back for your instance.",
|
|
76
|
+
forced: "Scalio has set the date.",
|
|
77
|
+
now: "Update now",
|
|
78
|
+
nowConfirm: "Install version {version} now? The application is unavailable for a few minutes.",
|
|
79
|
+
postpone: "Postpone until",
|
|
80
|
+
postponeGo: "Postpone",
|
|
81
|
+
mode: "Behaviour",
|
|
82
|
+
modeAuto: "Automatically in the maintenance window",
|
|
83
|
+
modeManual: "Only on a click (at the latest at the deadline)",
|
|
84
|
+
window: "Maintenance window",
|
|
85
|
+
windowHint: "Updates run only in this window. Without an own window, Scalio's default applies.",
|
|
86
|
+
days: "Days (none = every day)",
|
|
87
|
+
start: "Start",
|
|
88
|
+
hours: "Duration (hours)",
|
|
89
|
+
tz: "Time zone",
|
|
90
|
+
save: "Save window",
|
|
91
|
+
useDefault: "Use default",
|
|
92
|
+
ownWindow: "own window",
|
|
93
|
+
defaultWindow: "default",
|
|
94
|
+
last: "Last update",
|
|
95
|
+
lastDone: "{version} installed on {at}",
|
|
96
|
+
lastError: "{version} failed on {at}, back on the old version",
|
|
97
|
+
unreachable: "Compass cannot be reached right now. Updates still run as planned.",
|
|
98
|
+
saved: "Saved.",
|
|
99
|
+
weekdays: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function labels(lang, overrides) {
|
|
104
|
+
return Object.assign({}, LABELS[lang === "en" ? "en" : "de"], overrides || {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fill(text, vars) {
|
|
108
|
+
return Object.keys(vars || {}).reduce((s, k) => s.split(`{${k}}`).join(vars[k] == null ? "" : vars[k]), text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function when(iso, lang) {
|
|
112
|
+
if (!iso) return "";
|
|
113
|
+
const d = new Date(iso);
|
|
114
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
115
|
+
return d.toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "medium", timeStyle: "short" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function errorText(err) {
|
|
119
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
120
|
+
if (typeof d === "string") return d;
|
|
121
|
+
return (err && err.message) || String(err);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// api: axios instance with the app's base URL (/api).
|
|
125
|
+
function saasUpdatesApi(api) {
|
|
126
|
+
return {
|
|
127
|
+
banner: () => api.get("/saas-updates/banner").then((r) => r.data),
|
|
128
|
+
state: (refresh) => api.get(refresh ? "/saas-updates?refresh=1" : "/saas-updates").then((r) => r.data),
|
|
129
|
+
act: (op, body) => api.post(`/saas-updates/${op}`, body || {}).then((r) => r.data),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Props: client (saasUpdatesApi), lang, labels, settingsTo (address of Einstellungen -> Updates), onOpen() (e.g. navigate),
|
|
134
|
+
// storage (default sessionStorage; «Später» hides the banner for this version until the next session).
|
|
135
|
+
function UpdateBanner(props) {
|
|
136
|
+
const L = labels(props.lang, props.labels);
|
|
137
|
+
const [data, setData] = React.useState(null);
|
|
138
|
+
const [hidden, setHidden] = React.useState(false);
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
let alive = true;
|
|
141
|
+
props.client.banner().then((d) => alive && setData(d)).catch(() => {});
|
|
142
|
+
return () => { alive = false; };
|
|
143
|
+
}, [props.client]);
|
|
144
|
+
const offer = data && data.saas && data.offer;
|
|
145
|
+
const key = offer ? `scalio-update-later-${offer.version}` : "";
|
|
146
|
+
const store = props.storage || (typeof window !== "undefined" ? window.sessionStorage : null);
|
|
147
|
+
let dismissed = hidden;
|
|
148
|
+
try { dismissed = dismissed || (!!key && !!store && store.getItem(key) === "1"); } catch (e) { /* private mode */ }
|
|
149
|
+
if (!offer || dismissed) return null;
|
|
150
|
+
const later = () => {
|
|
151
|
+
try { if (store) store.setItem(key, "1"); } catch (e) { /* private mode */ }
|
|
152
|
+
setHidden(true);
|
|
153
|
+
};
|
|
154
|
+
if (!data.can_act) {
|
|
155
|
+
if (!offer.planned_at) return null;
|
|
156
|
+
return h(Notice, { kind: "info", banner: true, testId: "saas-update-banner", title: fill(L.plannedTitle, { planned: when(offer.planned_at, props.lang) }),
|
|
157
|
+
onDismiss: later, dismissLabel: L.later }, fill(L.plannedText, { version: offer.version }));
|
|
158
|
+
}
|
|
159
|
+
const text = [fill(L.bannerText, { due: when(offer.due_at, props.lang) }),
|
|
160
|
+
offer.planned_at ? fill(L.bannerPlanned, { planned: when(offer.planned_at, props.lang) }) : L.bannerManual].join(" ");
|
|
161
|
+
// onOpen (e.g. the router's navigate) wins over a plain link to settingsTo.
|
|
162
|
+
const action = props.onOpen
|
|
163
|
+
? { label: L.bannerAction, onClick: props.onOpen, testId: "saas-update-banner-open" }
|
|
164
|
+
: props.settingsTo ? { label: L.bannerAction, href: props.settingsTo, testId: "saas-update-banner-open" } : null;
|
|
165
|
+
return h(Notice, {
|
|
166
|
+
kind: offer.kind === "security" ? "warn" : "info", banner: true, testId: "saas-update-banner",
|
|
167
|
+
title: fill(offer.kind === "security" ? L.security : L.available, { version: offer.version }),
|
|
168
|
+
action, onDismiss: later, dismissLabel: L.later,
|
|
169
|
+
}, text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const EMPTY_WINDOW = { days: [], start: "02:00", hours: 3, tz: "Europe/Zurich" };
|
|
173
|
+
|
|
174
|
+
// Props: client (saasUpdatesApi), lang, labels, notify(message, tone), formatError(err).
|
|
175
|
+
function SaasUpdatesPanel(props) {
|
|
176
|
+
const L = labels(props.lang, props.labels);
|
|
177
|
+
const fmtErr = props.formatError || errorText;
|
|
178
|
+
const [st, setSt] = React.useState(null);
|
|
179
|
+
const [busy, setBusy] = React.useState(false);
|
|
180
|
+
const [error, setError] = React.useState("");
|
|
181
|
+
const [until, setUntil] = React.useState("");
|
|
182
|
+
const [win, setWin] = React.useState(EMPTY_WINDOW);
|
|
183
|
+
|
|
184
|
+
const take = (d) => {
|
|
185
|
+
setSt(d);
|
|
186
|
+
if (d && d.window) setWin(Object.assign({}, EMPTY_WINDOW, d.window));
|
|
187
|
+
};
|
|
188
|
+
React.useEffect(() => {
|
|
189
|
+
let alive = true;
|
|
190
|
+
props.client.state().then((d) => alive && take(d)).catch((e) => alive && setError(fmtErr(e)));
|
|
191
|
+
return () => { alive = false; };
|
|
192
|
+
}, [props.client]);
|
|
193
|
+
|
|
194
|
+
const act = async (op, body) => {
|
|
195
|
+
setBusy(true);
|
|
196
|
+
setError("");
|
|
197
|
+
try {
|
|
198
|
+
take(await props.client.act(op, body));
|
|
199
|
+
if (props.notify) props.notify(L.saved, "ok");
|
|
200
|
+
} catch (e) {
|
|
201
|
+
const t = fmtErr(e);
|
|
202
|
+
if (props.notify) props.notify(t, "error");
|
|
203
|
+
setError(t);
|
|
204
|
+
}
|
|
205
|
+
setBusy(false);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (!st) return h(SettingsSection, { id: "updates", title: L.title, description: L.hint, testId: "saas-updates" },
|
|
209
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null);
|
|
210
|
+
if (st.saas === false) return null;
|
|
211
|
+
|
|
212
|
+
const offer = st.offer;
|
|
213
|
+
const rows = [];
|
|
214
|
+
if (st.reachable === false) rows.push(h("div", { key: "u", className: "scalio-notice is-warn", "data-testid": "saas-updates-unreachable" }, L.unreachable));
|
|
215
|
+
rows.push(h(Field, { key: "cur", label: L.current }, h("div", { className: "scalio-settings-text", "data-testid": "saas-updates-current" }, st.current_version || "–")));
|
|
216
|
+
if (!offer) {
|
|
217
|
+
if (st.reachable !== false) rows.push(h("p", { key: "none", className: "scalio-settings-text", "data-testid": "saas-updates-none" }, L.none));
|
|
218
|
+
} else {
|
|
219
|
+
const due = offer.due_at ? offer.due_at.slice(0, 10) : "";
|
|
220
|
+
rows.push(h("div", { key: "offer", className: cxNotice(offer), "data-testid": "saas-updates-offer" },
|
|
221
|
+
h("div", { className: "scalio-notice-body" },
|
|
222
|
+
h("div", { className: "scalio-notice-title" }, fill(offer.kind === "security" ? L.security : L.available, { version: offer.version })),
|
|
223
|
+
h("div", { className: "scalio-notice-text" },
|
|
224
|
+
`${L.due}: ${when(offer.due_at, props.lang)} · ${L.planned}: ${offer.planned_at ? when(offer.planned_at, props.lang) : L.plannedNone}`,
|
|
225
|
+
offer.held ? h("div", null, L.held) : null, offer.forced ? h("div", null, L.forced) : null))));
|
|
226
|
+
if (offer.notes) rows.push(h(Field, { key: "notes", label: L.notes },
|
|
227
|
+
h("pre", { className: "scalio-settings-text", style: { whiteSpace: "pre-wrap" }, "data-testid": "saas-updates-notes" }, offer.notes)));
|
|
228
|
+
if (!offer.forced) {
|
|
229
|
+
rows.push(h("div", { key: "acts", className: "scalio-settings-section-footer" },
|
|
230
|
+
h("button", { type: "button", className: "scalio-btn is-solid", disabled: busy || offer.held, "data-testid": "saas-updates-now",
|
|
231
|
+
onClick: () => { if (window.confirm(fill(L.nowConfirm, { version: offer.version }))) act("now"); } },
|
|
232
|
+
h(Icons.Download, { className: "scalio-icon" }), L.now)));
|
|
233
|
+
rows.push(h(Field, { key: "post", id: "saas-updates-until", label: L.postpone },
|
|
234
|
+
h("div", { style: { display: "flex", gap: "0.5rem" } },
|
|
235
|
+
h("input", { id: "saas-updates-until", type: "date", className: "scalio-input", value: until, max: due, disabled: busy,
|
|
236
|
+
"data-testid": "saas-updates-until", onChange: (e) => setUntil(e.target.value) }),
|
|
237
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy || !until, "data-testid": "saas-updates-postpone",
|
|
238
|
+
onClick: () => act("postpone", { until: new Date(`${until}T00:00:00`).toISOString() }) }, L.postponeGo))));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
rows.push(h(Field, { key: "mode", label: L.mode },
|
|
242
|
+
["auto", "manual"].map((m) => h("label", { key: m, className: "scalio-settings-check" },
|
|
243
|
+
h("input", { type: "radio", name: "saas-updates-mode", checked: st.mode === m, disabled: busy, "data-testid": `saas-updates-mode-${m}`,
|
|
244
|
+
onChange: () => act("mode", { mode: m }) }),
|
|
245
|
+
h("span", null, m === "auto" ? L.modeAuto : L.modeManual)))));
|
|
246
|
+
const toggleDay = (d) => setWin(Object.assign({}, win, { days: win.days.includes(d) ? win.days.filter((x) => x !== d) : win.days.concat([d]).sort() }));
|
|
247
|
+
rows.push(h(Field, { key: "win", label: `${L.window} (${st.own_window ? L.ownWindow : L.defaultWindow})`, hint: L.windowHint },
|
|
248
|
+
h("div", { className: "scalio-settings-text" }, L.days),
|
|
249
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "0.75rem" } },
|
|
250
|
+
L.weekdays.map((name, d) => h("label", { key: d, className: "scalio-settings-check" },
|
|
251
|
+
h("input", { type: "checkbox", checked: win.days.includes(d), disabled: busy, "data-testid": `saas-updates-day-${d}`, onChange: () => toggleDay(d) }),
|
|
252
|
+
h("span", null, name)))),
|
|
253
|
+
h("div", { style: { display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" } },
|
|
254
|
+
h("label", null, L.start, " ", h("input", { type: "time", className: "scalio-input", value: win.start, disabled: busy, "data-testid": "saas-updates-start",
|
|
255
|
+
onChange: (e) => setWin(Object.assign({}, win, { start: e.target.value })) })),
|
|
256
|
+
h("label", null, L.hours, " ", h("input", { type: "number", min: 1, max: 12, className: "scalio-input", value: win.hours, disabled: busy, "data-testid": "saas-updates-hours",
|
|
257
|
+
onChange: (e) => setWin(Object.assign({}, win, { hours: Number(e.target.value) })) })),
|
|
258
|
+
h("label", null, L.tz, " ", h("input", { className: "scalio-input", value: win.tz, disabled: busy, "data-testid": "saas-updates-tz",
|
|
259
|
+
onChange: (e) => setWin(Object.assign({}, win, { tz: e.target.value })) }))),
|
|
260
|
+
h("div", { className: "scalio-settings-section-footer" },
|
|
261
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "saas-updates-save-window", onClick: () => act("window", { window: win }) }, L.save),
|
|
262
|
+
st.own_window ? h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "saas-updates-default-window", onClick: () => act("window", {}) }, L.useDefault) : null)));
|
|
263
|
+
if (st.last) rows.push(h(Field, { key: "last", label: L.last },
|
|
264
|
+
h("div", { className: "scalio-settings-text", "data-testid": "saas-updates-last" },
|
|
265
|
+
fill(st.last.state === "done" ? L.lastDone : L.lastError, { version: st.last.version, at: when(st.last.at, props.lang) }))));
|
|
266
|
+
if (error) rows.push(h("div", { key: "err", className: "scalio-notice is-error", role: "alert", "data-testid": "saas-updates-error" }, error));
|
|
267
|
+
return h(SettingsSection, { id: "updates", title: L.title, description: L.hint, testId: "saas-updates" }, rows);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function cxNotice(offer) {
|
|
271
|
+
return `scalio-notice ${offer.kind === "security" ? "is-warn" : "is-info"}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { UpdateBanner, SaasUpdatesPanel, saasUpdatesApi, SAAS_UPDATE_LABELS: LABELS };
|