feat(settings): preserve drafts and connection recovery
CI and Deploy / test (push) Failing after 4m27s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-10-17 10:50:40 +02:00
parent dac29278db
commit e7dba9a2f7
20 changed files with 3337 additions and 141 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
# Phase 7 — Commit and evidence ledger
Latest pending publication: [P7-09 separate OAuth sender repair](p7-09/OAUTH_SENDER.md), final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; final staged hygiene/commit/push pending. +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Actual commit/remote receipt follows observation. Both Settings presentation boundaries remain pending.
Current pending publication: [P7-09 Preferences/Connections](p7-09/PREFERENCES.md), final91/6 frontend, unchanged-backend52, native5+OAuth3+Rules/Settings8+ordinaryP2-D01=17 and type/lint/build PASS onInBPthl1fsnID3NNdgwbI. All18 new PNGs reviewed;54 scans0/284 remain qualified. Source34/earlier31/32 scopes and every RED/failed fixture attempt are [recorded](p7-09/evidence/preferences-results.json). Final staged hygiene/normal commit/push pending; +17/0 derives824/95 and+5 browser331, backend1054 unchanged. Data/privacy/plan/backup and its separate import prerequisite remain pending; main unchanged/productionNO-GO.
Latest published repair: [P7-09 OAuth sender](p7-09/OAUTH_SENDER.md), **dac29278db3c187349b97771ca2f383c9e6e0962**, clean normal push directly verified2026-10-17T08:27:48.643285UTC, HEAD=origin=live/main27657f3 unchanged. Final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates and staged hygiene PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Both Settings presentation boundaries remain pending.
Policy: commit each completed checkpoint or coherent repair and push normally to `codex/ui-redesign-phase4`, with direct remote=HEAD verification. Latest completed P7-08 publication is `c5176e3251c869e13284b994b59b2f7aa9220eb1`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Earlier Git connection failures are historical and superseded by the recorded successful receipts. Production main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`; no force push, main push or automatic deployment.
+2
View File
@@ -2,6 +2,8 @@
P7-09's [sender repair](p7-09/evidence/oauth-results.json) confirms/fixes uncorrelated OAuth messages and delayed startup-language feedback. Consumer regression retains89 axe incompletes, including46 roleless-span `aria-label` diagnostics (45 Notes/Tasks,1 Interview) for bounded P7-11 responsible-owner triage. No existing manual/provider/release gate is closed; the Settings presentation findings remain pending.
First-boundary [Preferences/Connections results](p7-09/evidence/preferences-results.json) repair notification draft/reset/recovery/snapshot/disposal and truthful provider loading/error/write-refresh/IMAP-port/focus states. New18 scans retain62 incompletes and existing36 retain222; indeterminate layered contrast, short badges and closing-menu settled focus/AT remain qualified. RD11/RD12/RD13 roots are not closed by successful or controlled recovery. Data lifecycle additionally requires the demonstrated app-export/import casing compatibility repair before its round-trip claim; no restore/transaction/replay capability is invented.
P7-07's [final local UI evidence](p7-07/evidence/ui-results.json) adds no whole-group or production acceptance. All RD01–RD28 dispositions remain. Its 15 axe scans retain 101 incomplete nodes: 15 short notification badges, 85 overlapped form backgrounds and one partially obscured Dashboard label inside the navigation scroll container. These require the existing manual contrast/state qualifications; zero automated violations does not close them.
Two bounded P7-11 residuals are recorded from actual images: a long English action wraps its final letter at 1024px, and the unchanged CV missing-source owner displays raw `Not Found` error text. The Documents attachment-error layout was corrected and reverified. Historical Git connection failures were superseded by successful normal pushes, most recently P7-08 `c5176e3`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Actual MariaDB, human/device/provider/output and production gates remain open.
+2
View File
@@ -2,6 +2,8 @@
P7-09's [separate sender repair](p7-09/evidence/oauth-results.json) preserves M01/M02/M06/E09 and all57 statuses:39 affected caller cases,3 new local-sender journeys,5 existing Application journeys and ordinary P2-D01 pass. No real provider grant, new permission, route, data or document-output acceptance is claimed. Preferences and data lifecycle remain pending.
The first [Preferences/Connections boundary](p7-09/evidence/preferences-results.json) now preserves S03/S04/S05/M06/E09:91 frontend,52 unchanged-backend,17 native including Rules/Settings/OAuth/ordinaryP2-D01 PASS; all18 new PNGs reviewed. Section save scopes, six Rules fields, canonical ownership and server admission remain intact. Manual/whole-group qualifications and all57 statuses/33routes stay unchanged. Data lifecycle and final publication remain pending.
Latest completed P7-08 reconciliation: [Operations results](p7-08/evidence/operations-results.json) retain all 57 statuses below, with 56 PARTIAL and C11 DEFERRED. The original 32 routes remain alongside the approved `/documents` route (33 current patterns). Fresh affected77/7 and native12 pass, including ordinary P2-D01. Human/device/output and whole-group qualifications remain. P7-08 publication `c5176e3251c869e13284b994b59b2f7aa9220eb1` was directly verified clean at 2026-10-17T02:03:53.815275 UTC; older connection failures remain historical evidence.
All **57 original protected groups** remain in scope. This initial matrix derives the exact IDs/statuses and original evidence/limitations from [Phase6 preservation](../phase-6/p6-05/PRESERVATION.md), reconciled against its [aggregate](../phase-6/p6-05/evidence/validation.json), [original functionality ledger](../audit/FUNCTIONALITY_INVENTORY.md), [Phase2 anchors](../phase-2/PRESERVATION_MATRIX.md) and [Phase3 impacts](../phase-3/PRESERVATION_IMPACT_MATRIX.md). The short rows never replace protected sub-capabilities.
+1 -1
View File
@@ -18,7 +18,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
| [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 | Separate sender repair final39/3+native9+types/lint/build PASS; final publication pending. Preferences/Connections then data/privacy/plan/backup follow. Existing Rules/save owners and all withholding/manual/release qualifications retained |
| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender repair dac2927 published. Preferences/Connections91/6+backend52+native17+gates PASS; all18 new PNGs reviewed; final hygiene/publication pending. Data/privacy/plan/backup follows with separate import compatibility prerequisite. Rules/save owners and all qualifications retained |
| P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; 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 |
@@ -39,3 +39,5 @@ Seven of76 retained final PNGs were actually displayed and reviewed, covering Co
The **16 new ordinary frontend cases / zero suites** derive **807/95**; three new browser cases derive **326**; backend stays **1054**. These are inventories, not fresh integrated passes. All57 groups stay56 PARTIAL/C11 DEFERRED and33 routes remain. Final [staged hygiene](evidence/oauth-static-checks.json) **PASS** at08:27:17 UTC: secret and all whitespace gates,25 docs/583 links,57 groups/33 routes. Normal commit/publication follows this gate. Bounded repair: **CONDITIONAL GO**; both P7-09 presentation boundaries remain pending.
Actual OAuth grants, provider interoperability and production qualification remain open. Local callback HTML and connect URLs are explicitly controlled client-guard fixtures, and canonical server status remains disconnected. No mailbox, inference, send or grant is contacted. P5-13/worker/model withholding and production **NO-GO** remain unchanged.
Normal publication **`dac29278db3c187349b97771ca2f383c9e6e0962`** is directly verified at **2026-10-17T08:27:48.643285 UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. This observed receipt supersedes the preceding pending-publication observation. [Preferences and Connections](PREFERENCES.md) now proceeds; no production or Phase8 authorization follows.
@@ -0,0 +1,49 @@
# P7-09 — Preferences and Connections
First presentation boundary from the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md). The separate [sender repair](OAUTH_SENDER.md) is normally pushed as **`dac29278db3c187349b97771ca2f383c9e6e0962`**. Direct refs at **2026-10-17T08:27:48.643285 UTC** prove clean HEAD=origin=live development and unchanged local/origin/live main27657f3. Exact receipt: `.artifacts/phase7/p7-09/oauth-push-receipt.json`. No publication blocker remains for that repair.
## Baseline and bounded scope
The published entry has final **39/3 frontend**, sender3/Application5/ordinaryP2-D01=9 native and four type/lint/build gates passing on `j_uwah2_BImymFemE3cLg`. These executed entry results remain separate from new presentation acceptance. Existing Settings14, Connections12 and correspondence13 are retained. Derived inventory at entry is807/95 frontend,326 browser,1054 backend; the latest whole-suite counts remain inherited untilP7-12.
| Owner | Confirmed friction | Required bounded result |
|---|---|---|
| Settings notification preference | Locale-dependent GET replaces unsaved edits; failed initial read lacks recovery; duplicate saves and late acknowledgements can overwrite newer choices | Stable single-flight read; explicit GET-only Retry; independent draft/last-acknowledged baseline, dirty/Discard and submitted-snapshot save acknowledgement; current-language feedback and disposal |
| Parent Rules session | Existing six-field draft/validation/snapshot/discard owner is accepted | Retain all fields,1–365 validation, internal-tab retention, section save scope and current routes/panel associations |
| Mailbox providers | Failed/unfinished status resembles disconnection; repeated actions and failed post-write refresh can mislead; IMAP fields remain editable during submission | Independent truthful loading/error/known status, GET-only Retry and no mutation replay; scoped action exclusion and captured IMAP payload; sender correlation unchanged |
| Connected accounts presentation | Legacy rounded wrapper, heavy weights and duplicated header style | Existing PageSection, appropriate heading hierarchy, wrapping and shared spacing; no new information architecture |
User/local appearance/table preferences, server language, notification email delivery and Rules remain independent. No save-all, new revision protocol, shared settings store, provider feature or backend/schema change. The existing theme/router/MUI/toast/session ownership stays authoritative. Failed real writes and RD11/RD12/RD13 remain visible and separately owned; a successful mocked or later real response does not diagnose their historical root cause.
## Evidence and acceptance
Meaningful ordinary REDs are retained: Settings **14 PASS / 7 FAIL** at08:31:47–08:31:58 UTC, before its product changes; Connections **12 PASS / 9 FAIL**, before its product changes. Failures demonstrate draft replacement, duplicate writes, stale feedback/disposal, missing recovery and incorrect provider/port states. Final GREEN and combined consumers, serialized production build/browser journeys, finite width/theme/language screenshots, all axe incomplete contexts, preservation reconciliation and staged hygiene are **PENDING**. Existing sender tests stay strict.
Candidate Settings **21/1 PASS** and Connections **22/1 PASS** retain every existing case. Connections adds a separate meaningful removed-IMAP-field focus regression (**21 PASS / 1 FAIL**, then22 PASS). The new boolean preference owner acknowledges only its submitted snapshot; provider failures retain an unknown or refresh-required state and Retry performs GET only. IMAP no longer coerces an empty/zero port to993; it validates the existing server range1–65535 before submitting the captured credentials. No protocol or grant semantics change.
The first combined **91/6 PASS** was followed by a failed lint gate (two cleanup-ref warnings). Capturing the stable ref objects in effect setup corrected those warnings without suppression. The next91/6 PASS/type gate exposed26 unsupported Testing Library `ByRole` `exact:true` options; only those options were removed, preserving literal accessible names, default exact matching and every assertion. No build/native ran in either failed batch. Raw capsules `.artifacts/phase7/p7-09/preferences-lint-attempt1/` and `preferences-types-attempt1/` retain the actual failures. Final combined **91/6 PASS**,08:37:32–08:37:59 UTC, and fresh app/browser TypeScript/lint PASS now precede browser validation. No failed gate is represented as successful acceptance.
Unchanged-binary backend **52/52 PASS**,08:30:10–08:30:13 UTC: ExternalOrigin14, Gmail19, Microsoft7, IMAP9 and NotificationSettings3. The command's additional `RulesControllerTests` filter matches no cases and supplies no Rules coverage; existing/native Rules evidence remains separate. Exact raw command/TRX/log is `.artifacts/phase7/current/backend-career/p709-preferences-contracts-20261017T083010Z/`. This is not a new backend test, Release build or whole-suite run. No real grant, IMAP connection, send, model call, billing, deletion, restore or production action belongs to this boundary.
PRES-S03/S04/S05/M06/E09 and all57 groups remain binding; no whole-group promotion. Human reader/touch/software keyboard/real zoom and production font/photo/output requirements remain open. **Presentation acceptance: PENDING.** Data/privacy/plan/backup follows this boundary; Phase8 implementation and deployment remain prohibited.
## Final first-boundary result
Implemented Preferences/Connections: **CONDITIONAL GO**, pending final [staged hygiene](evidence/preferences-static-checks.json) and normal publication. [Portable results](evidence/preferences-results.json) identify final91/6, backend52/52, all17 native cases, frozen source/export, peer review and every failed attempt. No skip, expected failure, assertion relaxation or flaky outcome is used.
| Executed evidence | Actual result |
|---|---|
| Combined frontend | **91/6 PASS**,08:37:32–08:37:59 UTC; Settings21, Connections22, correspondence13, i18n6, onboarding23, notification shell6 |
| Build and types | App TypeScript08:37:55–08:38:07 and production build08:38:13–08:38:28 PASS, export **`InBPthl1fsnID3NNdgwbI`**. Final native-only fixture edits get fresh lint08:45:29–08:46:00 and browser TypeScript08:46:00–08:46:07 PASS; no fictional rebuild |
| New browser journeys | **5/5 PASS**,08:41:11–08:42:04: actual persistence, separate saves, submitted snapshot/newer draft, owner/disposal, controlled read/write recovery and seven paired width/theme/language samples |
| Existing consumers | OAuth **3/3 PASS**,08:42:04–08:42:22; Rules4+Settings4 **8/8 PASS**,08:46:07–08:47:56; ordinary **P2-D01 1/1 PASS**,08:47:56–08:48:10 |
| Source preservation | Final34 inputs held08:45:29–08:48:37. Earlier build/unit31 inputs and primary/OAuth32 inputs match, with only the three separately recorded native fixture changes. Both backend DLLs unchanged |
| Visual and accessibility | All18 new PNGs actually displayed/reviewed: parent0–8, independent peer9–17. New18 scans0 violations/62 incompletes; existing36 scans0/222 retained separately |
The first browser attempt2/5 failed at an early mobile/desktop selector branch before the lazy panel appeared; waiting for either visible control corrects that fixture precondition. The first existing consumer run3/8 failed at a global status locator that also selected P7-08's unread announcement and four obsolete Skeleton assertions. Their replacements scope the exact Rules status and assert the actual named busy notification group/progress indicator. Held reads, exact HTTP/status/persistence/export and downstream assertions remain unchanged. All raw failures remain retained.
Every incomplete category is recorded: new43/consumer174 overlapped backgrounds, new18/consumer36 short badges, new1/consumer12 closing MUI-menu focus diagnostics. Full contexts remain raw. These are indeterminate contrast or pending settled keyboard/AT checks, not waived findings or human acceptance. No new rule is disabled. Consumer PNGs are retained without claiming all were visually reviewed.
**17 new frontend cases / zero suites** derive **824/95**; **five new browser cases** derive **331**; backend remains1054. These remain inventories, not full-suite execution; inherited whole573/89,1033 backend and49 sidecar awaitP7-12. All57 groups remain56 PARTIAL/C11 DEFERRED, with33 routes; RD11/RD12/RD13/RD21 and manual/provider/output/release debt remain. The next data lifecycle boundary first needs the separately reviewable PascalCase-export/camelCase-import compatibility repair; it is not implemented here.
Final staged hygiene **PASS**,2026-10-17T08:50:12 UTC: secret and working/staged/phase whitespace gates,26 docs/593 local links,57 groups/33 routes. All33 tracked staged inputs match the final canonical-LF snapshots; the sole ignored input is the recorded local runner. Normal commit/push follows; the actual hash/remote receipt is recorded after observation. Data lifecycle and wholeP7-09 remain pending.
+1 -1
View File
@@ -29,6 +29,6 @@ Notification reads depend on locale callbacks and can replace an unsaved choice;
All **57 groups remain 56 PARTIAL / C11 DEFERRED**, with **33 current routes**. RD11 language, RD12 notification and RD13 Rules failures remain independently diagnosed; successful reruns do not close them. F20/RD21, other reliability/scaling debt, human screen-reader, physical touch/software keyboard, real zoom and production fonts/photos/print remain qualified. P5-13 is withheld; followMode is deferred. Production remains **NO-GO**, main is unchanged and Phase 8 implementation has not started.
The [separate sender repair](OAUTH_SENDER.md) now has final39/3, four type/lint/build gates and nine ordinary browser cases passing, including P2-D01. [Finite results](evidence/oauth-results.json) retain the actual REDs,39/3 final source, three new client-guard cases and all qualifications. Derived inventories become807/95 frontend and326 browser; backend1054 unchanged. Sender repair final hygiene/publication remain pending; preferences/Connections then data/privacy/plan/backup are still the two approved presentation boundaries.
The [separate sender repair](OAUTH_SENDER.md) is published asdac2927, directly verified clean08:27:48 UTC; its39/3, native9, type/lint/build/hygiene and all qualifications remain recorded. The first [Preferences/Connections boundary](PREFERENCES.md) now has final91/6, backend52, native17 and gates PASS, all18 new PNGs reviewed and qualified54 axe scans0/284. [Finite results](evidence/preferences-results.json) retain precise source/run scopes and failures. Derived inventories become824/95 frontend and331 browser; backend1054 unchanged. First-boundary final hygiene/publication remain pending; data/privacy/plan/backup follows, beginning with the separately bounded import compatibility repair. Neither the second boundary nor allP7-09 is accepted yet.
**P7-09 acceptance: PENDING.** Each repair/presentation boundary needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification.
@@ -3,7 +3,7 @@
"checkpoint": "P7-09",
"slice": "Independent OAuth sender repair",
"recordedUtc": "2026-10-17T08:26:39.793290+00:00",
"decision": "Bounded client repair CONDITIONAL GO; final staged hygiene PASS, normal publication pending",
"decision": "Bounded client repair CONDITIONAL GO; staged hygiene PASS, normally pushed and directly verified",
"entry": {
"path": "docs/redesign/phase-7/p7-09/evidence/entry.json",
"bytes": 6161,
@@ -459,5 +459,34 @@
"path": "docs/redesign/phase-7/p7-09/evidence/oauth-static-checks.json",
"sha256": "2efe01046430d1b9522ec348b0dfb1462d16589dff137ef7068f4b5b6609017e",
"checkedUtc": "2026-10-17T08:27:18.310998+00:00"
},
"publication": {
"command": [
"rtk",
"proxy",
"git",
"push",
"origin",
"HEAD:refs/heads/codex/ui-redesign-phase4"
],
"startedUtc": "2026-10-17T08:27:46.777675+00:00",
"finishedUtc": "2026-10-17T08:27:48.414087+00:00",
"exitCode": 0,
"head": "dac29278db3c187349b97771ca2f383c9e6e0962",
"log": ".artifacts/phase7/p7-09/oauth-push-receipt.log",
"logSha256": "68af881724e54d7bfb8f98c4c3e8b7b53b7ac72ab0bfb4b269d65ba2e2e053d7",
"observedUtc": "2026-10-17T08:27:48.643285+00:00",
"originDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962",
"liveDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962",
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"pendingPaths": [],
"workingTreeClean": true,
"raw": {
"path": ".artifacts/phase7/p7-09/oauth-push-receipt.json",
"sha256": "d997d9128b720a690f456e5fe094a7af5bd59efce4b0415430c4b01804ffff68",
"bytes": 856
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
{
"schemaVersion": 1,
"checkpoint": "p7-09",
"status": "PASS",
"checkedUtc": "2026-10-17T08:50:12.966801+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-17T08:50:09.548379+00:00",
"finishedUtc": "2026-10-17T08:50:12.129725+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261017T085009Z/secret-scan.log",
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
},
{
"name": "working-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check"
],
"startedUtc": "2026-10-17T08:50:12.129725+00:00",
"finishedUtc": "2026-10-17T08:50:12.220613+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261017T085009Z/working-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "staged-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--cached",
"--check"
],
"startedUtc": "2026-10-17T08:50:12.220613+00:00",
"finishedUtc": "2026-10-17T08:50:12.315419+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261017T085009Z/staged-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "phase7-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check",
"b5319981ad3d1bd635daff861453d23c0de5320b"
],
"startedUtc": "2026-10-17T08:50:12.315419+00:00",
"finishedUtc": "2026-10-17T08:50:12.477488+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261017T085009Z/phase7-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
}
],
"untrackedSecretScan": {
"files": 0,
"findings": 0
},
"documentation": {
"files": 26,
"relativeLinks": 593,
"errors": []
},
"preservation": {
"groups": 57,
"statuses": {
"PARTIAL": 56,
"PASS": 0,
"FAIL": 0,
"DEFERRED": 1
},
"originalRoutesPreserved": true,
"currentConcreteRoutes": 33
},
"production": {
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"status": "NO-GO",
"qualification": "Local fetched refs; direct push observation recorded separately."
},
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
"limits": [
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
"Local links check target existence, not URL/heading anchors.",
"Static evidence is not runtime/manual/device/provider/production acceptance."
]
}
+1 -1
View File
@@ -92,7 +92,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
- [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.
- [ ] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): separate sender repair final39/3+native9+four gates PASS, final publication pending; both approved presentation boundaries remain. Derived807/95 frontend,326 browser,1054 backend; no full-suite claim.
- [ ] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): sender repairdac2927 published; Preferences/Connections91/6+backend52+native17+gates PASS, final hygiene/publication pending. Data/privacy/plan/backup follows separate import compatibility prerequisite. Derived824/95 frontend,331 browser,1054 backend; no full-suite claim.
- [ ] P7-10 Users, Audit and System administration presentation.
- [ ] P7-11 residual Analytics/error/empty/dialog integration.
- [ ] P7-12 integrated migration acceptance and Phase 8 handoff.
@@ -0,0 +1,149 @@
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 local API/Identity/CSRF/Free owners and authenticated notification/Rules DTO persistence are exercised. Only explicitly named GET503, PUT503 and long-address responses are controlled browser projections. Read retries never replay writes. Mailbox status remains actually disconnected; no provider connect/disconnect, IMAP credential submission, mail/model call, billing/deletion/restore, Docker, main or Phase8 action occurs. Google Fonts css2 stylesheet requests are recorded and aborted. All other nonlocal requests are rejected. Viewports and keyboard are Chromium automation; actual browser zoom, physical touch/software keyboard and human screen reader acceptance remain unverified.';
const rules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 };
function copy(key: string, language: Language = 'en') { return (translations[language] as Record<string, string>)[key] ?? key; }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
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: APIResponse, 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 = `p709-preferences-${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);
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 choose(page: Page, label: string, language: Language = 'en') {
const main = page.getByRole('main'); const selector = main.getByRole('combobox', { name: copy('settingsNavigation', language), exact: true });
const tab = main.getByRole('tab', { name: label, exact: true });
await expect.poll(async () => await selector.isVisible() || await tab.isVisible()).toBe(true);
if (await selector.isVisible()) { await keyboard(selector); const option = page.getByRole('option', { name: label, exact: true }); await option.focus(); await option.press('Enter'); await expect(selector).toBeFocused(); }
else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); }
}
function notificationGroup(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('group', { name: copy('settingsNotificationsTitle', language), exact: true }); }
function notificationCheckbox(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('checkbox', { name: copy('settingsNotificationsFollowUpReminders', language), exact: true }); }
async function toggle(control: Locator) { await expect(control).toBeEnabled(); await control.focus(); await control.press('Space'); }
async function persistedNotifications(session: Session, api: APIRequestContext) { return json<{ emailFollowUpRemindersEnabled: boolean }>(await call(session.requests, api, 'GET', '/notification-settings')); }
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); }
async function disconnected(session: Session, api: APIRequestContext) { for (const provider of ['gmail', 'microsoft-graph', 'imap']) expect(await json<{ connected: boolean }>(await call(session.requests, api, 'GET', `/${provider}/status`))).toMatchObject({ connected: false }); }
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'));
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').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([]);
}
test('[P7-09 S03 E09] notification and Rules saves stay independent, persist after reload and ignore forged owners across actual account disposal', async ({ page, playwright, session }, info) => {
test.setTimeout(150_000); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); await start(page);
const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('6');
await choose(page, copy('settingsTabNotifications')); const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox);
const notificationSave = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); const saved = await notificationSave; expect(saved.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); expect(saved.status()).toBe(200);
expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0);
await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); const ruleSave = response(page, 'PUT', '/rules'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('rulesSave'), exact: true })); expect((await ruleSave).status()).toBe(204);
expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual({ ...rules, appliedFollowUpDays: 6 }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false });
await page.reload(); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).not.toBeChecked(); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); await rule.fill('9');
const retainedA = await playwright.request.newContext({ storageState: await page.context().storageState() }); const anonymous = await playwright.request.newContext();
try {
const emailB = await createOwner(session.requests, session.admin, session.adminHeaders); const second = await login(session.requests, page.request, emailB);
expect((await call(session.requests, page.request, 'PUT', '/notification-settings', second.headers, { emailFollowUpRemindersEnabled: false })).status()).toBe(200);
expect((await call(session.requests, page.request, 'PUT', `/notification-settings?ownerUserId=${session.owner.id}`, second.headers, { emailFollowUpRemindersEnabled: true, ownerUserId: session.owner.id })).status()).toBe(200);
expect(await persistedNotifications(session, retainedA)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true });
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); await expect(rule).toHaveCount(0); await page.goto('/settings'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked(); await expect(notificationGroup(page).getByRole('status')).toHaveCount(0);
await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('14'); expect((await call(session.requests, anonymous, 'GET', '/notification-settings')).status()).toBe(401); expect((await call(session.requests, anonymous, 'PUT', '/notification-settings', undefined, { emailFollowUpRemindersEnabled: false })).status()).toBe(401);
await info.attach('native-independent-settings-owner-readback', { body: JSON.stringify({ notificationA: false, savedRuleA: 6, disposedRuleDraftA: 9, notificationB: true, ruleB: 14, forgedOwnerIgnored: true, anonymous: [401, 401], qualification: 'All saves/readbacks above are actual local HTTP; account B retains its own session and only B changed from the forged-owner request.' }), contentType: 'application/json' });
} finally { await retainedA.dispose(); await anonymous.dispose(); }
});
test('[P7-09 S03] a held actual notification acknowledgement preserves newer edits, rejects duplicate activation and retains separate Rules and Discard baselines', async ({ page, session }, info) => {
test.setTimeout(120_000); await start(page, '/settings', 390); const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('5'); await choose(page, copy('settingsTabNotifications'));
const gate = hold(); let writes = 0; let acknowledged = false;
await page.route(`${apiUrl}/notification-settings`, async route => { if (route.request().method() !== 'PUT') return route.continue(); writes++; expect(route.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); const actual = await route.fetch(); expect(actual.status()).toBe(200); acknowledged = true; await gate.pending; return route.fulfill({ response: actual }); });
try {
const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox); const saved = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); await page.keyboard.press('Enter'); await expect.poll(() => acknowledged).toBe(true); expect(writes).toBe(1); await expect(page.getByRole('main').getByRole('button', { name: copy('settingsSaving'), exact: true })).toBeDisabled();
await toggle(checkbox); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('5'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked();
const language = response(page, 'PUT', '/auth/preferences/language'); await keyboard(page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await language).status()).toBe(204); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(writes).toBe(1);
gate.release(); expect((await saved).status()).toBe(200); await expect(notificationGroup(page, 'nb').getByRole('status')).toContainText(copy('assetsUnsavedChanges', 'nb')); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0);
await capture(page, info, 'held-notification-newer-draft-nb-390'); await keyboard(notificationGroup(page, 'nb').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(notificationCheckbox(page, 'nb')).not.toBeChecked(); await expect(notificationGroup(page, 'nb').getByRole('status')).toHaveCount(0);
await choose(page, copy('settingsTabFollowUps', 'nb'), 'nb'); const translatedRule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays', 'nb'), exact: true }); await expect(translatedRule).toHaveValue('5'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(translatedRule).toHaveValue('14'); expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual(rules); expect(writes).toBe(1);
await info.attach('actual-notification-snapshot-acknowledgement', { body: JSON.stringify({ submitted: false, actualPersistedBeforeClientAck: false, newerDraft: true, notificationDiscardBaseline: false, independentRulesDraft: 5, rulesDiscardBaseline: 14, nativeWrites: writes, qualification: 'The actual successful local PUT response was held after route.fetch; no invented success DTO or backend write projection.' }), contentType: 'application/json' });
} finally { gate.release(); }
});
test('[P7-09 S03] controlled initial GET503 recovers by focused GET-only Retry and controlled save503 retains its draft for one explicit real save', async ({ page, session }, info) => {
test.setTimeout(120_000); let failRead = true; let failWrite = true;
await page.route(`${apiUrl}/notification-settings`, route => { const method = route.request().method(); if (method === 'GET' && failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } if (method === 'PUT' && failWrite) { failWrite = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); });
await start(page, '/settings', 320); await choose(page, copy('settingsTabNotifications')); const group = notificationGroup(page); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsLoadFailed')); await expect(notificationCheckbox(page)).toHaveCount(0); await capture(page, info, 'controlled-notification-initial-get503-en-320');
const read = response(page, 'GET', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(notificationCheckbox(page)).toBeChecked(); await expect(group).toBeFocused(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(0);
await toggle(notificationCheckbox(page)); const failed = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await failed).status()).toBe(503); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true });
await choose(page, copy('settingsTabGeneral')); await choose(page, copy('settingsTabNotifications')); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(1); await capture(page, info, 'controlled-notification-save503-draft-en-320');
const retried = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await retried).status()).toBe(200); await expect(group.getByRole('alert')).toHaveCount(0); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(2); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0); await disconnected(session, page.request);
await info.attach('controlled-notification-recovery-boundary', { body: JSON.stringify({ boundary, controlled: ['first browser GET503', 'first browser PUT503 without backend write'], actual: ['Retry GET200', 'explicit retry PUT200', 'owner readback false'], autoWriteRetries: 0, locale500OrSqliteQualification: 'RD11/RD12/RD13 are not reproduced or closed by controlled503 recovery. Every unprojected response remains a strict status assertion.' }), contentType: 'application/json' });
});
test('[P7-09 M06] a controlled Gmail status failure stays unknown while independent real providers remain disconnected and Retry only reads status', async ({ page, session }, info) => {
test.setTimeout(90_000); await disconnected(session, page.request); let failRead = true;
await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); if (failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); });
await start(page, '/settings/connected-accounts', 390); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); const microsoft = page.getByRole('region', { name: 'Outlook / Microsoft 365', exact: true }); const imap = page.getByRole('region', { name: copy('emailConnectionsOtherImap'), exact: true });
await expect(gmail.getByRole('alert')).toContainText(copy('emailConnectionsStatusFailed')); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toHaveCount(0); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toHaveCount(0); await expect(microsoft.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(microsoft.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); await expect(imap.getByRole('textbox', { name: copy('emailConnectionsImapHost'), exact: true })).toBeVisible();
await capture(page, info, 'controlled-gmail-status503-independent-providers-en-390'); const before = session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET'); const read = response(page, 'GET', '/gmail/status'); await keyboard(gmail.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(gmail.getByRole('heading', { level: 3, name: 'Gmail', exact: true })).toBeFocused(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); expect(browserRequests(session, '/gmail/status', 'GET')).toHaveLength(2); expect(browserRequests(session, '/microsoft-graph/status', 'GET')).toHaveLength(1); expect(browserRequests(session, '/imap/status', 'GET')).toHaveLength(1); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual(before); await disconnected(session, page.request);
await info.attach('controlled-connections-get-only-recovery', { body: JSON.stringify({ boundary, controlled: 'First browser Gmail status503 only', recovered: 'Actual Gmail GET200 connected=false', otherProviders: 'Actual Graph/IMAP disconnected before and after', providerMutationRequests: 0, realMailboxGrants: 0 }), contentType: 'application/json' });
});
test('[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths', async ({ page, session }, info) => {
test.setTimeout(240_000); await start(page); await disconnected(session, page.request);
const address = `controlled-${'long-mailbox-segment-'.repeat(9)}@example.test`; 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: 1280, language: 'nb', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'en', mode: 'dark' },
] as const;
await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); return route.fulfill({ status: 200, json: { connected: true, gmailAddress: address, lastSyncedAt: null, lastSyncAttemptedAt: null, lastSyncMode: null, lastSyncSource: null, lastSyncStatus: null, lastSyncError: null } }); });
for (const sample of samples) {
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: sample.language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
await page.evaluate(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample); await page.setViewportSize({ width: sample.width, height: sample.width < 768 ? 844 : 900 }); await page.goto('/settings'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode); await choose(page, copy('settingsTabNotifications', sample.language), sample.language); await expect(notificationCheckbox(page, sample.language)).toBeChecked(); await toggle(notificationCheckbox(page, sample.language)); const save = notificationGroup(page, sample.language).getByRole('button', { name: copy('settingsNotificationsSave', sample.language), exact: true }); await expect(save).toBeEnabled(); await save.focus(); await expect(save).toBeFocused(); expect(await save.evaluate(node => getComputedStyle(node).outlineStyle)).toBe('solid'); await capture(page, info, `notifications-${sample.width}-${sample.language}-${sample.mode}`);
await choose(page, copy('settingsTabGeneral', sample.language), sample.language); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsManageConnectedAccounts', sample.language), exact: true })); await expect(page).toHaveURL(/\/settings\/connected-accounts$/); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); await expect(gmail.getByText(address, { exact: true })).toBeVisible(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsDisconnect', sample.language), exact: true })).toBeEnabled(); await capture(page, info, `controlled-long-address-connections-${sample.width}-${sample.language}-${sample.mode}`);
}
await page.unroute(`${apiUrl}/gmail/status`); await disconnected(session, page.request); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]);
await info.attach('finite-settings-connections-matrix', { body: JSON.stringify({ boundary, samples, controlledProjection: { endpoint: 'GET /gmail/status', connected: true, gmailAddress: address }, actualServerStatus: 'Gmail/Graph/IMAP remain disconnected; the long address is a UI-only projection and no Disconnect/Connect action is activated.', scope: 'Seven paired viewport/theme/locale samples, not the full Cartesian product. Desktop and mobile keyboard section navigation, visible focused Save, raw reflow/axe and PNG evidence only; physical/reader/zoom remain pending.' }), contentType: 'application/json' });
});
@@ -190,7 +190,7 @@ test('[P5-15-01R-02] delayed native Rules reads and save completion do not reset
await route.continue();
});
await page.goto('/settings'); await choose(page, translations.en.settingsTabFollowUps);
await expect(page.getByRole('status')).toHaveText(translations.en.rulesLoading);
await expect(page.getByRole('main').getByRole('region', { name: translations.en.rulesTitle, exact: true }).getByRole('status')).toHaveText(translations.en.rulesLoading);
await choose(page, translations.en.settingsTabNotifications); await choose(page, translations.en.settingsTabFollowUps);
releaseRead();
const field = page.getByRole('spinbutton', { name: translations.en.rulesAppliedFollowUpDays, exact: true });
@@ -130,7 +130,9 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await page.reload();
await page.setViewportSize({ width: 320, height: 900 });
await chooseSection(t.settingsTabNotifications);
await expect(main.locator(".MuiSkeleton-root")).toBeVisible();
const notifications = main.getByRole("group", { name: t.settingsNotificationsTitle, exact: true });
await expect(notifications).toHaveAttribute("aria-busy", "true");
await expect(notifications.getByRole("progressbar", { name: t.loading, exact: true })).toBeVisible();
await capture(page, info, `notifications-loading-${suffix}`);
release();
const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true });
@@ -1,13 +1,27 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast";
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth";
import { PageSection } from "../layout/PageLayout";
type Provider = EmailOAuthProvider | "imap";
type ConnectionStatus = GmailStatus & MicrosoftGraphStatus & ImapStatus;
type ProviderState = {
status: ConnectionStatus | null;
loading: boolean;
readFailed: boolean;
refreshRequired: boolean;
busy: "connect" | "disconnect" | null;
error: { kind: "disconnect" | "imap"; cause: unknown } | null;
};
const providerPaths = { gmail: "/gmail", microsoft: "/microsoft-graph", imap: "/imap" };
const initialProvider = (): ProviderState => ({ status: null, loading: true, readFailed: false, refreshRequired: false, busy: null, error: null });
const providerWork = () => ({ reading: false, writing: false, refreshRequired: false, sequence: 0, controller: null as AbortController | null, status: null as ConnectionStatus | null });
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
@@ -17,49 +31,74 @@ export default function EmailProviderConnections() {
const { toast } = useToast();
const { t } = useI18n();
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
const [imapConnecting, setImapConnecting] = useState(false);
const [providers, setProviders] = useState({ gmail: initialProvider(), microsoft: initialProvider(), imap: initialProvider() });
const work = useRef({ gmail: providerWork(), microsoft: providerWork(), imap: providerWork() });
const headings = useRef<Partial<Record<Provider, HTMLHeadingElement | null>>>({});
const panels = useRef<Partial<Record<Provider, HTMLElement | null>>>({});
const pendingFocus = useRef<{ provider: Provider; element: HTMLElement } | null>(null);
const [imapForm, setImapForm] = useState({ host: "", port: "993", useSsl: true, username: "", password: "" });
const [invalidPort, setInvalidPort] = useState(false);
const oauth = useRef({ mounted: true, generation: 0, starting: new Set<EmailOAuthProvider>(), attempts: new Map<EmailOAuthProvider, EmailOAuthAttempt>() });
const startupTranslation = useRef(t);
useEffect(() => { startupTranslation.current = t; }, [t]);
const updateProvider = useCallback((provider: Provider, patch: Partial<ProviderState>) => {
setProviders(current => ({ ...current, [provider]: { ...current[provider], ...patch } }));
}, []);
const rememberFocus = (provider: Provider) => {
const element = document.activeElement;
if (element instanceof HTMLElement && panels.current[provider]?.contains(element)) pendingFocus.current = { provider, element };
};
useEffect(() => {
const pending = pendingFocus.current;
if (!pending) return;
pendingFocus.current = null;
const active = document.activeElement;
if (active === document.body || active === pending.element && (pending.element.matches(":disabled") || !pending.element.isConnected)) headings.current[pending.provider]?.focus();
}, [providers]);
useEffect(() => {
const session = oauth.current;
const providerSessions = work.current;
session.mounted = true;
return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); };
return () => {
session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear();
for (const provider of Object.values(providerSessions)) { provider.sequence += 1; provider.controller?.abort(); provider.reading = false; }
};
}, []);
const loadGmailStatus = useCallback(async () => {
const loadStatus = useCallback(async (provider: Provider, afterWrite = false): Promise<ConnectionStatus | null> => {
const session = oauth.current;
const current = work.current[provider];
if (!session.mounted || current.reading || current.writing && !afterWrite) return null;
current.reading = true;
const sequence = ++current.sequence;
const generation = session.generation;
const controller = new AbortController();
current.controller = controller;
updateProvider(provider, { loading: true, readFailed: false });
const active = () => session.mounted && generation === session.generation && sequence === current.sequence;
try {
const res = await api.get<GmailStatus>("/gmail/status");
setGmailStatus(res.data);
const { data } = await api.get<ConnectionStatus>(`${providerPaths[provider]}/status`, { signal: controller.signal });
if (!active()) return null;
if (typeof data?.connected !== "boolean") throw new Error("Invalid connection status");
current.status = data;
current.refreshRequired = false;
updateProvider(provider, { status: data, readFailed: false, refreshRequired: false });
return data;
} catch {
setGmailStatus({ connected: false });
if (active()) updateProvider(provider, { readFailed: true });
return null;
} finally {
if (active()) { current.reading = false; current.controller = null; updateProvider(provider, { loading: false }); }
}
}, []);
const loadMicrosoftStatus = useCallback(async () => {
try {
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
setMicrosoftStatus(res.data);
} catch {
setMicrosoftStatus({ connected: false });
}
}, []);
const loadImapStatus = useCallback(async () => {
try {
const res = await api.get<ImapStatus>("/imap/status");
setImapStatus(res.data);
} catch {
setImapStatus({ connected: false });
}
}, []);
}, [updateProvider]);
const loadGmailStatus = useCallback(async () => { await loadStatus("gmail"); }, [loadStatus]);
const loadMicrosoftStatus = useCallback(async () => { await loadStatus("microsoft"); }, [loadStatus]);
const loadImapStatus = useCallback(async () => { await loadStatus("imap"); }, [loadStatus]);
useEffect(() => {
void loadGmailStatus();
@@ -90,11 +129,14 @@ export default function EmailProviderConnections() {
const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => {
const session = oauth.current;
if (!session.mounted || session.starting.has(provider)) return;
const current = work.current[provider];
if (!session.mounted || session.starting.has(provider) || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return;
const previous = session.attempts.get(provider);
if (previous && !previous.popup.closed) { previous.popup.focus(); return; }
session.attempts.delete(provider);
session.starting.add(provider);
rememberFocus(provider);
updateProvider(provider, { busy: "connect", error: null });
const generation = session.generation;
try {
const res = await api.get<{ url: string }>(connectUrlPath);
@@ -107,75 +149,115 @@ export default function EmailProviderConnections() {
} catch (error) {
if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error");
} finally {
if (generation === session.generation) session.starting.delete(provider);
if (session.mounted && generation === session.generation) { session.starting.delete(provider); updateProvider(provider, { busy: null }); }
}
};
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
const disconnect = async (provider: Provider, providerLabel: string) => {
const session = oauth.current;
const current = work.current[provider];
if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status?.connected || provider !== "imap" && session.starting.has(provider)) return;
current.writing = true;
const generation = session.generation;
rememberFocus(provider);
updateProvider(provider, { busy: "disconnect", error: null });
try {
await api.delete(path);
await reload();
toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success");
await api.delete(`${providerPaths[provider]}/connection`);
if (!session.mounted || generation !== session.generation) return;
current.status = null;
current.refreshRequired = true;
updateProvider(provider, { status: null, refreshRequired: true });
const status = await loadStatus(provider, true);
if (session.mounted && generation === session.generation && status && !status.connected) toast(startupTranslation.current("emailConnectionsDisconnected", { provider: providerLabel }), "success");
} catch (error) {
toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error");
if (session.mounted && generation === session.generation) updateProvider(provider, { error: { kind: "disconnect", cause: error } });
} finally {
if (session.mounted && generation === session.generation) { current.writing = false; updateProvider(provider, { busy: null }); }
}
};
const connectImap = async () => {
const session = oauth.current;
const current = work.current.imap;
if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return;
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
toast(t("emailConnectionsRequired"), "error");
return;
}
setImapConnecting(true);
const port = Number(imapForm.port);
if (!imapForm.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) { setInvalidPort(true); return; }
const submitted = { ...imapForm };
current.writing = true;
const generation = session.generation;
rememberFocus("imap");
updateProvider("imap", { busy: "connect", error: null });
try {
await api.post("/imap/connect", imapForm);
setImapForm((prev) => ({ ...prev, password: "" }));
await loadImapStatus();
toast(t("emailConnectionsImapConnected"), "success");
await api.post("/imap/connect", { ...submitted, port });
if (!session.mounted || generation !== session.generation) return;
rememberFocus("imap");
// A completed submission must not clear credentials edited for another attempt.
setImapForm(previous => Object.keys(submitted).every(key => previous[key as keyof typeof previous] === submitted[key as keyof typeof submitted]) ? { ...previous, password: "" } : previous);
current.status = null;
current.refreshRequired = true;
updateProvider("imap", { status: null, refreshRequired: true });
const status = await loadStatus("imap", true);
if (session.mounted && generation === session.generation && status?.connected) toast(startupTranslation.current("emailConnectionsImapConnected"), "success");
} catch (error) {
toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error");
if (session.mounted && generation === session.generation) updateProvider("imap", { error: { kind: "imap", cause: error } });
} finally {
setImapConnecting(false);
if (session.mounted && generation === session.generation) { current.writing = false; updateProvider("imap", { busy: null }); }
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("emailConnectionsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("emailConnectionsBody")}
</Typography>
const providerFeedback = (provider: Provider, providerLabel: string) => {
const state = providers[provider];
return <>
{state.loading ? <Typography role="status" variant="body2" color="text.secondary">{t("emailConnectionsLoading")}</Typography> : null}
{state.readFailed ? <Alert severity="error" action={<Button color="inherit" onClick={() => { rememberFocus(provider); void loadStatus(provider); }}>{t("retry")}</Button>}>
{t(state.refreshRequired ? "emailConnectionsRefreshRequired" : "emailConnectionsStatusFailed")}
</Alert> : null}
{state.error ? <Alert severity="error">{getApiErrorMessage(state.error.cause, t(state.error.kind === "imap" ? "emailConnectionsImapFailed" : "emailConnectionsDisconnectFailed", { provider: providerLabel }))}</Alert> : null}
</>;
};
return (
<PageSection surface title={t("emailConnectionsTitle")} description={t("emailConnectionsBody")}>
<Stack spacing={2}>
<ProviderRow
label="Gmail"
connected={Boolean(gmailStatus?.connected)}
address={gmailStatus?.gmailAddress ?? null}
state={providers.gmail}
address={providers.gmail.status?.gmailAddress ?? null}
panelRef={node => { panels.current.gmail = node; }}
headingRef={node => { headings.current.gmail = node; }}
onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
/>
onDisconnect={() => void disconnect("gmail", "Gmail")}
>{providerFeedback("gmail", "Gmail")}</ProviderRow>
<Divider />
<ProviderRow
label="Outlook / Microsoft 365"
connected={Boolean(microsoftStatus?.connected)}
address={microsoftStatus?.mailAddress ?? null}
state={providers.microsoft}
address={providers.microsoft.status?.mailAddress ?? null}
panelRef={node => { panels.current.microsoft = node; }}
headingRef={node => { headings.current.microsoft = node; }}
onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
/>
onDisconnect={() => void disconnect("microsoft", "Outlook")}
>{providerFeedback("microsoft", "Outlook")}</ProviderRow>
<Divider />
<Box>
<ProviderRow
<ProviderRow
label={t("emailConnectionsOtherImap")}
connected={Boolean(imapStatus?.connected)}
address={imapStatus?.username ?? null}
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
/>
{!imapStatus?.connected && (
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
state={providers.imap}
address={providers.imap.status?.username ?? null}
panelRef={node => { panels.current.imap = node; }}
headingRef={node => { headings.current.imap = node; }}
onDisconnect={() => void disconnect("imap", "IMAP")}
>
{providerFeedback("imap", "IMAP")}
{providers.imap.status && !providers.imap.status.connected && !providers.imap.readFailed && !providers.imap.refreshRequired && (
<Box sx={{ display: "grid", gap: 1.5, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "minmax(0, 2fr) minmax(0, 1fr)" }, maxWidth: 640 }}>
<TextField
size="small"
label={t("emailConnectionsImapHost")}
@@ -188,7 +270,10 @@ export default function EmailProviderConnections() {
label={t("emailConnectionsPort")}
type="number"
value={imapForm.port}
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
error={invalidPort}
helperText={invalidPort ? t("emailConnectionsPortInvalid") : undefined}
slotProps={{ htmlInput: { min: 1, max: 65535, step: 1 } }}
onChange={(e) => { setImapForm((prev) => ({ ...prev, port: e.target.value })); setInvalidPort(false); }}
/>
<TextField
size="small"
@@ -213,55 +298,57 @@ export default function EmailProviderConnections() {
<Button
variant="contained"
onClick={connectImap}
disabled={imapConnecting}
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
disabled={providers.imap.loading || providers.imap.busy !== null}
sx={{ gridColumn: "1 / -1", justifySelf: "start", maxWidth: "100%", whiteSpace: "normal", minHeight: 40 }}
>
{imapConnecting ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
{providers.imap.busy === "connect" ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
</Button>
</Box>
)}
</Box>
</ProviderRow>
</Stack>
</Paper>
</PageSection>
);
}
function ProviderRow({
label,
connected,
state,
address,
panelRef,
headingRef,
onConnect,
onDisconnect,
children,
}: {
label: string;
connected: boolean;
state: ProviderState;
address: string | null;
panelRef: (node: HTMLElement | null) => void;
headingRef: (node: HTMLHeadingElement | null) => void;
onConnect?: () => void;
onDisconnect: () => void;
children: React.ReactNode;
}) {
const { t } = useI18n();
return (
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
{connected ? (
<Chip
size="small"
icon={<CheckCircleIcon fontSize="small" />}
color="success"
variant="outlined"
label={address || t("emailConnectionsConnected")}
sx={{ mt: 0.5 }}
/>
) : (
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("emailConnectionsNotConnected")}</Typography>
)}
</Box>
{connected ? (
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>{t("emailConnectionsDisconnect")}</Button>
) : (
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>{t("emailConnectionsConnect")}</Button>
)}
</Stack>
<Box component="section" aria-label={label} ref={panelRef} aria-busy={state.loading || state.busy !== null} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ minWidth: 0 }}>
<Box sx={{ flex: "1 1 12rem", minWidth: 0 }}>
<Typography component="h3" variant="subtitle1" ref={headingRef} tabIndex={-1} sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{label}</Typography>
{state.status && !state.loading && !state.readFailed && !state.refreshRequired ? <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, overflowWrap: "anywhere" }}>
{state.status.connected ? address || t("emailConnectionsConnected") : t("emailConnectionsNotConnected")}
</Typography> : null}
</Box>
{state.status && !state.readFailed && !state.refreshRequired ? state.status.connected ? (
<Button variant="outlined" color="error" disabled={state.loading || state.busy !== null} onClick={onDisconnect} sx={{ minHeight: 40, maxWidth: "100%", whiteSpace: "normal" }}>
{state.busy === "disconnect" ? t("emailConnectionsDisconnecting") : t("emailConnectionsDisconnect")}
</Button>
) : onConnect ? <Button variant="outlined" disabled={state.loading || state.busy !== null} onClick={onConnect} sx={{ minHeight: 40, maxWidth: "100%", whiteSpace: "normal" }}>
{state.busy === "connect" ? t("emailConnectionsConnecting") : t("emailConnectionsConnect")}
</Button> : null : null}
</Stack>
{children}
</Box>
);
}
+98 -24
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Box,
@@ -10,7 +10,6 @@ import {
InputLabel,
MenuItem,
Select,
Skeleton,
Tab,
Tabs,
Typography,
@@ -30,6 +29,7 @@ import { useI18n } from "../i18n/I18nProvider";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
interface Props {
pageSize: 15 | 20 | 25;
@@ -66,21 +66,38 @@ export default function SettingsView({
const { language, setLanguage, t } = useI18n();
const { toast } = useToast();
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs | null>(null);
const [notificationError, setNotificationError] = useState<string | null>(null);
const [savedNotificationPrefs, setSavedNotificationPrefs] = useState<NotificationPrefs | null>(null);
const [notificationError, setNotificationError] = useState<{ cause: unknown; action: boolean } | null>(null);
const [loadingNotifications, setLoadingNotifications] = useState(true);
const [savingNotifications, setSavingNotifications] = useState(false);
const [savedRules, setSavedRules] = useState<RuleSettings | null>(null);
const [rulesDraft, setRulesDraft] = useState<RuleDraft | null>(null);
const [rulesLoadError, setRulesLoadError] = useState<string | null>(null);
const [rulesLoadError, setRulesLoadError] = useState<{ cause: unknown } | null>(null);
const [rulesSaveError, setRulesSaveError] = useState(false);
const [invalidRules, setInvalidRules] = useState<RuleField[]>([]);
const [savingRules, setSavingRules] = useState(false);
const rulesSession = useRef({ mounted: true, loading: false, saving: false });
const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 });
const notificationBody = useRef<HTMLDivElement>(null);
const notificationRetryFocus = useRef<HTMLElement | null>(null);
const currentTranslation = useRef(t);
const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field])));
const notificationsDirty = Boolean(notificationPrefs && savedNotificationPrefs && notificationPrefs.emailFollowUpRemindersEnabled !== savedNotificationPrefs.emailFollowUpRemindersEnabled);
useEffect(() => { currentTranslation.current = t; }, [t]);
useEffect(() => {
const session = rulesSession.current;
const notifications = notificationSession.current;
session.mounted = true;
return () => { session.mounted = false; };
notifications.mounted = true;
return () => {
session.mounted = false;
notifications.mounted = false;
notifications.generation += 1;
notifications.loading = false;
notifications.saving = false;
};
}, []);
const loadRules = async () => {
@@ -94,7 +111,7 @@ export default function SettingsView({
setSavedRules(data);
setRulesDraft(ruleDraft(data));
} catch (error) {
if (session.mounted) setRulesLoadError(getApiErrorMessage(error, t("rulesLoadFailed")));
if (session.mounted) setRulesLoadError({ cause: error });
} finally { session.loading = false; }
};
@@ -120,7 +137,7 @@ export default function SettingsView({
setSavedRules(submitted);
// A completed save acknowledges its snapshot, not edits made while it was pending.
setRulesDraft(current => current && RULE_FIELDS.every(field => current[field] === submittedDraft[field]) ? ruleDraft(submitted) : current);
toast(t("rulesSaved"), "success");
toast(currentTranslation.current("rulesSaved"), "success");
} catch {
if (session.mounted) setRulesSaveError(true);
} finally {
@@ -136,27 +153,74 @@ export default function SettingsView({
setRulesSaveError(false);
};
const loadNotifications = useCallback(async () => {
const session = notificationSession.current;
if (!session.mounted || session.loading) return;
const generation = session.generation;
session.loading = true;
setLoadingNotifications(true);
setNotificationError(null);
try {
const { data } = await api.get<NotificationPrefs>("/notification-settings");
if (!session.mounted || generation !== session.generation) return;
setNotificationPrefs(data);
setSavedNotificationPrefs(data);
} catch (cause) {
if (session.mounted && generation === session.generation) setNotificationError({ cause, action: false });
} finally {
if (generation === session.generation) {
session.loading = false;
if (session.mounted) setLoadingNotifications(false);
}
}
}, []);
useEffect(() => { void loadNotifications(); }, [loadNotifications]);
const retryNotifications = () => {
const active = document.activeElement;
if (active instanceof HTMLElement && notificationBody.current?.contains(active)) notificationRetryFocus.current = active;
void loadNotifications();
};
useEffect(() => {
let active = true;
api.get<NotificationPrefs>("/notification-settings")
.then(({ data }) => { if (active) setNotificationPrefs(data); })
.catch((error) => { if (active) setNotificationError(getApiErrorMessage(error, t("settingsNotificationsLoadFailed"))); });
return () => { active = false; };
}, [t]);
const target = notificationRetryFocus.current;
if (loadingNotifications || !target) return;
notificationRetryFocus.current = null;
if (!target.isConnected && document.activeElement === document.body) notificationBody.current?.focus();
}, [loadingNotifications, notificationError, notificationPrefs]);
const saveNotifications = async () => {
if (!notificationPrefs) return;
const session = notificationSession.current;
if (!session.mounted || !notificationPrefs || session.saving) return;
const submitted = { emailFollowUpRemindersEnabled: notificationPrefs.emailFollowUpRemindersEnabled };
const generation = session.generation;
session.saving = true;
setSavingNotifications(true);
setNotificationError(null);
try {
const { data } = await api.put<NotificationPrefs>("/notification-settings", notificationPrefs);
setNotificationPrefs(data);
toast(t("settingsNotificationsSaved"), "success");
} catch (error) {
setNotificationError(getApiErrorMessage(error, t("settingsNotificationsSaveFailed")));
} finally { setSavingNotifications(false); }
const { data } = await api.put<NotificationPrefs>("/notification-settings", submitted);
if (!session.mounted || generation !== session.generation) return;
setSavedNotificationPrefs(data);
setNotificationPrefs(current => current?.emailFollowUpRemindersEnabled === submitted.emailFollowUpRemindersEnabled ? data : current);
toast(currentTranslation.current("settingsNotificationsSaved"), "success");
} catch (cause) {
if (session.mounted && generation === session.generation) setNotificationError({ cause, action: true });
} finally {
if (generation === session.generation) {
session.saving = false;
if (session.mounted) setSavingNotifications(false);
}
}
};
const discardNotifications = () => {
if (!savedNotificationPrefs || notificationSession.current.saving) return;
setNotificationPrefs(savedNotificationPrefs);
setNotificationError(null);
};
const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status;
return (
<Box sx={{ minWidth: 0 }}>
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
@@ -274,7 +338,7 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={1} label={t("settingsTabFollowUps")}>
<RulesSettingsCard draft={rulesDraft} loadError={rulesLoadError} saveError={rulesSaveError} invalid={invalidRules}
<RulesSettingsCard draft={rulesDraft} loadError={rulesLoadError ? getApiErrorMessage(rulesLoadError.cause, t("rulesLoadFailed")) : null} saveError={rulesSaveError} invalid={invalidRules}
saving={savingRules} dirty={rulesDirty} onSave={() => void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules}
onChange={(field, value) => {
setRulesDraft(current => current ? { ...current, [field]: value } : current);
@@ -284,15 +348,25 @@ export default function SettingsView({
<TabPanel value={tab} index={2} label={t("settingsTabNotifications")}>
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
{notificationError ? <Alert severity="error" sx={{ mb: 1.5 }}>{notificationError}</Alert> : null}
{!notificationPrefs ? <Skeleton variant="rounded" height={70} /> : <Box sx={{ display: "grid", gap: 1 }}>
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
<FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button></Box>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
</Box>
</Box>}
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}
</Typography>
@@ -62,6 +62,8 @@ describe("EmailProviderConnections", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockReset();
mockedApi.post.mockReset();
mockedApi.delete.mockReset();
window.localStorage.clear();
});
@@ -108,13 +110,14 @@ describe("EmailProviderConnections", () => {
await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test");
await userEvent.type(screen.getByLabelText("Username"), "user@example.test");
await userEvent.type(screen.getByLabelText("Password"), "secret");
await userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
await act(async () => { await userEvent.click(screen.getByRole("button", { name: /connect imap account/i })); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({
host: "imap.example.test",
username: "user@example.test",
password: "secret",
})));
await waitFor(() => expect(statusReads()).toHaveLength(4));
});
it("[P7-09 OAuth] unsolicited success and error messages cannot announce or refresh any connection", async () => {
@@ -221,4 +224,207 @@ describe("EmailProviderConnections", () => {
expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
it("[P7-09 Connections] unknown and failed provider reads remain truthful and Retry reads only that provider once", async () => {
const initial = deferred<any>(); const retry = deferred<any>(); let gmailReads = 0;
mockedApi.get.mockImplementation((url: string) => url === "/gmail/status"
? (++gmailReads === 1 ? initial.promise : retry.promise)
: Promise.resolve({ data: { connected: false } } as any));
renderComponent(); await screen.findByLabelText("IMAP host");
const gmail = screen.getByRole("region", { name: "Gmail" });
expect(within(gmail).getByText(translations.en.emailConnectionsLoading)).toBeVisible();
expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument();
expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument();
await act(async () => initial.reject(new Error("offline")));
expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsStatusFailed);
expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument();
const button = within(gmail).getByRole("button", { name: translations.en.retry });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/microsoft-graph/status")).toHaveLength(1);
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/imap/status")).toHaveLength(1);
await act(async () => retry.resolve({ data: { connected: false } }));
expect(within(gmail).getByText("Not connected")).toBeVisible();
expect(within(gmail).getByRole("button", { name: "Connect" })).toBeEnabled();
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
it("[P7-09 Connections] a held status read follows the current language without a locale refetch", async () => {
const pending = deferred<any>();
mockedApi.get.mockImplementation((url: string) => url === "/gmail/status" ? pending.promise : Promise.resolve({ data: { connected: false } } as any));
renderComponent(); await screen.findByLabelText("IMAP host");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
await act(async () => pending.reject(new Error("offline")));
expect(within(screen.getByRole("region", { name: "Gmail" })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsStatusFailed);
expect(screen.queryByText(translations.en.emailConnectionsStatusFailed)).not.toBeInTheDocument();
expect(statusReads()).toHaveLength(3);
});
it("[P7-09 Connections] a committed disconnect with failed refresh blocks writes until GET-only recovery", async () => {
const pending = deferred<any>(); let reads = 0;
mockedApi.get.mockImplementation((url: string) => {
if (url !== "/gmail/status") return Promise.resolve({ data: { connected: false } } as any);
reads += 1;
return reads === 1 ? Promise.resolve({ data: { connected: true, gmailAddress: "owner@example.test" } } as any)
: reads === 2 ? Promise.reject(new Error("refresh offline")) : Promise.resolve({ data: { connected: false } } as any);
});
mockedApi.delete.mockReturnValue(pending.promise);
renderComponent(); await screen.findByText("owner@example.test");
const gmail = screen.getByRole("region", { name: "Gmail" });
const disconnect = within(gmail).getByRole("button", { name: "Disconnect" });
act(() => { fireEvent.click(disconnect); fireEvent.click(disconnect); });
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).toHaveBeenCalledWith("/gmail/connection");
await act(async () => pending.resolve({ status: 204 }));
expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsRefreshRequired);
expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument();
expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument();
const repeated = within(gmail).queryByRole("button", { name: "Disconnect" });
if (repeated) { expect(repeated).toBeDisabled(); fireEvent.click(repeated); }
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsRefreshRequired);
fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.retry }));
await waitFor(() => expect(within(gmail).getByText(translations.nb.emailConnectionsNotConnected)).toBeVisible());
expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(reads).toBe(3);
expect(statusReads().filter(([url]) => url !== "/gmail/status")).toHaveLength(2);
});
it("[P7-09 Connections] failed disconnect preserves its known status and uses current-language explicit retry", async () => {
const pending = deferred<any>();
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/status" ? { connected: true, gmailAddress: "still-connected@example.test" } : { connected: false } } as any));
mockedApi.delete.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ status: 204 } as any);
renderComponent(); await screen.findByText("still-connected@example.test");
const gmail = screen.getByRole("region", { name: "Gmail" });
fireEvent.click(within(gmail).getByRole("button", { name: "Disconnect" }));
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
await act(async () => pending.reject(new Error("offline")));
expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsDisconnectFailed.replace("{provider}", "Gmail"));
expect(within(gmail).getByText("still-connected@example.test")).toBeVisible();
expect(statusReads()).toHaveLength(3);
fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.emailConnectionsDisconnect }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledTimes(2));
await waitFor(() => expect(statusReads()).toHaveLength(4));
});
it("[P7-09 Connections] IMAP submits one captured snapshot and a late success cannot clear a newer password", async () => {
disconnectedProviderReads(); const pending = deferred<any>(); mockedApi.post.mockReturnValue(pending.promise);
renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } });
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "submitted-secret" } });
const button = screen.getByRole("button", { name: "Connect IMAP account" });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "submitted-secret" });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "newer-secret" } });
await act(async () => pending.resolve({ data: { username: "owner@example.test" } }));
await waitFor(() => expect(statusReads()).toHaveLength(4));
expect(screen.getByLabelText("Password")).toHaveValue("newer-secret");
expect(screen.getByLabelText("IMAP host")).toHaveValue("imap.example.test");
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
it("[P7-09 Connections] failed IMAP save retains credentials for an explicit retry and current fallback", async () => {
disconnectedProviderReads(); const pending = deferred<any>(); mockedApi.post.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ data: {} } as any);
renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } });
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "retained-secret" } });
fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" }));
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
await act(async () => pending.reject(new Error("offline")));
expect(within(screen.getByRole("region", { name: translations.nb.emailConnectionsOtherImap })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsImapFailed);
expect(screen.getByLabelText("IMAP-vert")).toHaveValue("imap.example.test");
expect(screen.getByLabelText("Brukernavn")).toHaveValue("owner@example.test");
expect(screen.getByLabelText("Passord")).toHaveValue("retained-secret");
expect(statusReads()).toHaveLength(3);
fireEvent.click(screen.getByRole("button", { name: translations.nb.emailConnectionsConnectImap }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
expect(mockedApi.post.mock.calls[1]).toEqual(["/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "retained-secret" }]);
await waitFor(() => expect(screen.getByLabelText("Passord")).toHaveValue(""));
});
it("[P7-09 Connections] disposed reads and writes cannot refresh, toast or clear a new panel", async () => {
const oldRead = deferred<any>(); const oldWrite = deferred<any>(); let first = true;
mockedApi.get.mockImplementation((url: string) => first && url === "/gmail/status" ? oldRead.promise : Promise.resolve({ data: { connected: false } } as any));
mockedApi.post.mockReturnValue(oldWrite.promise);
const old = renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.old.test" } });
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "old@example.test" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "old-secret" } });
fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" }));
old.unmount(); first = false;
renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "new-panel-secret" } });
const reads = statusReads().length;
await act(async () => { oldRead.resolve({ data: { connected: true, gmailAddress: "disposed@example.test" } }); oldWrite.resolve({ data: {} }); });
expect(statusReads()).toHaveLength(reads);
expect(screen.queryByText("disposed@example.test")).not.toBeInTheDocument();
expect(screen.queryByText(translations.en.emailConnectionsImapConnected)).not.toBeInTheDocument();
expect(screen.getByLabelText("Password")).toHaveValue("new-panel-secret");
});
it("[P7-09 Connections] invalid IMAP ports remain visible and prevent sending a silently substituted port", async () => {
disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } });
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } });
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
const port = screen.getByLabelText("Port");
for (const value of ["", "0", "1.5", "65536"]) {
fireEvent.change(port, { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" }));
expect(port).toHaveValue(value ? Number(value) : null);
expect(port).toHaveAttribute("aria-invalid", "true");
expect(port).toHaveAccessibleDescription(translations.en.emailConnectionsPortInvalid);
}
expect(mockedApi.post).not.toHaveBeenCalled();
});
it("[P7-09 Connections] removed Retry restores meaningful focus without stealing deliberate field focus", async () => {
let attempt = 0; const pending = deferred<any>();
mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any)
: ++attempt === 1 ? Promise.reject(new Error("offline")) : pending.promise);
const first = renderComponent(); await screen.findByLabelText("IMAP host");
const gmail = screen.getByRole("region", { name: "Gmail" });
const retry = await within(gmail).findByRole("button", { name: translations.en.retry });
act(() => { retry.focus(); fireEvent.click(retry); });
await act(async () => pending.resolve({ data: { connected: false } }));
expect(gmail).toContainElement(document.activeElement as HTMLElement);
expect(document.body).not.toBe(document.activeElement);
// A later explicit field move while a retry is pending must remain authoritative.
let read = 0; const later = deferred<any>();
mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any)
: ++read === 1 ? Promise.reject(new Error("offline")) : later.promise);
// A new mounted panel is an independent read session.
first.unmount();
const current = renderComponent();
const panels = screen.getAllByRole("region", { name: "Gmail" }); const newest = panels[panels.length - 1];
const nextRetry = await within(newest).findByRole("button", { name: translations.en.retry });
act(() => { nextRetry.focus(); fireEvent.click(nextRetry); });
const fields = screen.getAllByLabelText("Password"); const field = fields[fields.length - 1]; act(() => field.focus());
await act(async () => later.resolve({ data: { connected: false } }));
expect(field).toHaveFocus(); current.unmount();
});
it("[P7-09 Connections] an IMAP field removed after commit has a meaningful fallback while refresh is pending", async () => {
const saved = deferred<any>(); const refreshed = deferred<any>(); let imapReads = 0;
mockedApi.get.mockImplementation((url: string) => url === "/imap/status" && ++imapReads > 1 ? refreshed.promise : Promise.resolve({ data: { connected: false } } as any));
mockedApi.post.mockReturnValue(saved.promise);
renderComponent(); await screen.findByLabelText("IMAP host");
fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } });
fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } });
const password = screen.getByLabelText("Password");
fireEvent.change(password, { target: { value: "secret" } });
const button = screen.getByRole("button", { name: "Connect IMAP account" });
act(() => { button.focus(); fireEvent.click(button); });
act(() => password.focus());
await act(async () => saved.resolve({ data: {} }));
expect(password).not.toBeInTheDocument();
const imap = screen.getByRole("region", { name: translations.en.emailConnectionsOtherImap });
expect(within(imap).getByRole("heading", { name: translations.en.emailConnectionsOtherImap })).toHaveFocus();
const other = connectButton("Gmail"); act(() => other.focus());
await act(async () => refreshed.resolve({ data: { connected: true, username: "owner@example.test" } }));
expect(other).toHaveFocus(); expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
});
+13 -1
View File
@@ -456,6 +456,11 @@ export const translations = {
connectedAccountsBody: "Connect the inboxes Jobbjakt may use to identify recruiter correspondence. Connecting an inbox never sends a message on your behalf.",
connectedAccountsBack: "Back to settings",
emailConnectionsTitle: "Linked email accounts",
emailConnectionsLoading: "Checking connection…",
emailConnectionsStatusFailed: "Connection status could not be checked.",
emailConnectionsRefreshRequired: "The action completed, but the connection status could not be refreshed. Refresh before taking another action.",
emailConnectionsDisconnecting: "Disconnecting…",
emailConnectionsPortInvalid: "Enter a whole port number from 1 to 65535.",
emailConnectionsBody: "Connect a mailbox so recruiter correspondence can be linked to jobs automatically.",
emailConnectionsGmailConnected: "Gmail connected.",
emailConnectionsGmailFailed: "Gmail connection failed.",
@@ -1291,6 +1296,7 @@ export const translations = {
settingsQuickCaptureManual: "Or copy the bookmarklet code",
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
settingsNotificationsDraftScope: "Notification changes stay while you switch Settings sections. Save or discard them before leaving Settings; leaving discards unsaved changes.",
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
settingsNotificationsInAppReminders: "Highlight reminders in the app",
@@ -3210,9 +3216,14 @@ export const translations = {
operationsFailed: "Mislyktes: {category}",
gmailReviewQueue: "Gmail-gjennomgang",
connectedAccounts: "Tilkoblede kontoer",
connectedAccountsBody: "Koble til innboksene Jobbjakt kan bruke til å identifisere korrespondanse med rekrutterere. En tilkoblet innboks sender aldri meldinger på dine vegne.",
connectedAccountsBody: "Koble til innboksene Jobbjakt kan bruke til å identifisere korrespondanse med rekrutterere. Å koble til en innboks sender ikke meldinger på dine vegne.",
connectedAccountsBack: "Tilbake til innstillinger",
emailConnectionsTitle: "Tilkoblede e-postkontoer",
emailConnectionsLoading: "Kontrollerer tilkoblingen…",
emailConnectionsStatusFailed: "Tilkoblingsstatusen kunne ikke kontrolleres.",
emailConnectionsRefreshRequired: "Handlingen er fullført, men tilkoblingsstatusen kunne ikke oppdateres. Oppdater før du utfører en ny handling.",
emailConnectionsDisconnecting: "Kobler fra…",
emailConnectionsPortInvalid: "Angi et helt portnummer fra 1 til 65535.",
emailConnectionsBody: "Koble til en innboks slik at korrespondanse med rekrutterere automatisk kan knyttes til jobber.",
emailConnectionsGmailConnected: "Gmail er koblet til.",
emailConnectionsGmailFailed: "Tilkobling til Gmail mislyktes.",
@@ -4048,6 +4059,7 @@ export const translations = {
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
settingsNotificationsDraftScope: "Varslingsendringer beholdes når du bytter del i Innstillinger. Lagre eller forkast dem før du går ut; ulagrede endringer går tapt når du forlater Innstillinger.",
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
+134 -1
View File
@@ -1,12 +1,13 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import SettingsView from './components/SettingsView';
import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import { api } from './api';
import { translations, type TranslationKey } from './i18n/translations';
jest.mock('./api', () => ({
api: {
@@ -316,3 +317,135 @@ test('[PRES-S03] Rules draft updates do not refetch an unrelated notification pr
await act(async () => undefined);
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings')).toHaveLength(reads);
});
function preferenceCopy(key: TranslationKey) { return translations[document.documentElement.lang === 'nb-NO' ? 'nb' : 'en'][key]; }
function notificationPanel() { return within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabNotifications') })); }
function notificationReads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings'); }
function notificationWrites() { return mockedApi.put.mock.calls.filter(([url]) => url === '/notification-settings'); }
async function openNotifications() {
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
return notificationPanel().findByRole('checkbox', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
}
async function preferenceLanguage(language: 'en' | 'nb') {
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
}
function preferenceDeferred() {
let resolve!: (value: any) => void;
const promise = new Promise<any>(yes => { resolve = yes; });
return { promise, resolve };
}
test('[P7-09 preferences] initial notification failure offers a single GET Retry and recovers its vanished focused action', async () => {
const pending = preferenceDeferred(); const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/notification-settings'
? ++reads === 1 ? Promise.reject(new Error('offline')) : pending.promise : ordinaryGet(url, config));
renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Notifications' }));
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsLoadFailed);
const retry = notificationPanel().getByRole('button', { name: 'Retry' });
act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
expect(await notificationPanel().findByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(notificationPanel().getByRole('group', { name: translations.en.settingsNotificationsTitle })).toHaveFocus();
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
});
test('[P7-09 preferences] notification drafts survive internal sections and locale changes without a GET or save', async () => {
renderView(); const checkbox = await openNotifications(); expect(checkbox).toBeChecked(); fireEvent.click(checkbox);
const reads = notificationReads().length;
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days');
fireEvent.click(screen.getByRole('tab', { name: 'General' })); await preferenceLanguage('nb');
const retained = await openNotifications();
expect(retained).not.toBeChecked(); expect(notificationReads()).toHaveLength(reads);
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.nb.settingsNotificationsDraftScope);
await preferenceLanguage('en'); expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationReads()).toHaveLength(reads); expect(mockedApi.put).not.toHaveBeenCalled();
});
test('[P7-09 preferences] one held notification save acknowledges its snapshot and preserves later edits across sections', async () => {
const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise);
renderView(); const checkbox = await openNotifications(); fireEvent.click(checkbox);
const save = notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave });
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(notificationWrites()).toEqual([['/notification-settings', { emailFollowUpRemindersEnabled: false }]]);
fireEvent.click(checkbox); expect(checkbox).toBeChecked();
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days');
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
expect(await openNotifications()).toBeChecked();
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.en.assetsUnsavedChanges);
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
expect(notificationReads()).toHaveLength(1); expect(notificationWrites()).toHaveLength(1);
});
test('[P7-09 preferences] failed notification save keeps its current-locale draft and retries only the original Save', async () => {
mockedApi.put.mockRejectedValueOnce(new Error('offline'));
renderView(); fireEvent.click(await openNotifications());
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave }));
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsSaveFailed);
const reads = notificationReads().length; await preferenceLanguage('nb');
expect(notificationPanel().getByRole('alert')).toHaveTextContent(translations.nb.settingsNotificationsSaveFailed);
expect(notificationPanel().getByRole('checkbox', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByRole('button', { name: translations.nb.retry })).not.toBeInTheDocument();
fireEvent.click(notificationPanel().getByRole('button', { name: translations.nb.settingsNotificationsSave }));
await waitFor(() => expect(notificationPanel().queryByRole('alert')).not.toBeInTheDocument());
expect(notificationWrites()).toEqual([
['/notification-settings', { emailFollowUpRemindersEnabled: false }],
['/notification-settings', { emailFollowUpRemindersEnabled: false }],
]);
expect(notificationReads()).toHaveLength(reads);
expect(await screen.findByText(translations.nb.settingsNotificationsSaved)).toBeVisible();
});
test('[P7-09 preferences] discarding a notification draft leaves the independent Rules draft untouched', async () => {
renderView(); fireEvent.change(await openRules(), { target: { value: '5' } });
fireEvent.click(await openNotifications());
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5);
expect(screen.getByRole('status')).toHaveTextContent(translations.en.rulesDraftScope);
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('[P7-09 preferences] a disposed notification save cannot publish into surviving providers or a new Settings session', async () => {
const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise);
function Session() {
const [visible, setVisible] = React.useState(true);
return <MemoryRouter><ToastProvider><I18nProvider>
<button onClick={() => setVisible(value => !value)}>Toggle Settings session</button>
{visible ? <SettingsView pageSize={20} onPageSizeChange={() => undefined} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => undefined} themeMode="dark" onThemeModeChange={() => undefined} /> : null}
</I18nProvider></ToastProvider></MemoryRouter>;
}
render(<Session />); fireEvent.click(await openNotifications());
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave }));
expect(notificationWrites()).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' }));
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
expect(screen.queryByText(translations.en.settingsNotificationsSaved)).not.toBeInTheDocument();
expect(notificationReads()).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' }));
expect(await openNotifications()).toBeChecked(); expect(notificationReads()).toHaveLength(2);
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
expect(notificationWrites()).toHaveLength(1);
});
test('[P7-09 preferences] Rules load failure follows locale without reloading and Retry retains the six-field contract', async () => {
const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0;
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/rules' && ++reads === 1 ? Promise.reject(new Error('offline')) : ordinaryGet(url, config));
renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en.rulesLoadFailed);
await preferenceLanguage('nb');
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.rulesLoadFailed);
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: translations.nb.retry }));
const fields = [
['rulesAppliedFollowUpDays', 14], ['rulesAppliedGhostDays', 30], ['rulesOfferFollowUpDays', 7],
['rulesOfferGhostDays', 14], ['rulesFeedbackFollowUpDays', 7], ['rulesFeedbackGhostDays', 14],
] as const;
for (const [key, value] of fields) expect(await screen.findByLabelText(translations.nb[key])).toHaveValue(value);
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(2);
expect(mockedApi.put).not.toHaveBeenCalled();
});
@@ -1,24 +1,20 @@
import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { Box, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
import EmailProviderConnections from "../components/EmailProviderConnections";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader } from "../layout/PageLayout";
export default function ConnectedAccountsPage() {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>{t("connectedAccounts")}</Typography>
<Typography sx={{ color: "text.secondary", maxWidth: 720 }}>
{t("connectedAccountsBody")}
</Typography>
<Button sx={{ mt: 1 }} onClick={() => navigate("/settings")}>{t("connectedAccountsBack")}</Button>
</Paper>
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<PageHeader level="h2" title={t("connectedAccounts")} description={t("connectedAccountsBody")}
actions={<Button variant="outlined" onClick={() => navigate("/settings")}>{t("connectedAccountsBack")}</Button>} />
<EmailProviderConnections />
</Box>
);