@scalio-cloud/ui 0.27.1 → 0.29.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 +49 -0
- package/nav.css +91 -0
- package/package.json +4 -4
- package/react/audit.js +619 -0
- package/react/index.js +1 -1
- package/react/mail.js +473 -0
package/README.md
CHANGED
|
@@ -325,6 +325,55 @@ 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
|
+
## Benachrichtigungen: E-Mail-Versand und E-Mail-Vorlagen (React)
|
|
329
|
+
|
|
330
|
+
Since 0.29.0 (ADR 0069 W5) every app sends mail the same way. The server side is scalio-platform 0.8.0 `mail`
|
|
331
|
+
(`Mailer.router`, mounted at `/api/mail`). Both blocks go into the settings group «Benachrichtigungen».
|
|
332
|
+
|
|
333
|
+
```jsx
|
|
334
|
+
import { MailTransport, MailTemplates, mailApi } from "@scalio-cloud/ui/react";
|
|
335
|
+
import { RichTextEditor } from "@/components/RichTextEditor"; // optional, the app's own editor (Compass: react-quill-new)
|
|
336
|
+
|
|
337
|
+
const mail = mailApi(api); // once, outside the component; mailApi(api, "/admin/mail") for another prefix
|
|
338
|
+
<MailTransport client={mail} lang={lang} notify={toast} graphHint={t("...Mail.Send and Mail.ReadWrite...")}>
|
|
339
|
+
<InboxSettings /> {/* optional blocks after «Testmail» */}
|
|
340
|
+
</MailTransport>
|
|
341
|
+
<MailTemplates client={mail} lang={lang} editor={RichTextEditor} />
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`MailTransport` shows three blocks: «E-Mail-Versand» (off, SMTP or Microsoft 365, sender, language; secrets come back
|
|
345
|
+
as a mask and the mask is sent back unchanged), «Testmail» (empty recipient: to me) and «Letzte Mails» (`outbox={false}`
|
|
346
|
+
hides it). On a test instance the backend answers `test_mailbox: true` and the server fields are locked.
|
|
347
|
+
`MailTemplates` picks mail and language, offers the placeholders as buttons, shows the preview of the real rendering
|
|
348
|
+
in a sandboxed frame, and saves, tests («Test an mich») or resets to the built-in text. A switchable mail gets the
|
|
349
|
+
checkbox «Diese Mail senden». The `editor` gets `{value, onChange, testId}`; without it a text field is used, so this
|
|
350
|
+
package keeps no dependencies. With an editor, plain built-in texts are passed as paragraphs (`textToHtml`).
|
|
351
|
+
|
|
352
|
+
## Protokoll: Aktionen und Jobs (React)
|
|
353
|
+
|
|
354
|
+
One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
|
|
355
|
+
`audit_router` and `jobs_router` (0.7.0).
|
|
356
|
+
|
|
357
|
+
```jsx
|
|
358
|
+
import { AuditNav, AuditLog, JobsPanel, auditApi, jobsApi } from "@scalio-cloud/ui/react";
|
|
359
|
+
|
|
360
|
+
const audit = auditApi(api); // GET /audit, /audit/export.csv
|
|
361
|
+
const jobs = jobsApi(api); // GET /jobs, /jobs/runs, /jobs/queue, POST /jobs/{key}/run, queue actions
|
|
362
|
+
|
|
363
|
+
<PageHeader title={L.auditLog} nav={<AuditNav link={NavLink} lang={lang} />} />
|
|
364
|
+
<AuditLog client={audit} lang={lang} notify={toast} actionLabel={(a, lang) => ACTIONS[lang][a]} tenantLabel="Kunde" />
|
|
365
|
+
<JobsPanel client={jobs} lang={lang} notify={toast} canRun={can("jobs.run")} link={Link} confirm={confirm} />
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
- `AuditLog`: note «unveränderlich», chips with counts (categories from the server), search, filters for action,
|
|
369
|
+
status, tenant and time range, sortable columns, column picker (person, tenant, IP), CSV with the same filters,
|
|
370
|
+
«Ältere laden» (cursor), view with «alt → neu», cards on phones. Extras: `toolbar`, `rowActions(entry)`,
|
|
371
|
+
`renderDetail(entry)` (Nexus KI, Citadel PDF, Nexus steps).
|
|
372
|
+
- `JobsPanel`: tabs Warteschlange (only when the app has one), Läufe (filter by job and status) and Hintergrundjobs
|
|
373
|
+
(schedule, last run, last result, «Jetzt ausführen» with `canRun`); reloads every 15 seconds (`pollMs`). Extras:
|
|
374
|
+
`queueActions(item, reload)`, `renderQueueDetail(item, reload)`.
|
|
375
|
+
- `AuditNav`: the tabs `/audit` and `/jobs` (`base` for another prefix, `jobs={false}` hides Jobs).
|
|
376
|
+
|
|
328
377
|
## Command palette (React)
|
|
329
378
|
|
|
330
379
|
Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
|
package/nav.css
CHANGED
|
@@ -686,3 +686,94 @@
|
|
|
686
686
|
.dark .scalio-selftest .is-ok, .dark .scalio-selftest li.is-ok .scalio-selftest-status { color: #4ade80; }
|
|
687
687
|
.dark .scalio-selftest .is-warn, .dark .scalio-selftest li.is-warn .scalio-selftest-status { color: #fbbf24; }
|
|
688
688
|
@media (max-width: 640px) { .scalio-selftest-run-head .scalio-btn { margin-left: 0; } }
|
|
689
|
+
|
|
690
|
+
/* Protokoll: Aktionen und Jobs (ADR 0070, react/audit.js) */
|
|
691
|
+
.scalio-audit, .scalio-jobs { display: flex; flex-direction: column; gap: 1rem; font-size: 0.875rem; }
|
|
692
|
+
.scalio-audit-note { display: flex; align-items: center; gap: 0.5rem; padding: 0.625rem 1rem; border: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.5); color: hsl(var(--muted-foreground)); font-size: 0.75rem; border-radius: 2px; }
|
|
693
|
+
.scalio-audit-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
694
|
+
.scalio-audit-chip { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 999px; background: hsl(var(--card)); color: hsl(var(--muted-foreground)); font-size: 0.75rem; font-weight: 600; cursor: pointer; }
|
|
695
|
+
.scalio-audit-chip:hover { border-color: hsl(var(--primary) / 0.5); background: hsl(var(--primary) / 0.05); }
|
|
696
|
+
.scalio-audit-chip.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
|
|
697
|
+
.scalio-audit-chip-count { font-family: 'JetBrains Mono', monospace; font-size: 0.625rem; opacity: 0.7; }
|
|
698
|
+
.scalio-audit-toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
|
|
699
|
+
.scalio-audit-toolbar select.scalio-input { width: auto; max-width: 14rem; }
|
|
700
|
+
.scalio-audit-search { position: relative; flex: 1 1 14rem; max-width: 20rem; display: flex; align-items: center; }
|
|
701
|
+
.scalio-audit-search .scalio-icon { position: absolute; left: 0.625rem; color: hsl(var(--muted-foreground)); }
|
|
702
|
+
.scalio-audit-search .scalio-input { width: 100%; padding-left: 2rem; }
|
|
703
|
+
.scalio-audit-date { display: flex; flex-direction: column; gap: 0.125rem; }
|
|
704
|
+
.scalio-audit-date span { color: hsl(var(--muted-foreground)); font-size: 0.6875rem; }
|
|
705
|
+
.scalio-audit-tools { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
706
|
+
.scalio-audit-count, .scalio-audit-mono { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
|
|
707
|
+
.scalio-audit-count { color: hsl(var(--muted-foreground)); }
|
|
708
|
+
.scalio-audit-mono { white-space: nowrap; }
|
|
709
|
+
.scalio-audit-cols { position: relative; }
|
|
710
|
+
.scalio-audit-cols-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; display: flex; flex-direction: column; gap: 0.375rem; min-width: 10rem; padding: 0.625rem 0.75rem; border: 1px solid hsl(var(--border)); background: hsl(var(--popover, var(--card))); border-radius: 2px; box-shadow: 0 6px 18px rgb(0 0 0 / 0.08); }
|
|
711
|
+
.scalio-audit-cols-menu label { display: flex; align-items: center; gap: 0.5rem; }
|
|
712
|
+
.scalio-audit-sort { background: none; border: 0; padding: 0; font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit; cursor: pointer; }
|
|
713
|
+
.scalio-audit-action { font-weight: 600; color: hsl(var(--foreground)); }
|
|
714
|
+
.scalio-audit-sub { margin-top: 0.125rem; color: hsl(var(--muted-foreground)); font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
715
|
+
.scalio-audit-table tr.is-error td:first-child { box-shadow: inset 3px 0 0 hsl(var(--destructive)); }
|
|
716
|
+
.scalio-audit-row-actions { text-align: right; white-space: nowrap; }
|
|
717
|
+
.scalio-audit-row-actions .scalio-btn { margin-left: 0.25rem; }
|
|
718
|
+
.scalio-audit-empty { padding: 2.5rem 1rem; text-align: center; color: hsl(var(--muted-foreground)); }
|
|
719
|
+
.scalio-audit-more { display: flex; justify-content: center; }
|
|
720
|
+
.scalio-audit-cards { display: none; flex-direction: column; gap: 0.75rem; }
|
|
721
|
+
.scalio-audit-card { padding: 1rem; border: 1px solid hsl(var(--border)); background: hsl(var(--card)); border-radius: 2px; }
|
|
722
|
+
.scalio-audit-card-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.5rem; color: hsl(var(--muted-foreground)); font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; }
|
|
723
|
+
.scalio-audit-card .scalio-audit-row-actions { margin-top: 0.5rem; text-align: left; border-top: 1px solid hsl(var(--border)); padding-top: 0.5rem; }
|
|
724
|
+
@media (max-width: 767px) {
|
|
725
|
+
.scalio-audit-table-wrap { display: none; }
|
|
726
|
+
.scalio-audit-cards { display: flex; }
|
|
727
|
+
.scalio-audit-tools { margin-left: 0; }
|
|
728
|
+
}
|
|
729
|
+
.scalio-audit-dialog { max-width: 42rem; }
|
|
730
|
+
.scalio-audit-dl { display: grid; grid-template-columns: 8rem 1fr; gap: 0.375rem 0.75rem; margin: 0; font-size: 0.8125rem; }
|
|
731
|
+
.scalio-audit-dl dt { color: hsl(var(--muted-foreground)); }
|
|
732
|
+
.scalio-audit-dl dd { margin: 0; overflow-wrap: anywhere; }
|
|
733
|
+
.scalio-audit-changes { margin-top: 1rem; border: 1px solid hsl(var(--border)); border-radius: 2px; }
|
|
734
|
+
.scalio-audit-changes-title { padding: 0.375rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.5); color: hsl(var(--muted-foreground)); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
|
|
735
|
+
.scalio-audit-change { display: grid; grid-template-columns: 8rem auto auto 1fr; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; border-top: 1px solid hsl(var(--border)); }
|
|
736
|
+
.scalio-audit-change:first-of-type { border-top: 0; }
|
|
737
|
+
.scalio-audit-field { font-weight: 600; }
|
|
738
|
+
.scalio-audit-old { font-family: 'JetBrains Mono', monospace; color: hsl(var(--muted-foreground)); text-decoration: line-through; }
|
|
739
|
+
.scalio-audit-new { font-family: 'JetBrains Mono', monospace; font-weight: 600; color: #15803d; }
|
|
740
|
+
.dark .scalio-audit-new { color: #4ade80; }
|
|
741
|
+
.scalio-jobs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); gap: 1px; background: hsl(var(--border)); border: 1px solid hsl(var(--border)); }
|
|
742
|
+
.scalio-jobs-card { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.25rem; background: hsl(var(--card)); }
|
|
743
|
+
.scalio-jobs-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
|
|
744
|
+
.scalio-jobs-name { font-family: 'Chivo', sans-serif; font-weight: 700; }
|
|
745
|
+
.scalio-jobs-card-foot { margin-top: auto; display: flex; align-items: center; justify-content: flex-end; gap: 0.5rem; }
|
|
746
|
+
.scalio-jobs-steps { margin: 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.375rem; }
|
|
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.
|
|
3
|
+
"version": "0.29.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": "
|
|
71
|
-
"react": "19.1
|
|
72
|
-
"react-dom": "19.1
|
|
70
|
+
"jsdom": "30.1.3",
|
|
71
|
+
"react": "19.3.1",
|
|
72
|
+
"react-dom": "19.3.1"
|
|
73
73
|
}
|
|
74
74
|
}
|
package/react/audit.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Protokoll in every app like Nexus (scalio-docs ADR 0070, prot/P1-P9): one menu item «Protokoll» with the tabs
|
|
3
|
+
// Aktionen (/audit) and Jobs (/jobs). AuditLog is the tab Aktionen, JobsPanel the tab Jobs, AuditNav the two tabs.
|
|
4
|
+
// auditApi / jobsApi map the routes of scalio-platform `audit_router` and `jobs_router`.
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const Icons = require("./icons");
|
|
8
|
+
const { SubNav } = require("./nav");
|
|
9
|
+
|
|
10
|
+
const LABELS = {
|
|
11
|
+
de: {
|
|
12
|
+
tabActions: "Aktionen",
|
|
13
|
+
tabJobs: "Jobs",
|
|
14
|
+
actionsTitle: "Protokoll",
|
|
15
|
+
actionsHint: "Wer hat wann was gemacht. Einträge werden 365 Tage aufbewahrt.",
|
|
16
|
+
immutable: "Dies ist ein unveränderliches Protokoll: jede Aktion wird erfasst und kann hier weder bearbeitet noch gelöscht werden.",
|
|
17
|
+
all: "Alle",
|
|
18
|
+
search: "Suchen nach Person, Objekt, Text…",
|
|
19
|
+
allActions: "Alle Aktionen",
|
|
20
|
+
allStatuses: "Alle Status",
|
|
21
|
+
allTenants: "Alle Tenants",
|
|
22
|
+
from: "Von",
|
|
23
|
+
to: "Bis",
|
|
24
|
+
reset: "Filter zurücksetzen",
|
|
25
|
+
columns: "Spalten",
|
|
26
|
+
exportCsv: "CSV",
|
|
27
|
+
exported: "CSV heruntergeladen.",
|
|
28
|
+
time: "Zeit",
|
|
29
|
+
person: "Person",
|
|
30
|
+
action: "Aktion",
|
|
31
|
+
object: "Objekt",
|
|
32
|
+
tenant: "Tenant",
|
|
33
|
+
status: "Status",
|
|
34
|
+
ip: "IP",
|
|
35
|
+
view: "Ansehen",
|
|
36
|
+
more: "Ältere laden",
|
|
37
|
+
empty: "Keine passenden Einträge.",
|
|
38
|
+
system: "System",
|
|
39
|
+
changes: "Änderungen (alt → neu)",
|
|
40
|
+
details: "Details",
|
|
41
|
+
close: "Schliessen",
|
|
42
|
+
statuses: { ok: "Erfolgreich", error: "Fehler", blocked: "Blockiert", warning: "Warnung", queued: "Wartet", running: "Läuft",
|
|
43
|
+
waiting: "Wartet", finished: "Fertig", cancelled: "Abgebrochen", done: "Fertig" },
|
|
44
|
+
jobsTitle: "Jobs",
|
|
45
|
+
jobsHint: "Hintergrundjobs, ihre Läufe und wartende Arbeit dieser Instanz.",
|
|
46
|
+
queue: "Warteschlange",
|
|
47
|
+
runs: "Läufe",
|
|
48
|
+
background: "Hintergrundjobs",
|
|
49
|
+
schedule: "Zeitplan",
|
|
50
|
+
lastRun: "Letzter Lauf",
|
|
51
|
+
never: "Noch nie gelaufen",
|
|
52
|
+
runNow: "Jetzt ausführen",
|
|
53
|
+
requested: "Angefordert",
|
|
54
|
+
queued: "Angefordert. Der Job startet in wenigen Sekunden.",
|
|
55
|
+
ranNow: "Job ausgeführt.",
|
|
56
|
+
job: "Job",
|
|
57
|
+
trigger: "Auslöser",
|
|
58
|
+
triggers: { schedule: "Zeitplan", manual: "von Hand" },
|
|
59
|
+
duration: "Dauer",
|
|
60
|
+
result: "Ergebnis",
|
|
61
|
+
allJobs: "Alle Jobs",
|
|
62
|
+
noRuns: "Noch keine Läufe.",
|
|
63
|
+
noQueue: "Nichts in der Warteschlange.",
|
|
64
|
+
type: "Typ",
|
|
65
|
+
created: "Erstellt",
|
|
66
|
+
by: "Von",
|
|
67
|
+
cancel: "Abbrechen",
|
|
68
|
+
retry: "Erneut versuchen",
|
|
69
|
+
delete: "Löschen",
|
|
70
|
+
open: "Öffnen",
|
|
71
|
+
steps: "Schritte",
|
|
72
|
+
done: "Erledigt.",
|
|
73
|
+
},
|
|
74
|
+
en: {
|
|
75
|
+
tabActions: "Actions",
|
|
76
|
+
tabJobs: "Jobs",
|
|
77
|
+
actionsTitle: "Activity log",
|
|
78
|
+
actionsHint: "Who did what and when. Entries are kept for 365 days.",
|
|
79
|
+
immutable: "This is an immutable log: every action is recorded and cannot be edited or deleted here.",
|
|
80
|
+
all: "All",
|
|
81
|
+
search: "Search by person, object, text…",
|
|
82
|
+
allActions: "All actions",
|
|
83
|
+
allStatuses: "All statuses",
|
|
84
|
+
allTenants: "All tenants",
|
|
85
|
+
from: "From",
|
|
86
|
+
to: "To",
|
|
87
|
+
reset: "Reset filters",
|
|
88
|
+
columns: "Columns",
|
|
89
|
+
exportCsv: "CSV",
|
|
90
|
+
exported: "CSV downloaded.",
|
|
91
|
+
time: "Time",
|
|
92
|
+
person: "Person",
|
|
93
|
+
action: "Action",
|
|
94
|
+
object: "Object",
|
|
95
|
+
tenant: "Tenant",
|
|
96
|
+
status: "Status",
|
|
97
|
+
ip: "IP",
|
|
98
|
+
view: "View",
|
|
99
|
+
more: "Load older",
|
|
100
|
+
empty: "No matching entries.",
|
|
101
|
+
system: "System",
|
|
102
|
+
changes: "Changes (old → new)",
|
|
103
|
+
details: "Details",
|
|
104
|
+
close: "Close",
|
|
105
|
+
statuses: { ok: "Succeeded", error: "Failed", blocked: "Blocked", warning: "Warning", queued: "Queued", running: "Running",
|
|
106
|
+
waiting: "Waiting", finished: "Finished", cancelled: "Cancelled", done: "Done" },
|
|
107
|
+
jobsTitle: "Jobs",
|
|
108
|
+
jobsHint: "Background jobs, their runs and queued work of this instance.",
|
|
109
|
+
queue: "Queue",
|
|
110
|
+
runs: "Runs",
|
|
111
|
+
background: "Background jobs",
|
|
112
|
+
schedule: "Schedule",
|
|
113
|
+
lastRun: "Last run",
|
|
114
|
+
never: "Never ran",
|
|
115
|
+
runNow: "Run now",
|
|
116
|
+
requested: "Requested",
|
|
117
|
+
queued: "Requested. The job starts within a few seconds.",
|
|
118
|
+
ranNow: "Job ran.",
|
|
119
|
+
job: "Job",
|
|
120
|
+
trigger: "Trigger",
|
|
121
|
+
triggers: { schedule: "schedule", manual: "by hand" },
|
|
122
|
+
duration: "Duration",
|
|
123
|
+
result: "Result",
|
|
124
|
+
allJobs: "All jobs",
|
|
125
|
+
noRuns: "No runs yet.",
|
|
126
|
+
noQueue: "Nothing queued.",
|
|
127
|
+
type: "Type",
|
|
128
|
+
created: "Created",
|
|
129
|
+
by: "By",
|
|
130
|
+
cancel: "Cancel",
|
|
131
|
+
retry: "Retry",
|
|
132
|
+
delete: "Delete",
|
|
133
|
+
open: "Open",
|
|
134
|
+
steps: "Steps",
|
|
135
|
+
done: "Done.",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function auditLabels(lang, overrides) {
|
|
140
|
+
return Object.assign({}, LABELS[lang === "en" ? "en" : "de"], overrides || {});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const TONE = { ok: "ok", finished: "ok", done: "ok", error: "error", blocked: "error", warning: "warn", running: "running",
|
|
144
|
+
queued: "neutral", waiting: "neutral", cancelled: "neutral" };
|
|
145
|
+
|
|
146
|
+
function cx() {
|
|
147
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function errorText(err) {
|
|
151
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
152
|
+
return typeof d === "string" ? d : (err && err.message) || String(err);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function fmtTime(iso, lang) {
|
|
156
|
+
if (!iso) return "";
|
|
157
|
+
try {
|
|
158
|
+
return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "medium" });
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return iso;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fmtDuration(sec) {
|
|
165
|
+
if (sec === null || sec === undefined) return "";
|
|
166
|
+
if (sec < 1) return `${Math.round(sec * 1000)} ms`;
|
|
167
|
+
if (sec < 60) return `${Math.round(sec * 10) / 10} s`;
|
|
168
|
+
return `${Math.floor(sec / 60)} min ${Math.round(sec % 60)} s`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function fmtValue(v) {
|
|
172
|
+
if (v === null || v === undefined || v === "") return "–";
|
|
173
|
+
return Array.isArray(v) ? v.join(", ") : String(v);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clean(params) {
|
|
177
|
+
const out = {};
|
|
178
|
+
Object.keys(params || {}).forEach((k) => {
|
|
179
|
+
const v = params[k];
|
|
180
|
+
if (v !== "" && v !== null && v !== undefined && v !== "all") out[k] = v;
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// api: axios instance with the app's base URL (/api).
|
|
186
|
+
function auditApi(api, base) {
|
|
187
|
+
const p = base || "/audit";
|
|
188
|
+
const data = (x) => x.then((r) => r.data);
|
|
189
|
+
return {
|
|
190
|
+
list: (params) => data(api.get(p, { params: clean(params) })),
|
|
191
|
+
exportCsv: (params) => data(api.get(`${p}/export.csv`, { params: clean(params), responseType: "blob" })),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function jobsApi(api, base) {
|
|
196
|
+
const p = base || "/jobs";
|
|
197
|
+
const data = (x) => x.then((r) => r.data);
|
|
198
|
+
return {
|
|
199
|
+
list: () => data(api.get(p)),
|
|
200
|
+
runs: (params) => data(api.get(`${p}/runs`, { params: clean(params) })),
|
|
201
|
+
run: (key) => data(api.post(`${p}/${encodeURIComponent(key)}/run`)),
|
|
202
|
+
queue: (params) => data(api.get(`${p}/queue`, { params: clean(params) })),
|
|
203
|
+
cancel: (id) => data(api.post(`${p}/queue/${encodeURIComponent(id)}/cancel`)),
|
|
204
|
+
retry: (id) => data(api.post(`${p}/queue/${encodeURIComponent(id)}/retry`)),
|
|
205
|
+
remove: (id) => data(api.delete(`${p}/queue/${encodeURIComponent(id)}`)),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The two tabs under the menu item «Protokoll». Props: link (router NavLink), activePath, lang, labels, jobs
|
|
210
|
+
// (false hides the tab Jobs), base ({audit: "/audit", jobs: "/jobs"}) for apps under another prefix (Compass).
|
|
211
|
+
function AuditNav(props) {
|
|
212
|
+
const L = auditLabels(props.lang, props.labels);
|
|
213
|
+
const base = Object.assign({ audit: "/audit", jobs: "/jobs" }, props.base || {});
|
|
214
|
+
return h(SubNav, {
|
|
215
|
+
link: props.link, activePath: props.activePath, testId: props.testId || "audit-subnav",
|
|
216
|
+
items: [
|
|
217
|
+
{ to: base.audit, label: L.tabActions, end: true, testId: "subnav-actions" },
|
|
218
|
+
{ to: base.jobs, label: L.tabJobs, testId: "subnav-jobs", hidden: props.jobs === false },
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function Badge({ status, L, testId }) {
|
|
224
|
+
return h("span", { className: `scalio-badge is-${TONE[status] || "neutral"}`, "data-testid": testId },
|
|
225
|
+
(L.statuses && L.statuses[status]) || status || "–");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function Dialog({ title, onClose, children, testId, closeLabel }) {
|
|
229
|
+
React.useEffect(() => {
|
|
230
|
+
const key = (e) => { if (e.key === "Escape") onClose(); };
|
|
231
|
+
document.addEventListener("keydown", key);
|
|
232
|
+
return () => document.removeEventListener("keydown", key);
|
|
233
|
+
}, [onClose]);
|
|
234
|
+
return h("div", { className: "scalio-dialog-root" },
|
|
235
|
+
h("div", { className: "scalio-dialog-backdrop", onClick: onClose }),
|
|
236
|
+
h("div", { className: "scalio-dialog scalio-audit-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, "data-testid": testId },
|
|
237
|
+
h("div", { className: "scalio-dialog-head" },
|
|
238
|
+
h("div", { className: "scalio-dialog-title" }, title),
|
|
239
|
+
h("button", { type: "button", className: "scalio-btn is-ghost scalio-btn-icon", onClick: onClose, "aria-label": closeLabel || "×", "data-testid": "dialog-close" },
|
|
240
|
+
h(Icons.X, { className: "scalio-icon" }))),
|
|
241
|
+
children));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function who(e, L) {
|
|
245
|
+
const a = e.actor || {};
|
|
246
|
+
if (a.kind === "system") return a.name || L.system;
|
|
247
|
+
return a.name || a.email || "–";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function download(blob, name) {
|
|
251
|
+
if (typeof window === "undefined" || !window.URL || !window.URL.createObjectURL) return;
|
|
252
|
+
const url = window.URL.createObjectURL(blob);
|
|
253
|
+
const a = document.createElement("a");
|
|
254
|
+
a.href = url;
|
|
255
|
+
a.download = name;
|
|
256
|
+
document.body.appendChild(a);
|
|
257
|
+
a.click();
|
|
258
|
+
a.remove();
|
|
259
|
+
window.URL.revokeObjectURL(url);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const COLS = ["person", "tenant", "ip"];
|
|
263
|
+
|
|
264
|
+
function loadCols(key) {
|
|
265
|
+
try {
|
|
266
|
+
const raw = window.localStorage.getItem(key);
|
|
267
|
+
return raw ? JSON.parse(raw) : null;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Tab Aktionen. Props: client (auditApi), lang, labels, notify(text, tone), actionLabel(key, lang) for readable
|
|
274
|
+
// action names, categoryLabel (optional), showTenant (default: when the server lists tenants), tenantLabel ("Kunde"),
|
|
275
|
+
// toolbar (extra buttons, e.g. Nexus KI or Citadel PDF), rowActions(entry) (extra buttons per row), renderDetail(entry)
|
|
276
|
+
// (extra content in the view, e.g. Nexus workflow steps), initialCategory, pageSize, storageKey, testId.
|
|
277
|
+
function AuditLog(props) {
|
|
278
|
+
const { client, notify } = props;
|
|
279
|
+
const lang = props.lang === "en" ? "en" : "de";
|
|
280
|
+
const L = auditLabels(lang, props.labels);
|
|
281
|
+
const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
|
|
282
|
+
const actionLabel = (a) => (props.actionLabel ? props.actionLabel(a, lang) || a : a);
|
|
283
|
+
const [filters, setFilters] = React.useState({ category: props.initialCategory || "all", action: "all", status: "all", tenant: "", since: "", until: "" });
|
|
284
|
+
const [q, setQ] = React.useState("");
|
|
285
|
+
const [query, setQuery] = React.useState("");
|
|
286
|
+
const [data, setData] = React.useState(null);
|
|
287
|
+
const [items, setItems] = React.useState([]);
|
|
288
|
+
const [error, setError] = React.useState("");
|
|
289
|
+
const [busy, setBusy] = React.useState(false);
|
|
290
|
+
const [open, setOpen] = React.useState(null);
|
|
291
|
+
const [sort, setSort] = React.useState({ key: "ts", dir: -1 });
|
|
292
|
+
const storageKey = props.storageKey || "scalio-audit-cols";
|
|
293
|
+
const [cols, setCols] = React.useState(() => Object.assign({ person: true, tenant: true, ip: false }, (typeof window !== "undefined" && loadCols(storageKey)) || {}));
|
|
294
|
+
const [colsOpen, setColsOpen] = React.useState(false);
|
|
295
|
+
|
|
296
|
+
React.useEffect(() => {
|
|
297
|
+
const t = setTimeout(() => setQuery(q.trim()), 300);
|
|
298
|
+
return () => clearTimeout(t);
|
|
299
|
+
}, [q]);
|
|
300
|
+
|
|
301
|
+
const params = React.useMemo(() => Object.assign({}, filters, { q: query, limit: props.pageSize || 200 }), [filters, query, props.pageSize]);
|
|
302
|
+
|
|
303
|
+
const load = React.useCallback(async () => {
|
|
304
|
+
try {
|
|
305
|
+
const d = await client.list(params);
|
|
306
|
+
setData(d);
|
|
307
|
+
setItems(d.items || []);
|
|
308
|
+
setError("");
|
|
309
|
+
} catch (err) {
|
|
310
|
+
setError(errorText(err));
|
|
311
|
+
}
|
|
312
|
+
}, [client, params]);
|
|
313
|
+
|
|
314
|
+
React.useEffect(() => { load(); }, [load]);
|
|
315
|
+
|
|
316
|
+
const more = async () => {
|
|
317
|
+
if (!data || !data.next) return;
|
|
318
|
+
setBusy(true);
|
|
319
|
+
try {
|
|
320
|
+
const d = await client.list(Object.assign({}, params, { before: data.next }));
|
|
321
|
+
setItems(items.concat(d.items || []));
|
|
322
|
+
setData(Object.assign({}, data, { next: d.next }));
|
|
323
|
+
} catch (err) {
|
|
324
|
+
say(errorText(err), "error");
|
|
325
|
+
} finally {
|
|
326
|
+
setBusy(false);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const exportCsv = async () => {
|
|
331
|
+
setBusy(true);
|
|
332
|
+
try {
|
|
333
|
+
const blob = await client.exportCsv(Object.assign({}, filters, { q: query, lang }));
|
|
334
|
+
download(blob, lang === "en" ? "activity-log.csv" : "protokoll.csv");
|
|
335
|
+
say(L.exported);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
say(errorText(err), "error");
|
|
338
|
+
} finally {
|
|
339
|
+
setBusy(false);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const setCol = (k, v) => {
|
|
344
|
+
const next = Object.assign({}, cols, { [k]: v });
|
|
345
|
+
setCols(next);
|
|
346
|
+
try { window.localStorage.setItem(storageKey, JSON.stringify(next)); } catch (e) { /* private window */ }
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const set = (k, v) => setFilters(Object.assign({}, filters, { [k]: v }));
|
|
350
|
+
const filtered = !!(query || filters.action !== "all" || filters.status !== "all" || filters.tenant || filters.since || filters.until || filters.category !== "all");
|
|
351
|
+
const reset = () => { setQ(""); setQuery(""); setFilters({ category: "all", action: "all", status: "all", tenant: "", since: "", until: "" }); };
|
|
352
|
+
|
|
353
|
+
if (!data) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
|
|
354
|
+
|
|
355
|
+
const counts = data.counts || {};
|
|
356
|
+
const cats = data.categories || [];
|
|
357
|
+
const tenants = data.tenants || [];
|
|
358
|
+
const showTenant = props.showTenant !== undefined ? props.showTenant : tenants.length > 0;
|
|
359
|
+
const tenantName = (id) => (tenants.find((t) => t.id === id) || {}).name || id || "–";
|
|
360
|
+
const catName = (c) => (props.categoryLabel ? props.categoryLabel(c, lang) : c[lang] || c.de || c.key);
|
|
361
|
+
|
|
362
|
+
const sortVal = {
|
|
363
|
+
ts: (e) => e.ts || "",
|
|
364
|
+
person: (e) => who(e, L).toLowerCase(),
|
|
365
|
+
action: (e) => actionLabel(e.action).toLowerCase(),
|
|
366
|
+
status: (e) => e.status || "",
|
|
367
|
+
};
|
|
368
|
+
const rows = items.slice().sort((a, b) => {
|
|
369
|
+
const f = sortVal[sort.key] || sortVal.ts;
|
|
370
|
+
const x = f(a);
|
|
371
|
+
const y = f(b);
|
|
372
|
+
return x < y ? -sort.dir : x > y ? sort.dir : 0;
|
|
373
|
+
});
|
|
374
|
+
const th = (key, label, cls) => h("th", { className: cls, key },
|
|
375
|
+
sortVal[key] ? h("button", { type: "button", className: "scalio-audit-sort", "data-testid": `audit-sort-${key}`,
|
|
376
|
+
onClick: () => setSort({ key, dir: sort.key === key ? -sort.dir : key === "ts" ? -1 : 1 }) },
|
|
377
|
+
label, sort.key === key ? (sort.dir < 0 ? " ↓" : " ↑") : "") : label);
|
|
378
|
+
|
|
379
|
+
const showPerson = cols.person !== false;
|
|
380
|
+
const showT = showTenant && cols.tenant !== false;
|
|
381
|
+
const showIp = !!cols.ip;
|
|
382
|
+
const colCount = 4 + (showPerson ? 1 : 0) + (showT ? 1 : 0) + (showIp ? 1 : 0);
|
|
383
|
+
const subline = (e) => [e.target && (e.target.name || e.target.id), e.detail].filter(Boolean).join(" · ");
|
|
384
|
+
|
|
385
|
+
return h("section", { className: "scalio-audit", "data-testid": props.testId || "audit-log" },
|
|
386
|
+
h("div", { className: "scalio-audit-note", "data-testid": "audit-immutable-note" },
|
|
387
|
+
h(Icons.KeyRound, { className: "scalio-icon", width: 13, height: 13 }), L.immutable),
|
|
388
|
+
cats.length ? h("div", { className: "scalio-audit-chips", "data-testid": "audit-chips" },
|
|
389
|
+
[{ key: "all", always: true }].concat(cats).filter((c) => c.always || c.key === filters.category || (counts[c.key] || 0) > 0).map((c) =>
|
|
390
|
+
h("button", { key: c.key, type: "button", className: cx("scalio-audit-chip", filters.category === c.key && "is-active"),
|
|
391
|
+
"data-testid": `audit-cat-${c.key}`, onClick: () => setFilters(Object.assign({}, filters, { category: c.key, action: "all" })) },
|
|
392
|
+
c.key === "all" ? L.all : catName(c), h("span", { className: "scalio-audit-chip-count" }, counts[c.key] || 0)))) : null,
|
|
393
|
+
h("div", { className: "scalio-audit-toolbar" },
|
|
394
|
+
h("label", { className: "scalio-audit-search" },
|
|
395
|
+
h(Icons.Search, { className: "scalio-icon", width: 14, height: 14 }),
|
|
396
|
+
h("input", { className: "scalio-input", value: q, placeholder: L.search, "aria-label": L.search, "data-testid": "audit-search",
|
|
397
|
+
onChange: (e) => setQ(e.target.value) })),
|
|
398
|
+
h("select", { className: "scalio-input", value: filters.action, "aria-label": L.action, "data-testid": "audit-filter-action", onChange: (e) => set("action", e.target.value) },
|
|
399
|
+
[h("option", { key: "all", value: "all" }, L.allActions)].concat((data.actions || []).map((a) => h("option", { key: a, value: a }, actionLabel(a))))),
|
|
400
|
+
h("select", { className: "scalio-input", value: filters.status, "aria-label": L.status, "data-testid": "audit-filter-status", onChange: (e) => set("status", e.target.value) },
|
|
401
|
+
["all", "ok", "error", "blocked"].map((s) => h("option", { key: s, value: s }, s === "all" ? L.allStatuses : L.statuses[s]))),
|
|
402
|
+
showTenant ? h("select", { className: "scalio-input", value: filters.tenant, "aria-label": props.tenantLabel || L.tenant, "data-testid": "audit-filter-tenant", onChange: (e) => set("tenant", e.target.value) },
|
|
403
|
+
[h("option", { key: "", value: "" }, props.allTenantsLabel || L.allTenants)].concat(tenants.map((t) => h("option", { key: t.id, value: t.id }, t.name || t.id)))) : null,
|
|
404
|
+
h("label", { className: "scalio-audit-date" }, h("span", null, L.from),
|
|
405
|
+
h("input", { type: "date", className: "scalio-input", value: filters.since, "data-testid": "audit-filter-since", onChange: (e) => set("since", e.target.value) })),
|
|
406
|
+
h("label", { className: "scalio-audit-date" }, h("span", null, L.to),
|
|
407
|
+
h("input", { type: "date", className: "scalio-input", value: filters.until, "data-testid": "audit-filter-until", onChange: (e) => set("until", e.target.value) })),
|
|
408
|
+
filtered ? h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": "audit-reset", onClick: reset }, L.reset) : null,
|
|
409
|
+
h("div", { className: "scalio-audit-tools" },
|
|
410
|
+
h("span", { className: "scalio-audit-count", "data-testid": "audit-count" }, `${items.length} / ${counts.all !== undefined ? counts.all : items.length}`),
|
|
411
|
+
props.toolbar || null,
|
|
412
|
+
h("div", { className: "scalio-audit-cols" },
|
|
413
|
+
h("button", { type: "button", className: "scalio-btn", "aria-expanded": colsOpen, "data-testid": "audit-columns", onClick: () => setColsOpen(!colsOpen) }, L.columns),
|
|
414
|
+
colsOpen ? h("div", { className: "scalio-audit-cols-menu", "data-testid": "audit-columns-menu" },
|
|
415
|
+
COLS.filter((k) => k !== "tenant" || showTenant).map((k) => h("label", { key: k },
|
|
416
|
+
h("input", { type: "checkbox", checked: k === "ip" ? !!cols[k] : cols[k] !== false, "data-testid": `audit-col-${k}`, onChange: (e) => setCol(k, e.target.checked) }),
|
|
417
|
+
k === "tenant" ? props.tenantLabel || L.tenant : L[k]))) : null),
|
|
418
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "audit-export-csv", onClick: exportCsv },
|
|
419
|
+
h(Icons.Download, { className: "scalio-icon" }), L.exportCsv))),
|
|
420
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
|
|
421
|
+
h("div", { className: "scalio-users-table-wrap scalio-audit-table-wrap" },
|
|
422
|
+
h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "audit-table" },
|
|
423
|
+
h("thead", null, h("tr", null,
|
|
424
|
+
th("ts", L.time),
|
|
425
|
+
showPerson ? th("person", L.person) : null,
|
|
426
|
+
th("action", L.action),
|
|
427
|
+
showT ? th("tenant", props.tenantLabel || L.tenant) : null,
|
|
428
|
+
showIp ? th("ip", L.ip) : null,
|
|
429
|
+
th("status", L.status),
|
|
430
|
+
h("th", { key: "x" }))),
|
|
431
|
+
h("tbody", null,
|
|
432
|
+
rows.length === 0 ? h("tr", null, h("td", { colSpan: colCount, className: "scalio-audit-empty", "data-testid": "audit-empty" }, L.empty)) : null,
|
|
433
|
+
rows.map((e) => h("tr", { key: e.id, "data-testid": "audit-row", className: e.status !== "ok" ? "is-error" : undefined },
|
|
434
|
+
h("td", { className: "scalio-audit-mono" }, fmtTime(e.ts, lang)),
|
|
435
|
+
showPerson ? h("td", null, who(e, L)) : null,
|
|
436
|
+
h("td", null,
|
|
437
|
+
h("div", { className: "scalio-audit-action" }, actionLabel(e.action)),
|
|
438
|
+
subline(e) ? h("div", { className: "scalio-audit-sub" }, subline(e)) : null),
|
|
439
|
+
showT ? h("td", null, e.tenant ? tenantName(e.tenant) : "–") : null,
|
|
440
|
+
showIp ? h("td", { className: "scalio-audit-mono" }, e.ip || "–") : null,
|
|
441
|
+
h("td", null, h(Badge, { status: e.status, L })),
|
|
442
|
+
h("td", { className: "scalio-audit-row-actions" },
|
|
443
|
+
props.rowActions ? props.rowActions(e) : null,
|
|
444
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": `audit-view-${e.id}`, onClick: () => setOpen(e) }, L.view))))))),
|
|
445
|
+
h("div", { className: "scalio-audit-cards", "data-testid": "audit-cards" },
|
|
446
|
+
rows.length === 0 ? h("div", { className: "scalio-audit-empty" }, L.empty) : null,
|
|
447
|
+
rows.map((e) => h("div", { key: e.id, className: "scalio-audit-card", "data-testid": "audit-card" },
|
|
448
|
+
h("div", { className: "scalio-audit-action" }, actionLabel(e.action)),
|
|
449
|
+
subline(e) ? h("div", { className: "scalio-audit-sub" }, subline(e)) : null,
|
|
450
|
+
h("div", { className: "scalio-audit-card-meta" }, h(Badge, { status: e.status, L }), h("span", null, `${who(e, L)} · ${fmtTime(e.ts, lang)}`)),
|
|
451
|
+
h("div", { className: "scalio-audit-row-actions" },
|
|
452
|
+
props.rowActions ? props.rowActions(e) : null,
|
|
453
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setOpen(e) }, L.view))))),
|
|
454
|
+
data.next ? h("div", { className: "scalio-audit-more" },
|
|
455
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "audit-more", onClick: more }, L.more)) : null,
|
|
456
|
+
open ? h(Dialog, { title: actionLabel(open.action), onClose: () => setOpen(null), testId: "audit-detail", closeLabel: L.close },
|
|
457
|
+
h("dl", { className: "scalio-audit-dl" },
|
|
458
|
+
h("dt", null, L.time), h("dd", null, fmtTime(open.ts, lang)),
|
|
459
|
+
h("dt", null, L.person), h("dd", null, who(open, L), open.actor && open.actor.email && open.actor.name ? ` (${open.actor.email})` : ""),
|
|
460
|
+
h("dt", null, L.status), h("dd", null, h(Badge, { status: open.status, L })),
|
|
461
|
+
open.target ? [h("dt", { key: "t" }, L.object), h("dd", { key: "tv" }, [open.target.type, open.target.name || open.target.id].filter(Boolean).join(" · "))] : null,
|
|
462
|
+
open.tenant ? [h("dt", { key: "n" }, props.tenantLabel || L.tenant), h("dd", { key: "nv" }, tenantName(open.tenant))] : null,
|
|
463
|
+
open.ip ? [h("dt", { key: "i" }, L.ip), h("dd", { key: "iv", className: "scalio-audit-mono" }, open.ip)] : null,
|
|
464
|
+
open.detail ? [h("dt", { key: "d" }, L.details), h("dd", { key: "dv" }, open.detail)] : null),
|
|
465
|
+
open.changes && open.changes.length ? h("div", { className: "scalio-audit-changes", "data-testid": "audit-changes" },
|
|
466
|
+
h("div", { className: "scalio-audit-changes-title" }, L.changes),
|
|
467
|
+
open.changes.map((c, i) => h("div", { key: i, className: "scalio-audit-change" },
|
|
468
|
+
h("span", { className: "scalio-audit-field" }, c.field),
|
|
469
|
+
h("span", { className: "scalio-audit-old" }, fmtValue(c.old)),
|
|
470
|
+
h("span", null, "→"),
|
|
471
|
+
h("span", { className: "scalio-audit-new" }, fmtValue(c.new))))) : null,
|
|
472
|
+
props.renderDetail ? props.renderDetail(open) : null) : null);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function jobName(job, lang) {
|
|
476
|
+
return (job && (job[lang] || job.de || job.key)) || "";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tab Jobs. Props: client (jobsApi), lang, labels, notify(text, tone), confirm({title, text, action, danger}),
|
|
480
|
+
// canRun (may run jobs and act on the queue), link (router Link for queue items with `link`), pollMs (default 15000),
|
|
481
|
+
// renderQueueDetail(item) (extra content, e.g. Nexus steps and debug), queueActions(item) (extra buttons), testId.
|
|
482
|
+
function JobsPanel(props) {
|
|
483
|
+
const { client, notify } = props;
|
|
484
|
+
const lang = props.lang === "en" ? "en" : "de";
|
|
485
|
+
const L = auditLabels(lang, props.labels);
|
|
486
|
+
const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
|
|
487
|
+
const pollMs = props.pollMs === undefined ? 15000 : props.pollMs;
|
|
488
|
+
const [info, setInfo] = React.useState(null);
|
|
489
|
+
const [runs, setRuns] = React.useState([]);
|
|
490
|
+
const [queue, setQueue] = React.useState([]);
|
|
491
|
+
const [tab, setTab] = React.useState(props.initialTab || "");
|
|
492
|
+
const [runFilter, setRunFilter] = React.useState({ key: "", status: "" });
|
|
493
|
+
const [busy, setBusy] = React.useState("");
|
|
494
|
+
const [error, setError] = React.useState("");
|
|
495
|
+
const [open, setOpen] = React.useState(null);
|
|
496
|
+
|
|
497
|
+
const load = React.useCallback(async () => {
|
|
498
|
+
try {
|
|
499
|
+
const i = await client.list();
|
|
500
|
+
const [r, qq] = await Promise.all([client.runs(runFilter), i.queue ? client.queue() : Promise.resolve([])]);
|
|
501
|
+
setInfo(i);
|
|
502
|
+
setRuns(r || []);
|
|
503
|
+
setQueue(qq || []);
|
|
504
|
+
setError("");
|
|
505
|
+
} catch (err) {
|
|
506
|
+
setError(errorText(err));
|
|
507
|
+
}
|
|
508
|
+
}, [client, runFilter]);
|
|
509
|
+
|
|
510
|
+
React.useEffect(() => { load(); }, [load]);
|
|
511
|
+
React.useEffect(() => {
|
|
512
|
+
if (!pollMs) return undefined;
|
|
513
|
+
const t = setInterval(load, pollMs);
|
|
514
|
+
return () => clearInterval(t);
|
|
515
|
+
}, [pollMs, load]);
|
|
516
|
+
|
|
517
|
+
if (!info) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
|
|
518
|
+
const jobs = info.jobs || [];
|
|
519
|
+
const actionsOk = info.queue_actions || {};
|
|
520
|
+
const current = tab || (info.queue ? "queue" : "jobs");
|
|
521
|
+
const byKey = {};
|
|
522
|
+
jobs.forEach((j) => { byKey[j.key] = j; });
|
|
523
|
+
|
|
524
|
+
const act = async (id, fn, ok, ask) => {
|
|
525
|
+
if (ask && props.confirm && !(await props.confirm(ask))) return;
|
|
526
|
+
setBusy(id);
|
|
527
|
+
try {
|
|
528
|
+
const res = await fn();
|
|
529
|
+
say(typeof ok === "function" ? ok(res) : ok);
|
|
530
|
+
await load();
|
|
531
|
+
} catch (err) {
|
|
532
|
+
say(errorText(err), "error");
|
|
533
|
+
} finally {
|
|
534
|
+
setBusy("");
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const tabs = [
|
|
539
|
+
info.queue ? { id: "queue", testId: "jobs-tab-queue", label: L.queue, badge: queue.filter((x) => ["queued", "running", "waiting", "error"].includes(x.status)).length || null } : null,
|
|
540
|
+
{ id: "runs", testId: "jobs-tab-runs", label: L.runs },
|
|
541
|
+
{ id: "jobs", testId: "jobs-tab-jobs", label: L.background },
|
|
542
|
+
].filter(Boolean);
|
|
543
|
+
|
|
544
|
+
const QueueLink = ({ item }) => {
|
|
545
|
+
if (!item.link) return null;
|
|
546
|
+
if (props.link) return h(props.link, { to: item.link, className: "scalio-btn is-ghost", "data-testid": `queue-open-${item.id}` }, L.open);
|
|
547
|
+
return h("a", { href: item.link, className: "scalio-btn is-ghost", "data-testid": `queue-open-${item.id}` }, L.open);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
return h("section", { className: "scalio-jobs", "data-testid": props.testId || "jobs-panel" },
|
|
551
|
+
h(SubNav, { items: tabs, value: current, onSelect: setTab, testId: "jobs-tabs" }),
|
|
552
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
|
|
553
|
+
|
|
554
|
+
current === "jobs" ? h("div", { className: "scalio-jobs-grid", "data-testid": "jobs-background" },
|
|
555
|
+
jobs.map((j) => h("div", { key: j.key, className: "scalio-jobs-card", "data-testid": `job-${j.key}` },
|
|
556
|
+
h("div", { className: "scalio-jobs-card-head" },
|
|
557
|
+
h("div", null,
|
|
558
|
+
h("div", { className: "scalio-jobs-name" }, jobName(j, lang)),
|
|
559
|
+
(j[`description_${lang}`] || j.description_de) ? h("div", { className: "scalio-audit-sub" }, j[`description_${lang}`] || j.description_de) : null),
|
|
560
|
+
j.last_status ? h(Badge, { status: j.last_status, L, testId: `job-status-${j.key}` }) : null),
|
|
561
|
+
h("dl", { className: "scalio-audit-dl" },
|
|
562
|
+
(j[`schedule_${lang}`] || j.schedule_de) ? [h("dt", { key: "s" }, L.schedule), h("dd", { key: "sv" }, j[`schedule_${lang}`] || j.schedule_de)] : null,
|
|
563
|
+
h("dt", null, L.lastRun), h("dd", null, j.last_run ? `${fmtTime(j.last_run, lang)}${j.last_duration !== null && j.last_duration !== undefined ? ` · ${fmtDuration(j.last_duration)}` : ""}` : L.never),
|
|
564
|
+
j.last_result && j.last_result.summary ? [h("dt", { key: "r" }, L.result), h("dd", { key: "rv" }, j.last_result.summary)] : null),
|
|
565
|
+
h("div", { className: "scalio-jobs-card-foot" },
|
|
566
|
+
j.requested ? h("span", { className: "scalio-badge is-running", "data-testid": `job-requested-${j.key}` }, L.requested) : null,
|
|
567
|
+
props.canRun && j.runnable ? h("button", { type: "button", className: "scalio-btn", disabled: busy === j.key || j.requested, "data-testid": `job-run-${j.key}`,
|
|
568
|
+
onClick: () => act(j.key, () => client.run(j.key), (res) => (res && res.queued === false ? L.ranNow : L.queued)) },
|
|
569
|
+
h(Icons.Activity, { className: "scalio-icon" }), L.runNow) : null)))) : null,
|
|
570
|
+
|
|
571
|
+
current === "runs" ? h("div", { "data-testid": "jobs-runs" },
|
|
572
|
+
h("div", { className: "scalio-audit-toolbar" },
|
|
573
|
+
h("select", { className: "scalio-input", value: runFilter.key, "data-testid": "runs-filter-job", onChange: (e) => setRunFilter(Object.assign({}, runFilter, { key: e.target.value })) },
|
|
574
|
+
[h("option", { key: "", value: "" }, L.allJobs)].concat(jobs.map((j) => h("option", { key: j.key, value: j.key }, jobName(j, lang))))),
|
|
575
|
+
h("select", { className: "scalio-input", value: runFilter.status, "data-testid": "runs-filter-status", onChange: (e) => setRunFilter(Object.assign({}, runFilter, { status: e.target.value })) },
|
|
576
|
+
["", "ok", "warning", "error"].map((s) => h("option", { key: s, value: s }, s ? L.statuses[s] : L.allStatuses)))),
|
|
577
|
+
h("div", { className: "scalio-users-table-wrap" },
|
|
578
|
+
h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "runs-table" },
|
|
579
|
+
h("thead", null, h("tr", null, h("th", null, L.time), h("th", null, L.job), h("th", null, L.result), h("th", null, L.trigger), h("th", null, L.duration), h("th", null, L.status))),
|
|
580
|
+
h("tbody", null,
|
|
581
|
+
runs.length === 0 ? h("tr", null, h("td", { colSpan: 6, className: "scalio-audit-empty", "data-testid": "runs-empty" }, L.noRuns)) : null,
|
|
582
|
+
runs.map((r) => h("tr", { key: r.id, "data-testid": "run-row", className: r.status === "error" ? "is-error" : undefined },
|
|
583
|
+
h("td", { className: "scalio-audit-mono" }, fmtTime(r.ts, lang)),
|
|
584
|
+
h("td", null, jobName(byKey[r.key], lang) || r.key),
|
|
585
|
+
h("td", null, r.summary || "–"),
|
|
586
|
+
h("td", null, (L.triggers[r.trigger] || r.trigger) + (r.by ? ` · ${r.by}` : "")),
|
|
587
|
+
h("td", { className: "scalio-audit-mono" }, fmtDuration(r.duration)),
|
|
588
|
+
h("td", null, h(Badge, { status: r.status, L })))))))) : null,
|
|
589
|
+
|
|
590
|
+
current === "queue" ? h("div", { "data-testid": "jobs-queue" },
|
|
591
|
+
h("div", { className: "scalio-users-table-wrap" },
|
|
592
|
+
h("table", { className: "scalio-users-table scalio-audit-table", "data-testid": "queue-table" },
|
|
593
|
+
h("thead", null, h("tr", null, h("th", null, L.job), h("th", null, L.type), h("th", null, L.created), h("th", null, L.status), h("th", null))),
|
|
594
|
+
h("tbody", null,
|
|
595
|
+
queue.length === 0 ? h("tr", null, h("td", { colSpan: 5, className: "scalio-audit-empty", "data-testid": "queue-empty" }, L.noQueue)) : null,
|
|
596
|
+
queue.map((it) => h("tr", { key: it.id, "data-testid": "queue-row", className: it.status === "error" ? "is-error" : undefined },
|
|
597
|
+
h("td", null, h("div", { className: "scalio-audit-action" }, it.title || it.id), it.detail ? h("div", { className: "scalio-audit-sub" }, it.detail) : null),
|
|
598
|
+
h("td", null, it.type || "–"),
|
|
599
|
+
h("td", { className: "scalio-audit-mono" }, fmtTime(it.created_at, lang), it.by ? h("div", { className: "scalio-audit-sub" }, it.by) : null),
|
|
600
|
+
h("td", null, h(Badge, { status: it.status, L })),
|
|
601
|
+
h("td", { className: "scalio-audit-row-actions" },
|
|
602
|
+
props.queueActions ? props.queueActions(it, load) : null,
|
|
603
|
+
(it.steps && it.steps.length) || props.renderQueueDetail ? h("button", { type: "button", className: "scalio-btn is-ghost", "data-testid": `queue-view-${it.id}`, onClick: () => setOpen(it) }, L.steps) : null,
|
|
604
|
+
h(QueueLink, { item: it }),
|
|
605
|
+
props.canRun && actionsOk.retry && it.can_retry ? h("button", { type: "button", className: "scalio-btn is-ghost", disabled: busy === it.id, "data-testid": `queue-retry-${it.id}`,
|
|
606
|
+
onClick: () => act(it.id, () => client.retry(it.id), L.done) }, L.retry) : null,
|
|
607
|
+
props.canRun && actionsOk.cancel && it.can_cancel ? h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy === it.id, "data-testid": `queue-cancel-${it.id}`,
|
|
608
|
+
onClick: () => act(it.id, () => client.cancel(it.id), L.done, { title: L.cancel, text: it.title || it.id, action: L.cancel, danger: true }) }, L.cancel) : null,
|
|
609
|
+
props.canRun && actionsOk.delete && it.can_delete ? h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy === it.id, "data-testid": `queue-delete-${it.id}`,
|
|
610
|
+
onClick: () => act(it.id, () => client.remove(it.id), L.done, { title: L.delete, text: it.title || it.id, action: L.delete, danger: true }) }, L.delete) : null))))))) : null,
|
|
611
|
+
|
|
612
|
+
open ? h(Dialog, { title: open.title || open.id, onClose: () => setOpen(null), testId: "queue-detail", closeLabel: L.close },
|
|
613
|
+
open.steps && open.steps.length ? h("ol", { className: "scalio-jobs-steps" },
|
|
614
|
+
open.steps.map((s, i) => h("li", { key: i, className: `is-${TONE[s.status] || "neutral"}` },
|
|
615
|
+
h(Badge, { status: s.status, L }), h("span", null, s.name), s.detail ? h("span", { className: "scalio-audit-sub" }, s.detail) : null))) : null,
|
|
616
|
+
props.renderQueueDetail ? props.renderQueueDetail(open, load) : null) : null);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
module.exports = { AuditLog, JobsPanel, AuditNav, auditApi, jobsApi, auditLabels, AUDIT_LABELS: LABELS };
|
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"));
|
|
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"));
|
|
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,473 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
129
|
+
return esc.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mailLabels(lang, overrides) {
|
|
133
|
+
return Object.assign({}, LABELS[lang === "en" ? "en" : "de"], overrides || {});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function errorText(err) {
|
|
137
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
138
|
+
if (typeof d === "string") return d;
|
|
139
|
+
if (Array.isArray(d)) return d.map((x) => (x && x.msg) || String(x)).join(" ");
|
|
140
|
+
return (err && err.message) || String(err);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function fmtTime(iso, lang) {
|
|
144
|
+
try {
|
|
145
|
+
return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "short" });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return iso || "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// api: axios instance with the app's base URL (/api); base: where the app mounted Mailer.router.
|
|
152
|
+
function mailApi(api, base) {
|
|
153
|
+
const b = base || "/mail";
|
|
154
|
+
const data = (r) => r.data;
|
|
155
|
+
const enc = encodeURIComponent;
|
|
156
|
+
return {
|
|
157
|
+
settings: () => api.get(`${b}/settings`).then(data),
|
|
158
|
+
save: (body) => api.put(`${b}/settings`, body).then(data),
|
|
159
|
+
test: (to) => api.post(`${b}/test`, { to: to || "" }).then(data),
|
|
160
|
+
outbox: () => api.get(`${b}/outbox`).then(data),
|
|
161
|
+
templates: () => api.get(`${b}/templates`).then(data),
|
|
162
|
+
saveTemplate: (kind, lang, body) => api.put(`${b}/templates/${enc(kind)}/${enc(lang)}`, body).then(data),
|
|
163
|
+
resetTemplate: (kind, lang) => api.delete(`${b}/templates/${enc(kind)}/${enc(lang)}`).then(data),
|
|
164
|
+
preview: (body) => api.post(`${b}/templates/preview`, body).then(data),
|
|
165
|
+
testTemplate: (body) => api.post(`${b}/templates/test`, body).then(data),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function useMessages(props) {
|
|
170
|
+
const [msg, setMsg] = React.useState(null);
|
|
171
|
+
const fmtErr = props.formatError || errorText;
|
|
172
|
+
return {
|
|
173
|
+
msg,
|
|
174
|
+
ok: (text) => (props.notify ? props.notify(text, "success") : setMsg({ tone: "ok", text })),
|
|
175
|
+
fail: (err) => {
|
|
176
|
+
const text = fmtErr(err);
|
|
177
|
+
if (props.notify) props.notify(text, "error");
|
|
178
|
+
else setMsg({ tone: "error", text });
|
|
179
|
+
},
|
|
180
|
+
clear: () => setMsg(null),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function Notice({ msg, testId }) {
|
|
185
|
+
if (!msg) return null;
|
|
186
|
+
return h("div", { className: `scalio-notice is-${msg.tone === "error" ? "error" : "ok"}`, role: msg.tone === "error" ? "alert" : "status", "data-testid": testId }, msg.text);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function input(id, value, onChange, extra) {
|
|
190
|
+
return h("input", Object.assign({ id, className: "scalio-input", value: value == null ? "" : value, "data-testid": id,
|
|
191
|
+
onChange: (e) => onChange(e.target.value) }, extra || {}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function select(id, value, options, onChange, extra) {
|
|
195
|
+
return h("select", Object.assign({ id, className: "scalio-input", value, "data-testid": id, onChange: (e) => onChange(e.target.value) }, extra || {}),
|
|
196
|
+
options.map(([v, text]) => h("option", { key: v, value: v }, text)));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 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), children (more blocks after «Testmail», e.g. Beacon's
|
|
201
|
+
// mail intake), outbox (false hides «Letzte Mails»).
|
|
202
|
+
function MailTransport(props) {
|
|
203
|
+
const { client, lang } = props;
|
|
204
|
+
const L = mailLabels(lang, props.labels);
|
|
205
|
+
const m = useMessages(props);
|
|
206
|
+
const [form, setForm] = React.useState(null);
|
|
207
|
+
const [busy, setBusy] = React.useState(false);
|
|
208
|
+
const [to, setTo] = React.useState("");
|
|
209
|
+
const [testing, setTesting] = React.useState(false);
|
|
210
|
+
const [testMsg, setTestMsg] = React.useState(null);
|
|
211
|
+
const [rows, setRows] = React.useState([]);
|
|
212
|
+
const showOutbox = props.outbox !== false;
|
|
213
|
+
|
|
214
|
+
const loadOutbox = React.useCallback(() => {
|
|
215
|
+
if (!showOutbox) return Promise.resolve();
|
|
216
|
+
return client.outbox().then((r) => setRows(r || [])).catch(() => setRows([]));
|
|
217
|
+
}, [client, showOutbox]);
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
let alive = true;
|
|
220
|
+
client.settings().then((d) => alive && setForm(d)).catch((e) => alive && m.fail(e));
|
|
221
|
+
loadOutbox();
|
|
222
|
+
return () => { alive = false; };
|
|
223
|
+
}, [client]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
224
|
+
|
|
225
|
+
if (!form) return h(SettingsSection, { id: "mail", title: L.transportTitle, description: L.transportHint, testId: "mail-transport" }, h(Notice, { msg: m.msg, testId: "mail-message" }));
|
|
226
|
+
|
|
227
|
+
const set = (patch) => setForm(Object.assign({}, form, patch));
|
|
228
|
+
const part = (name, key) => (v) => set({ [name]: Object.assign({}, form[name], { [key]: v }) });
|
|
229
|
+
const locked = !!form.test_mailbox;
|
|
230
|
+
|
|
231
|
+
const save = async (e) => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
setBusy(true);
|
|
234
|
+
m.clear();
|
|
235
|
+
try {
|
|
236
|
+
const body = { provider: form.provider, from_name: form.from_name, from_address: form.from_address, language: form.language };
|
|
237
|
+
if (form.provider === "smtp") body.smtp = Object.assign({}, form.smtp, { port: Number(form.smtp.port) || 587 });
|
|
238
|
+
if (form.provider === "graph") body.graph = form.graph;
|
|
239
|
+
setForm(await client.save(body));
|
|
240
|
+
m.ok(L.saved);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
m.fail(err);
|
|
243
|
+
}
|
|
244
|
+
setBusy(false);
|
|
245
|
+
};
|
|
246
|
+
const test = async () => {
|
|
247
|
+
setTesting(true);
|
|
248
|
+
setTestMsg(null);
|
|
249
|
+
try {
|
|
250
|
+
const r = await client.test(to.trim());
|
|
251
|
+
const text = L.testSent.replace("{to}", (r && r.to) || to);
|
|
252
|
+
if (props.notify) props.notify(text, "success");
|
|
253
|
+
else setTestMsg({ tone: "ok", text });
|
|
254
|
+
loadOutbox();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const text = (props.formatError || errorText)(err);
|
|
257
|
+
if (props.notify) props.notify(text, "error");
|
|
258
|
+
else setTestMsg({ tone: "error", text });
|
|
259
|
+
}
|
|
260
|
+
setTesting(false);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const providers = ["off", "smtp", "graph"];
|
|
264
|
+
const transport = h(
|
|
265
|
+
SettingsSection,
|
|
266
|
+
{ id: "mail", title: L.transportTitle, description: L.transportHint, testId: "mail-transport" },
|
|
267
|
+
h(
|
|
268
|
+
"form",
|
|
269
|
+
{ className: "scalio-settings-form", onSubmit: save },
|
|
270
|
+
locked ? h("div", { className: "scalio-notice is-info", "data-testid": "mail-test-mailbox" }, L.testMailbox) : null,
|
|
271
|
+
h("fieldset", { className: "scalio-mail-providers", disabled: busy || locked },
|
|
272
|
+
h("legend", { className: "scalio-field-label" }, L.provider),
|
|
273
|
+
providers.map((id) => h("label", { key: id, className: "scalio-settings-check" },
|
|
274
|
+
h("input", { type: "radio", name: "mail-provider", checked: form.provider === id, "data-testid": `mail-provider-${id}`,
|
|
275
|
+
onChange: () => set({ provider: id }) }), h("span", null, L.providers[id])))),
|
|
276
|
+
form.provider !== "off"
|
|
277
|
+
? h("div", { className: "scalio-mail-grid" },
|
|
278
|
+
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 })),
|
|
279
|
+
form.provider === "smtp"
|
|
280
|
+
? h(Field, { id: "mail-from", label: L.fromAddress }, input("mail-from", form.from_address, (v) => set({ from_address: v }),
|
|
281
|
+
{ type: "email", maxLength: 254, placeholder: "noreply@example.com", disabled: busy || locked }))
|
|
282
|
+
: null,
|
|
283
|
+
h(Field, { id: "mail-lang", label: L.language, hint: L.languageHint },
|
|
284
|
+
select("mail-lang", form.language, [["de", L.languages.de], ["en", L.languages.en]], (v) => set({ language: v }), { disabled: busy })))
|
|
285
|
+
: null,
|
|
286
|
+
form.provider === "smtp"
|
|
287
|
+
? h("div", { className: "scalio-mail-grid", "data-testid": "mail-smtp" },
|
|
288
|
+
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 })),
|
|
289
|
+
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 })),
|
|
290
|
+
h(Field, { id: "mail-smtp-security", label: L.security },
|
|
291
|
+
select("mail-smtp-security", form.smtp.security, Object.keys(L.securities).map((k) => [k, L.securities[k]]), part("smtp", "security"), { disabled: busy || locked })),
|
|
292
|
+
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 })),
|
|
293
|
+
h(Field, { id: "mail-smtp-password", label: L.password }, input("mail-smtp-password", form.smtp.password, part("smtp", "password"),
|
|
294
|
+
{ type: "password", maxLength: 500, autoComplete: "new-password", disabled: busy || locked })))
|
|
295
|
+
: null,
|
|
296
|
+
form.provider === "graph"
|
|
297
|
+
? h("div", { "data-testid": "mail-graph" },
|
|
298
|
+
h("div", { className: "scalio-mail-grid" },
|
|
299
|
+
h(Field, { id: "mail-graph-tenant", label: L.tenantId }, input("mail-graph-tenant", form.graph.tenant_id, part("graph", "tenant_id"),
|
|
300
|
+
{ maxLength: 36, placeholder: "00000000-0000-0000-0000-000000000000", className: "scalio-input scalio-mono", disabled: busy })),
|
|
301
|
+
h(Field, { id: "mail-graph-client", label: L.clientId }, input("mail-graph-client", form.graph.client_id, part("graph", "client_id"),
|
|
302
|
+
{ maxLength: 36, placeholder: "00000000-0000-0000-0000-000000000000", className: "scalio-input scalio-mono", disabled: busy })),
|
|
303
|
+
h(Field, { id: "mail-graph-secret", label: L.clientSecret }, input("mail-graph-secret", form.graph.client_secret, part("graph", "client_secret"),
|
|
304
|
+
{ type: "password", maxLength: 500, autoComplete: "new-password", disabled: busy })),
|
|
305
|
+
h(Field, { id: "mail-graph-sender", label: L.sender }, input("mail-graph-sender", form.graph.sender, part("graph", "sender"),
|
|
306
|
+
{ type: "email", maxLength: 254, placeholder: "it@example.com", disabled: busy }))),
|
|
307
|
+
h("p", { className: "scalio-settings-help" }, props.graphHint || L.graphHint))
|
|
308
|
+
: null,
|
|
309
|
+
h(Notice, { msg: m.msg, testId: "mail-message" }),
|
|
310
|
+
h("div", { className: "scalio-settings-section-footer" },
|
|
311
|
+
h("button", { type: "submit", className: "scalio-btn is-primary", disabled: busy || locked, "data-testid": "mail-save" },
|
|
312
|
+
h(Icons.Check, { className: "scalio-icon" }), L.save))
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const testBlock = h(
|
|
317
|
+
SettingsSection,
|
|
318
|
+
{ id: "mail-test", title: L.testTitle, description: L.testHint, testId: "mail-test-section" },
|
|
319
|
+
h("div", { className: "scalio-mail-test" },
|
|
320
|
+
input("mail-test-to", to, setTo, { type: "email", placeholder: L.recipient, "aria-label": L.recipient, maxLength: 254 }),
|
|
321
|
+
h("button", { type: "button", className: "scalio-btn", onClick: test, disabled: testing || form.provider === "off", "data-testid": "mail-test" },
|
|
322
|
+
h(Icons.CornerDownLeft, { className: "scalio-icon" }), L.sendTest)),
|
|
323
|
+
h(Notice, { msg: testMsg, testId: "mail-test-message" })
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const outbox = showOutbox
|
|
327
|
+
? h(
|
|
328
|
+
SettingsSection,
|
|
329
|
+
{ id: "mail-outbox", title: L.outboxTitle, description: L.outboxHint, testId: "mail-outbox-section" },
|
|
330
|
+
h("div", { className: "scalio-settings-section-footer" },
|
|
331
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: loadOutbox, "data-testid": "mail-outbox-refresh" }, L.refresh)),
|
|
332
|
+
rows.length === 0
|
|
333
|
+
? h("p", { className: "scalio-settings-text", "data-testid": "mail-outbox-empty" }, L.outboxEmpty)
|
|
334
|
+
: h("div", { className: "scalio-mail-outbox" },
|
|
335
|
+
h("table", { "data-testid": "mail-outbox" },
|
|
336
|
+
h("tbody", null, rows.map((r) => h("tr", { key: r.id },
|
|
337
|
+
h("td", { className: "scalio-mail-time" }, fmtTime(r.created_at, lang)),
|
|
338
|
+
h("td", null, r.to),
|
|
339
|
+
h("td", null, r.subject, r.error ? h("span", { className: "scalio-settings-help is-error" }, r.error) : null),
|
|
340
|
+
h("td", { className: "scalio-mail-state" },
|
|
341
|
+
h("span", { className: `scalio-badge is-${{ pending: "running", sent: "ok", failed: "error" }[r.status] || "neutral"}` },
|
|
342
|
+
L.states[r.status] || r.status)))))))
|
|
343
|
+
)
|
|
344
|
+
: null;
|
|
345
|
+
|
|
346
|
+
return h(React.Fragment, null, transport, testBlock, props.children || null, outbox);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Props: client (mailApi), lang, labels, notify, formatError, editor (the app's rich text editor: a component with
|
|
350
|
+
// {value, onChange, testId}; without it a text field), delay (ms before the preview reloads, default 450).
|
|
351
|
+
function MailTemplates(props) {
|
|
352
|
+
const { client, lang } = props;
|
|
353
|
+
const L = mailLabels(lang, props.labels);
|
|
354
|
+
const m = useMessages(props);
|
|
355
|
+
const [data, setData] = React.useState(null);
|
|
356
|
+
const [kind, setKind] = React.useState("");
|
|
357
|
+
const [tlang, setTlang] = React.useState("");
|
|
358
|
+
const [subject, setSubject] = React.useState("");
|
|
359
|
+
const [body, setBody] = React.useState("");
|
|
360
|
+
const [busy, setBusy] = React.useState(false);
|
|
361
|
+
const [showPreview, setShowPreview] = React.useState(true);
|
|
362
|
+
const [preview, setPreview] = React.useState({ subject: "", html: "" });
|
|
363
|
+
const [provider, setProvider] = React.useState(null);
|
|
364
|
+
const delay = props.delay == null ? 450 : props.delay;
|
|
365
|
+
|
|
366
|
+
const apply = (d, k, l) => {
|
|
367
|
+
setData(d);
|
|
368
|
+
const kk = k || (d.kinds[0] && d.kinds[0].code) || "";
|
|
369
|
+
const ll = l || d.language || "de";
|
|
370
|
+
setKind(kk);
|
|
371
|
+
setTlang(ll);
|
|
372
|
+
const meta = d.kinds.find((x) => x.code === kk);
|
|
373
|
+
const t = meta && meta.texts[ll];
|
|
374
|
+
setSubject(t ? t.subject : "");
|
|
375
|
+
setBody(t ? (props.editor ? textToHtml(t.body) : t.body) : "");
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
React.useEffect(() => {
|
|
379
|
+
let alive = true;
|
|
380
|
+
client.templates().then((d) => alive && apply(d)).catch((e) => alive && m.fail(e));
|
|
381
|
+
if (client.settings) client.settings().then((s) => alive && setProvider(s.provider)).catch(() => {});
|
|
382
|
+
return () => { alive = false; };
|
|
383
|
+
}, [client]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
384
|
+
|
|
385
|
+
React.useEffect(() => {
|
|
386
|
+
if (!showPreview || !kind || !tlang) return undefined;
|
|
387
|
+
let alive = true;
|
|
388
|
+
const t = setTimeout(() => {
|
|
389
|
+
client.preview({ kind, lang: tlang, subject, body }).then((p) => alive && setPreview(p)).catch(() => {});
|
|
390
|
+
}, delay);
|
|
391
|
+
return () => { alive = false; clearTimeout(t); };
|
|
392
|
+
}, [client, showPreview, kind, tlang, subject, body, delay]);
|
|
393
|
+
|
|
394
|
+
const section = (children) => h(SettingsSection, { id: "mail-templates", title: L.templatesTitle, description: L.templatesHint, testId: "mail-templates" }, children);
|
|
395
|
+
if (!data) return section(h(Notice, { msg: m.msg, testId: "mail-templates-message" }));
|
|
396
|
+
|
|
397
|
+
const meta = data.kinds.find((x) => x.code === kind) || data.kinds[0];
|
|
398
|
+
const text = meta.texts[tlang] || {};
|
|
399
|
+
const run = async (fn, okText) => {
|
|
400
|
+
setBusy(true);
|
|
401
|
+
m.clear();
|
|
402
|
+
try {
|
|
403
|
+
const r = await fn();
|
|
404
|
+
if (okText) m.ok(typeof okText === "function" ? okText(r) : okText);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
m.fail(err);
|
|
407
|
+
}
|
|
408
|
+
setBusy(false);
|
|
409
|
+
};
|
|
410
|
+
const save = () => run(async () => apply(await client.saveTemplate(kind, tlang, { subject, body }), kind, tlang), L.templateSaved);
|
|
411
|
+
const reset = () => run(async () => apply(await client.resetTemplate(kind, tlang), kind, tlang), L.templateReset);
|
|
412
|
+
const testMe = () => run(() => client.testTemplate({ kind, lang: tlang, subject, body }), (r) => L.testSent.replace("{to}", (r && r.to) || ""));
|
|
413
|
+
const toggle = (on) => run(async () => {
|
|
414
|
+
await client.save({ notify: { [kind]: on } });
|
|
415
|
+
apply(await client.templates(), kind, tlang);
|
|
416
|
+
});
|
|
417
|
+
const insert = (tok) => setBody((b) => `${b || ""}{${tok}}`);
|
|
418
|
+
|
|
419
|
+
const Editor = props.editor;
|
|
420
|
+
const editor = Editor
|
|
421
|
+
? h(Editor, { value: body, onChange: setBody, testId: "mail-template-body" })
|
|
422
|
+
: h("textarea", { id: "mail-template-body", className: "scalio-input scalio-textarea scalio-mail-body", rows: 14, value: body, maxLength: 50000,
|
|
423
|
+
"data-testid": "mail-template-body", onChange: (e) => setBody(e.target.value) });
|
|
424
|
+
|
|
425
|
+
return section(h(
|
|
426
|
+
"div",
|
|
427
|
+
{ className: "scalio-settings-form" },
|
|
428
|
+
provider === "off" ? h("div", { className: "scalio-notice is-info", "data-testid": "mail-templates-off" }, L.off) : null,
|
|
429
|
+
h("div", { className: "scalio-mail-grid" },
|
|
430
|
+
h(Field, { id: "mail-template-kind", label: L.kind, hint: meta.description[lang === "en" ? "en" : "de"] },
|
|
431
|
+
select("mail-template-kind", meta.code, data.kinds.map((k) => [k.code, k.name[lang === "en" ? "en" : "de"] +
|
|
432
|
+
(Object.values(k.texts).some((t) => t.own) ? ` (${L.own})` : "")]), (v) => apply(data, v, tlang))),
|
|
433
|
+
h(Field, { id: "mail-template-lang", label: L.lang, hint: text.own ? L.custom : L.builtIn },
|
|
434
|
+
select("mail-template-lang", tlang, data.languages.map((l) => [l, (L.languages[l] || l) + (meta.texts[l] && meta.texts[l].own ? " ✓" : "")]),
|
|
435
|
+
(v) => apply(data, meta.code, v)))),
|
|
436
|
+
meta.switchable
|
|
437
|
+
? h("label", { className: "scalio-settings-check" },
|
|
438
|
+
h("input", { type: "checkbox", checked: !!meta.enabled, disabled: busy, "data-testid": "mail-template-enabled", onChange: (e) => toggle(e.target.checked) }),
|
|
439
|
+
h("span", null, L.enabled))
|
|
440
|
+
: null,
|
|
441
|
+
h("div", null,
|
|
442
|
+
h("span", { className: "scalio-field-label" }, L.tokens),
|
|
443
|
+
h("div", { className: "scalio-mail-tokens" }, meta.tokens.map((tok) =>
|
|
444
|
+
h("button", { key: tok, type: "button", className: "scalio-mail-token", onClick: () => insert(tok), "data-testid": `mail-token-${tok}` }, `{${tok}}`)))),
|
|
445
|
+
h(Field, { id: "mail-template-subject", label: L.subject },
|
|
446
|
+
input("mail-template-subject", subject, setSubject, { maxLength: 300 })),
|
|
447
|
+
h("div", { className: "scalio-mail-editor-head" },
|
|
448
|
+
h("label", { className: "scalio-field-label", htmlFor: "mail-template-body" }, L.body),
|
|
449
|
+
h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setShowPreview((v) => !v), "data-testid": "mail-preview-toggle" },
|
|
450
|
+
showPreview ? L.hidePreview : L.showPreview)),
|
|
451
|
+
h("div", { className: showPreview ? "scalio-mail-editor has-preview" : "scalio-mail-editor" },
|
|
452
|
+
h("div", { className: "scalio-mail-editor-pane" }, editor),
|
|
453
|
+
showPreview
|
|
454
|
+
? h("div", { className: "scalio-mail-preview", "data-testid": "mail-preview" },
|
|
455
|
+
h("div", { className: "scalio-mail-preview-head" }, h("strong", null, L.preview), h("span", null, L.sample)),
|
|
456
|
+
h("div", { className: "scalio-mail-preview-subject" }, `${L.subject}: `, h("span", { "data-testid": "mail-preview-subject" }, preview.subject || "–")),
|
|
457
|
+
h("iframe", { title: L.preview, sandbox: "", "data-testid": "mail-preview-frame",
|
|
458
|
+
srcDoc: `<!doctype html><html><body style="margin:0;padding:16px;background:#fff;">${preview.html || ""}</body></html>` }))
|
|
459
|
+
: null),
|
|
460
|
+
h(Notice, { msg: m.msg, testId: "mail-templates-message" }),
|
|
461
|
+
h("div", { className: "scalio-settings-section-footer" },
|
|
462
|
+
text.own
|
|
463
|
+
? h("button", { type: "button", className: "scalio-btn is-danger", onClick: reset, disabled: busy, "data-testid": "mail-template-reset" },
|
|
464
|
+
h(Icons.History, { className: "scalio-icon" }), L.resetTemplate)
|
|
465
|
+
: null,
|
|
466
|
+
h("button", { type: "button", className: "scalio-btn", onClick: testMe, disabled: busy || provider === "off", "data-testid": "mail-template-test" },
|
|
467
|
+
h(Icons.CornerDownLeft, { className: "scalio-icon" }), L.testToMe),
|
|
468
|
+
h("button", { type: "button", className: "scalio-btn is-primary", onClick: save, disabled: busy || !subject.trim() || !body.trim(), "data-testid": "mail-template-save" },
|
|
469
|
+
h(Icons.Check, { className: "scalio-icon" }), L.saveTemplate))
|
|
470
|
+
));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
module.exports = { MailTransport, MailTemplates, mailApi, mailLabels, textToHtml, MAIL_LABELS: LABELS };
|