feat(admin): migrate system diagnostics and retain SMTP drafts

This commit is contained in:
cesnimda
2026-10-20 15:40:19 +02:00
parent 1d34406d1b
commit d824f59d2b
12 changed files with 1047 additions and 155 deletions
+2
View File
@@ -2,6 +2,8 @@
## Continuation — 20 October 2026
P7-10 [System](p7-10/SYSTEM.md):20unit/46existingbackend/3native plus app/browser TypeScript/lint/build PASS. ExportEAHpn0GPj6rRy3WtHltbf;14axe0violations/100incompletes, two final images inspected after dirty-label correction. Publication follows final staged checks. Audit **f9e1d06bf7fc4081b70ebf461f1645ef219e0a93** pushed/directly verified13:26:54UTC. Separate Qwen adapter preparation **1d34406d1bacdb33d180f0e7e37bb0cb099ece5e** pushed/directly verified13:33:16UTC, full offline sidecar56/56; model activation remains pending. Main d1fcd23 CI832 is independently running; no verified deployment claim.
P7-10 [Audit](p7-10/AUDIT.md) final bounded gate:9 frontend,18 existing backend and3 native cases PASS; app/browser TypeScript, lint/build PASS. Export vZuD4f-46FJekdIR5rhRk; seven scans0violations/110incomplete nodes, two final images inspected. Ordinary REDs and pre-layout candidate remain retained. Users hygiene follow-up **4760fd102c21c684bd139206a12194c6fc8f6104** passed staged/full-phase whitespace and was normally pushed/directly verified13:10:57UTC. Audit publication follows after staged checks; System tests remain separate WIP.
P7-10 Users **7ecc0d66054787b1e2b3da6da7226b9585c66e34** normally pushed/directly verified **13:10:07 UTC**, with Audit/System regression work preserved. Final staged whitespace returned1 for an extra EOF blank line; the root continued publication incorrectly. Immediate follow-up removes it and checks staged/full-phase whitespace without altering behavior or claiming a rerun of the existing runtime evidence. Implemented Users acceptance remains CONDITIONAL GO; Audit follows.
+2
View File
@@ -1,5 +1,7 @@
# Phase 7 — Initial preservation matrix
P7-10 [System](p7-10/SYSTEM.md) adds bounded PRES-A03/E09 proof: four independent diagnostic reads, explicit failed-read availability, denied-refresh disposal, GET-only retry, unchanged SMTP password/clear payloads, retained newer drafts and synchronized/session-guarded operations.20unit/46backend/3native cases pass; administrator browser writes are controlled fixtures, not operational acceptance. All57 groups remain56 PARTIAL/C11 DEFERRED and33 routes. No schema/renderer/provider/AI-safety change. Human/device/provider/output/release qualifications remain.
P7-10 [Audit](p7-10/AUDIT.md) adds PRES-A02/E08/E09 proof: exact Undo allowlist, owned Restore versus administrator Undo, persisted event/readback and ordinary-user denials. GET-only recovery, synchronized writes, current-language errors and disposed-session guards pass. All57 groups retain56 PARTIAL/C11 DEFERRED,33 routes. No server authorization/data contract or PDF/AI safeguard change. System and shared/manual accessibility acceptance remain pending.
P7-10 [Users](p7-10/USERS.md) adds bounded PRES-A01/E09 proof without changing any group disposition: native administrator create/role persistence, target-session revocation, non-admin denial, last-admin conflict and deletion-disabled response, plus retained self-demotion/exact-email guards. Failed reads are distinct from empty; committed-write recovery does not replay writes; submitted form and immutable session disposal are tested. Desktop grid scrolling remains intentional and all action rows fit. Seven scans report zero violations with28incomplete nodes retained. Audit/System, shared accessibility qualification and all human/provider/production requirements remain open;57groups/33routes unchanged.
+3 -1
View File
@@ -2,6 +2,8 @@
## Current continuation — 20 October 2026
Latest: P7-09 published8af9168; P7-10 Users7ecc0d6/4760fd1 and Auditf9e1d06 are published; System implemented CONDITIONAL GO with20unit/46backend/3native/types/lint/build PASS, final hygiene/publication pending. Current derived inventory884/99frontend,348browser/57files,1057backend; separate full sidecar56/56. These do not replace P7-12's fresh integrated run. Main d1fcd23 is testing in run832 after a narrowly identified Playwright-runtime corruption repair; deployment remains unverified. Qwen3.5 adapter preparation1d34406 is pushed on development, with no live model activation. Earlier observations below remain historical.
P7-00–08 and P7-09 presentation are implemented CONDITIONAL GO. The [P7-09 import prerequisite](p7-09/IMPORT_COMPATIBILITY.md) passes full frontend 837/97, full backend 1057, affected native 25 and type/lint/build gates, published as `3028d81`. [Remaining data presentation](p7-09/DATA_PRIVACY_BACKUP.md) has affected47/6 plus22backendcontracts and final native/type/lint/build evidence, with final consumer/hygiene/publication pending. Derived inventory is855/99frontend,339browser,1057backend; this is not a fresh full integrated suite. P7-10 Administration, P7-11 residual UI and P7-12 acceptance remain. The historical sections below retain their as-observed baselines.
The owner subsequently authorized main promotion despite existing release NO-GOs. Main currently contains `cc79771`, including bounded repairs for all five causes found in diagnostic run828. Corrected run831 is queued behind older runs; release success is not claimed. Release repairs are merged and pushed on development as `36fc5d5`, with all unfinished source preserved. P5-13 remains withheld, all 57 preservation qualifications remain binding, and Phase 8 implementation has not started.
@@ -25,7 +27,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
| [P7-07](p7-07/README.md) | Documents owner-scoped index | Implemented CONDITIONAL GO; contract/UI published and directly verified through9dfea12 at21:01:11UTC. Final affected86/8, native8+CV4+ordinaryP2-D01/types/lint/build PASS;18 reviewed PNGs,15axe0/101 qualified. Original32+Documents=33routes; actual MariaDB/human/device/production qualifications remain |
| [P7-08](p7-08/README.md) | Existing notifications and operations | Implemented CONDITIONAL GO and published: UTC9dfea/recent40cfe04/Operationsc5176e3, clean direct refs verified02:03:53UTC. Ops77/7+native12/types/lint/build/14Ops+1P2image reviews PASS;12Opsaxe0/3+14notifaxe0/102 qualified.57groups33routes/F20 preserved |
| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender/Preferences published dac2927/e7dba9a; company/import prerequisites published5addcde/3028d81; release repairs merged/pushed36fc5d5 with all WIP preserved. Remaining data presentation implemented CONDITIONAL GO:47/6 affected frontend,22backendcontracts, nativeSettings9/import4/CSV1/P2-D01 +types/lint/build PASS;37axe0violations/141incomplete nodes qualified,six final PNGs inspected. Final consumer/hygiene/publication receipt follows. Inventory855/99frontend,339browser,1057backend; full integrated acceptance remainsP7-12 |
| [P7-10](p7-10/README.md) | Admin operational presentation | Users published7ecc0d6/4760fd1; Audit implemented CONDITIONAL GO with9 frontend/18backend/3native PASS and final hygiene/publication pending. System follows separately. Derived868/99frontend,345browser/56files; no fresh integrated pass |
| [P7-10](p7-10/README.md) | Admin operational presentation | Users7ecc0d6/4760fd1 and Auditf9e1d06 published; System implemented CONDITIONAL GO with20frontend/46backend/3native/types/lint/build PASS; final hygiene/publication pending. Derived884/99frontend,348browser/57files; no fresh integrated pass |
| P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned |
| P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned |
+3 -3
View File
@@ -6,8 +6,8 @@ Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md): exactly th
| Boundary | State |
|---|---|
| [Users](USERS.md) | Implemented CONDITIONAL GO; pushed7ecc0d6/direct remote verified13:10:07UTC. Staged end-of-file whitespace failure is corrected in the immediate follow-up; no behavior change |
| [Audit](AUDIT.md) | Implemented CONDITIONAL GO; final hygiene/publication pending; exact Undo allowlist and owner-filtered Restore distinction verified |
| System | Pending; preserve independent SMTP/probe/test/maintenance scopes, write-only secret semantics and truthful diagnostic availability |
| [Users](USERS.md) | Implemented CONDITIONAL GO; published7ecc0d6/4760fd1; follow-up passed staged/full-phase whitespace and remote verification13:10:57UTC |
| [Audit](AUDIT.md) | Implemented CONDITIONAL GO; publishedf9e1d06, remote verified13:26:54UTC; exact Undo allowlist and owner-filtered Restore distinction verified |
| [System](SYSTEM.md) | Implemented CONDITIONAL GO;20frontend/46backend/3native plus types/lint/build PASS, final hygiene/publication pending; SMTP drafts and operational scopes preserved |
All 57 preservation groups retain **56 PARTIAL / C11 DEFERRED**, with 33 routes. P5-13 is withheld; followMode remains deferred. No Phase 8 implementation. Production readiness remains NO-GO; the independently owner-authorized main release is still unverified after run831 failed with browser crashes/malformed Playwright JavaScript. Do not infer admin recent-auth enforcement: the current Users/Audit/System actions do not implement that separate guard. Preserve actual account-lifecycle/export recent-auth checks and record this gap rather than inventing a security protocol during presentation migration.
+25
View File
@@ -0,0 +1,25 @@
# P7-10 System
**Implemented CONDITIONAL GO; final hygiene/publication pending.** Entry: Audit `f9e1d06bf7fc4081b70ebf461f1645ef219e0a93`, normally pushed and directly verified20October13:26:54UTC; separate Qwen3.5 adapter preparation `1d34406d1bacdb33d180f0e7e37bb0cb099ece5e`, pushed/directly verified13:33:16UTC. Main remains at its separately authorized CI repair `d1fcd23`; these development commits do not establish deployment.
## Findings and bounded implementation
The old combined load discarded successful diagnostic responses on another failure, swallowed optional failures as absent results, and erased SMTP drafts and write-only password/clear state on refresh or locale changes. Initial failure could display invented offline/disabled/zero metrics. Tabs lacked named panels. Operational writes could compete, and a late acknowledgement could replace newer edits.
The existing four read endpoints now retain independent availability and targeted GET-only retry. No status means no fabricated metric cards. A failed refresh qualifies retained values; denied access clears sensitive diagnostics and prevents operations. Shared page/section surfaces, density tokens, wrapping metadata/actions and linked tabs/panels retain the existing status/settings architecture and all original diagnostic values/helpers.
SMTP fields, password and clear-state revisions preserve unsaved edits through refresh and newer edits through a submitted acknowledgement. Blank-password retention and explicit clear payloads are unchanged. All existing save/test/probe/allowlisted-maintenance operations share synchronous admission and session/lifetime guards. Successful explicit probe retains its status readback; failed readback offers GET recovery without replaying POST. Focus moves to the named pending region and returns to a surviving control or heading. No automatic operation runs on entry or tab changes.
## Evidence and qualifications
Initial ordinary unit baseline:4 passing/5 failing against the old owner. Expanded regression covers independent optional reads, denied refresh, failed-save retention, password clearing, newer snapshots, probe/readback recovery and session disposal. Existing backend **46/46 PASS**, zero skipped: AuthAndSystemControllerTests, MaintenanceJobDispatcherTests and ApiAuthorizationPostureTests, unchanged Release binary.
The first candidate passed20 frontend cases,3 native cases, app/browser TypeScript, lint and production build. Native cases use real isolated login/non-admin403 checks but controlled administrator System reads and SMTP PUT acknowledgements. They never send email, change actual SMTP configuration, run AI probes or dispatch maintenance. Six paired EN/NB/light/dark widths320–1920 exercise both panels and root reflow. Fourteen all-enabled axe scans reported0violations/100incomplete nodes, including the inherited roleless version badge, short badge text and overlapped focus/field backgrounds. These remain qualified automated evidence.
Three candidate PNGs were actually inspected:320px Bokmål dark settings,1024px English dark diagnostics and1440px retained-newer SMTP draft. That review found a saved-message ambiguity while newer edits remained unsaved. A localized SMTP dirty caption now distinguishes newer/failed edits from acknowledged values; two existing unit assertions and a native assertion failed ordinarily before the correction. No saving semantics changed.
Final **20/20 frontend PASS**13:38:37–13:38:53UTC; **3/3 native PASS**13:38:27–13:39:04UTC, zero skips/flaky/retries, export **`EAHpn0GPj6rRy3WtHltbf`**. App/browser TypeScript, lint and build pass on the final source. [Portable results](evidence/system-results.json) record command/source/image identities; source hashes are post-run observations. Both final320px Bokmål settings and1440px newer-draft images were actually inspected. Twelve other final images remain automated evidence; earlier candidates/REDs are retained separately. Fourteen final axe scans remain0violations/100incomplete nodes. Full-page fixed-shell/skip-link capture artifacts are qualified rather than human/device acceptance.
Sixteen added System cases derive884 frontend cases/99 suites; three native additions derive348 cases/57 files. These are inventories, not fresh whole-suite passes. Sidecar is separately56/56 after the seven-case Qwen compatibility repair. Final integrated acceptance remains P7-12.
No API/schema, provider capability, diagnostic source, renderer/PDF/revision/frozen association or AI-safety contract changes. Actual SMTP/probe/maintenance/provider execution, human screen-reader, physical touch/software keyboard, actual zoom and production output remain unverified. Separate recent-auth enforcement is absent in existing administrator operations and is not claimed. All57 groups retain56 PARTIAL/C11 DEFERRED and33 routes; P5-13 stays withheld. Phase8 has not started. Production readiness remains NO-GO despite separate owner release authorization.
@@ -0,0 +1,342 @@
{
"observedUtc": "2026-10-20T13:39:08.502281+00:00",
"sourceScope": "Post-run observations, not pre-run captures",
"sources": {
"src/views/AdminSystemPage.tsx": "8459906c2b2c98d5302d3f5ada287374fa0edbbd19e04afed92f22196143640b",
"src/admin-system-page.test.tsx": "0bca15c42206c31e2bdee9f81a362fa1c75045892b410f87688af3927530f216",
"src/i18n/translations.ts": "deff5960dbc405defe451eac91f6d2484809bfdb141ab111629648135a66d421",
"e2e/phase7-admin-system.spec.ts": "131ab1895aa106b1f0c8d3e69f2967e63020686e86399f71f668500734a15a23"
},
"gates": {
"typecheck": {
"path": ".artifacts/phase7/current/ui/typecheck-command.json",
"sha256": "ed55b1be024788a211cb6b2f13670c05f61b6e9a2074efe3f9f83c3082d5954a",
"name": "typecheck",
"command": [
"rtk",
"proxy",
"npm.cmd",
"run",
"typecheck"
],
"startedUtc": "2026-10-20T13:38:12.450213+00:00",
"finishedUtc": "2026-10-20T13:38:28.890264+00:00",
"exitCode": 0
},
"lint": {
"path": ".artifacts/phase7/current/ui/lint-command.json",
"sha256": "f0c88affb45215c84734b90fe4845a8edad075ba637fe03846d5b8a909540d11",
"name": "lint",
"command": [
"rtk",
"proxy",
"npm.cmd",
"run",
"lint"
],
"startedUtc": "2026-10-20T13:38:12.457245+00:00",
"finishedUtc": "2026-10-20T13:38:44.531849+00:00",
"exitCode": 0
},
"browser-typecheck": {
"path": ".artifacts/phase7/current/ui/browser-typecheck-command.json",
"sha256": "f217f7e123b6d0bfd6ea812956e214c2300c4fb73f70688a50ec7155aa2f62c4",
"name": "browser-typecheck",
"command": [
"rtk",
"proxy",
"npm.cmd",
"exec",
"tsc",
"--",
"--project",
"../.artifacts/phase7/e2e-tsconfig.json",
"--noEmit"
],
"startedUtc": "2026-10-20T13:38:54.293887+00:00",
"finishedUtc": "2026-10-20T13:39:02.147860+00:00",
"exitCode": 0
},
"build": {
"path": ".artifacts/phase7/current/ui/build-command.json",
"sha256": "60b31ffa094f84ef5b219d99a576610000e926fee3bcf02c52a06d168c1b46be",
"name": "build",
"command": [
"rtk",
"proxy",
"npm.cmd",
"run",
"build"
],
"startedUtc": "2026-10-20T13:37:58.329731+00:00",
"finishedUtc": "2026-10-20T13:38:14.705969+00:00",
"exitCode": 0
},
"admin-system": {
"path": ".artifacts/phase7/current/ui/admin-system-command.json",
"sha256": "23366d27a8912cd6c5170eef060da9eaed1d8fcd9e9f34b819f1f82b202c2786",
"name": "admin-system",
"command": [
"rtk",
"proxy",
"node",
"node_modules/@playwright/test/cli.js",
"test",
"e2e/phase7-admin-system.spec.ts",
"--retries=0",
"--reporter=list,json",
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\admin-system-results"
],
"startedUtc": "2026-10-20T13:38:27.233516+00:00",
"finishedUtc": "2026-10-20T13:39:04.904214+00:00",
"exitCode": 0
}
},
"images": [
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-read-recovery-390-en-light.png",
"sha256": "66c4c5e460578e98c0d4d989cdaa4363ead059f9e82c1e676d2d16f9b4b73f3d"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-newer-smtp-draft-1440-en-light.png",
"sha256": "42b1aa930daf52844ed6279d72c2074d1a71318e22922e954290ea7013808a38"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-320-nb-dark.png",
"sha256": "98d2451c06504cce7faffc8df4a344f58dcf9f9f195048fb767889aa28dae3c7"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-320-nb-dark.png",
"sha256": "dac223bb10f01e2ad9c74d08ce0231a89ee63fb1e477dcd51d83d09b17ab6199"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-390-en-light.png",
"sha256": "9daa32566218bb73309b4c3f4177d1e3c7696ee717c2e2040775b60c6f8d43ad"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-390-en-light.png",
"sha256": "878684d2cada2185a558ad16f0b2019b34482ae928a1903320726bed332aaa8e"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-768-nb-light.png",
"sha256": "6872f0b47da55e2dc647623b45f76612392ce962097bec75deaedb5a8be817b3"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-768-nb-light.png",
"sha256": "8af5eadc7060fc97a33a900d2504dfd0e66d2e9e71af006531bfb610e078793f"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1024-en-dark.png",
"sha256": "a36434d8030236db601d4f2160de6ee81232409345ad77479e5c2d4b6fbfbc43"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1024-en-dark.png",
"sha256": "1fc022c083a8fcf250a0e8b598cf831dea9cd88eb253a47bbe4b7e7e9878229e"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1440-en-light.png",
"sha256": "00247a899ec72bf03a64c4f8885bf50ac35507dbde6002f921158f6f8cd11b7c"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1440-en-light.png",
"sha256": "51052ba8fc32683acf157c4c8f026f72382c2a5cc63d6423a5b9a2a029d64d42"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1920-nb-dark.png",
"sha256": "ec4e3b669330bd35ac2f4e10f38b0d4c3c11245ecc2281b4228ff577db974d41"
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1920-nb-dark.png",
"sha256": "68cabb407cd59c17c1f0dbf8563e47e692d8f1c0dc0e35a90a57e3cef5999ee5"
}
],
"scans": [
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-read-recovery-390-en-light-axe.json",
"violations": 0,
"incompleteNodes": 1,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-newer-smtp-draft-1440-en-light-axe.json",
"violations": 0,
"incompleteNodes": 15,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-320-nb-dark-axe.json",
"violations": 0,
"incompleteNodes": 1,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-320-nb-dark-axe.json",
"violations": 0,
"incompleteNodes": 12,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-390-en-light-axe.json",
"violations": 0,
"incompleteNodes": 1,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-390-en-light-axe.json",
"violations": 0,
"incompleteNodes": 12,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-768-nb-light-axe.json",
"violations": 0,
"incompleteNodes": 1,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-768-nb-light-axe.json",
"violations": 0,
"incompleteNodes": 12,
"incompleteRules": [
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1024-en-dark-axe.json",
"violations": 0,
"incompleteNodes": 2,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1024-en-dark-axe.json",
"violations": 0,
"incompleteNodes": 13,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1440-en-light-axe.json",
"violations": 0,
"incompleteNodes": 2,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1440-en-light-axe.json",
"violations": 0,
"incompleteNodes": 13,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-status-1920-nb-dark-axe.json",
"violations": 0,
"incompleteNodes": 2,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
},
{
"path": ".artifacts/phase7/p7-10/system-final-visuals/system-settings-1920-nb-dark-axe.json",
"violations": 0,
"incompleteNodes": 13,
"incompleteRules": [
"aria-prohibited-attr",
"color-contrast"
]
}
],
"frontend": {
"path": ".artifacts/phase7/current/unit/admin-system-dirty-final-20261020T133837Z-command.json",
"sha256": "d06bb477f61c61db5f966fdf34632d84e4132d9384b9d35fdf5798a3434a8fa4",
"command": [
"rtk",
"proxy",
"npm.cmd",
"test",
"--",
"--runInBand",
"--watchAll=false",
"--json",
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\admin-system-dirty-final-20261020T133837Z.json",
"src/admin-system-page.test.tsx"
],
"startedUtc": "2026-10-20T13:38:37.528717+00:00",
"finishedUtc": "2026-10-20T13:38:53.482309+00:00",
"exitCode": 0,
"counts": {
"numPassedTests": 20,
"numFailedTests": 0,
"numPendingTests": 0,
"numTodoTests": 0,
"numPassedTestSuites": 1,
"numFailedTestSuites": 0
}
},
"backend": {
"path": ".artifacts/phase7/current/backend-career/p710-system-contracts-20261020T133136Z/command.json",
"sha256": "44e81e42ccd9dbc61eb2a6da618d48a1a9c47c21aaaefb499d104934ebf5e132",
"command": [
"rtk",
"proxy",
"C:/Users/Connor/.dotnet/dotnet.exe",
"test",
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
"--configuration",
"Release",
"--no-build",
"--filter",
"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~MaintenanceJobDispatcherTests|FullyQualifiedName~ApiAuthorizationPostureTests",
"--logger",
"trx;LogFileName=career.trx",
"--results-directory",
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p710-system-contracts-20261020T133136Z"
],
"startedUtc": "2026-10-20T13:31:36.300068+00:00",
"finishedUtc": "2026-10-20T13:31:38.924898+00:00",
"exitCode": 0,
"logSha256": "5823beffd630c5ec8db5d85b288c7a29b92175a9ee53193d177f2727feb44829",
"counts": {
"total": 46,
"passed": 46,
"failed": 0,
"notExecuted": 0
},
"resultSha256": "ffce3255d00f773af12ecd8ab90a8346bb5a97303ece9b9c8f74542a0c727272"
},
"buildId": "EAHpn0GPj6rRy3WtHltbf",
"nativeStats": {
"startTime": "2026-10-20T13:38:27.913Z",
"duration": 36945.229,
"expected": 3,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"nativeResultSha256": "eb40e9476189bb8f25513fd8dee80edc52c6d7ae3a8a41ec002e5a5c4eb12c0a"
}
@@ -0,0 +1,104 @@
{
"schemaVersion": 1,
"checkpoint": "p7-10",
"status": "PASS",
"checkedUtc": "2026-10-20T13:40:08.969086+00:00",
"commands": [
{
"name": "secret-scan",
"command": [
"rtk",
"proxy",
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
"-X",
"utf8",
"scripts/supply-chain.py",
"scan-secrets"
],
"startedUtc": "2026-10-20T13:40:05.432381+00:00",
"finishedUtc": "2026-10-20T13:40:08.147471+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261020T134005Z/secret-scan.log",
"logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
},
{
"name": "working-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check"
],
"startedUtc": "2026-10-20T13:40:08.147977+00:00",
"finishedUtc": "2026-10-20T13:40:08.236175+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261020T134005Z/working-whitespace.log",
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"name": "staged-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--cached",
"--check"
],
"startedUtc": "2026-10-20T13:40:08.236676+00:00",
"finishedUtc": "2026-10-20T13:40:08.329638+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261020T134005Z/staged-whitespace.log",
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"name": "phase7-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check",
"b5319981ad3d1bd635daff861453d23c0de5320b"
],
"startedUtc": "2026-10-20T13:40:08.329638+00:00",
"finishedUtc": "2026-10-20T13:40:08.521089+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261020T134005Z/phase7-whitespace.log",
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
],
"untrackedSecretScan": {
"files": 0,
"findings": 0
},
"documentation": {
"files": 34,
"relativeLinks": 635,
"errors": []
},
"preservation": {
"groups": 57,
"statuses": {
"PARTIAL": 56,
"PASS": 0,
"FAIL": 0,
"DEFERRED": 1
},
"originalRoutesPreserved": true,
"currentConcreteRoutes": 33
},
"production": {
"main": "d1fcd2308755dfc112a2bd6a736dd763bbc895f9",
"originMain": "d1fcd2308755dfc112a2bd6a736dd763bbc895f9",
"status": "NO-GO",
"qualification": "Local fetched refs; direct push observation recorded separately."
},
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
"limits": [
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
"Local links check target existence, not URL/heading anchors.",
"Static evidence is not runtime/manual/device/provider/production acceptance."
]
}
+1 -1
View File
@@ -93,7 +93,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
- [x] P7-07 [owner-scoped Documents index](../redesign/phase-7/p7-07/README.md): implemented CONDITIONAL GO, final hygiene PASS; UI5a67ec3 and evidence84350b7 published through9dfea12, directly verified21:01:11UTC. Finite86/8+native13/types/lint/build/18images/15axe0/101qualified;33routes/57statuses retained. MariaDB/human/device/production qualifications remain.
- [x] P7-08 [existing notifications and operations](../redesign/phase-7/p7-08/README.md): bothpresentationboundariesimplementedCONDITIONALGO. UTC9dfea/recent40cfe04 published; Operations77/7+native12+types/lint/build/14Ops+1P2actualimages PASS;12Opsaxe0/3+14notifaxe0/102qualified. FinalOpshygiene/normalpushpending.791/95frontend323browser1054backend derivedonly;57groups33routes/mainunchanged/productionNO-GO. P7-09next.
- [x] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): implemented CONDITIONAL GO. Sender/Preferences/import/company published; remaining data presentation47/6 affected frontend,22backendcontracts, final native23/types/lint/build PASS. 37axe0violations/141incomplete nodes qualified; six final PNGs inspected. Final hygiene/publication recorded in checkpoint ledger. Inventory855/99frontend,339browser,1057backend; no fresh whole-suite claim. P5-13 withheld, manual/provider/output/release qualifications retained.
- [ ] P7-10 Users published7ecc0d6/4760fd1; Audit bounded9frontend/18backend/3native plus types/lint/build PASS, publication pending; System follows separately. Shared/manual accessibility conditions remain.
- [x] P7-10 Users7ecc0d6/4760fd1 and Auditf9e1d06 published; System20frontend/46backend/3native plus types/lint/build PASS, final hygiene/publication pending. Implemented CONDITIONAL GO; shared/manual accessibility conditions remain.
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
- [ ] P7-12 integrated migration acceptance and Phase 8 handoff.
@@ -0,0 +1,132 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Language = 'en' | 'nb';
type Headers = Record<string, string>;
type Owner = { id: string; email: string; headers: Headers };
type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number };
type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] };
const boundary = 'Actual isolated Identity/CSRF login and non-admin GET denials; administrator System GET diagnostics and SMTP PUT acknowledgements are controlled browser fixtures. No actual SMTP delivery/configuration, AI probe/model calls, maintenance dispatch or production writes. Six paired language/theme widths and automated axe do not establish human/device acceptance.';
function copy(key: string, language: Language = 'en') { return (translations[language] as Record<string, string>)[key] ?? key; }
function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); }
async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) {
const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data });
const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result;
}
async function json<T>(response: Pick<APIResponse, 'status' | 'url' | 'json'>, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me'));
const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
}
async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) {
const email = `p710-system-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext();
context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); });
context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); });
await context.route('**/*', route => {
const request = route.request(); const url = new URL(request.url());
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); }
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname);
const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings';
if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
if (url.pathname.startsWith('/api/admin/system')) { forbidden.push(`${request.method()} ${url.pathname}`); return route.abort(); }
return route.continue();
});
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); });
try {
const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email);
expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false);
await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]);
} finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); }
async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); }
async function capture(page: Page, info: TestInfo, name: string) {
const geometry = await page.getByRole('main').evaluate(main => {
const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root'));
// MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets.
// System diagnostics wrap within the page; no document canvas exception applies.
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => !node.closest('table') && node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) };
});
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]);
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]);
}
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method()===method && new URL(value.url()).pathname===`/api${path}`); }
const smtp = { enabled: true, host: 'smtp.example.test', port: 587, user: 'mailer@example.test', from: 'noreply@example.test', fromName: 'Jobbjakt', enableSsl: true, timeoutMs: 15000, usesOverrides: false, hasPassword: true };
const status = {
environment: 'Controlled fixture', contentRoot: '/app', version: '1.2.3', commitSha: 'abc1234', buildStamp: '2026-10-20 UTC',
storage: { dataRoot: '/data', dbPath: '/data/jobtracker.db', dbExists: true, dbSizeBytes: 2048, companyCount: 3, jobCount: 7, deletedCount: 1 },
email: smtp, database: { provider: 'mariadb', looksConfigured: true, canConnect: true, target: 'server=db', usesFileStorage: false, warning: null },
runtime: { framework: '.NET 9', osDescription: 'Linux', processArchitecture: 'X64', machineName: 'fixture' }, auth: { required: true, hasJwtKey: true, googleConfigured: false, gmailConfigured: false },
ai: { healthy: true, model: 'fixture-only', device: 'cpu', gpuAvailable: false, ocrAvailable: true, ocrLanguages: 'eng', ollamaConfigured: false, requests: 18, cacheHits: 9, cacheMisses: 9, failures: 0, probeFailures: 0, averageLatencyMs: 42.2, ocrRequests: 5, ocrFailures: 0, averageOcrLatencyMs: 88.4 }
};
async function fixtures(page: Page, failStatus: () => boolean = () => false) {
await page.route('**/api/admin/system**', route => {
const request=route.request(); const path=new URL(request.url()).pathname;
expect(request.method()).toBe('GET');
if (path==='/api/admin/system' && failStatus()) return route.fulfill({ status:503, json: { title:'Unavailable' } });
const data=path.endsWith('/email-settings') ? smtp : path.endsWith('/maintenance-jobs') ? [] : path.endsWith('/cv-benchmark') ? { rootPath:'/data/fixture', indexJson:null, reportMarkdown:'Controlled benchmark report', lastUpdatedAtUtc:null } : status;
return route.fulfill({ status:200, json:data });
});
}
test('[P7-10 System A03 E09] non-admin read denial and controlled initial failure retain GET-only focused recovery', async ({ page, session }) => {
for (const path of ['/admin/system','/admin/system/email-settings','/admin/system/cv-benchmark','/admin/system/maintenance-jobs']) expect((await call(session.requests,page.request,'GET',path)).status()).toBe(403);
await login(session.requests,page.request,'e2e@example.test'); let failed=true; await fixtures(page,()=>failed);
await start(page,'/admin/system',390);
const alert=page.getByRole('alert').filter({ hasText:'Unavailable' }); await expect(alert).toBeVisible();
await expect(page.getByText(copy('adminSystemOffline'), { exact:true })).toHaveCount(0);
failed=false; const count=browserRequests(session,'/admin/system').length;
await keyboard(alert.getByRole('button',{name:copy('retry'),exact:true})); await expect(page.getByText('Controlled fixture',{exact:true})).toBeVisible();
expect(browserRequests(session,'/admin/system').length).toBe(count+1);
expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET')).toEqual([]);
await capture(page,test.info(),'system-status-read-recovery-390-en-light');
});
test('[P7-10 System A03] SMTP submitted acknowledgement preserves newer edits and refresh draft without operational calls', async ({page,session},info) => {
await login(session.requests,page.request,'e2e@example.test'); await fixtures(page); await start(page,'/admin/system');
await keyboard(page.getByRole('tab',{name:copy('adminSystemSettingsTab'),exact:true}));
const panel=page.getByRole('tabpanel',{name:copy('adminSystemSettingsTab'),exact:true}); await expect(panel).toBeVisible();
await panel.getByLabel(copy('adminSystemHost'),{exact:true}).fill('submitted.example.test');
await panel.getByLabel(copy('adminSystemPassword'),{exact:true}).fill('synthetic-submitted');
let release!:()=>void; const held=new Promise<void>(resolve=>{release=resolve;}); let payload:Record<string,unknown>|undefined;
await page.route('**/api/admin/system/email-settings',async route=>{ if(route.request().method()!=='PUT') return route.fallback(); payload=route.request().postDataJSON(); await held; return route.fulfill({status:200,json:{...smtp,host:'submitted.example.test',usesOverrides:true}}); });
await keyboard(panel.getByRole('button',{name:copy('adminSystemSaveSettings'),exact:true})); await expect.poll(()=>payload?.host).toBe('submitted.example.test');
expect(payload?.password).toBe('synthetic-submitted'); expect(payload?.clearPassword).toBe(false);
await panel.getByLabel(copy('adminSystemHost'),{exact:true}).fill('newer.example.test'); await panel.getByLabel(copy('adminSystemPassword'),{exact:true}).fill('synthetic-newer');
await expect(panel.getByRole('button',{name:copy('adminSystemSendTestEmail'),exact:true})).toBeDisabled(); await expect(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})).toBeDisabled();
release(); await expect(panel.getByRole('button',{name:copy('adminSystemSaveSettings'),exact:true})).toBeEnabled();
await expect(panel.getByLabel(copy('adminSystemHost'),{exact:true})).toHaveValue('newer.example.test'); await expect(panel.getByLabel(copy('adminSystemPassword'),{exact:true})).toHaveValue('synthetic-newer');
await expect(panel.getByText(copy('adminSystemUnsavedChanges'),{exact:true})).toBeVisible();
await keyboard(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})); await expect(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})).toBeEnabled();
await expect(panel.getByLabel(copy('adminSystemHost'),{exact:true})).toHaveValue('newer.example.test'); await expect(panel.getByLabel(copy('adminSystemPassword'),{exact:true})).toHaveValue('synthetic-newer');
expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET').map(value=>[value.method,value.path])).toEqual([['PUT','/api/admin/system/email-settings']]);
await capture(page,info,'system-newer-smtp-draft-1440-en-light');
});
test('[P7-10 System A03] bilingual diagnostic and settings panels reflow at six finite theme widths',async({page,session},info)=>{
const administrator=await login(session.requests,page.request,'e2e@example.test'); await fixtures(page);
for(const sample of [{width:320,language:'nb',mode:'dark'},{width:390,language:'en',mode:'light'},{width:768,language:'nb',mode:'light'},{width:1024,language:'en',mode:'dark'},{width:1440,language:'en',mode:'light'},{width:1920,language:'nb',mode:'dark'}] as const){
expect((await call(session.requests,page.request,'PUT','/auth/preferences/language',administrator.headers,{language:sample.language==='nb'?'nb-NO':'en'})).status()).toBe(204);
await page.addInitScript(value=>{localStorage.setItem('uiLanguage',value.language);localStorage.setItem('jobtracker.themeMode',value.mode);},sample); await start(page,'/admin/system',sample.width); await expect(page.getByText('Controlled fixture',{exact:true})).toBeVisible();
await capture(page,info,`system-status-${sample.width}-${sample.language}-${sample.mode}`);
await keyboard(page.getByRole('tab',{name:copy('adminSystemSettingsTab',sample.language),exact:true})); await expect(page.getByRole('tabpanel',{name:copy('adminSystemSettingsTab',sample.language),exact:true})).toBeVisible();
await capture(page,info,`system-settings-${sample.width}-${sample.language}-${sample.mode}`);
}
expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET')).toEqual([]);
});
+227 -2
View File
@@ -1,11 +1,15 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import AdminSystemPage from './views/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
let mockSessionContext = 'system-owner';
let mockSessionInvalidated = false;
jest.mock('./sessionBoundary', () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSessionContext, isSessionInvalidated: () => mockSessionInvalidated }));
jest.mock('./api', () => ({
api: {
get: jest.fn(),
@@ -19,9 +23,11 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
describe('AdminSystemPage', () => {
beforeEach(() => {
mockSessionContext = 'system-owner'; mockSessionInvalidated = false;
window.localStorage.clear();
mockedApi.get.mockImplementation((url: string) => {
if (url === '/admin/system') {
@@ -180,6 +186,223 @@ describe('AdminSystemPage', () => {
}
});
it('associates each tab with its named panel', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const statusTab = screen.getByRole('tab', { name: 'Status' });
const statusPanel = screen.getByRole('tabpanel', { name: 'Status' });
expect(statusTab).toHaveAttribute('aria-controls', statusPanel.id);
expect(statusPanel).toHaveAttribute('aria-labelledby', statusTab.id);
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
const settingsTab = screen.getByRole('tab', { name: 'Settings' });
expect(settingsTab).toHaveAttribute('aria-controls', screen.getByRole('tabpanel', { name: 'Settings' }).id);
expect(screen.queryByRole('tabpanel', { name: 'Status' })).not.toBeInTheDocument();
});
it('never fabricates offline metrics after an initial status failure and retries only the failed read', async () => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
const alert = await screen.findByRole('alert');
expect(screen.queryByText('Offline')).not.toBeInTheDocument();
expect(screen.queryByText('Requests')).not.toBeInTheDocument();
mockedApi.get.mockImplementation(get);
mockedApi.get.mockClear();
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }));
expect(await screen.findByText('Production')).toBeVisible();
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system']);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it('keeps SMTP edits and write-only password through explicit refresh', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'unsaved.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'new-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled());
expect(screen.getByLabelText('Host')).toHaveValue('unsaved.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('new-secret');
expect(screen.queryByText('new-secret')).not.toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
it('submits one SMTP snapshot, retains newer edits and prevents competing operational actions', async () => {
const pending = deferred<any>();
mockedApi.put.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'submitted.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'submitted-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'newer.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'newer-secret' } });
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Run probe now' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith('/admin/system/email-settings', expect.objectContaining({ host: 'submitted.example.test', password: 'submitted-secret', clearPassword: false }));
await act(async () => pending.resolve({ data: { enabled: true, host: 'submitted.example.test', hasPassword: true } }));
expect(screen.getByLabelText('Host')).toHaveValue('newer.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('newer-secret');
expect(screen.getByText('SMTP settings saved.')).toBeVisible();
expect(screen.getByText('Unsaved SMTP changes.')).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it.each([
['/admin/system/cv-benchmark', 'CV benchmark results could not be loaded.'],
['/admin/system/maintenance-jobs', 'Maintenance availability could not be loaded.'],
])('reports an optional %s failure and retries that GET without operational writes', async (path, message) => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === path ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
expect(await screen.findByText(message)).toBeVisible();
expect(screen.getByText('Production')).toBeVisible();
expect(screen.queryByText('Maintenance dispatch is unavailable.')).not.toBeInTheDocument();
mockedApi.get.mockImplementation(get); mockedApi.get.mockClear();
fireEvent.click(within(screen.getByText(message).closest('[role="alert"]')!).getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.queryByText(message)).not.toBeInTheDocument());
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual([path]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('keeps explicit password clearing through refresh and acknowledges only the submitted secret', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
const clear = screen.getByRole('checkbox', { name: /clear stored password/i });
fireEvent.click(clear);
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled());
expect(clear).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
await screen.findByText('SMTP settings saved.');
expect(mockedApi.put).toHaveBeenCalledWith('/admin/system/email-settings', expect.objectContaining({ password: null, clearPassword: true }));
expect(clear).not.toBeChecked();
expect(screen.getByLabelText('SMTP password')).toHaveValue('');
expect(screen.queryByText('Unsaved SMTP changes.')).not.toBeInTheDocument();
expect(screen.getByLabelText('Host')).toHaveValue('smtp.changed.test');
});
it('retains failed SMTP edits and only writes again after an explicit save', async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503, data: { title: 'Private object must not stringify' } } });
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'keep.example.test' } });
fireEvent.change(screen.getByLabelText('SMTP password'), { target: { value: 'keep-secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to save email settings.');
expect(screen.getByLabelText('Host')).toHaveValue('keep.example.test');
expect(screen.getByLabelText('SMTP password')).toHaveValue('keep-secret');
expect(screen.getByText('Unsaved SMTP changes.')).toBeVisible();
expect(screen.queryByText('[object Object]')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Save settings' })).toBeEnabled());
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Host')).toHaveValue('keep.example.test');
});
it('waits for one probe before allowing another action and never runs on entry or tab changes', async () => {
const pending = deferred<any>(); mockedApi.post.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
expect(mockedApi.post).not.toHaveBeenCalled();
const probe = screen.getByRole('button', { name: 'Run probe now' });
fireEvent.click(probe); fireEvent.click(probe);
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/admin/system/ai/probe');
await act(async () => pending.resolve({ data: {} }));
expect(await screen.findByText('AI probe completed.')).toBeVisible();
});
it('refreshes probe metrics after success and a failed follow-up read retries GET without replaying the probe', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject(new Error('offline')) : get(url, config));
mockedApi.get.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Run probe now' }));
expect(await screen.findByText('System status could not be refreshed. Previously loaded values remain.')).toBeVisible();
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system']);
mockedApi.get.mockImplementation(get);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument());
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it('does not acknowledge an SMTP write into a changed session', async () => {
const pending = deferred<any>(); mockedApi.put.mockReturnValueOnce(pending.promise);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
fireEvent.change(screen.getByLabelText('Host'), { target: { value: 'owner-a.example.test' } });
fireEvent.click(screen.getByRole('button', { name: 'Save settings' }));
mockSessionContext = 'second-session';
await act(async () => pending.resolve({ data: { enabled: true, host: 'must-not-apply.example.test', hasPassword: true } }));
expect(screen.getByLabelText('Host')).toHaveValue('owner-a.example.test');
expect(screen.queryByText('SMTP settings saved.')).not.toBeInTheDocument();
});
it('does not refresh or announce a completed probe after unmount', async () => {
const pending = deferred<any>(); mockedApi.post.mockReturnValueOnce(pending.promise);
const view = render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('button', { name: 'Run probe now' }));
const count = mockedApi.get.mock.calls.length;
view.unmount();
await act(async () => pending.resolve({ data: {} }));
expect(mockedApi.get).toHaveBeenCalledTimes(count);
});
it('clears sensitive diagnostics after a denied refresh and offers no retry or operational action', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
mockedApi.get.mockRejectedValue({ response: { status: 403 } });
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(screen.queryByText('Production')).not.toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Run probe now' })).toBeDisabled();
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(screen.queryByLabelText('SMTP password')).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('keeps the last diagnostic values under a qualified refresh error', async () => {
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system' ? Promise.reject({ response: { status: 503, data: 'Temporary failure' } }) : get(url, config));
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
expect(await screen.findByText('System status could not be refreshed. Previously loaded values remain.')).toBeVisible();
expect(screen.getByText('Production')).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('does not present unavailable SMTP settings as a blank editable configuration', async () => {
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/admin/system/email-settings' ? Promise.reject(new Error('offline')) : get(url, config));
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
await screen.findByText('Production');
fireEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(await screen.findByText('SMTP settings could not be loaded.')).toBeVisible();
expect(screen.queryByLabelText('Host')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled();
mockedApi.get.mockImplementation(get); mockedApi.get.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByLabelText('Host')).toHaveValue('smtp.example.test');
expect(mockedApi.get.mock.calls.map(call => call[0])).toEqual(['/admin/system/email-settings']);
});
it('renders AI service health, latency, and OCR readiness', async () => {
render(
<I18nProvider>
@@ -230,7 +453,9 @@ describe('AdminSystemPage', () => {
mockedApi.post.mockResolvedValueOnce({ data: { jobId: 'cv-benchmark', status: 'queued' } } as any);
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
fireEvent.click(await screen.findByRole('button', { name: /run benchmark/i }));
const run = await screen.findByRole('button', { name: /run benchmark/i });
await waitFor(() => expect(run).toBeEnabled());
fireEvent.click(run);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/admin/system/maintenance-jobs/cv-benchmark/runs');
+16
View File
@@ -1841,6 +1841,14 @@ export const translations = {
kanbanDragStartedAnnouncement: "{title} picked up.",
adminSystemTitle: "System status",
adminSystemLoadFailed: "Failed to load system status.",
adminSystemBenchmarkLoadFailed: "CV benchmark results could not be loaded.",
adminSystemMaintenanceLoadFailed: "Maintenance availability could not be loaded.",
adminSystemEmailSettingsLoadFailed: "SMTP settings could not be loaded.",
adminSystemSettingsSaved: "SMTP settings saved.",
adminSystemUnsavedChanges: "Unsaved SMTP changes.",
adminSystemTestEmailSent: "Test email sent.",
adminSystemProbeCompleted: "AI probe completed.",
adminSystemRefreshFailed: "System status could not be refreshed. Previously loaded values remain.",
adminSystemTestEmailFailed: "Failed to send test email.",
adminSystemDefaultTestMessage: "This is a test email from the Jobbjakt system panel.",
adminSystemEmailSaveFailed: "Failed to save email settings.",
@@ -4615,6 +4623,14 @@ export const translations = {
kanbanDragStartedAnnouncement: "{title} er løftet.",
adminSystemTitle: "Systemstatus",
adminSystemLoadFailed: "Kunne ikke laste systemstatus.",
adminSystemBenchmarkLoadFailed: "Kunne ikke hente resultatene fra CV-referansetesten.",
adminSystemMaintenanceLoadFailed: "Kunne ikke hente tilgjengelige vedlikeholdsoppgaver.",
adminSystemEmailSettingsLoadFailed: "Kunne ikke hente SMTP-innstillingene.",
adminSystemSettingsSaved: "SMTP-innstillingene er lagret.",
adminSystemUnsavedChanges: "SMTP-endringene er ikke lagret.",
adminSystemTestEmailSent: "Test-e-posten er sendt.",
adminSystemProbeCompleted: "KI-kontrollen er fullført.",
adminSystemRefreshFailed: "Kunne ikke oppdatere systemstatus. Tidligere innlastede verdier beholdes.",
adminSystemTestEmailFailed: "Kunne ikke sende test-e-post.",
adminSystemDefaultTestMessage: "Dette er en test-e-post fra systempanelet i Jobbjakt.",
adminSystemEmailSaveFailed: "Kunne ikke lagre e-postinnstillingene.",
+188 -146
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -13,10 +13,14 @@ import {
Typography,
Checkbox,
FormControlLabel,
LinearProgress,
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader, PageSection } from "../layout/PageLayout";
import type { TranslationKey } from "../i18n/translations";
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
type AiServiceMetrics = {
healthy: boolean;
@@ -201,7 +205,7 @@ function benchmarkTone(value?: number | null) {
function SummaryCard({ title, value, subtitle, tone = "default" }: { title: string; value: string; subtitle?: string; tone?: "default" | "success" | "warning" | "error" }) {
const color = tone === "success" ? "success.main" : tone === "warning" ? "warning.main" : tone === "error" ? "error.main" : "text.primary";
return (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{title}</Typography>
<Typography component="p" variant="h5" sx={{ fontWeight: 950, color }}>{value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{subtitle || "-"}</Typography>
@@ -210,9 +214,15 @@ function SummaryCard({ title, value, subtitle, tone = "default" }: { title: stri
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return <Typography variant="body2"><strong>{label}:</strong> {value}</Typography>;
return <Typography variant="body2" sx={{ overflowWrap: "anywhere", minWidth: 0 }}><strong>{label}:</strong> {value}</Typography>;
}
type ReadKind = "status" | "email" | "benchmark" | "maintenance";
type ActionKind = "save" | "test" | "probe" | "maintenance";
const readPaths: Record<ReadKind, string> = { status: "/admin/system", email: "/admin/system/email-settings", benchmark: "/admin/system/cv-benchmark", maintenance: "/admin/system/maintenance-jobs" };
const readErrorKeys: Record<ReadKind, TranslationKey> = { status: "adminSystemLoadFailed", email: "adminSystemEmailSettingsLoadFailed", benchmark: "adminSystemBenchmarkLoadFailed", maintenance: "adminSystemMaintenanceLoadFailed" };
const actionErrorKeys: Record<ActionKind, TranslationKey> = { save: "adminSystemEmailSaveFailed", test: "adminSystemTestEmailFailed", probe: "adminSystemProbeFailed", maintenance: "adminMaintenanceFailed" };
export default function AdminSystemPage() {
const { language, t } = useI18n();
const [tab, setTab] = useState(0);
@@ -220,50 +230,114 @@ export default function AdminSystemPage() {
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
const [maintenanceJobs, setMaintenanceJobs] = useState<MaintenanceJob[]>([]);
const [runningMaintenanceJob, setRunningMaintenanceJob] = useState<string | null>(null);
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
const [smtpPassword, setSmtpPassword] = useState("");
const [clearPassword, setClearPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [runningProbe, setRunningProbe] = useState(false);
const [savingSettings, setSavingSettings] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [readErrors, setReadErrors] = useState<Partial<Record<ReadKind, unknown>>>({});
const [accessDenied, setAccessDenied] = useState(false);
const [action, setAction] = useState<ActionKind | null>(null);
const [actionError, setActionError] = useState<{ kind: ActionKind; cause: unknown } | null>(null);
const [message, setMessage] = useState<TranslationKey | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("Jobbjakt SMTP test");
const [testEmailMessage, setTestEmailMessage] = useState(() => t("adminSystemDefaultTestMessage"));
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const mounted = useRef(false);
const generation = useRef(0);
const readPending = useRef(false);
const actionPending = useRef(false);
const denied = useRef(false);
const controller = useRef<AbortController | null>(null);
const draftRevision = useRef(0);
const passwordRevision = useRef(0);
const savedRevision = useRef(0);
const bodyRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const restoreFocus = useRef<HTMLElement | null>(null);
const navigationId = useId();
const load = useCallback(async () => {
const load = useCallback(async (kind?: ReadKind, afterWrite = false) => {
checkSessionCookie();
if (!mounted.current || denied.current || isSessionInvalidated() || readPending.current || actionPending.current && !afterWrite) return;
const context = getSessionContext();
const lifetime = generation.current;
const active = () => { checkSessionCookie(); return mounted.current && lifetime === generation.current && !isSessionInvalidated() && getSessionContext() === context; };
readPending.current = true;
const abort = new AbortController();
controller.current = abort;
setLoading(true);
setError(null);
const revision = draftRevision.current;
await Promise.all((kind ? [kind] : Object.keys(readPaths) as ReadKind[]).map(async (part) => {
try {
const [statusRes, emailRes, benchmarkRes, maintenanceRes] = await Promise.all([
api.get<SystemStatus>("/admin/system"),
api.get<EditableEmailSettings>("/admin/system/email-settings"),
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
api.get<MaintenanceJob[]>("/admin/system/maintenance-jobs").catch(() => ({ data: [] } as any)),
]);
setStatus(statusRes.data);
setEmailSettings(emailRes.data);
setBenchmarkStatus(benchmarkRes.data ?? null);
setMaintenanceJobs(maintenanceRes.data ?? []);
setSmtpPassword("");
setClearPassword(false);
} catch (e: any) {
setError(getApiErrorMessage(e, t("adminSystemLoadFailed")));
setStatus(null);
setEmailSettings(null);
setBenchmarkStatus(null);
setMaintenanceJobs([]);
} finally {
setLoading(false);
const response = await api.get(readPaths[part], { signal: abort.signal });
if (!active() || abort.signal.aborted || denied.current) return;
if (part === "status") setStatus(response.data);
if (part === "email" && revision === draftRevision.current && revision === savedRevision.current) setEmailSettings(response.data);
if (part === "benchmark") setBenchmarkStatus(response.data ?? null);
if (part === "maintenance") setMaintenanceJobs(response.data ?? []);
setReadErrors(errors => { const next = { ...errors }; delete next[part]; return next; });
} catch (cause) {
if (active() && !abort.signal.aborted) {
const responseStatus = (cause as { response?: { status?: number } })?.response?.status;
if (responseStatus === 401 || responseStatus === 403) {
denied.current = true; setAccessDenied(true);
setStatus(null); setEmailSettings(null); setBenchmarkStatus(null); setMaintenanceJobs([]);
setSmtpPassword(""); setClearPassword(false);
}
}, [t]);
setReadErrors(errors => ({ ...errors, [part]: cause }));
}
}
}));
if (active() && !abort.signal.aborted) { readPending.current = false; setLoading(false); }
}, []);
useEffect(() => {
mounted.current = true;
generation.current += 1;
void load();
return () => { mounted.current = false; generation.current += 1; controller.current?.abort(); readPending.current = false; };
}, [load]);
useEffect(() => {
if (loading || action || !restoreFocus.current) return;
const target = restoreFocus.current;
restoreFocus.current = null;
if (document.activeElement !== bodyRef.current) return;
if (target.isConnected && !target.matches(":disabled")) target.focus();
else headingRef.current?.focus();
}, [loading, action]);
const rememberFocus = () => {
const target = document.activeElement;
if (target instanceof HTMLElement && bodyRef.current?.contains(target)) { restoreFocus.current = target; bodyRef.current.focus(); }
};
const refresh = (kind?: ReadKind) => { if (!readPending.current && !actionPending.current) { rememberFocus(); void load(kind); } };
const updateSettings = (update: Partial<EditableEmailSettings>) => {
draftRevision.current += 1;
setEmailSettings(previous => previous ? { ...previous, ...update } : previous);
};
const locked = loading || action !== null || accessDenied;
const runAction = async (kind: ActionKind, write: (active: () => boolean) => Promise<void>, success: TranslationKey) => {
checkSessionCookie();
if (!mounted.current || denied.current || isSessionInvalidated() || readPending.current || actionPending.current) return;
const context = getSessionContext();
const lifetime = generation.current;
const active = () => { checkSessionCookie(); return mounted.current && lifetime === generation.current && !isSessionInvalidated() && getSessionContext() === context; };
actionPending.current = true;
rememberFocus(); setAction(kind); setActionError(null); setMessage(null);
try { await write(active); if (active()) setMessage(success); }
catch (cause) { if (active()) setActionError({ kind, cause }); }
finally { actionPending.current = false; if (active()) setAction(null); }
};
const readNotice = (kind: ReadKind) => {
if (!readErrors[kind]) return null;
const title = t(kind === "status" && status ? "adminSystemRefreshFailed" : readErrorKeys[kind]);
const detail = getApiErrorMessage(readErrors[kind], title);
return <Alert severity="error" sx={{ overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={!accessDenied ? <Button color="inherit" disabled={locked} onClick={() => refresh(kind)}>{t("retry")}</Button> : undefined}>
<Typography variant="body2">{title}</Typography>
{detail !== title ? <Typography variant="body2">{detail}</Typography> : null}
</Alert>;
};
const dbTone = useMemo(() => {
if (!status) return "default" as const;
if (!status.database.looksConfigured || !status.database.canConnect) return "error" as const;
@@ -307,106 +381,79 @@ export default function AdminSystemPage() {
}).slice(0, 10);
}, [benchmarkIndex, t]);
const sendTestEmail = async () => {
setSendingTestEmail(true);
try {
const sendTestEmail = () => runAction("test", async () => {
await api.post("/users/send-test-email", {
toEmail: testEmailTo.trim() || null,
subject: testEmailSubject.trim() || null,
message: testEmailMessage.trim() || null,
});
setError(null);
} catch (e: any) {
setError(getApiErrorMessage(e, t("adminSystemTestEmailFailed")));
} finally {
setSendingTestEmail(false);
}
};
}, "adminSystemTestEmailSent");
const saveEmailSettings = async () => {
if (!emailSettings) return;
setSavingSettings(true);
try {
const revision = draftRevision.current;
const secretRevision = passwordRevision.current;
const snapshot = emailSettings;
await runAction("save", async (active) => {
const res = await api.put<EditableEmailSettings>("/admin/system/email-settings", {
enabled: emailSettings.enabled,
host: emailSettings.host,
port: Number(emailSettings.port) || 587,
user: emailSettings.user,
enabled: snapshot.enabled,
host: snapshot.host,
port: Number(snapshot.port) || 587,
user: snapshot.user,
password: smtpPassword.trim() || null,
clearPassword,
from: emailSettings.from,
fromName: emailSettings.fromName,
enableSsl: emailSettings.enableSsl,
timeoutMs: Number(emailSettings.timeoutMs) || 15000,
from: snapshot.from,
fromName: snapshot.fromName,
enableSsl: snapshot.enableSsl,
timeoutMs: Number(snapshot.timeoutMs) || 15000,
});
setEmailSettings(res.data);
setSmtpPassword("");
setClearPassword(false);
await load();
} catch (e: any) {
setError(getApiErrorMessage(e, t("adminSystemEmailSaveFailed")));
} finally {
setSavingSettings(false);
}
if (!active()) return;
if (draftRevision.current === revision) { setEmailSettings(res.data); savedRevision.current = revision; }
if (passwordRevision.current === secretRevision) { setSmtpPassword(""); setClearPassword(false); }
setReadErrors(errors => { const next = { ...errors }; delete next.email; return next; });
}, "adminSystemSettingsSaved");
};
const runMaintenanceJob = async (jobId: string) => {
setRunningMaintenanceJob(jobId);
setMaintenanceMessage(null);
setError(null);
try {
if (jobId !== "cv-benchmark" || !cvBenchmarkJob?.available || readErrors.maintenance) return;
await runAction("maintenance", async () => {
await api.post(`/admin/system/maintenance-jobs/${encodeURIComponent(jobId)}/runs`);
setMaintenanceMessage(t("adminMaintenanceQueued"));
} catch (e: any) {
setError(getApiErrorMessage(e, t("adminMaintenanceFailed")));
} finally {
setRunningMaintenanceJob(null);
}
}, "adminMaintenanceQueued");
};
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box>
<Typography component="h2" variant="h5" sx={{ fontWeight: 950 }}>{t("adminSystemTitle")}</Typography>
<Typography sx={{ color: "text.secondary" }}>{t("adminSystemSubtitle")}</Typography>
</Box>
<Box ref={bodyRef} role="region" aria-label={t("adminSystemTitle")} aria-busy={locked} tabIndex={-1} sx={{ display: "flex", flexDirection: "column", gap: "var(--app-section-gap)", minWidth: 0, overflowWrap: "anywhere", "& .MuiButton-root": { whiteSpace: "normal", overflowWrap: "anywhere", maxWidth: "100%", minHeight: { xs: 44, sm: 36 }, "@media (pointer: coarse)": { minHeight: 44 } }, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
<PageHeader level="h2" titleRef={headingRef} title={t("adminSystemTitle")} description={t("adminSystemSubtitle")} actions={
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
variant="outlined"
onClick={async () => {
setRunningProbe(true);
setError(null);
try {
await api.post("/admin/system/ai/probe");
await load();
} catch (e: any) {
setError(getApiErrorMessage(e, t("adminSystemProbeFailed")));
} finally {
setRunningProbe(false);
}
await runAction("probe", async (active) => { await api.post("/admin/system/ai/probe"); if (active()) await load("status", true); }, "adminSystemProbeCompleted");
}}
disabled={loading || runningProbe}
disabled={locked || !status || !!readErrors.status}
>
{runningProbe ? t("adminSystemRunningProbe") : t("adminSystemRunProbe")}
{action === "probe" ? t("adminSystemRunningProbe") : t("adminSystemRunProbe")}
</Button>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
<Button variant="contained" onClick={() => refresh()} disabled={locked}>
{loading ? t("adminSystemRefreshing") : t("adminSystemRefresh")}
</Button>
</Box>
</Box>
</Box> } />
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
<Tab label={t("adminSystemStatusTab")} />
<Tab label={t("adminSystemSettingsTab")} />
<Tabs value={tab} aria-label={t("adminSystemTitle")} onChange={(_, value) => setTab(value)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
<Tab id={`${navigationId}-tab-0`} aria-controls={`${navigationId}-panel-0`} label={t("adminSystemStatusTab")} />
<Tab id={`${navigationId}-tab-1`} aria-controls={`${navigationId}-panel-1`} label={t("adminSystemSettingsTab")} />
</Tabs>
{error ? <Alert severity="error">{error}</Alert> : null}
{loading ? <LinearProgress aria-label={t("loading")} /> : null}
{actionError ? <Alert severity="error" sx={{ overflowWrap: "anywhere" }}>{getApiErrorMessage(actionError.cause, t(actionErrorKeys[actionError.kind]))}</Alert> : null}
{message ? <Alert severity="success" role="status">{t(message)}</Alert> : null}
{readNotice("status")}
{status?.database.warning && tab === 0 ? <Alert severity={status.database.canConnect ? "warning" : "error"}>{status.database.warning}</Alert> : null}
{status?.ai.lastError && tab === 0 ? <Alert severity={status.ai.healthy ? "warning" : "error"}>{status.ai.lastError}</Alert> : null}
{tab === 0 ? (
<>
<Box role="tabpanel" id={`${navigationId}-panel-0`} aria-labelledby={`${navigationId}-tab-0`} hidden={tab !== 0} sx={{ minWidth: 0 }}>
<Stack spacing={1}>{readNotice("benchmark")}{readNotice("maintenance")}</Stack>
{status ? <Stack spacing={2}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<SummaryCard
title={t("adminSystemEnvironment")}
@@ -438,8 +485,7 @@ export default function AdminSystemPage() {
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
<PageSection surface level="h3" title={t("adminSystemDatabaseStorage")}>
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
<DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
@@ -454,10 +500,9 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
<DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
</Stack>
</Paper>
</PageSection>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
<PageSection surface level="h3" title={t("adminSystemRuntimeAuth")}>
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemFramework")} value={status?.runtime.framework || "-"} />
<DetailRow label={t("adminSystemOs")} value={status?.runtime.osDescription || "-"} />
@@ -470,12 +515,11 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? t("yes") : t("noWord")} />
</Stack>
</Paper>
</PageSection>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
<PageSection surface level="h3" title={t("adminSystemEmailConfig")}>
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? t("yes") : t("noWord")} />
<DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
@@ -484,10 +528,9 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemPort")} value={status?.email.port ?? "-"} />
<DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? t("yes") : t("noWord")} />
</Stack>
</Paper>
</PageSection>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
<PageSection surface level="h3" title={t("adminSystemSummarizerRuntime")}>
<Stack spacing={0.75}>
<DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
@@ -505,11 +548,10 @@ export default function AdminSystemPage() {
<DetailRow label={t("adminSystemLastSuccessfulProbe")} value={formatDate(status?.ai.lastProbeSuccessAt, language === "nb" ? "nb-NO" : "en")} />
<DetailRow label={t("adminSystemLastSummarizationSuccess")} value={formatDate(status?.ai.lastSuccessAt, language === "nb" ? "nb-NO" : "en")} />
</Stack>
</Paper>
</PageSection>
</Box>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
<PageSection surface level="h3" title={t("adminSystemSummarizerTelemetry")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(8, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemRequests")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheHits")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box>
@@ -534,12 +576,11 @@ export default function AdminSystemPage() {
<Chip key={`loaded-${model}`} label={t("adminSystemLoadedModel", { model })} color="primary" variant="outlined" size="small" />
))}
</Box>
</Paper>
</PageSection>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<PageSection surface level="h3" title={t("adminBenchmarkTitle")}>
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{t("adminBenchmarkTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
{cvBenchmarkJob?.description || t("adminBenchmarkRunScript")}
</Typography>
@@ -552,15 +593,15 @@ export default function AdminSystemPage() {
) : null}
<Button
variant="contained"
disabled={!cvBenchmarkJob?.available || runningMaintenanceJob === "cv-benchmark"}
disabled={locked || !cvBenchmarkJob?.available || !!readErrors.maintenance}
onClick={() => void runMaintenanceJob("cv-benchmark")}
>
{runningMaintenanceJob === "cv-benchmark" ? t("adminMaintenanceStarting") : t("adminMaintenanceRunBenchmark")}
{action === "maintenance" ? t("adminMaintenanceStarting") : t("adminMaintenanceRunBenchmark")}
</Button>
</Stack>
</Box>
{!cvBenchmarkJob?.available ? <Alert severity="info" sx={{ mb: 1.5 }}>{t("adminMaintenanceUnavailable")}</Alert> : null}
{maintenanceMessage ? <Alert severity="success" sx={{ mb: 1.5 }}>{maintenanceMessage}</Alert> : null}
{!loading && !readErrors.maintenance && !cvBenchmarkJob?.available ? <Alert severity="info" sx={{ mb: 1.5 }}>{t("adminMaintenanceUnavailable")}</Alert> : null}
{!readErrors.benchmark && !loading ? <>
<Stack spacing={0.75}>
<DetailRow label={t("adminBenchmarkRoot")} value={benchmarkStatus?.rootPath || "-"} />
<DetailRow label={t("adminBenchmarkLastUpdate")} value={formatDate(benchmarkStatus?.lastUpdatedAtUtc, language === "nb" ? "nb-NO" : "en")} />
@@ -621,39 +662,40 @@ export default function AdminSystemPage() {
{benchmarkStatus?.reportMarkdown || t("adminBenchmarkRunScript")}
</Typography>
</Box>
)}
</Paper>
</>
) : (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailSettingsTitle")}</Typography>
)}</> : null}
</PageSection>
</Stack> : null}
</Box>
<Box role="tabpanel" id={`${navigationId}-panel-1`} aria-labelledby={`${navigationId}-tab-1`} hidden={tab !== 1} sx={{ minWidth: 0 }}>
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{readNotice("email")}
{emailSettings ? <PageSection surface level="h3" title={t("adminSystemEmailSettingsTitle")}>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("adminSystemEmailSettingsBody")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<FormControlLabel
control={<Checkbox checked={Boolean(emailSettings?.enabled)} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, enabled: e.target.checked } : prev)} />}
control={<Checkbox checked={Boolean(emailSettings?.enabled)} onChange={(e) => updateSettings({ enabled: e.target.checked })} />}
label={t("adminSystemEnabled")}
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/>
<TextField label={t("adminSystemHost")} value={emailSettings?.host ?? ""} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, host: e.target.value } : prev)} fullWidth />
<TextField label={t("adminSystemPort")} type="number" value={emailSettings?.port ?? 587} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, port: Number(e.target.value) } : prev)} fullWidth />
<TextField label={t("adminSystemUsername")} value={emailSettings?.user ?? ""} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, user: e.target.value } : prev)} fullWidth />
<TextField label={t("adminSystemPassword")} type="password" value={smtpPassword} onChange={(e) => { setSmtpPassword(e.target.value); if (e.target.value.trim()) setClearPassword(false); }} helperText={emailSettings?.hasPassword ? t("adminSystemPasswordStored") : t("adminSystemPasswordMissing")} fullWidth />
<TextField label={t("adminSystemFrom")} value={emailSettings?.from ?? ""} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, from: e.target.value } : prev)} fullWidth />
<TextField label={t("adminSystemFromName")} value={emailSettings?.fromName ?? ""} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, fromName: e.target.value } : prev)} fullWidth />
<TextField label={t("adminSystemTimeoutMs")} type="number" value={emailSettings?.timeoutMs ?? 15000} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, timeoutMs: Number(e.target.value) } : prev)} fullWidth />
<FormControlLabel control={<Checkbox checked={Boolean(emailSettings?.enableSsl)} onChange={(e) => setEmailSettings((prev) => prev ? { ...prev, enableSsl: e.target.checked } : prev)} />} label={t("adminSystemSsl")} />
<FormControlLabel control={<Checkbox checked={clearPassword} onChange={(e) => { setClearPassword(e.target.checked); if (e.target.checked) setSmtpPassword(""); }} />} label={t("adminSystemClearStoredPassword")} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField label={t("adminSystemHost")} value={emailSettings?.host ?? ""} onChange={(e) => updateSettings({ host: e.target.value })} fullWidth />
<TextField label={t("adminSystemPort")} type="number" value={emailSettings?.port ?? 587} onChange={(e) => updateSettings({ port: Number(e.target.value) })} fullWidth />
<TextField label={t("adminSystemUsername")} value={emailSettings?.user ?? ""} onChange={(e) => updateSettings({ user: e.target.value })} fullWidth />
<TextField label={t("adminSystemPassword")} type="password" value={smtpPassword} onChange={(e) => { draftRevision.current += 1; passwordRevision.current += 1; setSmtpPassword(e.target.value); if (e.target.value.trim()) setClearPassword(false); }} helperText={emailSettings?.hasPassword ? t("adminSystemPasswordStored") : t("adminSystemPasswordMissing")} fullWidth />
<TextField label={t("adminSystemFrom")} value={emailSettings?.from ?? ""} onChange={(e) => updateSettings({ from: e.target.value })} fullWidth />
<TextField label={t("adminSystemFromName")} value={emailSettings?.fromName ?? ""} onChange={(e) => updateSettings({ fromName: e.target.value })} fullWidth />
<TextField label={t("adminSystemTimeoutMs")} type="number" value={emailSettings?.timeoutMs ?? 15000} onChange={(e) => updateSettings({ timeoutMs: Number(e.target.value) })} fullWidth />
<FormControlLabel control={<Checkbox checked={Boolean(emailSettings?.enableSsl)} onChange={(e) => updateSettings({ enableSsl: e.target.checked })} />} label={t("adminSystemSsl")} />
<FormControlLabel control={<Checkbox checked={clearPassword} onChange={(e) => { draftRevision.current += 1; passwordRevision.current += 1; setClearPassword(e.target.checked); if (e.target.checked) setSmtpPassword(""); }} />} label={t("adminSystemClearStoredPassword")} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
{draftRevision.current !== savedRevision.current ? <Typography variant="body2" color="text.secondary" role="status" sx={{ mt: 1.5 }}>{t("adminSystemUnsavedChanges")}</Typography> : null}
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
<Button variant="contained" disabled={savingSettings || !emailSettings} onClick={() => void saveEmailSettings()}>
{savingSettings ? t("adminSystemSaving") : t("adminSystemSaveSettings")}
<Button variant="contained" disabled={locked || !!readErrors.email} onClick={() => void saveEmailSettings()}>
{action === "save" ? t("adminSystemSaving") : t("adminSystemSaveSettings")}
</Button>
</Box>
</Paper>
</PageSection> : null}
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSmtpTest")}</Typography>
<PageSection surface level="h3" title={t("adminSystemSmtpTest")}>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
{t("adminSystemSmtpTestBody")}
</Typography>
@@ -663,13 +705,13 @@ export default function AdminSystemPage() {
<TextField label={t("adminSystemMessage")} multiline minRows={3} value={testEmailMessage} onChange={(e) => setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
<Button variant="contained" disabled={sendingTestEmail} onClick={() => void sendTestEmail()}>
{sendingTestEmail ? t("adminSystemSending") : t("adminSystemSendTestEmail")}
<Button variant="contained" disabled={locked || !emailSettings || !!readErrors.email} onClick={() => void sendTestEmail()}>
{action === "test" ? t("adminSystemSending") : t("adminSystemSendTestEmail")}
</Button>
</Box>
</Paper>
</PageSection>
</Box>
</Box>
)}
</Box>
);
}