@scalio-cloud/ui 0.28.0 → 0.30.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
@@ -325,6 +325,48 @@ 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
+
346
+ ## Benachrichtigungen: E-Mail-Versand und E-Mail-Vorlagen (React)
347
+
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
+ (`Mailer.router`, mounted at `/api/mail`). Both blocks go into the settings group «Benachrichtigungen».
350
+
351
+ ```jsx
352
+ import { MailTransport, MailTemplates, mailApi } from "@scalio-cloud/ui/react";
353
+ import { RichTextEditor } from "@/components/RichTextEditor"; // optional, the app's own editor (Compass: react-quill-new)
354
+
355
+ const mail = mailApi(api); // once, outside the component; mailApi(api, "/admin/mail") for another prefix
356
+ <MailTransport client={mail} lang={lang} notify={toast} graphHint={t("...Mail.Send and Mail.ReadWrite...")}>
357
+ <InboxSettings /> {/* optional blocks after «Testmail» */}
358
+ </MailTransport>
359
+ <MailTemplates client={mail} lang={lang} editor={RichTextEditor} />
360
+ ```
361
+
362
+ `MailTransport` shows three blocks: «E-Mail-Versand» (off, SMTP or Microsoft 365, sender, language; secrets come back
363
+ as a mask and the mask is sent back unchanged), «Testmail» (empty recipient: to me) and «Letzte Mails» (`outbox={false}`
364
+ hides it). On a test instance the backend answers `test_mailbox: true` and the server fields are locked.
365
+ `MailTemplates` picks mail and language, offers the placeholders as buttons, shows the preview of the real rendering
366
+ in a sandboxed frame, and saves, tests («Test an mich») or resets to the built-in text. A switchable mail gets the
367
+ checkbox «Diese Mail senden». The `editor` gets `{value, onChange, testId}`; without it a text field is used, so this
368
+ package keeps no dependencies. With an editor, plain built-in texts are passed as paragraphs (`textToHtml`).
369
+
328
370
  ## Protokoll: Aktionen und Jobs (React)
329
371
 
330
372
  One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
package/nav.css CHANGED
@@ -745,3 +745,35 @@
745
745
  .scalio-jobs-card-foot { margin-top: auto; display: flex; align-items: center; justify-content: flex-end; gap: 0.5rem; }
746
746
  .scalio-jobs-steps { margin: 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.375rem; }
747
747
  .scalio-jobs-steps li { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
748
+
749
+ /* E-Mail-Versand and E-Mail-Vorlagen (ADR 0069 W5, react/mail.js) */
750
+ .scalio-mail-providers { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0; padding: 0; border: 0; }
751
+ .scalio-mail-providers legend { width: 100%; margin-bottom: 0.5rem; }
752
+ .scalio-mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; }
753
+ .scalio-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
754
+ .scalio-mail-test { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
755
+ .scalio-mail-test .scalio-input { flex: 1 1 15rem; width: auto; }
756
+ .scalio-mail-outbox { overflow-x: auto; }
757
+ .scalio-mail-outbox table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
758
+ .scalio-mail-outbox td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid hsl(var(--border)); vertical-align: top; }
759
+ .scalio-mail-outbox tr:last-child td { border-bottom: 0; }
760
+ .scalio-mail-time { white-space: nowrap; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
761
+ .scalio-mail-state { text-align: right; padding-right: 0 !important; }
762
+ .scalio-settings-help.is-error { color: hsl(var(--destructive)); }
763
+ .scalio-mail-tokens { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.375rem; }
764
+ .scalio-mail-token {
765
+ padding: 0.125rem 0.5rem; border: 1px solid hsl(var(--primary) / 0.25); border-radius: 2px; background: hsl(var(--primary) / 0.06);
766
+ color: hsl(var(--primary)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; cursor: pointer;
767
+ }
768
+ .scalio-mail-token:hover { background: hsl(var(--primary) / 0.12); }
769
+ .scalio-mail-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: -0.5rem; }
770
+ .scalio-mail-editor { display: grid; grid-template-columns: 1fr; gap: 1rem; }
771
+ .scalio-mail-editor.has-preview { grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); }
772
+ .scalio-mail-editor-pane { min-width: 0; }
773
+ .scalio-mail-body { width: 100%; min-height: 20rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8125rem; }
774
+ .scalio-mail-preview { display: flex; flex-direction: column; min-height: 20rem; border: 1px solid hsl(var(--border)); border-radius: 2px; overflow: hidden; }
775
+ .scalio-mail-preview-head { display: flex; justify-content: space-between; padding: 0.5rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
776
+ .scalio-mail-preview-head strong { text-transform: uppercase; letter-spacing: 0.05em; }
777
+ .scalio-mail-preview-subject { padding: 0.5rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); font-size: 0.875rem; }
778
+ .scalio-mail-preview-subject span { font-weight: 600; }
779
+ .scalio-mail-preview iframe { flex: 1; width: 100%; min-height: 16rem; border: 0; background: #fff; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.28.0",
3
+ "version": "0.30.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,
@@ -67,8 +67,8 @@
67
67
  "node": ">=18"
68
68
  },
