diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index 7d48983..9c1f3e5 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -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. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 24277a7..6a04e3d 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -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. diff --git a/docs/redesign/phase-7/README.md b/docs/redesign/phase-7/README.md index b2bba2e..f48ee40 100644 --- a/docs/redesign/phase-7/README.md +++ b/docs/redesign/phase-7/README.md @@ -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 | diff --git a/docs/redesign/phase-7/p7-10/README.md b/docs/redesign/phase-7/p7-10/README.md index 65afbf1..0dc6aa1 100644 --- a/docs/redesign/phase-7/p7-10/README.md +++ b/docs/redesign/phase-7/p7-10/README.md @@ -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. diff --git a/docs/redesign/phase-7/p7-10/SYSTEM.md b/docs/redesign/phase-7/p7-10/SYSTEM.md new file mode 100644 index 0000000..d2fe6f4 --- /dev/null +++ b/docs/redesign/phase-7/p7-10/SYSTEM.md @@ -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. diff --git a/docs/redesign/phase-7/p7-10/evidence/system-results.json b/docs/redesign/phase-7/p7-10/evidence/system-results.json new file mode 100644 index 0000000..6864f3b --- /dev/null +++ b/docs/redesign/phase-7/p7-10/evidence/system-results.json @@ -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" +} diff --git a/docs/redesign/phase-7/p7-10/evidence/system-static-checks.json b/docs/redesign/phase-7/p7-10/evidence/system-static-checks.json new file mode 100644 index 0000000..2b47274 --- /dev/null +++ b/docs/redesign/phase-7/p7-10/evidence/system-static-checks.json @@ -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." + ] +} diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md index 0e1422c..e28987a 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -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. diff --git a/job-tracker-ui/e2e/phase7-admin-system.spec.ts b/job-tracker-ui/e2e/phase7-admin-system.spec.ts new file mode 100644 index 0000000..00eea13 --- /dev/null +++ b/job-tracker-ui/e2e/phase7-admin-system.spec.ts @@ -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; +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)[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(response: Pick, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } +async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise { + 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(resolve=>{release=resolve;}); let payload:Record|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([]); +}); diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index cc7e476..77eab3d 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -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; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(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(); + 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(); + 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(); + 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(); + mockedApi.put.mockReturnValueOnce(pending.promise); + render(); + 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(); + 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(); + 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(); + 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(); mockedApi.post.mockReturnValueOnce(pending.promise); + render(); + 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(); + 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(); mockedApi.put.mockReturnValueOnce(pending.promise); + render(); + 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(); mockedApi.post.mockReturnValueOnce(pending.promise); + const view = render(); + 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(); + 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(); + 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(); + 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( @@ -230,7 +453,9 @@ describe('AdminSystemPage', () => { mockedApi.post.mockResolvedValueOnce({ data: { jobId: 'cv-benchmark', status: 'queued' } } as any); render(); - 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'); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 09085e6..88f7442 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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.", diff --git a/job-tracker-ui/src/views/AdminSystemPage.tsx b/job-tracker-ui/src/views/AdminSystemPage.tsx index 4102703..c4b6c8c 100644 --- a/job-tracker-ui/src/views/AdminSystemPage.tsx +++ b/job-tracker-ui/src/views/AdminSystemPage.tsx @@ -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 ( - + {title} {value} {subtitle || "-"} @@ -210,9 +214,15 @@ function SummaryCard({ title, value, subtitle, tone = "default" }: { title: stri } function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { - return {label}: {value}; + return {label}: {value}; } +type ReadKind = "status" | "email" | "benchmark" | "maintenance"; +type ActionKind = "save" | "test" | "probe" | "maintenance"; +const readPaths: Record = { status: "/admin/system", email: "/admin/system/email-settings", benchmark: "/admin/system/cv-benchmark", maintenance: "/admin/system/maintenance-jobs" }; +const readErrorKeys: Record = { status: "adminSystemLoadFailed", email: "adminSystemEmailSettingsLoadFailed", benchmark: "adminSystemBenchmarkLoadFailed", maintenance: "adminSystemMaintenanceLoadFailed" }; +const actionErrorKeys: Record = { 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(null); const [benchmarkStatus, setBenchmarkStatus] = useState(null); const [maintenanceJobs, setMaintenanceJobs] = useState([]); - const [runningMaintenanceJob, setRunningMaintenanceJob] = useState(null); - const [maintenanceMessage, setMaintenanceMessage] = useState(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(null); + const [loading, setLoading] = useState(true); + const [readErrors, setReadErrors] = useState>>({}); + const [accessDenied, setAccessDenied] = useState(false); + const [action, setAction] = useState(null); + const [actionError, setActionError] = useState<{ kind: ActionKind; cause: unknown } | null>(null); + const [message, setMessage] = useState(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(null); + const draftRevision = useRef(0); + const passwordRevision = useRef(0); + const savedRevision = useRef(0); + const bodyRef = useRef(null); + const headingRef = useRef(null); + const restoreFocus = useRef(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); - try { - const [statusRes, emailRes, benchmarkRes, maintenanceRes] = await Promise.all([ - api.get("/admin/system"), - api.get("/admin/system/email-settings"), - api.get("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)), - api.get("/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); - } - }, [t]); + const revision = draftRevision.current; + await Promise.all((kind ? [kind] : Object.keys(readPaths) as ReadKind[]).map(async (part) => { + try { + 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); + } + 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) => { + draftRevision.current += 1; + setEmailSettings(previous => previous ? { ...previous, ...update } : previous); + }; + const locked = loading || action !== null || accessDenied; + const runAction = async (kind: ActionKind, write: (active: () => boolean) => Promise, 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 refresh(kind)}>{t("retry")} : undefined}> + {title} + {detail !== title ? {detail} : null} + ; + }; + 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("/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 ( - - - - {t("adminSystemTitle")} - {t("adminSystemSubtitle")} - + + - - - + } /> - setTab(value)}> - - + setTab(value)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile> + + - {error ? {error} : null} + {loading ? : null} + {actionError ? {getApiErrorMessage(actionError.cause, t(actionErrorKeys[actionError.kind]))} : null} + {message ? {t(message)} : null} + {readNotice("status")} {status?.database.warning && tab === 0 ? {status.database.warning} : null} {status?.ai.lastError && tab === 0 ? {status.ai.lastError} : null} - {tab === 0 ? ( - <> + - {!cvBenchmarkJob?.available ? {t("adminMaintenanceUnavailable")} : null} - {maintenanceMessage ? {maintenanceMessage} : null} + {!loading && !readErrors.maintenance && !cvBenchmarkJob?.available ? {t("adminMaintenanceUnavailable")} : null} + {!readErrors.benchmark && !loading ? <> @@ -621,39 +662,40 @@ export default function AdminSystemPage() { {benchmarkStatus?.reportMarkdown || t("adminBenchmarkRunScript")} - )} - - - ) : ( - - - {t("adminSystemEmailSettingsTitle")} + )} : null} + + : null} + + + : null} - - {t("adminSystemSmtpTest")} + {t("adminSystemSmtpTestBody")} @@ -663,13 +705,13 @@ export default function AdminSystemPage() { setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> - - + - )} + ); }