feat(admin): migrate audit presentation and protect pending actions
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Continuation — 20 October 2026
|
## Continuation — 20 October 2026
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
Remaining P7-09 data presentation **8af9168014631633e38e8ba7cd22899fdf1092e2** was normally pushed and directly verified **07:58:49 UTC**, with clean HEAD=origin=remote development. P7-09 implemented scope is CONDITIONAL GO; qualifications remain. [P7-10 Users](p7-10/USERS.md) now has18/5 affected frontend,25backend and finalnative3/type/lint/build PASS; final hygiene/publication follows. Six new frontend cases derive861/99, three native cases derive342 browser tests; these are inventories, not fresh whole-suite results. Audit/System remain pending.
|
Remaining P7-09 data presentation **8af9168014631633e38e8ba7cd22899fdf1092e2** was normally pushed and directly verified **07:58:49 UTC**, with clean HEAD=origin=remote development. P7-09 implemented scope is CONDITIONAL GO; qualifications remain. [P7-10 Users](p7-10/USERS.md) now has18/5 affected frontend,25backend and finalnative3/type/lint/build PASS; final hygiene/publication follows. Six new frontend cases derive861/99, three native cases derive342 browser tests; these are inventories, not fresh whole-suite results. Audit/System remain pending.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Phase 7 — Initial preservation matrix
|
# Phase 7 — Initial preservation matrix
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
## Import continuation — 20 October 2026
|
## Import continuation — 20 October 2026
|
||||||
|
|||||||
@@ -25,7 +25,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-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-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-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 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned |
|
| [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-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned |
|
| 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 |
|
| P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# P7-10 Audit
|
||||||
|
|
||||||
|
**Implemented CONDITIONAL GO; publication pending.** Entry is the published Users follow-up `4760fd102c21c684bd139206a12194c6fc8f6104`, which passed staged/full-phase whitespace and normal remote verification at13:10:57UTC. Users' earlier staged-check failure remains recorded in its report.
|
||||||
|
|
||||||
|
## Bounded changes
|
||||||
|
|
||||||
|
The existing `/admin/audit` read owner and shared read-state component remain. Shared h2/h3 page/section presentation, localized Events heading and a named keyboard-scrollable table replace the one-off surface. Loading, failure and actual empty results remain distinct; Retry only performs GET. Existing supported Undo types and Deleted-only Restore visibility are unchanged.
|
||||||
|
|
||||||
|
Mutations now share synchronous admission, pending focus and session/lifetime guards. They retain current-language safe errors and cannot start a private refresh or toast after disposal. A successful write followed by failed refresh offers read recovery without replaying the write. No endpoint, authorization, tenant filter or stored event changes.
|
||||||
|
|
||||||
|
Visual review found a fixed-layout Job column only92.5px wide. An ordinary browser regression failed before removing the fixed table layout and giving Job/Details useful minimum widths. The named table retains intentional two-dimensional keyboard scrolling within the page; app-root overflow is checked separately. Exceptionally long identities still wrap and produce taller rows rather than losing data.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Frontend **9/9 PASS**: seven added regressions, two existing cases. Initial candidate produced three passing/six failing cases against the old owner. Final rerun13:24:37–13:24:41UTC passes with no pending/todo cases.
|
||||||
|
- Existing backend **18/18 PASS**, zero skipped: lifecycle events, ordinary job authorization and API authorization posture, using the unchanged Release binary.
|
||||||
|
- Native **3/3 PASS**, no skips/flaky/retries,13:23:21–13:23:49UTC, export `vZuD4f-46FJekdIR5rhRk`. Actual isolated HTTP proves owned Restore204, foreign ordinary Restore404, authorized Audit Undo200, unsupported Created Undo400, non-admin GET/POST403 and persisted event/readback. Controlled503 recovery remains GET-only.
|
||||||
|
- Finite English/Bokmål light/dark samples at320,390,768,1024,1440,1920. Seven all-enabled axe scans have zero violations and110 incomplete nodes. Contrast incompletes include clipped/overlapping backgrounds and the invisible badge; the inherited shell version badge also has an unresolved role/ARIA incomplete. No rules are suppressed.
|
||||||
|
- App/browser TypeScript, lint and production build PASS. [Portable results](evidence/audit-results.json) retain command hashes, source hashes explicitly captured after execution, native counts and image identities.
|
||||||
|
|
||||||
|
Final320px Bokmål dark and1440px English light PNGs were actually inspected; five others remain automated evidence. Full-page captures place fixed shell chrome over scrolled content and expose a skip-link capture artifact; these images do not establish human screen-reader, real zoom or physical-device acceptance. Earlier candidate results/images and both ordinary layout failures remain retained locally.
|
||||||
|
|
||||||
|
## Preservation and remaining work
|
||||||
|
|
||||||
|
PRES-A02/E08/E09 gain bounded evidence; all57 groups remain56 PARTIAL/C11 DEFERRED and33 routes. Derived inventory becomes868 frontend cases/99 suites and345 browser cases/56 files, not a fresh whole-suite pass. No backend, CV renderer, revision, frozen association or AI safeguard changes. Separate administrator recent-auth enforcement is absent in the existing contract and is not claimed. System follows as its own boundary; shared-shell accessibility and full integrated review remain pending. P5-13 stays withheld, production readiness stays NO-GO, Phase8 has not started.
|
||||||
@@ -7,7 +7,7 @@ Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md): exactly th
|
|||||||
| Boundary | State |
|
| 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 |
|
| [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 | Pending; preserve the exact supported Undo allowlist and ordinary owner-filtered Restore distinction |
|
| [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 |
|
| System | Pending; preserve independent SMTP/probe/test/maintenance scopes, write-only secret semantics and truthful diagnostic availability |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"observedUtc": "2026-10-20T13:24:48.227216+00:00",
|
||||||
|
"sourceScope": "Post-run observations, not pre-run captures",
|
||||||
|
"sources": {
|
||||||
|
"src/views/AdminAuditPage.tsx": "1c7011fa935efe2e532f6ab8f917005e486aa6fb525ad3a28aa1f67f28e461c4",
|
||||||
|
"src/admin-audit-page.test.tsx": "45f2a6274f465aa0c2b805d1ef163cee920b806827b2f3199ef7f89d76b1de3f",
|
||||||
|
"src/i18n/translations.ts": "6d06a414ec4860e11dcddc15d9da9fdfac2fc4903e932660f48fe87f97fefa3d",
|
||||||
|
"e2e/phase7-admin-audit.spec.ts": "cb29e9f7fc346a0a94f7a260eb17565f729172d784d55689cd1677597d1d7b92"
|
||||||
|
},
|
||||||
|
"gates": {
|
||||||
|
"typecheck": {
|
||||||
|
"path": ".artifacts/phase7/current/ui/typecheck-command.json",
|
||||||
|
"sha256": "c1e4f4c5aae8756320a67c8f0ac041b0af2449b0b092eeeb6b7c5c196dc625c2",
|
||||||
|
"name": "typecheck",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"npm.cmd",
|
||||||
|
"run",
|
||||||
|
"typecheck"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:23:37.025665+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:23:51.807391+00:00",
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"path": ".artifacts/phase7/current/ui/lint-command.json",
|
||||||
|
"sha256": "69765e83fcfd7f3a9bf00f1c50b6b5534d29e1ec7496899b0285e428c02f2430",
|
||||||
|
"name": "lint",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"npm.cmd",
|
||||||
|
"run",
|
||||||
|
"lint"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:23:58.717915+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:24:26.797518+00:00",
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
"browser-typecheck": {
|
||||||
|
"path": ".artifacts/phase7/current/ui/browser-typecheck-command.json",
|
||||||
|
"sha256": "6498790444342ace32d26a6b348a42c6a7d86351e63b630dcd844eb85de58984",
|
||||||
|
"name": "browser-typecheck",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"npm.cmd",
|
||||||
|
"exec",
|
||||||
|
"tsc",
|
||||||
|
"--",
|
||||||
|
"--project",
|
||||||
|
"../.artifacts/phase7/e2e-tsconfig.json",
|
||||||
|
"--noEmit"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:23:58.727010+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:24:06.141866+00:00",
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"path": ".artifacts/phase7/current/ui/build-command.json",
|
||||||
|
"sha256": "702efce7d8e68fa1e4fe554db569496a96426ed126ff44fa00f7a8bd4928c7ca",
|
||||||
|
"name": "build",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"npm.cmd",
|
||||||
|
"run",
|
||||||
|
"build"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:23:06.425913+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:23:13.499797+00:00",
|
||||||
|
"exitCode": 0
|
||||||
|
},
|
||||||
|
"admin-audit": {
|
||||||
|
"path": ".artifacts/phase7/current/ui/admin-audit-command.json",
|
||||||
|
"sha256": "f365ec9e6375d2c9d841b16c369d1656641d0e104de88eec11a675e4cf424396",
|
||||||
|
"name": "admin-audit",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"node",
|
||||||
|
"node_modules/@playwright/test/cli.js",
|
||||||
|
"test",
|
||||||
|
"e2e/phase7-admin-audit.spec.ts",
|
||||||
|
"--retries=0",
|
||||||
|
"--reporter=list,json",
|
||||||
|
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\admin-audit-results"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:23:20.787111+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:23:49.160207+00:00",
|
||||||
|
"exitCode": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-actual-read-recovery-en-light-390.png",
|
||||||
|
"sha256": "e0e0bf2c39858d8e6dc3484ad258d99517a2d56a4edca6ed094af3f1826eba82"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-320-nb-dark.png",
|
||||||
|
"sha256": "a42776f1f9f969a04335bb1bebf29af803e2f2d932effca29a07cc7b1c6367c5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-390-en-light.png",
|
||||||
|
"sha256": "cef8b12e281ff0941af9843b229e031a157c7c23a32fe5e31e4c697248cdcfac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-768-nb-light.png",
|
||||||
|
"sha256": "862e138da313fe3b98c67333d9edffa856a90c725bb3018e16607c24eb0d27b7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1024-en-dark.png",
|
||||||
|
"sha256": "ad83e6f81db626e22433b052129d68064320b50801547cc7aa9b2e7e9e858079"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1440-en-light.png",
|
||||||
|
"sha256": "2e318c01e8c34602095fb4916aa0af7b3350cde8d4200ce1f56814c3d16ac223"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1920-nb-dark.png",
|
||||||
|
"sha256": "fddfee7b450800a5d92bd1c4470efafa6ee8f91586a4ddeb8f2d98da9209838e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scans": [
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-actual-read-recovery-en-light-390-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 17,
|
||||||
|
"incompleteRules": [
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-320-nb-dark-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 30,
|
||||||
|
"incompleteRules": [
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-390-en-light-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 30,
|
||||||
|
"incompleteRules": [
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-768-nb-light-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 14,
|
||||||
|
"incompleteRules": [
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1024-en-dark-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 15,
|
||||||
|
"incompleteRules": [
|
||||||
|
"aria-prohibited-attr",
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1440-en-light-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 2,
|
||||||
|
"incompleteRules": [
|
||||||
|
"aria-prohibited-attr",
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".artifacts/phase7/p7-10/audit-corrected-visuals/audit-long-event-1920-nb-dark-axe.json",
|
||||||
|
"violations": 0,
|
||||||
|
"incompleteNodes": 2,
|
||||||
|
"incompleteRules": [
|
||||||
|
"aria-prohibited-attr",
|
||||||
|
"color-contrast"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frontend": {
|
||||||
|
"path": ".artifacts/phase7/current/unit/admin-audit-final-20261020T132437Z-command.json",
|
||||||
|
"sha256": "68a102a137f0afdd3f28beb56b10080488a3731d888a810f622b0c9a2664e72c",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"npm.cmd",
|
||||||
|
"test",
|
||||||
|
"--",
|
||||||
|
"--runInBand",
|
||||||
|
"--watchAll=false",
|
||||||
|
"--json",
|
||||||
|
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\admin-audit-final-20261020T132437Z.json",
|
||||||
|
"src/admin-audit-page.test.tsx"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:24:37.036615+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:24:41.662819+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"counts": {
|
||||||
|
"numPassedTests": 9,
|
||||||
|
"numFailedTests": 0,
|
||||||
|
"numPendingTests": 0,
|
||||||
|
"numTodoTests": 0,
|
||||||
|
"numPassedTestSuites": 1,
|
||||||
|
"numFailedTestSuites": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"path": ".artifacts/phase7/current/backend-career/p710-audit-contracts-20261020T131806Z/command.json",
|
||||||
|
"sha256": "414225a57ab39e08dd969f731783d02a8fedb18bbf909cf23cff8d00d2297063",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||||
|
"test",
|
||||||
|
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||||
|
"--configuration",
|
||||||
|
"Release",
|
||||||
|
"--no-build",
|
||||||
|
"--filter",
|
||||||
|
"FullyQualifiedName~JobLifecycleEventTests|FullyQualifiedName~JobApplicationsAuthorizationTests|FullyQualifiedName~ApiAuthorizationPostureTests",
|
||||||
|
"--logger",
|
||||||
|
"trx;LogFileName=career.trx",
|
||||||
|
"--results-directory",
|
||||||
|
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p710-audit-contracts-20261020T131806Z"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:18:06.101978+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:18:08.677765+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"logSha256": "c32da774fcaa0683a52253fa57169d2c4887c7847644bf304cadc2544a9e0ec9",
|
||||||
|
"counts": {
|
||||||
|
"total": 18,
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 0,
|
||||||
|
"notExecuted": 0
|
||||||
|
},
|
||||||
|
"resultSha256": "0ed622b407eff7a8c78b862422cc0c5dcdc9fb6eb4ec5dfad16ba4cdcc49a566"
|
||||||
|
},
|
||||||
|
"buildId": "vZuD4f-46FJekdIR5rhRk",
|
||||||
|
"nativeStats": {
|
||||||
|
"startTime": "2026-10-20T13:23:21.336Z",
|
||||||
|
"duration": 27787.152000000002,
|
||||||
|
"expected": 3,
|
||||||
|
"skipped": 0,
|
||||||
|
"unexpected": 0,
|
||||||
|
"flaky": 0
|
||||||
|
},
|
||||||
|
"nativeResultSha256": "54b901ecf079d252716da2f57bad553a64b898c7f0f4b2acbd05459d8c8d21ea"
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"checkpoint": "p7-10",
|
||||||
|
"status": "PASS",
|
||||||
|
"checkedUtc": "2026-10-20T13:26:37.561220+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:26:33.986812+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:26:36.708184+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"log": ".artifacts/phase7/current/static/20261020T132633Z/secret-scan.log",
|
||||||
|
"logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "working-whitespace",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--check"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:26:36.708184+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:26:36.811807+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"log": ".artifacts/phase7/current/static/20261020T132633Z/working-whitespace.log",
|
||||||
|
"logSha256Raw": "3a1889bb3f9660d6e0f7bcf9d41ea52f9b3b845c85f2847257728d2388fc0290"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "staged-whitespace",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--cached",
|
||||||
|
"--check"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:26:36.811807+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:26:36.906524+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"log": ".artifacts/phase7/current/static/20261020T132633Z/staged-whitespace.log",
|
||||||
|
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phase7-whitespace",
|
||||||
|
"command": [
|
||||||
|
"rtk",
|
||||||
|
"proxy",
|
||||||
|
"git",
|
||||||
|
"diff",
|
||||||
|
"--check",
|
||||||
|
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||||
|
],
|
||||||
|
"startedUtc": "2026-10-20T13:26:36.907534+00:00",
|
||||||
|
"finishedUtc": "2026-10-20T13:26:37.105504+00:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"log": ".artifacts/phase7/current/static/20261020T132633Z/phase7-whitespace.log",
|
||||||
|
"logSha256Raw": "3a1889bb3f9660d6e0f7bcf9d41ea52f9b3b845c85f2847257728d2388fc0290"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"untrackedSecretScan": {
|
||||||
|
"files": 0,
|
||||||
|
"findings": 0
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"files": 33,
|
||||||
|
"relativeLinks": 631,
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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-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-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.
|
- [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, Audit and System administration presentation.
|
- [ ] P7-10 Users published7ecc0d6/4760fd1; Audit bounded9frontend/18backend/3native plus types/lint/build PASS, publication pending; System follows separately. Shared/manual accessibility conditions remain.
|
||||||
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
|
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
|
||||||
- [ ] P7-12 integrated migration acceptance and Phase 8 handoff.
|
- [ ] P7-12 integrated migration acceptance and Phase 8 handoff.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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 API/Identity/CSRF administrators and ordinary owners exercise owned job soft-delete/Restore and authorized cross-owner Admin Audit Undo, persisted readback, unsupported400, foreign ordinary-Restore404 and non-admin403. No account deletion, provider/model/billing/probe/maintenance, production or Phase8 action occurs. The named Audit GET503 is controlled; read Retry does not replay writes. Google Fonts stylesheets are aborted and other nonlocal requests rejected. Named table horizontal scrolling is intentional and keyboard verified; human reader/device/zoom acceptance remains unverified.';
|
||||||
|
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-audit-${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(); }
|
||||||
|
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.
|
||||||
|
// The named Audit region intentionally scrolls its table; keyboard reachability is tested separately.
|
||||||
|
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}`); }
|
||||||
|
|
||||||
|
type Event = { id: number; type: string; jobApplicationId: number };
|
||||||
|
async function makeJob(api: APIRequestContext, owner: Owner, title: string) {
|
||||||
|
const company=await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: title+' AS' } }), 201);
|
||||||
|
return json<{ id: number }>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: company.id, jobTitle: title, status: 'Saved' } }), 201);
|
||||||
|
}
|
||||||
|
async function events(api: APIRequestContext) { return json<Event[]>(await api.get(`${apiUrl}/admin/audit?take=200`)); }
|
||||||
|
function deletedRow(page: Page, title: string, language: Language='en') { return page.getByRole('row').filter({ hasText: title }).filter({ has: page.getByRole('button', { name: copy('adminAuditRestore', language), exact: true }) }); }
|
||||||
|
|
||||||
|
test('[P7-10 Audit A02 E09] persisted authorized Undo differs from ordinary owner-filtered Restore; unsupported and ordinary-user requests stay denied', async ({ page, playwright, session }) => {
|
||||||
|
const foreignTitle=`Foreign audit fixture ${Date.now()}`; const foreign=await makeJob(page.request, session.owner, foreignTitle);
|
||||||
|
expect((await page.request.delete(`${apiUrl}/jobapplications/${foreign.id}`, { headers: session.owner.headers })).status()).toBe(204);
|
||||||
|
const administrator=await login(session.requests, page.request, 'e2e@example.test');
|
||||||
|
const ownTitle=`Owned audit fixture ${Date.now()}`; const own=await makeJob(page.request, administrator, ownTitle);
|
||||||
|
expect((await page.request.delete(`${apiUrl}/jobapplications/${own.id}`, { headers: administrator.headers })).status()).toBe(204);
|
||||||
|
const initial=await events(page.request); const foreignDelete=initial.find(value => value.jobApplicationId===foreign.id && value.type==='Deleted'); expect(foreignDelete).toBeDefined();
|
||||||
|
const unsupported=initial.find(value => value.jobApplicationId===own.id && value.type==='Created'); expect(unsupported).toBeDefined();
|
||||||
|
expect((await page.request.post(`${apiUrl}/admin/audit/${unsupported!.id}/undo`, { headers: administrator.headers, data: {} })).status()).toBe(400);
|
||||||
|
await start(page, '/admin/audit', 390);
|
||||||
|
const denied=response(page, 'POST', `/jobapplications/${foreign.id}/restore`); await keyboard(deletedRow(page, foreignTitle).getByRole('button', { name: copy('adminAuditRestore'), exact: true })); expect((await denied).status()).toBe(404);
|
||||||
|
const undone=response(page, 'POST', `/admin/audit/${foreignDelete!.id}/undo`); await keyboard(deletedRow(page, foreignTitle).getByRole('button', { name: copy('adminAuditUndo'), exact: true })); expect((await undone).status()).toBe(200);
|
||||||
|
const restored=response(page, 'POST', `/jobapplications/${own.id}/restore`); await keyboard(deletedRow(page, ownTitle).getByRole('button', { name: copy('adminAuditRestore'), exact: true })); expect((await restored).status()).toBe(204);
|
||||||
|
expect((await page.request.get(`${apiUrl}/jobapplications/${own.id}`)).status()).toBe(200);
|
||||||
|
await page.reload(); await expect(deletedRow(page, ownTitle)).toBeVisible();
|
||||||
|
const other=await playwright.request.newContext();
|
||||||
|
try { const owner=await login(session.requests, other, session.owner.email); expect((await other.get(`${apiUrl}/jobapplications/${foreign.id}`)).status()).toBe(200); expect((await other.get(`${apiUrl}/admin/audit?take=200`)).status()).toBe(403); expect((await other.post(`${apiUrl}/admin/audit/${foreignDelete!.id}/undo`, { headers: owner.headers, data: {} })).status()).toBe(403); }
|
||||||
|
finally { await other.dispose(); }
|
||||||
|
expect((await events(page.request)).some(value => value.type==='Undo' && value.jobApplicationId===foreign.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P7-10 Audit A02 E08] failed read Retry remains GET-only and preserves the named focus target', async ({ page, session }, info) => {
|
||||||
|
await login(session.requests, page.request, 'e2e@example.test'); let failed=false;
|
||||||
|
await page.route(`${apiUrl}/admin/audit?take=200`, route => { if (!failed) { failed=true; return route.fulfill({ status: 503, json: { title: 'Controlled Audit unavailable' } }); } return route.continue(); });
|
||||||
|
await start(page, '/admin/audit', 390); await expect(page.getByRole('alert').filter({ hasText: copy('adminAuditLoadFailed') })).toBeVisible();
|
||||||
|
await keyboard(page.getByRole('button', { name: copy('retry'), exact: true }));
|
||||||
|
await expect(page.getByRole('region', { name: copy('adminAuditTitle'), exact: true })).toBeFocused(); await expect(page.getByRole('table', { name: copy('adminAuditTitle'), exact: true })).toBeVisible();
|
||||||
|
expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toEqual([]);
|
||||||
|
await capture(page, info, 'audit-actual-read-recovery-en-light-390');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P7-10 Audit A02] finite bilingual theme widths retain long event identity and keyboard table scrolling', async ({ page, session }, info) => {
|
||||||
|
test.setTimeout(180_000); const administrator=await login(session.requests, page.request, 'e2e@example.test');
|
||||||
|
const title=`Long owned audit event Æ Ø Å ${Date.now()} `+'International collaboration '.repeat(4); const own=await makeJob(page.request, administrator, title);
|
||||||
|
expect((await page.request.delete(`${apiUrl}/jobapplications/${own.id}`, { headers: administrator.headers })).status()).toBe(204);
|
||||||
|
const samples=[{ 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;
|
||||||
|
for (const sample of samples) {
|
||||||
|
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/audit', sample.width);
|
||||||
|
const region=page.getByRole('region', { name: copy('adminAuditTitle', sample.language), exact: true }); await region.focus(); await expect(region).toBeFocused();
|
||||||
|
const jobColumn=await region.getByRole('columnheader', { name: copy('adminAuditJob', sample.language), exact: true }).boundingBox(); expect(jobColumn).not.toBeNull(); expect(jobColumn!.width).toBeGreaterThanOrEqual(240);
|
||||||
|
const before=await region.evaluate(node => node.scrollLeft); await region.press('ArrowRight'); if (sample.width<1024) await expect.poll(() => region.evaluate(node => node.scrollLeft)).toBeGreaterThan(before);
|
||||||
|
await expect(deletedRow(page, title, sample.language)).toBeVisible(); await capture(page, info, `audit-long-event-${sample.width}-${sample.language}-${sample.mode}`);
|
||||||
|
}
|
||||||
|
expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toEqual([]);
|
||||||
|
});
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
import { api } from "./api";
|
import { api, getApiErrorMessage } from "./api";
|
||||||
import { I18nProvider } from "./i18n/I18nProvider";
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
import { ToastProvider } from "./toast";
|
import { ToastProvider } from "./toast";
|
||||||
import AdminAuditPage from "./views/AdminAuditPage";
|
import AdminAuditPage from "./views/AdminAuditPage";
|
||||||
|
import { translations } from "./i18n/translations";
|
||||||
|
|
||||||
|
const mockToast = jest.fn();
|
||||||
|
let mockSession = "one";
|
||||||
|
jest.mock("./sessionBoundary", () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSession, isSessionInvalidated: () => false }));
|
||||||
|
jest.mock("./toast", () => ({ ...jest.requireActual("./toast"), useToast: () => ({ toast: mockToast }) }));
|
||||||
|
|
||||||
jest.mock("./api", () => ({
|
jest.mock("./api", () => ({
|
||||||
api: { get: jest.fn(), post: jest.fn() },
|
api: { get: jest.fn(), post: jest.fn() },
|
||||||
@@ -21,6 +27,77 @@ function renderPage() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
mockSession = "one";
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = { id: 7, type: "Deleted", at: "2026-10-20T12:00:00Z", jobApplicationId: 42, note: "Owner authored text" };
|
||||||
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
||||||
|
|
||||||
|
test("[P7-10 audit] retains shared heading hierarchy and a named keyboard scroll region", async () => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [row] } as any);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("button", { name: "Undo" });
|
||||||
|
expect(screen.getByRole("heading", { name: "Audit log", level: 2 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: translations.en.adminAuditEvents, level: 3 })).toBeInTheDocument();
|
||||||
|
const region = screen.getByRole("region", { name: "Audit log" });
|
||||||
|
expect(region).toHaveAttribute("tabindex", "0");
|
||||||
|
expect(within(region).getByRole("table")).toHaveAccessibleName("Audit log");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[P7-10 audit] exact Undo allowlist and deleted-only Restore retain endpoint contracts", async () => {
|
||||||
|
const types = ["StatusChanged", "FollowUpSet", "ResponseUpdated", "Deleted", "Restored", "Created", "NoteAdded"];
|
||||||
|
mockedApi.get.mockResolvedValue({ data: types.map((type, index) => ({ ...row, type, id: index + 1 })) } as any);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findAllByRole("button", { name: "Undo" })).toHaveLength(5);
|
||||||
|
expect(screen.getAllByRole("button", { name: "Restore" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[P7-10 audit] Undo and Restore serialize across rows, retaining focus on pending table", async () => {
|
||||||
|
const held = deferred<any>();
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [row, { ...row, id: 8 }] } as any);
|
||||||
|
mockedApi.post.mockReturnValue(held.promise);
|
||||||
|
renderPage();
|
||||||
|
const undo = (await screen.findAllByRole("button", { name: "Undo" }))[0];
|
||||||
|
act(() => undo.focus());
|
||||||
|
act(() => { fireEvent.click(undo); fireEvent.click(undo); fireEvent.click(screen.getAllByRole("button", { name: "Restore" })[1]); });
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith("/admin/audit/7/undo", {});
|
||||||
|
expect(screen.getByRole("region", { name: "Audit log" })).toHaveFocus();
|
||||||
|
expect(screen.getByRole("region", { name: "Audit log" })).toHaveAttribute("aria-busy", "true");
|
||||||
|
expect(screen.getAllByRole("button", { name: "Undo" })[1]).toBeDisabled();
|
||||||
|
await act(async () => held.resolve({ data: { ok: true } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[P7-10 audit] a committed Restore with failed refresh recovers through GET only", async () => {
|
||||||
|
mockedApi.get.mockResolvedValueOnce({ data: [row] } as any).mockRejectedValueOnce(new Error("offline")).mockResolvedValue({ data: [] } as any);
|
||||||
|
mockedApi.post.mockResolvedValue({ data: {} } as any);
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Restore" }));
|
||||||
|
expect(await screen.findByText(translations.en.adminAuditLoadFailed)).toBeInTheDocument();
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/42/restore", {});
|
||||||
|
const retry = screen.getByRole("button", { name: "Retry" }); act(() => retry.focus()); fireEvent.click(retry);
|
||||||
|
expect(await screen.findByText("No events.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("region", { name: "Audit log" })).toHaveFocus();
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["unmount", "session"])("[P7-10 audit] late Undo after %s cannot notify or refresh", async boundary => {
|
||||||
|
const held = deferred<any>(); mockedApi.post.mockReturnValue(held.promise); mockedApi.get.mockResolvedValue({ data: [row] } as any);
|
||||||
|
const view = renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
|
||||||
|
if (boundary === "unmount") view.unmount(); else mockSession = "two";
|
||||||
|
await act(async () => held.resolve({ data: { message: "Undone" } }));
|
||||||
|
expect(mockToast).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[P7-10 audit] pending failure uses safe current-language feedback without a locale GET", async () => {
|
||||||
|
const held = deferred<any>(); mockedApi.post.mockReturnValue(held.promise); mockedApi.get.mockResolvedValue({ data: [row] } as any);
|
||||||
|
renderPage(); fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
|
||||||
|
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||||
|
const error = { response: { data: { title: "<html>proxy error</html>" } } };
|
||||||
|
await act(async () => held.resolve(Promise.reject(error)));
|
||||||
|
await waitFor(() => expect(mockToast).toHaveBeenCalledWith(translations.nb.adminAuditUndoFailed, "error"));
|
||||||
|
expect(getApiErrorMessage).toHaveBeenCalledWith(error, translations.nb.adminAuditUndoFailed);
|
||||||
|
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinguishes an unavailable audit log from an empty audit log and retries", async () => {
|
test("distinguishes an unavailable audit log from an empty audit log and retries", async () => {
|
||||||
|
|||||||
@@ -2158,6 +2158,7 @@ export const translations = {
|
|||||||
adminAuditUndone: "Undone.",
|
adminAuditUndone: "Undone.",
|
||||||
adminAuditUndoFailed: "Undo failed.",
|
adminAuditUndoFailed: "Undo failed.",
|
||||||
adminAuditLoadFailed: "Unable to load audit events",
|
adminAuditLoadFailed: "Unable to load audit events",
|
||||||
|
adminAuditEvents: "Events",
|
||||||
adminAuditLoadFailedBody: "The audit log cannot reach the API right now.",
|
adminAuditLoadFailedBody: "The audit log cannot reach the API right now.",
|
||||||
adminAuditJobFallback: "Job #{id}",
|
adminAuditJobFallback: "Job #{id}",
|
||||||
adminAuditTitle: "Audit log",
|
adminAuditTitle: "Audit log",
|
||||||
@@ -4931,6 +4932,7 @@ export const translations = {
|
|||||||
adminAuditUndone: "Angret.",
|
adminAuditUndone: "Angret.",
|
||||||
adminAuditUndoFailed: "Kunne ikke angre.",
|
adminAuditUndoFailed: "Kunne ikke angre.",
|
||||||
adminAuditLoadFailed: "Kunne ikke laste revisjonshendelser",
|
adminAuditLoadFailed: "Kunne ikke laste revisjonshendelser",
|
||||||
|
adminAuditEvents: "Hendelser",
|
||||||
adminAuditLoadFailedBody: "Revisjonsloggen fikk ikke kontakt med API-et akkurat nå.",
|
adminAuditLoadFailedBody: "Revisjonsloggen fikk ikke kontakt med API-et akkurat nå.",
|
||||||
adminAuditJobFallback: "Jobb #{id}",
|
adminAuditJobFallback: "Jobb #{id}",
|
||||||
adminAuditTitle: "Revisjonslogg",
|
adminAuditTitle: "Revisjonslogg",
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Paper,
|
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -14,11 +13,13 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { useViewResource } from "../hooks/useViewResource";
|
import { useViewResource } from "../hooks/useViewResource";
|
||||||
import ViewStateNotice from "../components/ViewStateNotice";
|
import ViewStateNotice from "../components/ViewStateNotice";
|
||||||
|
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||||
|
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
|
||||||
|
|
||||||
type AuditItem = {
|
type AuditItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,72 +44,69 @@ export default function AdminAuditPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const body=useRef<HTMLDivElement>(null);
|
||||||
|
const owner=useRef({ mounted: true, generation: 0, busy: false });
|
||||||
|
const currentTranslation=useRef(t);
|
||||||
|
const fallback=useRef(t("adminAuditLoadFailed"));
|
||||||
|
useEffect(() => { currentTranslation.current=t; }, [t]);
|
||||||
|
useEffect(() => { const lifetime=owner.current; lifetime.mounted=true; lifetime.generation+=1; return () => { lifetime.mounted=false; lifetime.generation+=1; }; }, []);
|
||||||
|
|
||||||
const auditResource = useViewResource(
|
const auditResource = useViewResource(
|
||||||
async () => {
|
async () => {
|
||||||
const r = await api.get<AuditItem[]>("/admin/audit?take=200");
|
const r = await api.get<AuditItem[]>("/admin/audit?take=200");
|
||||||
return r.data ?? [];
|
return r.data ?? [];
|
||||||
},
|
},
|
||||||
{ initialData: [], errorMessage: t("adminAuditLoadFailed"), deps: [t] },
|
{ initialData: [], errorMessage: fallback.current },
|
||||||
);
|
);
|
||||||
|
|
||||||
const undo = async (e: AuditItem) => {
|
const mutate = async (e: AuditItem, action: "undo" | "restore") => {
|
||||||
if (!canUndo(e.type)) return;
|
if (action === "undo" ? !canUndo(e.type) : e.type !== "Deleted") return;
|
||||||
|
checkSessionCookie(); const lifetime=owner.current;
|
||||||
|
if (!lifetime.mounted || lifetime.busy || isSessionInvalidated()) return;
|
||||||
|
lifetime.busy=true;
|
||||||
|
const generation=lifetime.generation; const context=getSessionContext();
|
||||||
|
const active=() => { checkSessionCookie(); return lifetime.mounted && lifetime.generation===generation && !isSessionInvalidated() && getSessionContext()===context; };
|
||||||
|
if (body.current?.contains(document.activeElement)) body.current.focus();
|
||||||
setBusyId(e.id);
|
setBusyId(e.id);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ ok: boolean; message: string }>(`/admin/audit/${e.id}/undo`, {});
|
const res=await api.post<{ ok: boolean; message?: string }>(action === "undo" ? `/admin/audit/${e.id}/undo` : `/jobapplications/${e.jobApplicationId}/restore`, {});
|
||||||
toast(res.data?.message || t("adminAuditUndone"), "success");
|
if (!active()) return;
|
||||||
|
toast(action === "undo" && res.data?.message || currentTranslation.current(action === "undo" ? "adminAuditUndone" : "adminAuditRestored"), "success");
|
||||||
await auditResource.reload();
|
await auditResource.reload();
|
||||||
} catch (err: any) {
|
} catch (error) {
|
||||||
const msg = err?.response?.data?.message || err?.response?.data || err?.message || t("adminAuditUndoFailed");
|
if (active()) toast(getApiErrorMessage(error, currentTranslation.current(action === "undo" ? "adminAuditUndoFailed" : "adminAuditRestoreFailed")), "error");
|
||||||
toast(String(msg), "error");
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
lifetime.busy=false;
|
||||||
}
|
if (active()) setBusyId(null);
|
||||||
};
|
|
||||||
|
|
||||||
const restore = async (e: AuditItem) => {
|
|
||||||
setBusyId(e.id);
|
|
||||||
try {
|
|
||||||
await api.post(`/jobapplications/${e.jobApplicationId}/restore`, {});
|
|
||||||
toast(t("adminAuditRestored"), "success");
|
|
||||||
await auditResource.reload();
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err?.response?.data || err?.message || t("adminAuditRestoreFailed");
|
|
||||||
toast(String(msg), "error");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = auditResource.data;
|
const rows = auditResource.data;
|
||||||
|
const error=auditResource.error?.message===fallback.current ? { ...auditResource.error, message: t("adminAuditLoadFailed") } : auditResource.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 2 }}>
|
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 900, mb: 1 }}>
|
<PageHeader level="h2" title={t("adminAuditTitle")} description={t("adminAuditSubtitle")} />
|
||||||
{t("adminAuditTitle")}
|
<PageSection level="h3" surface title={t("adminAuditEvents")}>
|
||||||
</Typography>
|
<Box ref={body} role="region" aria-label={t("adminAuditTitle")} tabIndex={0} aria-busy={busyId!==null || auditResource.loading || auditResource.refreshing} sx={{ minWidth: 0, overflow: "auto" }}>
|
||||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
|
||||||
{t("adminAuditSubtitle")}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<ViewStateNotice
|
<ViewStateNotice
|
||||||
loading={auditResource.loading}
|
loading={auditResource.loading}
|
||||||
error={auditResource.error}
|
error={error}
|
||||||
title={t("adminAuditLoadFailed")}
|
title={t("adminAuditLoadFailed")}
|
||||||
description={t("adminAuditLoadFailedBody")}
|
description={t("adminAuditLoadFailedBody")}
|
||||||
onRetry={auditResource.reload}
|
onRetry={async () => { body.current?.focus(); await auditResource.reload(); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!auditResource.error ? <TableContainer sx={{ borderRadius: 2, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
{!auditResource.error ? <TableContainer sx={{ overflow: "visible" }}>
|
||||||
<Table size="small">
|
<Table size="small" aria-label={t("adminAuditTitle")} sx={{ minWidth: 900, "& th, & td": { overflowWrap: "anywhere" }, "& button": { whiteSpace: "normal", "@media (pointer: coarse)": { minHeight: 44 } } }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ width: 165 }}>{t("adminAuditAt")}</TableCell>
|
<TableCell sx={{ width: 165 }}>{t("adminAuditAt")}</TableCell>
|
||||||
<TableCell sx={{ width: 160 }}>{t("adminAuditType")}</TableCell>
|
<TableCell sx={{ width: 160 }}>{t("adminAuditType")}</TableCell>
|
||||||
<TableCell>{t("adminAuditJob")}</TableCell>
|
<TableCell sx={{ minWidth: 260 }}>{t("adminAuditJob")}</TableCell>
|
||||||
<TableCell sx={{ width: 220 }}>{t("adminAuditUser")}</TableCell>
|
<TableCell sx={{ width: 220 }}>{t("adminAuditUser")}</TableCell>
|
||||||
<TableCell>{t("adminAuditDetails")}</TableCell>
|
<TableCell sx={{ minWidth: 220 }}>{t("adminAuditDetails")}</TableCell>
|
||||||
<TableCell sx={{ width: 170 }}>{t("adminAuditActions")}</TableCell>
|
<TableCell sx={{ width: 170 }}>{t("adminAuditActions")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -130,7 +128,7 @@ export default function AdminAuditPage() {
|
|||||||
const jobLabel = e.jobTitle || t("adminAuditJobFallback", { id: e.jobApplicationId });
|
const jobLabel = e.jobTitle || t("adminAuditJobFallback", { id: e.jobApplicationId });
|
||||||
const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-";
|
const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-";
|
||||||
const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : "";
|
const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : "";
|
||||||
const disabled = busyId === e.id;
|
const disabled = busyId !== null;
|
||||||
const showRestore = e.type === "Deleted";
|
const showRestore = e.type === "Deleted";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -160,12 +158,12 @@ export default function AdminAuditPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
{canUndo(e.type) ? (
|
{canUndo(e.type) ? (
|
||||||
<Button size="small" variant="outlined" disabled={disabled} onClick={() => void undo(e)}>
|
<Button size="small" variant="outlined" disabled={disabled} onClick={() => void mutate(e, "undo")}>
|
||||||
{t("adminAuditUndo")}
|
{t("adminAuditUndo")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{showRestore ? (
|
{showRestore ? (
|
||||||
<Button size="small" variant="contained" disabled={disabled} onClick={() => void restore(e)}>
|
<Button size="small" variant="contained" disabled={disabled} onClick={() => void mutate(e, "restore")}>
|
||||||
{t("adminAuditRestore")}
|
{t("adminAuditRestore")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -178,6 +176,8 @@ export default function AdminAuditPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer> : null}
|
</TableContainer> : null}
|
||||||
</Paper>
|
</Box>
|
||||||
|
</PageSection>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user