69
69
  "devDependencies": {
70
- "jsdom": "25.0.1",
71
- "react": "19.1.0",
72
- "react-dom": "19.1.0"
70
+ "jsdom": "30.1.3",
71
+ "react": "19.3.1",
72
+ "react-dom": "19.3.1"
73
73
  }
74
74
  }
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"));
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 ADDED
@@ -0,0 +1,474 @@
1
+ "use strict";
2
+ // Einstellungen -> Benachrichtigungen (ADR 0069 wartung/W5, Welle 2): «E-Mail-Versand» (MailTransport) and
3
+ // «E-Mail-Vorlagen» (MailTemplates), the same in every app. Backend: scalio-platform `mail` (Mailer.router, /api/mail).
4
+ // The rich text editor comes from the app (prop editor, e.g. react-quill-new like Compass); without it a text field.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+ const { SettingsSection, SettingsField: Field } = require("./settings");
9
+
10
+ const LABELS = {
11
+ de: {
12
+ transportTitle: "E-Mail-Versand",
13
+ transportHint: "Über welchen Server die App Mails schickt. Passwort und Client-Secret werden verschlüsselt gespeichert und nie angezeigt.",
14
+ provider: "Mails senden mit",
15
+ providers: { off: "Aus", smtp: "SMTP-Server", graph: "Microsoft 365" },
16
+ fromName: "Absendername",
17
+ fromAddress: "Absenderadresse",
18
+ language: "Sprache der Mails",
19
+ languageHint: "Gilt, wenn die App die Sprache der empfangenden Person nicht kennt.",
20
+ host: "Server",
21
+ port: "Port",
22
+ security: "Verbindungssicherheit",
23
+ securities: { starttls: "STARTTLS", ssl: "SSL/TLS", none: "Keine (nicht empfohlen)" },
24
+ username: "Benutzername",
25
+ password: "Passwort",
26
+ tenantId: "Tenant-ID",
27
+ clientId: "Client-ID der App",
28
+ clientSecret: "Client-Secret",
29
+ sender: "Postfach, das sendet",
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
+ testMailbox: "Testinstanz: Mails gehen über das Testpostfach, die Einstellungen hier gelten nicht.",
32
+ save: "Speichern",
33
+ saved: "Gespeichert",
34
+ testTitle: "Testmail",
35
+ testHint: "Schickt sofort eine Mail mit den gespeicherten Einstellungen und zeigt den Fehler, wenn es nicht klappt.",
36
+ recipient: "Empfänger (leer: an mich)",
37
+ sendTest: "Testmail senden",
38
+ testSent: "Testmail gesendet an {to}",
39
+ outboxTitle: "Letzte Mails",
40
+ outboxHint: "Wartende Mails werden alle 30 Sekunden gesendet, bei Fehlern bis zu fünfmal.",
41
+ outboxEmpty: "Noch keine Mails.",
42
+ refresh: "Aktualisieren",
43
+ states: { pending: "Wartet", sent: "Gesendet", failed: "Fehlgeschlagen" },
44
+ templatesTitle: "E-Mail-Vorlagen",
45
+ templatesHint: "Betreff und Text jeder Mail pro Sprache. Platzhalter wie {name} werden beim Versand ersetzt. Ohne eigene Vorlage gilt der eingebaute Text.",
46
+ kind: "Mail",
47
+ lang: "Sprache",
48
+ languages: { de: "Deutsch", en: "Englisch" },
49
+ own: "eigene Vorlage",
50
+ builtIn: "Eingebauter Text. Speichern Sie, um ihn anzupassen.",
51
+ custom: "Eigene Vorlage für diese Sprache.",
52
+ enabled: "Diese Mail senden",
53
+ tokens: "Platzhalter (Klick fügt ein)",
54
+ subject: "Betreff",
55
+ body: "Text",
56
+ preview: "Vorschau",
57
+ showPreview: "Vorschau zeigen",
58
+ hidePreview: "Vorschau ausblenden",
59
+ sample: "Beispieldaten",
60
+ saveTemplate: "Vorlage speichern",
61
+ testToMe: "Test an mich",
62
+ resetTemplate: "Auf Standard zurücksetzen",
63
+ templateSaved: "Vorlage gespeichert",
64
+ templateReset: "Eingebauter Text gilt wieder",
65
+ off: "Der Versand ist ausgeschaltet. Vorlagen können Sie trotzdem bearbeiten.",
66
+ },
67
+ en: {
68
+ transportTitle: "E-mail sending",
69
+ transportHint: "Which server the app sends mail through. Password and client secret are stored encrypted and never shown.",
70
+ provider: "Send mail with",
71
+ providers: { off: "Off", smtp: "SMTP server", graph: "Microsoft 365" },
72
+ fromName: "Sender name",
73
+ fromAddress: "Sender address",
74
+ language: "Language of the mails",
75
+ languageHint: "Used when the app does not know the recipient's language.",
76
+ host: "Server",
77
+ port: "Port",
78
+ security: "Connection security",
79
+ securities: { starttls: "STARTTLS", ssl: "SSL/TLS", none: "None (not recommended)" },
80
+ username: "User name",
81
+ password: "Password",
82
+ tenantId: "Tenant ID",
83
+ clientId: "Client ID of the app",
84
+ clientSecret: "Client secret",
85
+ sender: "Mailbox that sends",
86
+ graphHint: "The app needs the application permission Mail.Send. Limit it to the sending mailbox with RBAC for Applications in Exchange Online.",
87
+ testMailbox: "Test instance: mails go through the test mailbox, the settings here do not apply.",
88
+ save: "Save",
89
+ saved: "Saved",
90
+ testTitle: "Test mail",
91
+ testHint: "Sends a mail at once with the saved settings and shows the error if it fails.",
92
+ recipient: "Recipient (empty: to me)",
93
+ sendTest: "Send test mail",
94
+ testSent: "Test mail sent to {to}",
95
+ outboxTitle: "Last mails",
96
+ outboxHint: "Waiting mails are sent every 30 seconds, after an error up to five times.",
97
+ outboxEmpty: "No mails yet.",
98
+ refresh: "Refresh",
99
+ states: { pending: "Waiting", sent: "Sent", failed: "Failed" },
100
+ templatesTitle: "E-mail templates",
101
+ templatesHint: "Subject and text of every mail per language. Placeholders like {name} are filled in when the mail is sent. Without an own template the built-in text applies.",
102
+ kind: "Mail",
103
+ lang: "Language",
104
+ languages: { de: "German", en: "English" },
105
+ own: "own template",
106
+ builtIn: "Built-in text. Save to change it.",
107
+ custom: "Own template for this language.",
108
+ enabled: "Send this mail",
109
+ tokens: "Placeholders (click to insert)",
110
+ subject: "Subject",
111
+ body: "Text",
112
+ preview: "Preview",
113
+ showPreview: "Show preview",
114
+ hidePreview: "Hide preview",
115
+ sample: "Sample data",
116
+ saveTemplate: "Save template",
117
+ testToMe: "Send test to me",
118
+ resetTemplate: "Reset to default",
119
+ templateSaved: "Template saved",
120
+ templateReset: "The built-in text applies again",
121
+ off: "Sending is switched off. You can still edit the templates.",
122
+ },
123
+ };
124
+
125
+ // Plain text (the built-in templates) for a rich text editor: paragraphs and line breaks, HTML escaped.
126
+ function textToHtml(text) {
127
+ if (!text || /<[a-zA-Z/][^>]*>/.test(text)) return text || "";
128
+ const esc = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
129
+ // Quill keeps a paragraph per line, so an empty paragraph keeps the blank line between two paragraphs.
130
+ return esc.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("<p><br></p>");
131
+ }
132
+
133
+ function mailLabels(lang, overrides) {
134
+ return Object.assign({}, LABELS[lang === "en" ? "en" : "de"], overrides || {});
135
+ }
136
+
137
+ function errorText(err) {
138
+ const d = err && err.response && err.response.data && err.response.data.detail;
139
+ if (typeof d === "string") return d;
140
+ if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
141
+ return (err && err.message) || String(err);
142
+ }
143
+
144
+ function fmtTime(iso, lang) {
145
+ try {
146
+ return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "short" });
147
+ } catch (e) {
148
+ return iso || "";
149
+ }
150
+ }
151
+
152
+ // api: axios instance with the app's base URL (/api); base: where the app mounted Mailer.router.
153
+ function mailApi(api, base) {
154
+ const b = base || "/mail";
155
+ const data = (r) => r.data;
156
+ const enc = encodeURIComponent;
157
+ return {
158
+ settings: () => api.get(`${b}/settings`).then(data),
159
+ save: (body) => api.put(`${b}/settings`, body).then(data),
160
+ test: (to) => api.post(`${b}/test`, { to: to || "" }).then(data),
161
+ outbox: () => api.get(`${b}/outbox`).then(data),
162
+ templates: () => api.get(`${b}/templates`).then(data),
163
+ saveTemplate: (kind, lang, body) => api.put(`${b}/templates/${enc(kind)}/${enc(lang)}`, body).then(data),
164
+ resetTemplate: (kind, lang) => api.delete(`${b}/templates/${enc(kind)}/${enc(lang)}`).then(data),
165
+ preview: (body) => api.post(`${b}/templates/preview`, body).then(data),
166
+ testTemplate: (body) => api.post(`${b}/templates/test`, body).then(data),
167
+ };
168
+ }
169
+
170
+ function useMessages(props) {
171
+ const [msg, setMsg] = React.useState(null);
172
+ const fmtErr = props.formatError || errorText;
173
+ return {
174
+ msg,
175
+ ok: (text) => (props.notify ? props.notify(text, "success") : setMsg({ tone: "ok", text })),
176
+ fail: (err) => {
177
+ const text = fmtErr(err);
178
+ if (props.notify) props.notify(text, "error");
179
+ else setMsg({ tone: "error", text });
180
+ },
181
+ clear: () => setMsg(null),
182
+ };
183
+ }
184
+
185
+ function Notice({ msg, testId }) {
186
+ if (!msg) return null;
187
+ return h("div", { className: `scalio-notice is-${msg.tone === "error" ? "error" : "ok"}`, role: msg.tone === "error" ? "alert" : "status", "data-testid": testId }, msg.text);
188
+ }
189
+
190
+ function input(id, value, onChange, extra) {
191
+ return h("input", Object.assign({ id, className: "scalio-input", value: value == null ? "" : value, "data-testid": id,
192
+ onChange: (e) => onChange(e.target.value) }, extra || {}));
193
+ }
194
+
195
+ function select(id, value, options, onChange, extra) {
196
+ return h("select", Object.assign({ id, className: "scalio-input", value, "data-testid": id, onChange: (e) => onChange(e.target.value) }, extra || {}),
197
+ options.map(([v, text]) => h("option", { key: v, value: v }, text)));
198
+ }
199
+
200
+ // Props: client (mailApi), lang, labels, notify(message, tone), formatError(err), graphHint (the app's own text for
201
+ // the Graph permissions, e.g. Beacon also needs Mail.ReadWrite), children (more blocks after «Testmail», e.g. Beacon's
202
+ // mail intake), outbox (false hides «Letzte Mails»).
203
+ function MailTransport(props) {
204
+ const { client, lang } = props;
205
+ const L = mailLabels(lang, props.labels);
206
+ const m = useMessages(props);
207
+ const [form, setForm] = React.useState(null);
208
+ const [busy, setBusy] = React.useState(false);
209
+ const [to, setTo] = React.useState("");
210
+ const [testing, setTesting] = React.useState(false);
211
+ const [testMsg, setTestMsg] = React.useState(null);
212
+ const [rows, setRows] = React.useState([]);
213
+ const showOutbox = props.outbox !== false;
214
+
215
+ const loadOutbox = React.useCallback(() => {
216
+ if (!showOutbox) return Promise.resolve();
217
+ return client.outbox().then((r) => setRows(r || [])).catch(() => setRows([]));
218
+ }, [client, showOutbox]);
219
+ React.useEffect(() => {
220
+ let alive = true;
221
+ client.settings().then((d) => alive && setForm(d)).catch((e) => alive && m.fail(e));
222
+ loadOutbox();
223
+ return () => { alive = false; };
224
+ }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
225
+
226
+ if (!form) return h(SettingsSection, { id: "mail", title: L.transportTitle, description: L.transportHint, testId: "mail-transport" }, h(Notice, { msg: m.msg, testId: "mail-message" }));
227
+
228
+ const set = (patch) => setForm(Object.assign({}, form, patch));
229
+ const part = (name, key) => (v) => set({ [name]: Object.assign({}, form[name], { [key]: v }) });
230
+ const locked = !!form.test_mailbox;
231
+
232
+ const save = async (e) => {
233
+ e.preventDefault();
234
+ setBusy(true);
235
+ m.clear();
236
+ try {
237
+ const body = { provider: form.provider, from_name: form.from_name, from_address: form.from_address, language: form.language };
238
+ if (form.provider === "smtp") body.smtp = Object.assign({}, form.smtp, { port: Number(form.smtp.port) || 587 });
239
+ if (form.provider === "graph") body.graph = form.graph;
240
+ setForm(await client.save(body));
241
+ m.ok(L.saved);
242
+ } catch (err) {
243
+ m.fail(err);
244
+ }
245
+ setBusy(false);
246
+ };
247
+ const test = async () => {
248
+ setTesting(true);
249
+ setTestMsg(null);
250
+ try {
251
+ const r = await client.test(to.trim());
252
+ const text = L.testSent.replace("{to}", (r && r.to) || to);
253
+ if (props.notify) props.notify(text, "success");
254
+ else setTestMsg({ tone: "ok", text });
255
+ loadOutbox();
256
+ } catch (err) {
257
+ const text = (props.formatError || errorText)(err);
258
+ if (props.notify) props.notify(text, "error");
259
+ else setTestMsg({ tone: "error", text });
260
+ }
261
+ setTesting(false);
262
+ };
263
+
264
+ const providers = ["off", "smtp", "graph"];
265
+ const transport = h(
266
+ SettingsSection,
267
+ { id: "mail", title: L.transportTitle, description: L.transportHint, testId: "mail-transport" },
268
+ h(
269
+ "form",
270
+ { className: "scalio-settings-form", onSubmit: save },
271
+ locked ? h("div", { className: "scalio-notice is-info", "data-testid": "mail-test-mailbox" }, L.testMailbox) : null,
272
+ h("fieldset", { className: "scalio-mail-providers", disabled: busy || locked },
273
+ h("legend", { className: "scalio-field-label" }, L.provider),
274
+ providers.map((id) => h("label", { key: id, className: "scalio-settings-check" },
275
+ h("input", { type: "radio", name: "mail-provider", checked: form.provider === id, "data-testid": `mail-provider-${id}`,
276
+ onChange: () => set({ provider: id }) }), h("span", null, L.providers[id])))),
277
+ form.provider !== "off"
278
+ ? h("div", { className: "scalio-mail-grid" },
279
+ h(Field, { id: "mail-from-name", label: L.fromName }, input("mail-from-name", form.from_name, (v) => set({ from_name: v }), { maxLength: 80, disabled: busy })),
280
+ form.provider === "smtp"
281
+ ? h(Field, { id: "mail-from", label: L.fromAddress }, input("mail-from", form.from_address, (v) => set({ from_address: v }),
282
+ { type: "email", maxLength: 254, placeholder: "noreply@example.com", disabled: busy || locked }))
283
+ : null,
284
+ h(Field, { id: "mail-lang", label: L.language, hint: L.languageHint },
285
+ select("mail-lang", form.language, [["de", L.languages.de], ["en", L.languages.en]], (v) => set({ language: v }), { disabled: busy })))
286
+ : null,
287
+ form.provider === "smtp"
288
+ ? h("div", { className: "scalio-mail-grid", "data-testid": "mail-smtp" },
289
+ h(Field, { id: "mail-smtp-host", label: L.host }, input("mail-smtp-host", form.smtp.host, part("smtp", "host"), { maxLength: 253, placeholder: "smtp.example.com", disabled: busy || locked })),
290
+ h(Field, { id: "mail-smtp-port", label: L.port }, input("mail-smtp-port", form.smtp.port, part("smtp", "port"), { type: "number", min: 1, max: 65535, disabled: busy || locked })),
291
+ h(Field, { id: "mail-smtp-security", label: L.security },
292
+ select("mail-smtp-security", form.smtp.security, Object.keys(L.securities).map((k) => [k, L.securities[k]]), part("smtp", "security"), { disabled: busy || locked })),
293
+ h(Field, { id: "mail-smtp-user", label: L.username }, input("mail-smtp-user", form.smtp.username, part("smtp", "username"), { maxLength: 200, autoComplete: "off", disabled: busy || locked })),
294
+ h(Field, { id: "mail-smtp-password", label: L.password }, input("mail-smtp-password", form.smtp.password, part("smtp", "password"),
295
+ { type: "password", maxLength: 500, autoComplete: "new-password", disabled: busy || locked })))
296
+ : null,
297
+ form.provider === "graph"
298
+ ? h("div", { "data-testid": "mail-graph" },
299
+ h("div", { className: "scalio-mail-grid" },
300
+ h(Field, { id: "mail-graph-tenant", label: L.tenantId }, input("mail-graph-tenant", form.graph.tenant_id, part("graph", "tenant_id"),
301
+ { maxLength: 36, placeholder: "00000000-0000-0000-0000-000000000000", className: "scalio-input scalio-mono", disabled: busy })),
302
+ h(Field, { id: "mail-graph-client", label: L.clientId }, input("mail-graph-client", form.graph.client_id, part("graph", "client_id"),
303
+ { maxLength: 36, placeholder: "00000000-0000-0000-0000-000000000000", className: "scalio-input scalio-mono", disabled: busy })),
304
+ h(Field, { id: "mail-graph-secret", label: L.clientSecret }, input("mail-graph-secret", form.graph.client_secret, part("graph", "client_secret"),
305
+ { type: "password", maxLength: 500, autoComplete: "new-password", disabled: busy })),
306
+ h(Field, { id: "mail-graph-sender", label: L.sender }, input("mail-graph-sender", form.graph.sender, part("graph", "sender"),
307
+ { type: "email", maxLength: 254, placeholder: "it@example.com", disabled: busy }))),
308
+ h("p", { className: "scalio-settings-help" }, props.graphHint || L.graphHint))
309
+ : null,
310
+ h(Notice, { msg: m.msg, testId: "mail-message" }),
311
+ h("div", { className: "scalio-settings-section-footer" },
312
+ h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || locked, "data-testid": "mail-save" },
313
+ h(Icons.Check, { className: "scalio-icon" }), L.save))
314
+ )
315
+ );
316
+
317
+ const testBlock = h(
318
+ SettingsSection,
319
+ { id: "mail-test", title: L.testTitle, description: L.testHint, testId: "mail-test-section" },
320
+ h("div", { className: "scalio-mail-test" },
321
+ input("mail-test-to", to, setTo, { type: "email", placeholder: L.recipient, "aria-label": L.recipient, maxLength: 254 }),
322
+ h("button", { type: "button", className: "scalio-btn", onClick: test, disabled: testing || form.provider === "off", "data-testid": "mail-test" },
323
+ h(Icons.CornerDownLeft, { className: "scalio-icon" }), L.sendTest)),
324
+ h(Notice, { msg: testMsg, testId: "mail-test-message" })
325
+ );
326
+
327
+ const outbox = showOutbox
328
+ ? h(
329
+ SettingsSection,
330
+ { id: "mail-outbox", title: L.outboxTitle, description: L.outboxHint, testId: "mail-outbox-section" },
331
+ h("div", { className: "scalio-settings-section-footer" },
332
+ h("button", { type: "button", className: "scalio-btn is-ghost", onClick: loadOutbox, "data-testid": "mail-outbox-refresh" }, L.refresh)),
333
+ rows.length === 0
334
+ ? h("p", { className: "scalio-settings-text", "data-testid": "mail-outbox-empty" }, L.outboxEmpty)
335
+ : h("div", { className: "scalio-mail-outbox" },
336
+ h("table", { "data-testid": "mail-outbox" },
337
+ h("tbody", null, rows.map((r) => h("tr", { key: r.id },
338
+ h("td", { className: "scalio-mail-time" }, fmtTime(r.created_at, lang)),
339
+ h("td", null, r.to),
340
+ h("td", null, r.subject, r.error ? h("span", { className: "scalio-settings-help is-error" }, r.error) : null),
341
+ h("td", { className: "scalio-mail-state" },
342
+ h("span", { className: `scalio-badge is-${{ pending: "running", sent: "ok", failed: "error" }[r.status] || "neutral"}` },
343
+ L.states[r.status] || r.status)))))))
344
+ )
345
+ : null;
346
+
347
+ return h(React.Fragment, null, transport, testBlock, props.children || null, outbox);
348
+ }
349
+
350
+ // Props: client (mailApi), lang, labels, notify, formatError, editor (the app's rich text editor: a component with
351
+ // {value, onChange, testId}; without it a text field), delay (ms before the preview reloads, default 450).
352
+ function MailTemplates(props) {
353
+ const { client, lang } = props;
354
+ const L = mailLabels(lang, props.labels);
355
+ const m = useMessages(props);
356
+ const [data, setData] = React.useState(null);
357
+ const [kind, setKind] = React.useState("");
358
+ const [tlang, setTlang] = React.useState("");
359
+ const [subject, setSubject] = React.useState("");
360
+ const [body, setBody] = React.useState("");
361
+ const [busy, setBusy] = React.useState(false);
362
+ const [showPreview, setShowPreview] = React.useState(true);
363
+ const [preview, setPreview] = React.useState({ subject: "", html: "" });
364
+ const [provider, setProvider] = React.useState(null);
365
+ const delay = props.delay == null ? 450 : props.delay;
366
+
367
+ const apply = (d, k, l) => {
368
+ setData(d);
369
+ const kk = k || (d.kinds[0] && d.kinds[0].code) || "";
370
+ const ll = l || d.language || "de";
371
+ setKind(kk);
372
+ setTlang(ll);
373
+ const meta = d.kinds.find((x) => x.code === kk);
374
+ const t = meta && meta.texts[ll];
375
+ setSubject(t ? t.subject : "");
376
+ setBody(t ? (props.editor ? textToHtml(t.body) : t.body) : "");
377
+ };
378
+
379
+ React.useEffect(() => {
380
+ let alive = true;
381
+ client.templates().then((d) => alive && apply(d)).catch((e) => alive && m.fail(e));
382
+ if (client.settings) client.settings().then((s) => alive && setProvider(s.provider)).catch(() => {});
383
+ return () => { alive = false; };
384
+ }, [client]); // eslint-disable-line react-hooks/exhaustive-deps
385
+
386
+ React.useEffect(() => {
387
+ if (!showPreview || !kind || !tlang) return undefined;
388
+ let alive = true;
389
+ const t = setTimeout(() => {
390
+ client.preview({ kind, lang: tlang, subject, body }).then((p) => alive && setPreview(p)).catch(() => {});
391
+ }, delay);
392
+ return () => { alive = false; clearTimeout(t); };
393
+ }, [client, showPreview, kind, tlang, subject, body, delay]);
394
+
395
+ const section = (children) => h(SettingsSection, { id: "mail-templates", title: L.templatesTitle, description: L.templatesHint, testId: "mail-templates" }, children);
396
+ if (!data) return section(h(Notice, { msg: m.msg, testId: "mail-templates-message" }));
397
+
398
+ const meta = data.kinds.find((x) => x.code === kind) || data.kinds[0];
399
+ const text = meta.texts[tlang] || {};
400
+ const run = async (fn, okText) => {
401
+ setBusy(true);
402
+ m.clear();
403
+ try {
404
+ const r = await fn();
405
+ if (okText) m.ok(typeof okText === "function" ? okText(r) : okText);
406
+ } catch (err) {
407
+ m.fail(err);
408
+ }
409
+ setBusy(false);
410
+ };
411
+ const save = () => run(async () => apply(await client.saveTemplate(kind, tlang, { subject, body }), kind, tlang), L.templateSaved);
412
+ const reset = () => run(async () => apply(await client.resetTemplate(kind, tlang), kind, tlang), L.templateReset);
413
+ const testMe = () => run(() => client.testTemplate({ kind, lang: tlang, subject, body }), (r) => L.testSent.replace("{to}", (r && r.to) || ""));
414
+ const toggle = (on) => run(async () => {
415
+ await client.save({ notify: { [kind]: on } });
416
+ apply(await client.templates(), kind, tlang);
417
+ });
418
+ const insert = (tok) => setBody((b) => `${b || ""}{${tok}}`);
419
+
420
+ const Editor = props.editor;
421
+ const editor = Editor
422
+ ? h(Editor, { value: body, onChange: setBody, testId: "mail-template-body" })
423
+ : h("textarea", { id: "mail-template-body", className: "scalio-input scalio-textarea scalio-mail-body", rows: 14, value: body, maxLength: 50000,
424
+ "data-testid": "mail-template-body", onChange: (e) => setBody(e.target.value) });
425
+
426
+ return section(h(
427
+ "div",
428
+ { className: "scalio-settings-form" },
429
+ provider === "off" ? h("div", { className: "scalio-notice is-info", "data-testid": "mail-templates-off" }, L.off) : null,
430
+ h("div", { className: "scalio-mail-grid" },
431
+ h(Field, { id: "mail-template-kind", label: L.kind, hint: meta.description[lang === "en" ? "en" : "de"] },
432
+ select("mail-template-kind", meta.code, data.kinds.map((k) => [k.code, k.name[lang === "en" ? "en" : "de"] +
433
+ (Object.values(k.texts).some((t) => t.own) ? ` (${L.own})` : "")]), (v) => apply(data, v, tlang))),
434
+ h(Field, { id: "mail-template-lang", label: L.lang, hint: text.own ? L.custom : L.builtIn },
435
+ select("mail-template-lang", tlang, data.languages.map((l) => [l, (L.languages[l] || l) + (meta.texts[l] && meta.texts[l].own ? " ✓" : "")]),
436
+ (v) => apply(data, meta.code, v)))),
437
+ meta.switchable
438
+ ? h("label", { className: "scalio-settings-check" },
439
+ h("input", { type: "checkbox", checked: !!meta.enabled, disabled: busy, "data-testid": "mail-template-enabled", onChange: (e) => toggle(e.target.checked) }),
440
+ h("span", null, L.enabled))
441
+ : null,
442
+ h("div", null,
443
+ h("span", { className: "scalio-field-label" }, L.tokens),
444
+ h("div", { className: "scalio-mail-tokens" }, meta.tokens.map((tok) =>
445
+ h("button", { key: tok, type: "button", className: "scalio-mail-token", onClick: () => insert(tok), "data-testid": `mail-token-${tok}` }, `{${tok}}`)))),
446
+ h(Field, { id: "mail-template-subject", label: L.subject },
447
+ input("mail-template-subject", subject, setSubject, { maxLength: 300 })),
448
+ h("div", { className: "scalio-mail-editor-head" },
449
+ h("label", { className: "scalio-field-label", htmlFor: "mail-template-body" }, L.body),
450
+ h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setShowPreview((v) => !v), "data-testid": "mail-preview-toggle" },
451
+ showPreview ? L.hidePreview : L.showPreview)),
452
+ h("div", { className: showPreview ? "scalio-mail-editor has-preview" : "scalio-mail-editor" },
453
+ h("div", { className: "scalio-mail-editor-pane" }, editor),
454
+ showPreview
455
+ ? h("div", { className: "scalio-mail-preview", "data-testid": "mail-preview" },
456
+ h("div", { className: "scalio-mail-preview-head" }, h("strong", null, L.preview), h("span", null, L.sample)),
457
+ h("div", { className: "scalio-mail-preview-subject" }, `${L.subject}: `, h("span", { "data-testid": "mail-preview-subject" }, preview.subject || "–")),
458
+ h("iframe", { title: L.preview, sandbox: "", "data-testid": "mail-preview-frame",
459
+ srcDoc: `<!doctype html><html><body style="margin:0;padding:16px;background:#fff;">${preview.html || ""}</body></html>` }))
460
+ : null),
461
+ h(Notice, { msg: m.msg, testId: "mail-templates-message" }),
462
+ h("div", { className: "scalio-settings-section-footer" },
463
+ text.own
464
+ ? h("button", { type: "button", className: "scalio-btn is-danger", onClick: reset, disabled: busy, "data-testid": "mail-template-reset" },
465
+ h(Icons.History, { className: "scalio-icon" }), L.resetTemplate)
466
+ : null,
467
+ h("button", { type: "button", className: "scalio-btn", onClick: testMe, disabled: busy || provider === "off", "data-testid": "mail-template-test" },
468
+ h(Icons.CornerDownLeft, { className: "scalio-icon" }), L.testToMe),
469
+ h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: busy || !subject.trim() || !body.trim(), "data-testid": "mail-template-save" },
470
+ h(Icons.Check, { className: "scalio-icon" }), L.saveTemplate))
471
+ ));
472
+ }
473
+
474
+ module.exports = { MailTransport, MailTemplates, mailApi, mailLabels, textToHtml, MAIL_LABELS: LABELS };
@@ -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 };