From 8af9168014631633e38e8ba7cd22899fdf1092e2 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 20 Oct 2026 09:58:46 +0200 Subject: [PATCH] feat(settings): complete privacy usage and data recovery presentation --- docs/redesign/phase-7/COMMITS.md | 2 + docs/redesign/phase-7/PRESERVATION.md | 2 + docs/redesign/phase-7/README.md | 6 +- .../phase-7/p7-09/DATA_PRIVACY_BACKUP.md | 39 + .../phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md | 42 + docs/redesign/phase-7/p7-09/README.md | 8 +- .../evidence/data-privacy-backup-results.json | 851 ++++++++++++++++++ .../data-privacy-backup-static-checks.json | 104 +++ .../evidence/owner-authorized-release.json | 201 +++++ docs/roadmap/todo.md | 2 +- .../e2e/phase7-settings-preferences.spec.ts | 113 ++- .../src/ai-privacy-settings.test.tsx | 70 ++ job-tracker-ui/src/ai-usage-card.test.tsx | 100 +- job-tracker-ui/src/backup-lifetime.test.tsx | 50 + .../src/components/AiPrivacySettingsCard.tsx | 62 +- job-tracker-ui/src/components/AiUsageCard.tsx | 85 +- job-tracker-ui/src/components/BackupCard.tsx | 90 +- job-tracker-ui/src/i18n/translations.ts | 4 + 18 files changed, 1757 insertions(+), 74 deletions(-) create mode 100644 docs/redesign/phase-7/p7-09/DATA_PRIVACY_BACKUP.md create mode 100644 docs/redesign/phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md create mode 100644 docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-results.json create mode 100644 docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-static-checks.json create mode 100644 docs/redesign/phase-7/p7-09/evidence/owner-authorized-release.json create mode 100644 job-tracker-ui/src/ai-privacy-settings.test.tsx create mode 100644 job-tracker-ui/src/backup-lifetime.test.tsx diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index 3291c90..f3d5a5f 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -2,6 +2,8 @@ ## Continuation — 20 October 2026 +Import prerequisite **3028d8172c78d3d86b16d621cbdc418b9b318dc2** was normally pushed and directly verified **07:30:08 UTC**; development remote matched HEAD. Release repairs were merged normally and pushed as **36fc5d54e781dcad077c2064edc5c5adef1d4e24**, directly verified **07:50:40 UTC**, preserving all working-file hashes. Main is separately owner-authorized `cc7977131a4f991578f29d83eec17a242a858fca`, with corrected CI queued and deployment unverified. Remaining [data presentation](p7-09/DATA_PRIVACY_BACKUP.md) has47/6 affected frontend,22backendcontracts, final native9+4+1+ordinaryP2-D01 and types/lint/build PASS; final consumer/hygiene/publication receipt follows. Qualifications remain unchanged. + Company compatibility repair **5addcde1e22e9625d1b39df037a05b24c69c9cde** is pushed, directly verified 07:16:14 UTC. It preserves owner-scoped exact Norwegian names and ASCII case reuse; targeted backend 3, full backend 1057 and native Companies six pass. Separate [import completion](p7-09/IMPORT_COMPATIBILITY.md) has full frontend 837/97, native 25, types/lint/build and final hygiene PASS; its publication receipt follows this commit. Original candidate/RED evidence remains retained. Latest published presentation: [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 PASS; e7dba9a normal push directly verified clean08:50:42UTC; +17/0 derives824/95 and+5 browser331, backend1054 unchanged. Data/privacy/plan/backup and its separate import prerequisite remain pending; main unchanged/productionNO-GO. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 6d7f79c..7eed1b5 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -2,6 +2,8 @@ ## Import continuation — 20 October 2026 +The remaining [privacy/usage/backup presentation](p7-09/DATA_PRIVACY_BACKUP.md) adds S03/S04/S05/J10/E09/W11 evidence without changing any full-group disposition: two-boolean privacy persistence and newer-draft acknowledgement, GET-only independent recovery, disabled billing/deletion capability truth, actual protected ZIP response/download byte identity, session disposal and existing exact confirmation/recent-auth guards. Quick Capture's manual code selection and all eight finite width samples pass. No native deletion/restore/provider/model/billing action is executed. 37 scans have zero violations with 141 incomplete nodes retained. Human/device/output and production requirements remain open. + All 57 groups retain **56 PARTIAL / C11 DEFERRED** and 33 routes. [Import compatibility](p7-09/IMPORT_COMPATIBILITY.md) adds bounded PRES-J10/E09/S03/S04/W11 proof: real export/create round trip, create-field allowlist, whole-file validation, owned graph exclusion, actual account disposal, acknowledged counts and uncertain-write stop, internal-tab retention and preserved Rules saving. Fresh full frontend 837/97, backend 1057 and 25 affected browser cases pass. This does not close S05 backup/restore, full integrated browser acceptance, manual accessibility, production or the remaining P7-09 presentation owners. No route, ownership or document rendering contract changes. 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. diff --git a/docs/redesign/phase-7/README.md b/docs/redesign/phase-7/README.md index def3734..4168d81 100644 --- a/docs/redesign/phase-7/README.md +++ b/docs/redesign/phase-7/README.md @@ -2,9 +2,9 @@ ## Current continuation — 20 October 2026 -P7-00–08 and P7-09 Preferences/Connections remain implemented CONDITIONAL GO. The [P7-09 import prerequisite](p7-09/IMPORT_COMPATIBILITY.md) now passes full frontend 837/97, full backend 1057, affected native 25 and type/lint/build gates. Its backend prerequisite `5addcde` is pushed and remotely verified; import UI publication follows final hygiene. Browser inventory is 335, with full integrated acceptance still due at P7-12. Privacy/usage/Quick Capture/backup, P7-10 Administration, P7-11 residual UI and P7-12 acceptance remain. The historical sections below retain their as-observed baselines. +P7-00–08 and P7-09 presentation are implemented CONDITIONAL GO. The [P7-09 import prerequisite](p7-09/IMPORT_COMPATIBILITY.md) passes full frontend 837/97, full backend 1057, affected native 25 and type/lint/build gates, published as `3028d81`. [Remaining data presentation](p7-09/DATA_PRIVACY_BACKUP.md) has affected47/6 plus22backendcontracts and final native/type/lint/build evidence, with final consumer/hygiene/publication pending. Derived inventory is855/99frontend,339browser,1057backend; this is not a fresh full integrated suite. P7-10 Administration, P7-11 residual UI and P7-12 acceptance remain. The historical sections below retain their as-observed baselines. -The owner subsequently authorized main promotion despite existing release NO-GOs. Main currently contains `f9bf31c`, while the diagnostic Linux run exposed additional test portability, login-fixture and dialog accessibility failures. Release success is not claimed. P5-13 remains withheld, all 57 preservation qualifications remain binding, and Phase 8 implementation has not started. +The owner subsequently authorized main promotion despite existing release NO-GOs. Main currently contains `cc79771`, including bounded repairs for all five causes found in diagnostic run828. Corrected run831 is queued behind older runs; release success is not claimed. Release repairs are merged and pushed on development as `36fc5d5`, with all unfinished source preserved. P5-13 remains withheld, all 57 preservation qualifications remain binding, and Phase 8 implementation has not started. **Authorised development execution; production remains NO-GO.** The owner resolved the main/deployment conflict by directing Phase 7 to continue on development while keeping main unchanged. Follow the [original Phase 7 scope](../IMPLEMENTATION_PLAN.md#phase-7--full-migration) and the exact [Phase 6 handoff](../phase-6/p6-05/PHASE7_HANDOFF.md); this record does not replace the approved architecture or introduce another phase plan. @@ -24,7 +24,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 | Sender repair dac2927 published. Preferences/Connections91/6+backend52+native17+gates PASS; all18 new PNGs reviewed; final hygiene PASS, e7dba9a normal push directly verified clean08:50:42UTC. Data/privacy/plan/backup follows with separate import compatibility prerequisite. Rules/save owners and all qualifications retained | +| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender/Preferences published dac2927/e7dba9a; company/import prerequisites published5addcde/3028d81; release repairs merged/pushed36fc5d5 with all WIP preserved. Remaining data presentation implemented CONDITIONAL GO:47/6 affected frontend,22backendcontracts, nativeSettings9/import4/CSV1/P2-D01 +types/lint/build PASS;37axe0violations/141incomplete nodes qualified,six final PNGs inspected. Final consumer/hygiene/publication receipt follows. Inventory855/99frontend,339browser,1057backend; full integrated acceptance remainsP7-12 | | P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned | | P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned | | P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned | diff --git a/docs/redesign/phase-7/p7-09/DATA_PRIVACY_BACKUP.md b/docs/redesign/phase-7/p7-09/DATA_PRIVACY_BACKUP.md new file mode 100644 index 0000000..2c3cf77 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/DATA_PRIVACY_BACKUP.md @@ -0,0 +1,39 @@ +# P7-09 — Privacy, usage and backup presentation + +**Implemented CONDITIONAL GO; final hygiene and publication pending.** Preferences/Connections and the separate [import prerequisite](IMPORT_COMPATIBILITY.md) are already published. This closes the remaining approved second boundary without enabling billing, deletion, restore, provider grants or P5-13. + +## Contracts and confirmed findings + +Privacy retains the authenticated `/ai/settings` GET/PUT owner and its exact two-boolean write payload. Availability, effective policy and provider remain server-owned. The previous card had no read recovery, could overwrite newer edits on save acknowledgement, and could report success after unmount or in the previous language. Its new six-case regression recorded five ordinary failures and one pass before repair. An earlier mock-setup failure is retained but is not product RED evidence. + +Usage retains independent `/ai/usage` and `/billing/status` GET owners, server values/limits and existing capability-gated checkout/portal endpoints. The previous card lost its heading after a read failure, silently hid billing read errors and formatted numbers in the browser host's locale. Focused RED recorded five failures and two passes. No statistics or entitlements are invented; zero limits remain server values rather than inferred eligibility. + +Backup retains readable account ZIP and encrypted backup formats, filenames/content-disposition, recent-auth enforcement and exact server-provided deletion confirmation. An unavailable lifecycle read previously looked disabled. Synchronous competing downloads and late disposal also failed the new four-case regression. No backend/schema, deletion flag or restore behavior changes. + +Quick Capture already uses the shared section and an accessible read-only/manual-code alternative to dragging. Preserve its origin, encoded source URL, bookmarklet and click hint. Verify it rather than rebuilding it. + +## Changes and finite evidence + +Privacy has GET-only Retry, truthful read versus save errors, draft/discard state, submitted-snapshot acknowledgement, synchronous save admission and session/unmount disposal. Current-locale completion feedback uses the existing notification owner. Historical external consent can still be revoked even when new external processing is unavailable. Retry focus has a named landing target; later edits are not discarded or silently saved. + +Usage preserves its heading/loading/error shell and independently retries failed reads without replaying writes or resetting the other resource. Numbers and byte units follow the selected English/Bokmål locale; zero storage limits produce finite progress. Checkout/portal admission and late-response guards preserve the existing server redirect contract. They do not activate billing or qualify real billing navigation. + +Backup distinguishes loading, unknown/error, explicitly disabled, available and pending states. Downloads and deletion confirmation share synchronous admission; callbacks check the immutable session/lifetime before downloading, notifying or acting on a prompt result. Existing server error/recent-auth feedback remains. Readable export is the primary action; encrypted backup remains a secondary action. The card remains lazy and disposable on its existing Settings tab boundary. A review-found Retry focus loss failed an ordinary additional regression before correction. + +Affected frontend **47 cases / six suites PASS** on 20 October 2026: six privacy, ten usage, four backup lifetime, four existing account-data export, two Settings lifetime and 21 existing Settings cases. Backend **22 existing cases PASS** across privacy policy, account export/deletion and billing contracts, using the unchanged Release test binary. These are focused results, not a fresh whole-suite claim. New cases derive frontend inventory **855/99** from the last full **837/97** baseline; four added browser cases derive **339** from 335. Final gate timestamps/export/source identities and exact native results must be appended below before acceptance. + +## Preservation and qualifications + +The final native Settings suite passes **9/9**, zero skips/retries/flaky outcomes, **07:52:55–07:54:24 UTC**. Four cases are new: actual privacy persistence/isolation/recovery, eight-width privacy/backup/Quick Capture, independent actual Free usage/billing read recovery, and actual protected ZIP byte identity/lifecycle/disposal. It retains all five existing Preferences/Connections cases. Import four, actual CSV consumer one and ordinary P2-D01 one also pass on the final export. Fresh Rules/Settings eight-case consumer completion is recorded in the final results before publication. + +There are **37 PNGs and 37 all-enabled axe scans**, zero violations, **141 incomplete nodes**: 37 invisible zero-count badge text checks, 99 overlapping-background contrast checks, and five closing MUI menu portal focus checks. Raw rule/node contexts are retained without exclusions. The latter portals are marked `aria-hidden` while the closing option remains mounted; native section selection restores focus, but settled tabbing and human AT acceptance remain qualified. Fixed chrome/snackbar and scrolled control overlap prevents automated background determination; contextual contrast/manual obligations remain. No incomplete is relabelled a passing contrast or reader check. + +Six final PNGs were actually inspected: 320px Bokmål dark Backup and dirty Privacy, 768px Bokmål light Privacy, 390px English light usage recovery and real export, and 1440px English light Privacy. Other images are retained automated evidence. The added geometry checks initially identified MUI Switch's intentionally transparent 300%-wide input; only unpainted opacity-zero input proxies are excluded from painted-control bounds. Their visible labels/controls, root overflow and all-enabled axe checks remain tested. This is fixture triage, not a product overflow fix or accessibility exclusion. + +The [portable final results](evidence/data-privacy-backup-results.json) record exact commands, result hashes, final export identity, during-run source observation and matching post-run identities. The observation is explicitly not represented as preceding command start. Earlier candidates, ordinary REDs, setup failures and their builds remain historical evidence. + +Final export **`TUs6Z9wfC6aEYGOeW_UZ-`** passes app/browser TypeScript, lint and build plus **23/23 native cases**, zero skips/retries/flaky outcomes: Settings nine, import four, CSV one, ordinary P2-D01 one and fresh Rules/Settings consumers eight. The consumer lane completed **07:57:22 UTC**. This supersedes the pending-consumer observations above. All nine observed source identities still match after the runs. Final hygiene and publication are recorded in the ledger; no whole-suite, deployed or human acceptance is inferred from these gates. + +All 57 preservation groups remain **56 PARTIAL / C11 DEFERRED**, with 33 routes. S03/S04/S05/J10/E09/W11 receive bounded presentation, lifecycle and persistence evidence. Existing Rules ownership, language/local preferences, notification delivery and provider controls remain separate. Backend ownership, actual deletion availability and recent-auth checks are not replaced by UI state. + +Account deletion is not executed in native tests. Unit tests exercise its existing exact-phrase and disposal guards with mocks. A downloaded ZIP is not backup/restore proof. Real billing/provider/SMTP/model/deletion/restore operations, human screen-reader, physical touch/software keyboard, actual browser zoom, production fonts/photos/print and all retained reliability/production debts remain unverified. P5-13 stays withheld, followMode stays deferred, Phase 8 implementation has not started. Production readiness remains NO-GO despite the owner's separate authorization to deploy the existing release. diff --git a/docs/redesign/phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md b/docs/redesign/phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md new file mode 100644 index 0000000..1f06cf7 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md @@ -0,0 +1,42 @@ +# Owner-authorized main promotion — 2026-10-17 + +**The owner authorized one main promotion despite the recorded NO-GO qualifications. Deployment has been triggered; live deployment is not yet verified.** This authorization does not convert technical release readiness into GO. + +The owner first requested a push to main, then explicitly accepted the outstanding NO-GOs/pending items and asked for the completed work to go live. This later decision supersedes the earlier instruction to keep main unchanged for this one promotion. The authorization and exact command are retained in the [portable receipt](evidence/owner-authorized-release.json), with hashed original observations. + +## Promoted revision and boundaries + +The root agent pushed only the latest completed, already published development commit, **`e7dba9a2f776c55e3c4f4df7ef59076f93fa92df`** (`feat(settings): preserve drafts and connection recovery`), to `refs/heads/main`. The normal push ran **2026-10-17 13:54:33.243078–13:54:35.214338 UTC** and exited 0. + +Direct verification at **13:54:35.567674 UTC** recorded local/origin/live main and development HEAD/live development at that revision. Main previously referenced `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Historical statements that main remained at that earlier revision are valid for their recorded observation times; the standing main revision after this promotion is e7dba9a. + +The pending import compatibility repair, General lifetime foundation, backend company-create prerequisite, associated tests and unfinished documentation were **excluded**. The recorded working-tree path lists match before and after the push; remote revision equality does not mean the worktree was clean. No unfinished change was committed or included merely to deploy it. + +## Deployment observation + +Read-only Gitea API observations identify [run 820, number 401](https://git.cesnimda.uk/cesnimda/jobtrackingapp/actions/runs/820), triggered by a main push for the exact e7dba9a revision. + +| Observation UTC | Recorded state | +|---|---| +| `13:55:16.507277` | Run 820 `in_progress`, conclusion absent. | +| `13:56:07.968311` | Run still `in_progress`; test job 1699 `in_progress`; deploy job 1700 `waiting`; neither has a conclusion. | + +These observations prove the trigger and workflow identity. They do not prove successful deployment, deployed runtime identity, authenticated smoke behavior or production health. The root agent will append the eventual CI/deployment outcome when supported by evidence. This report performs no additional push or deployment action. + +## Qualifications remain + +The [debt register](../DEBT.md) and [preservation matrix](../PRESERVATION.md) remain authoritative. Production ingress/topology, complete backup/restore, effective schema/configuration, tested rollback, deployed revision/smoke/monitoring and applicable human/device/provider/output qualifications remain open. P5-13 stays withheld; Phase 8 has not started. P7-09's remaining data/privacy/plan/backup work is still pending. + +**Technical production readiness: NO-GO qualifications retained. Owner-authorized promotion: pushed. Deployment/live verification: pending.** This was a one-time override for e7dba9a, not authorization to promote later development commits automatically or to mark outstanding requirements complete. Further main promotion needs separate owner authorization and an explicit evidence-based release decision. + +## Authorized repair continuation — 20 October 2026 + +The owner subsequently reported the failed deployment, supplied runner SSH access and explicitly instructed continued investigation and pushes to main. This authorizes coherent release repairs; it does not automatically promote unfinished Phase 7 work or close release qualifications. The specific dev-only braces advisory exception through 31 October 2026 was separately owner-approved. It does not cover other advisories or production dependencies. + +Main now references **`cc7977131a4f991578f29d83eec17a242a858fca`**, directly verified against the remote. The chronological repair chain is dependency/audit policy `2278473`, visible browser diagnostics `32bc65c`, approved-route census `f9bf31c`, portable Python fixtures `e5aba4c`, confirmation keyboard scroll `985c719`, shared login-fixture capacity `9234d0a`, and exact notification-caption locator `cc79771`. Each repair has its own infrastructure record, focused verification and normal push. No force push or production database operation was used. + +Diagnostic [run 828](https://git.cesnimda.uk/cesnimda/jobtrackingapp/actions/runs/828) passed backend 1054, frontend 824/95 and sidecar 49, then failed its browser lane with **274 passed / 57 failed**. All 57 cases are accounted for: 35 shared-fixture login 429, 15 missing local wrapper in Linux seed fixtures, four nonexact caption queries matching a live announcement, two stale route assertions and one actual scrollable confirmation keyboard barrier. The corresponding targeted fixes preserve assertions, ownership, rate-limit contracts and accessibility checks. Its complete 2,143-entry browser artifact was downloaded and ZIP CRC-checked, SHA256 `e9059617eb3a784de94695b0497227fd1f5f5f1ad10c1e40c9b6019c288c0847`. + +The corrected [run 831](https://git.cesnimda.uk/cesnimda/jobtrackingapp/actions/runs/831) is queued behind older runs as of 07:47 UTC. Existing Git-token authentication cannot cancel web-only queued runs through a supported API; no sessions or server records were forged or altered. Deployment, deployed source identity and live smoke remain **unverified**. The last verified healthy live source remains the older `7d641ed` revision. The separately downloaded Qwen 3.5 candidate has not been activated; P5-13 remains withheld. + +Development independently published company repair `5addcde` and import prerequisite `3028d81`; neither was silently included in the release. The normal development merge **`36fc5d54e781dcad077c2064edc5c5adef1d4e24`**, pushed and directly verified **07:50:40 UTC**, incorporates the release repairs while preserving every intervening working-file hash. Privacy/usage/backup changes remain a separate unfinished boundary at this observation. Technical production readiness remains NO-GO under the existing operator/manual gates despite explicit owner authorization to deploy the release. diff --git a/docs/redesign/phase-7/p7-09/README.md b/docs/redesign/phase-7/p7-09/README.md index e5e4864..afcc8e1 100644 --- a/docs/redesign/phase-7/p7-09/README.md +++ b/docs/redesign/phase-7/p7-09/README.md @@ -35,4 +35,10 @@ The [separate sender repair](OAUTH_SENDER.md) is published asdac2927, directly v The [import compatibility boundary](IMPORT_COMPATIBILITY.md) is implemented **CONDITIONAL GO**: fresh full frontend 837/97, full backend 1057, native 25 and type/lint/build gates pass; 11 all-enabled scans have zero violations with contextual incompletes retained. The independent company contract repair is published as `5addcde1e22e9625d1b39df037a05b24c69c9cde`, remote verified 07:16:14 UTC. Import's 13 new frontend cases/two suites and four browser cases derive inventory 837/97 and 335 respectively. These do not claim a complete 335-case browser acceptance. -**P7-09 acceptance: PENDING.** Privacy, usage/plan presentation, Quick Capture and backup/data lifecycle remain next within the approved second boundary. Each needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification. Main was separately owner-authorized for deployment despite retained NO-GO qualifications; current release CI is being repaired, with no deployment success claimed. +## Remaining data presentation completion — 20 October 2026 + +[Privacy, usage and backup](DATA_PRIVACY_BACKUP.md) now have 47 affected frontend cases/six suites, 22 existing backend contracts, final native Settings nine, import four, CSV one and ordinary P2-D01 one PASS. Types/lint/build pass; fresh Rules/Settings eight-case consumer completion and final hygiene/publication are recorded before exit. Quick Capture's manual code remains accessible and is verified rather than rebuilt. The 37 final axe scans report zero violations and 141 contextually triaged incomplete nodes; six actual final PNGs were inspected. Inventory becomes frontend **855/99**, browser **339**, backend **1057**; fresh whole integrated acceptance remains P7-12. + +Final Rules/Settings consumer eight PASS at **07:57:22 UTC**, bringing the final export's focused native total to **23/23**, zero skipped/flaky/retries. Export `TUs6Z9wfC6aEYGOeW_UZ-` and all observed source identities match the [final portable evidence](evidence/data-privacy-backup-results.json). + +**P7-09 implemented acceptance: CONDITIONAL GO, publication pending.** The two approved presentation boundaries and independent sender/import/company repairs are complete within their bounded scope. All 57-group/manual/provider/output/reliability/production qualifications remain. P5-13 is withheld and followMode deferred. [Owner-authorized release continuation](OWNER_AUTHORIZED_RELEASE.md) is separate; current main `cc79771` has a queued corrected release run, with no deployment success claimed. P7-10 Users, Audit and System follows in three separate commits after publication. diff --git a/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-results.json b/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-results.json new file mode 100644 index 0000000..801dce8 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-results.json @@ -0,0 +1,851 @@ +{ + "observedUtc": "2026-10-20T07:57:41.951973+00:00", + "sourceObservation": { + "observedUtc": "2026-10-20T07:51:59.477231+00:00", + "scope": "Captured during validation command, before browser lane; not before command start", + "sources": { + "src/components/AiPrivacySettingsCard.tsx": "7534422effa4b8720e27ee34e3ff1c9fb5c74e6d5d6db3fe3810028aff7d1e7e", + "src/components/AiUsageCard.tsx": "0592eea7ea9c43f72a9685e03a2e9911f96dc760e8d60ba17ac754956f9d700e", + "src/components/BackupCard.tsx": "b356961a48d35a4631f145f3b34cf7997bba20f4a1d2bb25508b2305e7e7b111", + "src/components/QuickCaptureCard.tsx": "0b0da2d773a7323a3341f082a803e6cd3cef8079d7c2101100b706b64c305bbb", + "src/i18n/translations.ts": "62094ca66c7da22670d2a886430d838059e8c9f8a8f45fdf0236566dbbc3116a", + "src/ai-privacy-settings.test.tsx": "a53eda1c305cc9e3ab16a4f52603a8cb6d881aa58e88a22e112eb33103c5a404", + "src/ai-usage-card.test.tsx": "922d3c18cce51df0ed0ba89bf1dd383bc8611f2323fbcb51677c3562808a87a9", + "src/backup-lifetime.test.tsx": "e67d22d13a5edaedc29d57e3afc36614cdf4844d510d1ecd661d5254d39e2ca3", + "e2e/phase7-settings-preferences.spec.ts": "6269a229d0caecc5b248490bfd7e1c820220494c278f204cca3085db2096f9cc" + } + }, + "allObservedSourcesStillMatch": true, + "buildId": "TUs6Z9wfC6aEYGOeW_UZ-", + "gates": { + "typecheck": { + "commandReport": ".artifacts/phase7/current/ui/typecheck-command.json", + "commandReportSha256": "cba98f83767f45cf39eedafd58267565a61b694316880b020d813e0cd82ab3f6", + "name": "typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "typecheck" + ], + "startedUtc": "2026-10-20T07:51:49.264976+00:00", + "finishedUtc": "2026-10-20T07:52:03.819879+00:00", + "exitCode": 0 + }, + "browser-typecheck": { + "commandReport": ".artifacts/phase7/current/ui/browser-typecheck-command.json", + "commandReportSha256": "63f0ed813c9aa30f3b5a70fe8d39c463e5cdd8a39231807b021be16f3bd6e2d6", + "name": "browser-typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "exec", + "tsc", + "--", + "--project", + "../.artifacts/phase7/e2e-tsconfig.json", + "--noEmit" + ], + "startedUtc": "2026-10-20T07:52:03.822905+00:00", + "finishedUtc": "2026-10-20T07:52:11.243170+00:00", + "exitCode": 0 + }, + "lint": { + "commandReport": ".artifacts/phase7/current/ui/lint-command.json", + "commandReportSha256": "d8355db8535887672d6a9c9ac1bf03dd7e7500ba92f0ea20f8f1a358816f9558", + "name": "lint", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "lint" + ], + "startedUtc": "2026-10-20T07:52:11.245684+00:00", + "finishedUtc": "2026-10-20T07:52:39.638127+00:00", + "exitCode": 0 + }, + "build": { + "commandReport": ".artifacts/phase7/current/ui/build-command.json", + "commandReportSha256": "82e051d76a20c7b0703dae5a4ae9d131d373eb6dd24e3e3cd5c5bb193216717e", + "name": "build", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "build" + ], + "startedUtc": "2026-10-20T07:52:39.641167+00:00", + "finishedUtc": "2026-10-20T07:52:54.735129+00:00", + "exitCode": 0 + }, + "settings-preferences": { + "commandReport": ".artifacts/phase7/current/ui/settings-preferences-command.json", + "commandReportSha256": "4903b52856e3b86b0e53660d9b54de0b8d90280a304f497084be93e29853950d", + "name": "settings-preferences", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-settings-preferences.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\settings-preferences-results" + ], + "startedUtc": "2026-10-20T07:52:54.742749+00:00", + "finishedUtc": "2026-10-20T07:54:24.305964+00:00", + "exitCode": 0, + "stats": { + "startTime": "2026-10-20T07:52:55.385Z", + "duration": 88851.89600000001, + "expected": 9, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultSha256": "a0e30d00df58f5465b95cfefca3cd8e82802beec9bc895f1217c0760ab72d614" + }, + "job-import": { + "commandReport": ".artifacts/phase7/current/ui/job-import-command.json", + "commandReportSha256": "f706aee3a938f2e3fc8aaba38be945e6f7c78d64453b850b4966bd3c9bd5f68d", + "name": "job-import", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-job-import.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\job-import-results" + ], + "startedUtc": "2026-10-20T07:54:24.313711+00:00", + "finishedUtc": "2026-10-20T07:54:59.006743+00:00", + "exitCode": 0, + "stats": { + "startTime": "2026-10-20T07:54:24.872Z", + "duration": 34086.384000000005, + "expected": 4, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultSha256": "732de69d2276c76608f535fd86d31f21e229d0be0ba27cc061d9c1cfbabbafe6" + }, + "job-import-consumers": { + "commandReport": ".artifacts/phase7/current/ui/job-import-consumers-command.json", + "commandReportSha256": "a169eafb28425afccd9c9861ec52417f608ee3a27e7fd2b7bc06f663df7ed7d0", + "name": "job-import-consumers", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/csv-export.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\job-import-consumers-results" + ], + "startedUtc": "2026-10-20T07:54:59.013133+00:00", + "finishedUtc": "2026-10-20T07:55:12.280125+00:00", + "exitCode": 0, + "stats": { + "startTime": "2026-10-20T07:54:59.656Z", + "duration": 12588.025, + "expected": 1, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultSha256": "f16ea2fc807bcdd05e755c7dedf9f633865017c4eca62eda40361c54fd90c093" + }, + "application-p2d01": { + "commandReport": ".artifacts/phase7/current/ui/application-p2d01-command.json", + "commandReportSha256": "dfaa2bf42db6504a403ac1ec06746f22674401a0cf8ea39343a5d2e6615c8b98", + "name": "application-p2d01", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/redesign-preservation.spec.ts", + "--grep", + "legacy workspace preserves frozen documents", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\application-p2d01-results" + ], + "startedUtc": "2026-10-20T07:55:12.288185+00:00", + "finishedUtc": "2026-10-20T07:55:26.008529+00:00", + "exitCode": 0, + "stats": { + "startTime": "2026-10-20T07:55:12.844Z", + "duration": 13127.508999999998, + "expected": 1, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultSha256": "f738c9c39dd238b42bb96bfd977e85a628414198b54ecd2fe255992a650420e5" + }, + "settings-consumers": { + "commandReport": ".artifacts/phase7/current/ui/settings-consumers-command.json", + "commandReportSha256": "fa49a9c14bdf1d709e1066b951857c608cadcf63ecc7d4db6b340395fe238a3d", + "name": "settings-consumers", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/rules-draft-repair.spec.ts", + "e2e/settings-page-layout.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\settings-consumers-results" + ], + "startedUtc": "2026-10-20T07:55:36.638831+00:00", + "finishedUtc": "2026-10-20T07:57:22.106599+00:00", + "exitCode": 0, + "stats": { + "startTime": "2026-10-20T07:55:37.208Z", + "duration": 104857.419, + "expected": 8, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultSha256": "59c6302f99df8046ac42e95a4aac0bc7ce6a438386704e589b63dada6f9faa43" + } + }, + "nativeImages": [ + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-controlled-save-failure-en-light-390.png", + "sha256": "139925c538543e15efb2d15cb859627b1ad64bfeb6f7b8c42c96d3dce18a8e55" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-320-nb-dark.png", + "sha256": "c2a3519ddd1b30223286483fc79121343089f1bc95fe5aca480811ca8f6c21c0" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-320-nb-dark.png", + "sha256": "f4bc27cdb9ba16d772c52e63d30377428d5c619acbfad8a83d63191f6903b1c5" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-360-en-light.png", + "sha256": "2e69bc285dcfcb78d36f55f598697fc2f8c817525800b2fd0a6e1e609d90c21a" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-360-en-light.png", + "sha256": "9e38c3f9507e9485400c760d7fb57d80d17743c3a017b8c8657133f3de71e327" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-390-en-dark.png", + "sha256": "7771f2ed991d1fd17d16995a34b0deb61567210e24f1a69696676c95d2689a99" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-390-en-dark.png", + "sha256": "da4a80368f93fe89d054d2e6583ba61c3f27ace5d53e4348935d25cdacd71c1a" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-768-nb-light.png", + "sha256": "d444084d22e91a57fd3a08405965f4186110c79701842f79627715b4436a88c9" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-768-nb-light.png", + "sha256": "8f35df7e2aa318499f5ed6d201e06168ad54e06cb5bde400541229a1290070d8" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1024-en-dark.png", + "sha256": "15b54068e58a44638891e0573fcf9caa49010ae98e9667660fab4ce6a4b92cb0" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1024-en-dark.png", + "sha256": "ba068b0fc45d7dbd2240589f669f79a1714beef59d3cdf5117ed61678172b42d" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1280-nb-light.png", + "sha256": "b401fab54e29ba0f2298acd4cd24cd5b3a8b5c2d6164e64bb2348ed7a4d35dfd" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1280-nb-light.png", + "sha256": "c3631f902190ddea4601eab491ceb612b9fce08783cdf6901397cad0a2708b6a" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1440-en-light.png", + "sha256": "aefa978879a29b9f30b6481c996d31caa919ecc8a117f7a1f3195a23eff087f8" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1440-en-light.png", + "sha256": "14862f226bd8b9a36ac116e050e7675798ea928ee3e852eff729f7643f636a60" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1920-nb-dark.png", + "sha256": "025b0ab5cdd183e2e4406afdb54b45fa5096dd24a14f23f8c942ac99f1fa9a36" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1920-nb-dark.png", + "sha256": "81297950588804aa22e62c2d41d14b95a577e6953896fb2329504a9e5769c42b" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/usage-actual-free-independent-read-recovery-en-light-390.png", + "sha256": "451348584b261a771b4cec4af500a47cb6b0d16caed992f12c34e8a51ca2a662" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-actual-readonly-lifecycle-and-protected-export-en-light-390.png", + "sha256": "c93faa707992d628f863115fc7ebc02156966c2905ff305813ad3e9995fd2a44" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/held-notification-newer-draft-nb-390.png", + "sha256": "d46c2208b584a8c32cf739066446ebdf66789c24061cc9d01a434483802774ec" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-notification-initial-get503-en-320.png", + "sha256": "d9731d0cf9c9a370469632304c78c64bff1e294a77cdc5178ffeadc6bd3d3123" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-notification-save503-draft-en-320.png", + "sha256": "abd2216196c750e807835e8e396b82aa2e0a20f6ce13fc64601458f43990a8f8" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-gmail-status503-independent-providers-en-390.png", + "sha256": "55b5e520ff8f1020490a4e517a71ed8950f6bd1bb3482703d8a250a1db36e9a4" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-320-nb-dark.png", + "sha256": "cf8dd8bb1c6da172ca10f8899eab003475296f7f83bc3940c0122d234ecebef8" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-320-nb-dark.png", + "sha256": "b4af4840485e32dbb879b156aa21f15c58a3ee38e2adb6707ee40c39d7ab95d0" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-390-en-light.png", + "sha256": "735c650b55c81d319527ed51f45b2186df6072647f6d2192ccf6d130fa3e3b89" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-390-en-light.png", + "sha256": "d6e41504799bbc96ce7079be82d0499dd7034c2e580dbe75c4b778f445b710d8" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-768-nb-light.png", + "sha256": "a4f30556036141f50df8c96cdbb70d58663c1f12b9fa77af1de49e8363a83e64" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-768-nb-light.png", + "sha256": "331f3464838c1b4dd3a77aef2991d15b84d7a2f667ad687e75b0ca4ba62419ea" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1024-en-dark.png", + "sha256": "f97b5e2591c50d8cc908f8130c8f7cb44c3f0864155c2e3d6b066592e5c5b451" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1024-en-dark.png", + "sha256": "64b5b82c7b8e75aa253b07e265c3d8aa8e78cf007919028cf05feaf791d56a03" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1280-nb-dark.png", + "sha256": "2749d3a3f21d775db59fd7f26386b4dcbfaed7c541a2a22cfa708720fd469d16" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1280-nb-dark.png", + "sha256": "c6c8a179fc69ab19f2ec657e3a651cd31c2b8e5c5dfd3a09f6c696f26db78880" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1440-en-light.png", + "sha256": "9c2b65ee148d251760689646712c27ab8c7612c08662cd155b70f63b7545d2c0" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1440-en-light.png", + "sha256": "72847c1d419003c96401a1361add8cd732ff448d4809f7668e4f9c7b8eeda76e" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1920-en-dark.png", + "sha256": "ac64333c28ee74ac4cf97a367145aef9c1d041aa34691a1e0b979194f1714381" + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1920-en-dark.png", + "sha256": "335ae0e61ece476bc2cbbef4e9d1e6df00528a1a430b60921f3557e3d4a6f737" + } + ], + "axe": [ + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-controlled-save-failure-en-light-390-axe.json", + "sha256": "93d46aeaeefb653bc7a669f3d3793a16967e6a0e78afd309acfb0a851de55b8c", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 7, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-320-nb-dark-axe.json", + "sha256": "625c49c7d6f3abb2ee1c84ce2d4b78c4fcd5782de3e1351c4d588be3dc2c8349", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-320-nb-dark-axe.json", + "sha256": "70df2d0d0ba9cb54b28830a68d99b99c3315ae863218f213216f1305d8aee9c1", + "violations": 0, + "incompleteGroups": 2, + "incompleteNodes": 3, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-360-en-light-axe.json", + "sha256": "c73d5f410960ba78703f2b5dd59747902a4597635460a771a6c2e47b31818c96", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-360-en-light-axe.json", + "sha256": "e7e783564764d8970eee7ef06d13261d39116b60a9d0504cb2a4124850d9ec2b", + "violations": 0, + "incompleteGroups": 2, + "incompleteNodes": 3, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-390-en-dark-axe.json", + "sha256": "dfba0e2d83eb0521f8137866054a35e93a21c689ae274750dd001f59c547b425", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-390-en-dark-axe.json", + "sha256": "e7e783564764d8970eee7ef06d13261d39116b60a9d0504cb2a4124850d9ec2b", + "violations": 0, + "incompleteGroups": 2, + "incompleteNodes": 3, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-768-nb-light-axe.json", + "sha256": "eb1ad502af6635bfde32e2a26811645a694715f2ee0f96ed2b208f0fbb05420b", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-768-nb-light-axe.json", + "sha256": "8cedfafb85b72ea79df3954030df769da226aeecb8d7e91f92905246eec951a7", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1024-en-dark-axe.json", + "sha256": "04bb23b453e6a78f8f0d85aa07d2244f3cb46c57a08fc7f1a9c9f598dc153b54", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1024-en-dark-axe.json", + "sha256": "8cedfafb85b72ea79df3954030df769da226aeecb8d7e91f92905246eec951a7", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1280-nb-light-axe.json", + "sha256": "0feabcf543b5433507ca4578033d691a038692f67e9b7fc567e9ed37f84da46f", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1280-nb-light-axe.json", + "sha256": "8cedfafb85b72ea79df3954030df769da226aeecb8d7e91f92905246eec951a7", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1440-en-light-axe.json", + "sha256": "ed63686ec7dc957368edbe79329445eb400f0fcd2a9e8f2e6cb5785895b22b2d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1440-en-light-axe.json", + "sha256": "8cedfafb85b72ea79df3954030df769da226aeecb8d7e91f92905246eec951a7", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/privacy-dirty-1920-nb-dark-axe.json", + "sha256": "0736d0e4d8bc11c10181cae84f871d159a676a57cb9254904a79b44a87128a4f", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-disabled-1920-nb-dark-axe.json", + "sha256": "8cedfafb85b72ea79df3954030df769da226aeecb8d7e91f92905246eec951a7", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/usage-actual-free-independent-read-recovery-en-light-390-axe.json", + "sha256": "c73d5f410960ba78703f2b5dd59747902a4597635460a771a6c2e47b31818c96", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/backup-actual-readonly-lifecycle-and-protected-export-en-light-390-axe.json", + "sha256": "0ff42db4e9e427373beca84fca5f2acef60e7bd120760b505f985dd30d8cefe4", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 3, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/held-notification-newer-draft-nb-390-axe.json", + "sha256": "883e8ec56809f9252c959d0b629c6cf1a79107f6ac8a394fbc581c25a9f619b9", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 3, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-notification-initial-get503-en-320-axe.json", + "sha256": "7fca66307879c302a9789fd8b18e50e7446aaaa93c6494ca2939d157eb484d8b", + "violations": 0, + "incompleteGroups": 2, + "incompleteNodes": 3, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-notification-save503-draft-en-320-axe.json", + "sha256": "7fca66307879c302a9789fd8b18e50e7446aaaa93c6494ca2939d157eb484d8b", + "violations": 0, + "incompleteGroups": 2, + "incompleteNodes": 3, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-gmail-status503-independent-providers-en-390-axe.json", + "sha256": "505b7cad594f5ea8d48edd2974020f9d17626b2b1cd3a3e8ee9caa9b4ff3c389", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-320-nb-dark-axe.json", + "sha256": "4ebfa00c4389905c8ec6580fa86299c605388a2a80599c306361bb6f080c831b", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-320-nb-dark-axe.json", + "sha256": "8a2c88c22638ec87e930799a201d6f92998dbd908570dfbd7f951f91dcdd764b", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-390-en-light-axe.json", + "sha256": "df10ac73cf443967575a4dfedc3536d418b69298c8b17bbbac660cc220a261ca", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-390-en-light-axe.json", + "sha256": "8a2c88c22638ec87e930799a201d6f92998dbd908570dfbd7f951f91dcdd764b", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-768-nb-light-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-768-nb-light-axe.json", + "sha256": "8a2c88c22638ec87e930799a201d6f92998dbd908570dfbd7f951f91dcdd764b", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1024-en-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1024-en-dark-axe.json", + "sha256": "56f9f9728201d4e792e0783a88950dbc0f4263c05a3fb131d9b094c5e801306a", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1280-nb-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1280-nb-dark-axe.json", + "sha256": "56f9f9728201d4e792e0783a88950dbc0f4263c05a3fb131d9b094c5e801306a", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1440-en-light-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1440-en-light-axe.json", + "sha256": "56f9f9728201d4e792e0783a88950dbc0f4263c05a3fb131d9b094c5e801306a", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/notifications-1920-en-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-09/data-final-20261020/controlled-long-address-connections-1920-en-dark-axe.json", + "sha256": "56f9f9728201d4e792e0783a88950dbc0f4263c05a3fb131d9b094c5e801306a", + "violations": 0, + "incompleteGroups": 1, + "incompleteNodes": 5, + "incompleteRules": [ + "color-contrast" + ] + } + ], + "focusedFrontend": { + "report": ".artifacts/phase7/current/unit/data-final-focused-20261020T074922Z-command.json", + "sha256": "4c7204b5e4e8f293a6ddd32c81484aeb518706f46dcb07af4c9d189faf885ff8", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "test", + "--", + "--runInBand", + "--watchAll=false", + "--json", + "--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\data-final-focused-20261020T074922Z.json", + "src/ai-privacy-settings.test.tsx", + "src/ai-usage-card.test.tsx", + "src/backup-lifetime.test.tsx", + "src/account-data-export.test.tsx", + "src/settings-import-lifetime.test.tsx", + "src/settings-view.test.tsx" + ], + "startedUtc": "2026-10-20T07:49:22.892045+00:00", + "finishedUtc": "2026-10-20T07:49:52.920162+00:00", + "exitCode": 0, + "counts": { + "numPassedTests": 47, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "numPassedTestSuites": 6, + "numFailedTestSuites": 0 + } + }, + "existingBackendContracts": { + "report": ".artifacts/phase7/current/backend-career/p709-data-contracts-20261020T074451Z/command.json", + "sha256": "622fe0542faa7a43f047a7bae2c1f03741f21e43a2aa20a7f55f223713e64366", + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-build", + "--filter", + "FullyQualifiedName~AiPrivacyPolicyTests|FullyQualifiedName~AccountDataExportTests|FullyQualifiedName~AccountDeletionTests|FullyQualifiedName~BillingControllerTests", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-data-contracts-20261020T074451Z" + ], + "startedUtc": "2026-10-20T07:44:51.950856+00:00", + "finishedUtc": "2026-10-20T07:44:55.839722+00:00", + "exitCode": 0, + "logSha256": "8d53bf5c33de39b9d7c3b7d29a12b50351428dbabbcc68271565dbe81acc9088", + "counts": { + "total": 22, + "passed": 22, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "47b7f946d574a3bff6328e201700d62e4602d85957c6c8751e7cf4d15449fd08" + }, + "inventory": { + "frontendTests": 855, + "frontendSuites": 99, + "browserTests": 339, + "backendTests": 1057, + "fullSuiteClaim": false + }, + "visualInspection": { + "newImagesReviewed": [ + "backup-disabled-320-nb-dark", + "privacy-dirty-768-nb-light", + "usage-actual-free-independent-read-recovery-en-light-390", + "privacy-dirty-320-nb-dark", + "backup-actual-readonly-lifecycle-and-protected-export-en-light-390", + "privacy-dirty-1440-en-light" + ], + "scope": "Six actual PNGs inspected. Other retained PNGs are automated evidence. Human/device/zoom/output acceptance remains pending." + }, + "incompleteTriage": { + "color-contrast:shortTextContent": { + "nodes": 37, + "context": "Invisible aria-hidden zero-count badges; not a passing contrast claim." + }, + "color-contrast:bgOverlap": { + "nodes": 99, + "context": "Fixed chrome, focused/scrolled controls and snackbar overlap prevents background determination; contextual contrast/manual requirements retained." + }, + "aria-hidden-focus:focusable-modal-open": { + "nodes": 5, + "context": "Closing MUI section-selector menu portals marked aria-hidden, with option still mounted during transition. Keyboard selector focus recovery passes; settled tabbing and human AT acceptance remain qualified. No axe exclusion." + } + } +} diff --git a/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-static-checks.json b/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-static-checks.json new file mode 100644 index 0000000..3963e53 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/data-privacy-backup-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-09", + "status": "PASS", + "checkedUtc": "2026-10-20T07:58:14.045820+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-20T07:58:09.739974+00:00", + "finishedUtc": "2026-10-20T07:58:12.899975+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T075809Z/secret-scan.log", + "logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-20T07:58:12.899975+00:00", + "finishedUtc": "2026-10-20T07:58:13.025608+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T075809Z/working-whitespace.log", + "logSha256Raw": "398baedb3d20830c4a35d0dba3a14da19716bd138bdd645c8f4fcca9c6ef2737" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-20T07:58:13.026116+00:00", + "finishedUtc": "2026-10-20T07:58:13.120188+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T075809Z/staged-whitespace.log", + "logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-20T07:58:13.120188+00:00", + "finishedUtc": "2026-10-20T07:58:13.345018+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T075809Z/phase7-whitespace.log", + "logSha256Raw": "afcd01be43a972cd44330f1452b03e04f3c1f09e189ecb95b85e6cce9b8b1989" + } + ], + "untrackedSecretScan": { + "files": 6, + "findings": 0 + }, + "documentation": { + "files": 30, + "relativeLinks": 621, + "errors": [] + }, + "preservation": { + "groups": 57, + "statuses": { + "PARTIAL": 56, + "PASS": 0, + "FAIL": 0, + "DEFERRED": 1 + }, + "originalRoutesPreserved": true, + "currentConcreteRoutes": 33 + }, + "production": { + "main": "cc7977131a4f991578f29d83eec17a242a858fca", + "originMain": "cc7977131a4f991578f29d83eec17a242a858fca", + "status": "NO-GO", + "qualification": "Local fetched refs; direct push observation recorded separately." + }, + "hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.", + "limits": [ + "Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.", + "Local links check target existence, not URL/heading anchors.", + "Static evidence is not runtime/manual/device/provider/production acceptance." + ] +} diff --git a/docs/redesign/phase-7/p7-09/evidence/owner-authorized-release.json b/docs/redesign/phase-7/p7-09/evidence/owner-authorized-release.json new file mode 100644 index 0000000..f417200 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/owner-authorized-release.json @@ -0,0 +1,201 @@ +{ + "schemaVersion": 1, + "scope": "One owner-authorized promotion of completed published e7dba9a; this receipt does not certify deployment or technical readiness.", + "authorization": { + "source": "Direct owner instruction in parent conversation, recorded in original push receipt", + "chronology": [ + "Owner requested main promotion.", + "Owner explicitly accepted recorded NO-GOs and pending items and requested live now.", + "Root promoted only the latest completed published revision; unfinished work was excluded." + ], + "recordedText": "Owner explicitly requested main push and accepted recorded NO-GOs/pending items on 2026-10-17. One promotion of latest completed published development commit only; unfinished import/backend work excluded; no future automatic deployment authorization.", + "futureAutomaticMainPromotionAuthorized": false + }, + "promotion": { + "command": [ + "rtk", + "proxy", + "git", + "push", + "origin", + "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df:refs/heads/main" + ], + "startedUtc": "2026-10-17T13:54:33.243078+00:00", + "finishedUtc": "2026-10-17T13:54:35.214338+00:00", + "exitCode": 0, + "previousMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "candidate": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df" + }, + "directRefs": { + "observedUtc": "2026-10-17T13:54:35.567674+00:00", + "localMain": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "originMain": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "liveRemoteMain": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "developmentHead": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "liveRemoteDevelopment": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "qualification": "Remote-ref equality is not deployed runtime identity and does not imply a clean worktree." + }, + "excludedWork": { + "description": "Unfinished import compatibility, Settings lifetime, backend company-create prerequisite, associated tests and documentation were not included.", + "pendingPathsBefore": [ + "M JobTrackerApi/Controllers/CompaniesController.cs", + " M docs/redesign/phase-7/COMMITS.md", + " M docs/redesign/phase-7/PRESERVATION.md", + " M docs/redesign/phase-7/README.md", + " M docs/redesign/phase-7/p7-09/PREFERENCES.md", + " M docs/redesign/phase-7/p7-09/README.md", + " M docs/redesign/phase-7/p7-09/evidence/preferences-results.json", + " M job-tracker-ui/e2e/csv-export.spec.ts", + " M job-tracker-ui/src/components/ImportExportJobs.tsx", + " M job-tracker-ui/src/components/SettingsView.tsx", + " M job-tracker-ui/src/i18n/translations.ts", + "?? JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs", + "?? docs/redesign/phase-7/p7-09/IMPORT_COMPATIBILITY.md", + "?? job-tracker-ui/e2e/phase7-job-import.spec.ts", + "?? job-tracker-ui/src/job-import.test.tsx", + "?? job-tracker-ui/src/settings-import-lifetime.test.tsx" + ], + "pendingPathsAfter": [ + "M JobTrackerApi/Controllers/CompaniesController.cs", + " M docs/redesign/phase-7/COMMITS.md", + " M docs/redesign/phase-7/PRESERVATION.md", + " M docs/redesign/phase-7/README.md", + " M docs/redesign/phase-7/p7-09/PREFERENCES.md", + " M docs/redesign/phase-7/p7-09/README.md", + " M docs/redesign/phase-7/p7-09/evidence/preferences-results.json", + " M job-tracker-ui/e2e/csv-export.spec.ts", + " M job-tracker-ui/src/components/ImportExportJobs.tsx", + " M job-tracker-ui/src/components/SettingsView.tsx", + " M job-tracker-ui/src/i18n/translations.ts", + "?? JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs", + "?? docs/redesign/phase-7/p7-09/IMPORT_COMPATIBILITY.md", + "?? job-tracker-ui/e2e/phase7-job-import.spec.ts", + "?? job-tracker-ui/src/job-import.test.tsx", + "?? job-tracker-ui/src/settings-import-lifetime.test.tsx" + ], + "pathListsUnchanged": true + }, + "ciObservations": [ + { + "observedUtc": "2026-10-17T13:55:16.507277+00:00", + "method": "GET", + "httpStatus": 200, + "matchingRuns": [ + { + "id": 820, + "name": null, + "head_branch": "main", + "head_sha": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "event": "push", + "status": "in_progress", + "conclusion": null, + "html_url": "https://git.cesnimda.uk/cesnimda/jobtrackingapp/actions/runs/820", + "created_at": null, + "updated_at": null, + "run_number": 401 + } + ] + }, + { + "observedUtc": "2026-10-17T13:56:07.968311+00:00", + "method": "GET", + "httpStatus": 200, + "matchingRuns": [ + { + "id": 820, + "name": null, + "head_branch": "main", + "head_sha": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df", + "event": "push", + "status": "in_progress", + "conclusion": null, + "html_url": "https://git.cesnimda.uk/cesnimda/jobtrackingapp/actions/runs/820", + "created_at": null, + "updated_at": null, + "run_number": 401, + "jobs": [ + { + "id": 1699, + "name": "test", + "status": "in_progress", + "conclusion": null, + "started_at": "2026-10-17T13:55:05Z", + "completed_at": "1970-01-01T00:00:00Z" + }, + { + "id": 1700, + "name": "deploy", + "status": "waiting", + "conclusion": null, + "started_at": "1970-01-01T00:00:00Z", + "completed_at": "1970-01-01T00:00:00Z" + } + ] + } + ] + } + ], + "decisions": { + "technicalProductionReadiness": "NO-GO qualifications retained", + "ownerAuthorizedPromotion": "PUSHED", + "deploymentVerification": "PENDING", + "verifiedLiveRuntime": false, + "unfinishedP709DataBoundary": "PENDING", + "phase8": "NOT STARTED" + }, + "remainingRequirements": [ + "Production ingress/topology and trusted peer evidence", + "Complete backup/restore recovery set", + "Effective schema/configuration/secrets verification", + "Tested rollback", + "Deployed runtime revision, authenticated smoke and monitoring", + "Applicable human screen-reader, physical device, provider and output qualifications", + "P5-13 remains withheld" + ], + "report": { + "path": "docs/redesign/phase-7/p7-09/OWNER_AUTHORIZED_RELEASE.md", + "sha256": "4a21780015104ea82fa57cb9e5b0c024b8f707672c48029167c3e30d8f6203f4", + "wordCount": 758, + "historicalWorkerReportSha256": "4601cd09e090183f2c2b403ece631324ad44e40c757d4927d44b6f4dce308f21", + "historicalWorkerWordCount": 412, + "continuationScope": "Root appended the separately authorized 20 October release repair observations; original promotion and CI observations below remain historical." + }, + "rawReferences": [ + { + "path": ".artifacts/phase7/p7-09/owner-authorized-main-push.json", + "sha256": "8374142c7bac98e2861d74404167a8a4ec6e7f68b8178f972a4c41f9e6f813b8", + "bytes": 3180 + }, + { + "path": ".artifacts/phase7/p7-09/owner-authorized-main-push.log", + "sha256": "2095817c619ba1fb6bfdda68809aec2d428dcf77b221fe1a38a1922ff2fd4c9d", + "bytes": 293 + }, + { + "path": ".artifacts/phase7/p7-09/main-ci/20261017T135515Z/observation.json", + "sha256": "65fa31fcf38a9e494263210f19fed2ba81a732b3f4fa1d28cdb6c395c97091d0", + "bytes": 711 + }, + { + "path": ".artifacts/phase7/p7-09/main-ci/20261017T135515Z/response.json", + "sha256": "ce9f25974b94a28ef5a585c2842532104efc693af523a6f6e3a4df86c8f4e006", + "bytes": 58756 + }, + { + "path": ".artifacts/phase7/p7-09/main-ci/20261017T135607Z/jobs-820.json", + "sha256": "75e9d80bbac0c4b33c2867228f64b65dc39ae387fddf990537688528c2eaba24", + "bytes": 6818 + }, + { + "path": ".artifacts/phase7/p7-09/main-ci/20261017T135607Z/observation.json", + "sha256": "1b81e386d4e099d75d8063b10326568d44d341a556756f9ddea88542fe44a656", + "bytes": 1213 + }, + { + "path": ".artifacts/phase7/p7-09/main-ci/20261017T135607Z/response.json", + "sha256": "ce9f25974b94a28ef5a585c2842532104efc693af523a6f6e3a4df86c8f4e006", + "bytes": 58756 + } + ], + "provenance": "Derived from existing root-agent push and read-only CI artifacts; this worker issued no Git push, API/network request, build, test or deployment. Later CI/deployment outcomes must be appended with their own observations." +} diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md index 284b327..53c6d68 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -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): 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. +- [x] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): implemented CONDITIONAL GO. Sender/Preferences/import/company published; remaining data presentation47/6 affected frontend,22backendcontracts, final native23/types/lint/build PASS. 37axe0violations/141incomplete nodes qualified; six final PNGs inspected. Final hygiene/publication recorded in checkpoint ledger. Inventory855/99frontend,339browser,1057backend; no fresh whole-suite claim. P5-13 withheld, manual/provider/output/release qualifications retained. - [ ] P7-10 Users, Audit and System administration presentation. - [ ] P7-11 residual Analytics/error/empty/dialog integration. - [ ] P7-12 integrated migration acceptance and Phase 8 handoff. diff --git a/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts b/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts index 84a7c93..a2e8d78 100644 --- a/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts +++ b/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts @@ -1,6 +1,8 @@ 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'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; @@ -40,7 +42,7 @@ const test = base.extend<{ session: Session }>({ 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); + const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings'; if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } return route.continue(); }); @@ -70,13 +72,120 @@ async function disconnected(session: Session, api: APIRequestContext) { for (con 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) }; + // MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets. + return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; }); await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]); } +test('[P7-09 privacy S04 E09] read-only recovery, retained failed draft and actual privacy persistence stay account scoped', async ({ page, playwright, session }, info) => { + const original = await json<{ enabled: boolean; externalProcessingAllowed: boolean }>(await call(session.requests, page.request, 'GET', '/ai/settings')); + let failedRead = false; + await page.route(`${apiUrl}/ai/settings`, route => { + if (route.request().method() === 'GET' && !failedRead) { failedRead = true; return route.fulfill({ status: 503, json: { title: 'Controlled unavailable read' } }); } + return route.continue(); + }); + await start(page, '/settings', 390); + const privacy = page.getByRole('region', { name: copy('settingsAiPrivacyTitle'), exact: true }); + await expect(privacy.getByRole('alert')).toContainText(copy('settingsAiPrivacyUnavailable')); + await keyboard(privacy.getByRole('button', { name: copy('retry'), exact: true })); + const enabled = privacy.getByRole('switch', { name: copy('settingsAiEnabled'), exact: true }); + await expect(enabled).toBeChecked({ checked: original.enabled }); + await expect(privacy.getByRole('group', { name: copy('settingsAiPrivacyTitle'), exact: true })).toBeFocused(); + expect(browserRequests(session, '/ai/settings', 'PUT')).toHaveLength(0); + await toggle(enabled); + await page.route(`${apiUrl}/ai/settings`, route => route.request().method() === 'PUT' ? route.fulfill({ status: 503, json: { title: 'Controlled unsaved write' } }) : route.continue()); + await keyboard(privacy.getByRole('button', { name: copy('settingsAiSave'), exact: true })); + await expect(privacy.getByRole('alert')).toContainText(copy('settingsAiSaveFailed')); + await expect(enabled).toBeChecked({ checked: !original.enabled }); + expect(await json(await call(session.requests, page.request, 'GET', '/ai/settings'))).toMatchObject(original); + await capture(page, info, 'privacy-controlled-save-failure-en-light-390'); + await page.unroute(`${apiUrl}/ai/settings`); + const saved = response(page, 'PUT', '/ai/settings'); await keyboard(privacy.getByRole('button', { name: copy('settingsAiSave'), exact: true })); expect((await saved).status()).toBe(200); + await expect(privacy.getByText(copy('assetsUnsavedChanges'), { exact: true })).toHaveCount(0); + expect(await json(await call(session.requests, page.request, 'GET', '/ai/settings'))).toMatchObject({ enabled: !original.enabled, externalProcessingAllowed: original.externalProcessingAllowed }); + await page.reload(); await expect(page.getByRole('switch', { name: copy('settingsAiEnabled'), exact: true })).toBeChecked({ checked: !original.enabled }); + const foreign = await playwright.request.newContext(); + try { const email=await createOwner(session.requests, session.admin, session.adminHeaders); const other=await login(session.requests, foreign, email); expect(other.id).not.toBe(session.owner.id); expect(await json(await call(session.requests, foreign, 'GET', '/ai/settings'))).toMatchObject(original); } + finally { await foreign.dispose(); } +}); + +test('[P7-09 privacy S04] bilingual theme widths retain dirty controls and explicit local discard without model calls', async ({ page, session }, info) => { + test.setTimeout(240_000); + const samples = [{ width: 320, language: 'nb', mode: 'dark' }, { width: 360, language: 'en', mode: 'light' }, { width: 390, language: 'en', mode: 'dark' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1280, language: 'nb', mode: 'light' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'nb', mode: 'dark' }] as const; + for (const sample of samples) { + expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: sample.language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204); + await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample); + await start(page, '/settings', sample.width); + const privacy=page.getByRole('region', { name: copy('settingsAiPrivacyTitle', sample.language), exact: true }); + const enabled=privacy.getByRole('switch', { name: copy('settingsAiEnabled', sample.language), exact: true }); await expect(enabled).toBeChecked(); await toggle(enabled); + await expect(privacy.getByRole('status')).toHaveText(copy('assetsUnsavedChanges', sample.language)); + await capture(page, info, `privacy-dirty-${sample.width}-${sample.language}-${sample.mode}`); + await keyboard(privacy.getByRole('button', { name: copy('assetsDiscardChanges', sample.language), exact: true })); await expect(enabled).toBeChecked(); + const bookmarklet=page.getByRole('textbox', { name: copy('settingsQuickCaptureManual', sample.language), exact: true }); + await bookmarklet.focus(); await expect(bookmarklet).toHaveAttribute('readonly', ''); + const code=await bookmarklet.inputValue(); expect(code).toContain("http://localhost:3300/?add="); expect(code).toContain('encodeURIComponent(location.href)'); + expect(await bookmarklet.evaluate(node => { const input=node as HTMLInputElement; return [input.selectionStart, input.selectionEnd]; })).toEqual([0, code.length]); + await choose(page, copy('settingsTabBackup', sample.language), sample.language); + const backup=page.getByRole('region', { name: copy('backupTitle', sample.language), exact: true }); await expect(backup.getByText(copy('accountDeleteDisabled', sample.language), { exact: true })).toBeVisible(); + await expect(backup.getByRole('button', { name: copy('accountDeleteButton', sample.language), exact: true })).toHaveCount(0); + await capture(page, info, `backup-disabled-${sample.width}-${sample.language}-${sample.mode}`); + } + expect(browserRequests(session, '/ai/settings', 'PUT')).toHaveLength(0); +}); + +test('[P7-09 usage S04] independent usage and billing GET failures recover without provider actions or invented disabled state', async ({ page, session }, info) => { + let usageFailed=false; let billingFailed=false; + await page.route(`${apiUrl}/ai/usage`, route => { if (!usageFailed) { usageFailed=true; return route.fulfill({ status: 503, json: { title: 'Controlled usage read failure' } }); } return route.continue(); }); + await page.route(`${apiUrl}/billing/status`, route => { if (!billingFailed) { billingFailed=true; return route.fulfill({ status: 503, json: { title: 'Controlled billing read failure' } }); } return route.continue(); }); + await start(page, '/settings', 390); + const usage=page.getByRole('region', { name: copy('settingsUsageTitle'), exact: true }); + await expect(usage.getByRole('heading', { name: copy('settingsUsageTitle'), exact: true })).toBeVisible(); + await expect(usage.getByRole('alert')).toHaveCount(2); await expect(usage.getByText(copy('settingsBillingNotConfigured'), { exact: true })).toHaveCount(0); + const usageError=usage.getByRole('alert').filter({ hasText: copy('settingsUsageUnavailable') }); + await keyboard(usageError.getByRole('button', { name: copy('retry'), exact: true })); + await expect(usage.getByText(copy('settingsUsageNoAi'), { exact: true })).toBeVisible(); + await expect(usage.getByRole('group', { name: copy('settingsUsageTitle'), exact: true })).toBeFocused(); + expect(browserRequests(session, '/billing/status', 'GET')).toHaveLength(1); + const billingError=usage.getByRole('alert').filter({ hasText: copy('settingsBillingUnavailable') }); + await keyboard(billingError.getByRole('button', { name: copy('retry'), exact: true })); + await expect(usage.getByText(copy('settingsBillingNotConfigured'), { exact: true })).toBeVisible(); + expect(browserRequests(session, '/ai/usage', 'GET')).toHaveLength(2); + await expect(usage.getByRole('button', { name: copy('settingsBillingUpgrade'), exact: true })).toHaveCount(0); + await capture(page, info, 'usage-actual-free-independent-read-recovery-en-light-390'); + expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]); +}); + +test('[P7-09 backup S05 J10] unknown lifecycle recovers read-only, actual protected ZIP download retains response bytes and disposal rejects late download', async ({ page, session }, info) => { + test.setTimeout(120_000); let failed=false; + await page.route(`${apiUrl}/account-lifecycle/status`, route => { if (!failed) { failed=true; return route.fulfill({ status: 503, json: { title: 'Controlled lifecycle read failure' } }); } return route.continue(); }); + await start(page, '/settings', 390); await choose(page, copy('settingsTabBackup')); + const backup=page.getByRole('region', { name: copy('backupTitle'), exact: true }); + const unavailable=backup.getByRole('alert').filter({ hasText: copy('accountLifecycleUnavailable') }); await expect(unavailable).toBeVisible(); + await expect(backup.getByText(copy('accountDeleteDisabled'), { exact: true })).toHaveCount(0); + await keyboard(unavailable.getByRole('button', { name: copy('retry'), exact: true })); + await expect(backup.getByText(copy('accountDeleteDisabled'), { exact: true })).toBeVisible(); + await expect(backup.getByRole('group', { name: copy('backupTitle'), exact: true })).toBeFocused(); + expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]); + const downloadEvent=page.waitForEvent('download'); const exportResponse=response(page, 'POST', '/export/account'); + await keyboard(backup.getByRole('button', { name: copy('accountExportDownload'), exact: true })); + const result=await exportResponse; expect(result.status()).toBe(200); const downloaded=await downloadEvent; expect(downloaded.suggestedFilename()).toMatch(/\.zip$/); + const savedPath=await downloaded.path(); expect(savedPath).not.toBeNull(); const bytes=await readFile(savedPath!); expect(bytes.equals(await result.body())).toBe(true); expect(bytes.readUInt32LE(0)).toBe(0x04034b50); expect(bytes.includes(Buffer.from([0x50, 0x4b, 0x05, 0x06]))).toBe(true); + await info.attach('actual-account-zip-byte-identity', { body: JSON.stringify({ source: 'Actual protected isolated account export response and browser download', byteLength: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), restoreVerified: false }), contentType: 'application/json' }); + await capture(page, info, 'backup-actual-readonly-lifecycle-and-protected-export-en-light-390'); + await page.getByRole('alert').filter({ hasText: copy('accountExportDownloaded') }).getByRole('button').click(); + const held=hold(); const received=hold(); + await page.route(`${apiUrl}/export/account`, async route => { const accepted=await route.fetch(); received.release(); await held.pending; await route.fulfill({ response: accepted }); }); + const lateDownloads: string[]=[]; page.on('download', value => lateDownloads.push(value.suggestedFilename())); + await keyboard(backup.getByRole('button', { name: copy('accountExportDownload'), exact: true })); await received.pending; + await choose(page, copy('settingsTabNotifications')); const lateResponse=response(page, 'POST', '/export/account'); held.release(); expect((await lateResponse).status()).toBe(200); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await expect(page.getByRole('alert').filter({ hasText: copy('accountExportDownloaded') })).toHaveCount(0); + expect(lateDownloads).toEqual([]); expect(browserRequests(session, '/account-lifecycle/delete', 'POST')).toHaveLength(0); +}); + 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'); diff --git a/job-tracker-ui/src/ai-privacy-settings.test.tsx b/job-tracker-ui/src/ai-privacy-settings.test.tsx new file mode 100644 index 0000000..4e1e936 --- /dev/null +++ b/job-tracker-ui/src/ai-privacy-settings.test.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import AiPrivacySettingsCard from "./components/AiPrivacySettingsCard"; +import { I18nProvider, useI18n } from "./i18n/I18nProvider"; +import { translations } from "./i18n/translations"; +import { api } from "./api"; + +const mockToast = jest.fn(); +jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn() } })); +jest.mock("./toast", () => ({ useToast: () => ({ toast: mockToast }), useOptionalToast: () => null })); +const saved = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: true, effectiveExternalProcessing: false, provider: "local" }; +function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((yes, no) => { resolve=yes; reject=no; }); return { promise, resolve, reject }; } +function Locale() { const { setLanguage }=useI18n(); return ; } +function mount() { return render(); } +beforeEach(() => { jest.clearAllMocks(); window.localStorage.clear(); (api.get as jest.Mock).mockResolvedValue({ data: saved }); }); + +test("[P7-09 privacy] failed initial read has a read-only Retry and no perpetual loading", async () => { + (api.get as jest.Mock).mockRejectedValueOnce(new Error("offline")); mount(); + await screen.findByRole("alert"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: translations.en.retry })); + expect(await screen.findByRole("switch", { name: translations.en.settingsAiEnabled })).toBeChecked(); + await waitFor(() => expect(screen.getByRole("group", { name: translations.en.settingsAiPrivacyTitle })).toHaveFocus()); + expect(api.get).toHaveBeenCalledTimes(2); expect(api.put).not.toHaveBeenCalled(); +}); + +test("[P7-09 privacy] submitted acknowledgement preserves a newer edit and serializes Save", async () => { + const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); mount(); + const enabled=await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); + fireEvent.click(enabled); const save=screen.getByRole("button", { name: translations.en.settingsAiSave }); + fireEvent.click(save); fireEvent.click(save); + expect(api.put).toHaveBeenCalledTimes(1); + expect(api.put).toHaveBeenCalledWith("/ai/settings", { enabled: false, externalProcessingAllowed: false }); + fireEvent.click(enabled); + await act(async () => held.resolve({ data: { ...saved, enabled: false } })); + expect(enabled).toBeChecked(); expect(screen.getByText(translations.en.assetsUnsavedChanges)).toBeInTheDocument(); +}); + +test("[P7-09 privacy] a failed save retains the draft and never replays through read recovery", async () => { + (api.put as jest.Mock).mockRejectedValue(new Error("offline")); mount(); + const enabled=await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); fireEvent.click(enabled); + fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave })); await screen.findByRole("alert"); + expect(enabled).not.toBeChecked(); expect(api.get).toHaveBeenCalledTimes(1); expect(api.put).toHaveBeenCalledTimes(1); + expect(screen.getByText(translations.en.assetsUnsavedChanges)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: translations.en.assetsDiscardChanges })); + expect(enabled).toBeChecked(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(api.put).toHaveBeenCalledTimes(1); +}); + +test("[P7-09 privacy] unmount ignores an old save acknowledgement and success notification", async () => { + const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); const view=mount(); + await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); + fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave })); view.unmount(); + await act(async () => held.resolve({ data: saved })); expect(mockToast).not.toHaveBeenCalled(); +}); + +test("[P7-09 privacy] pending save uses the current language without refetching the draft", async () => { + const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); mount(); + await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave })); + fireEvent.click(screen.getByRole("button", { name: "Bokmål" })); + await screen.findByRole("switch", { name: translations.nb.settingsAiEnabled }); + await act(async () => held.resolve({ data: saved })); + await waitFor(() => expect(mockToast).toHaveBeenCalledWith(translations.nb.settingsAiSaved, "success")); expect(api.get).toHaveBeenCalledTimes(1); +}); + +test("[P7-09 privacy] local-only capability permits revoking a historical external consent", async () => { + (api.get as jest.Mock).mockResolvedValue({ data: { ...saved, externalProcessingAvailable: false, externalProcessingAllowed: true } }); mount(); + const external=await screen.findByRole("switch", { name: translations.en.settingsAiExternalAllowed }); expect(external).toBeEnabled(); fireEvent.click(external); expect(external).not.toBeChecked(); expect(external).toBeDisabled(); + expect(api.put).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/ai-usage-card.test.tsx b/job-tracker-ui/src/ai-usage-card.test.tsx index 560dc94..73b0f96 100644 --- a/job-tracker-ui/src/ai-usage-card.test.tsx +++ b/job-tracker-ui/src/ai-usage-card.test.tsx @@ -1,11 +1,14 @@ import React from "react"; import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import AiUsageCard from "./components/AiUsageCard"; import { api } from "./api"; jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() } })); -jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, params?: Record) => { +let mockLanguage = "en"; +let mockSession = "one"; +jest.mock("./sessionBoundary", () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSession, isSessionInvalidated: () => false })); +jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ language: mockLanguage, t: (key: string, params?: Record) => { const messages: Record = { settingsUsageTitle: "Account usage", settingsUsageUnavailable: "Account usage is temporarily unavailable.", @@ -16,12 +19,103 @@ jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, par settingsUsageReset: "AI limits reset at the start of each calendar month.", settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.", settingsBillingUpgrade: "Upgrade to Pro", + settingsBillingUnavailable: "Billing is temporarily unavailable.", + settingsUsageStorageProgress: "Attachment storage used", + loading: "Loading", + retry: "Retry", settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.", }; return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key); } }) })); -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { jest.clearAllMocks(); mockLanguage = "en"; mockSession = "one"; }); + +const usage = { currentMonth: { calls: 1234, estimatedTokens: 12000 }, plan: "pro", monthlyCallLimit: 2000, monthlyTokenLimit: 40000, storageUsedBytes: 1250000, storageLimitBytes: 250000000 }; +const billing = { enabled: true, canCheckout: true, canManage: false }; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } + +test("keeps its heading and named loading state, then retries only a failed usage GET", async () => { + const held = deferred(); + (api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.resolve({ data: billing }) : held.promise); + render(); + expect(screen.getByRole("heading", { name: "Account usage" })).toBeInTheDocument(); + expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0); + await act(async () => { held.resolve(Promise.reject(new Error("offline"))); }); + expect(await screen.findByText("Account usage is temporarily unavailable.")).toBeInTheDocument(); + (api.get as jest.Mock).mockResolvedValue({ data: usage }); + const retry = screen.getByRole("button", { name: "Retry" }); + act(() => retry.focus()); + fireEvent.click(retry); + expect(screen.getByRole("group", { name: "Account usage" })).toHaveFocus(); + expect(await screen.findByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument(); + expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/ai/usage"]); + expect(api.post).not.toHaveBeenCalled(); +}); + +test("billing failure remains visible beside usage and its Retry only reads billing", async () => { + (api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.reject(new Error("offline")) : Promise.resolve({ data: usage })); + render(); + expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument(); + expect(screen.getByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument(); + expect(screen.queryByText(/not available in this deployment/)).not.toBeInTheDocument(); + (api.get as jest.Mock).mockResolvedValue({ data: billing }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument(); + expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/billing/status"]); + expect(api.post).not.toHaveBeenCalled(); +}); + +test("locale changes format numbers and byte decimals without reloading; zero storage limit stays finite", async () => { + (api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : { ...usage, storageLimitBytes: 0 } })); + const view = render(); + expect(await screen.findByText("1,234 of 2,000 generations this month")).toBeInTheDocument(); + mockLanguage = "nb"; + view.rerender(); + expect(screen.getByText((_, element) => element?.tagName === "P" && element.textContent === `${(1234).toLocaleString("nb-NO")} of ${(2000).toLocaleString("nb-NO")} generations this month`)).toBeInTheDocument(); + expect(screen.getByText("1,3 MB of 0 KB attachment storage")).toBeInTheDocument(); + expect(screen.getByRole("progressbar", { name: "Attachment storage used" })).toHaveAttribute("aria-valuenow", "0"); + expect(api.get).toHaveBeenCalledTimes(2); +}); + +test.each(["unmount", "session"])("one billing POST ignores a late failure after %s", async boundary => { + const held = deferred(); + (api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : usage })); + (api.post as jest.Mock).mockReturnValue(held.promise); + const view = render(); + const button = await screen.findByRole("button", { name: "Upgrade to Pro" }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(api.post).toHaveBeenCalledTimes(1); + if (boundary === "unmount") view.unmount(); else mockSession = "two"; + await act(async () => { held.resolve(Promise.reject(new Error("offline"))); }); + expect(screen.queryByText("Billing is temporarily unavailable.")).not.toBeInTheDocument(); +}); + +test.each(["unmount", "session"])("late billing success after %s never consumes the redirect URL", async boundary => { + const held = deferred(); + const url = jest.fn(() => "https://billing.example/checkout"); + (api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? billing : usage })); + (api.post as jest.Mock).mockReturnValue(held.promise); + const view = render(); + fireEvent.click(await screen.findByRole("button", { name: "Upgrade to Pro" })); + if (boundary === "unmount") view.unmount(); else mockSession = "two"; + await act(async () => { held.resolve({ data: { get url() { return url(); } } }); }); + expect(url).not.toHaveBeenCalled(); +}); + +test("a failed billing POST offers the existing explicit action without automatic replay", async () => { + (api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? { ...billing, canCheckout: false, canManage: true } : usage })); + (api.post as jest.Mock).mockRejectedValue(new Error("offline")); + render(); + fireEvent.click(await screen.findByRole("button", { name: "settingsBillingManage" })); + expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument(); + expect(api.post).toHaveBeenCalledTimes(1); + expect(api.post).toHaveBeenCalledWith("/billing/portal"); + expect(api.get).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "settingsBillingManage" })); + await screen.findByText("Billing is temporarily unavailable."); + expect(api.post).toHaveBeenCalledTimes(2); +}); test("shows monthly AI call and token limits", async () => { (api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ diff --git a/job-tracker-ui/src/backup-lifetime.test.tsx b/job-tracker-ui/src/backup-lifetime.test.tsx new file mode 100644 index 0000000..e1bf0bc --- /dev/null +++ b/job-tracker-ui/src/backup-lifetime.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import BackupCard from "./components/BackupCard"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { translations } from "./i18n/translations"; +import { api } from "./api"; + +const mockToast = jest.fn(); const mockPrompt = jest.fn(); +jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback })); +jest.mock("./toast", () => ({ useToast: () => ({ toast: mockToast }), useOptionalToast: () => null })); +jest.mock("./prompt", () => ({ usePrompt: () => ({ prompt: mockPrompt }) })); +const saved={ deletionEnabled: false, deletionStatus: "active", requiredConfirmation: "DELETE owner@example.test", request: null }; +function deferred() { let resolve!: (value: T) => void; const promise=new Promise(yes => { resolve=yes; }); return { promise, resolve }; } +function mount() { return render(); } +beforeEach(() => { jest.clearAllMocks(); window.localStorage.clear(); (api.get as jest.Mock).mockResolvedValue({ data: saved }); (api.post as jest.Mock).mockReset().mockRejectedValue(new Error('Unexpected mutation')); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test') }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }); jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); }); +afterEach(() => jest.restoreAllMocks()); + +test("[P7-09 backup] unknown lifecycle never claims disabled and Retry only reads", async () => { + (api.get as jest.Mock).mockRejectedValueOnce(new Error("offline")); mount(); + const retry=await screen.findByRole('button', { name: translations.en.retry }); + expect(screen.queryByText(translations.en.accountDeleteDisabled)).not.toBeInTheDocument(); + retry.focus(); fireEvent.click(retry); await screen.findByText(translations.en.accountDeleteDisabled); + expect(screen.getByRole('group', { name: translations.en.backupTitle })).toHaveFocus(); + expect(api.get).toHaveBeenCalledTimes(2); expect(api.post).not.toHaveBeenCalled(); +}); + +test("[P7-09 backup] synchronous duplicate and competing downloads admit only one request", async () => { + const held=deferred(); (api.post as jest.Mock).mockReturnValue(held.promise); mount(); + await screen.findByText(translations.en.accountDeleteDisabled); + const readable=screen.getByRole('button', { name: translations.en.accountExportDownload }); const encrypted=screen.getByRole('button', { name: translations.en.backupDownload }); + act(() => { readable.click(); readable.click(); encrypted.click(); }); + expect(api.post).toHaveBeenCalledTimes(1); + await act(async () => held.resolve({ data: new Blob(['zip']), headers: {} })); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); +}); + +test("[P7-09 backup] disposal rejects a late readable export without downloading private bytes or notifying", async () => { + const held=deferred(); (api.post as jest.Mock).mockReturnValue(held.promise); const view=mount(); + fireEvent.click(screen.getByRole('button', { name: translations.en.accountExportDownload })); view.unmount(); + await act(async () => held.resolve({ data: new Blob(['private zip']), headers: {} })); + expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(mockToast).not.toHaveBeenCalled(); +}); + +test("[P7-09 backup] disposal during the existing deletion prompt cannot issue a later delete", async () => { + (api.get as jest.Mock).mockResolvedValue({ data: { ...saved, deletionEnabled: true } }); + const held=deferred(); mockPrompt.mockReturnValue(held.promise); const view=mount(); + fireEvent.click(await screen.findByRole('button', { name: translations.en.accountDeleteButton })); view.unmount(); + await act(async () => held.resolve(saved.requiredConfirmation)); expect(api.post).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/components/AiPrivacySettingsCard.tsx b/job-tracker-ui/src/components/AiPrivacySettingsCard.tsx index a1a6a22..7d0112e 100644 --- a/job-tracker-ui/src/components/AiPrivacySettingsCard.tsx +++ b/job-tracker-ui/src/components/AiPrivacySettingsCard.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Alert, Box, Button, FormControlLabel, Skeleton, Switch, Typography } from "@mui/material"; import { PageSection } from "../layout/PageLayout"; import { api } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { useToast } from "../toast"; +import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; type AiSettings = { enabled: boolean; @@ -17,19 +18,47 @@ export default function AiPrivacySettingsCard() { const { t } = useI18n(); const { toast } = useToast(); const [settings, setSettings] = useState(null); + const [savedSettings, setSavedSettings] = useState(null); const [failed, setFailed] = useState(false); const [saving, setSaving] = useState(false); + const [readAttempt, setReadAttempt] = useState(0); + const [loading, setLoading] = useState(true); + const session = useRef({ mounted: true, generation: 0, saving: false }); + const currentTranslation = useRef(t); + const body = useRef(null); + const retryFocus = useRef(null); + const dirty = Boolean(settings && savedSettings && (settings.enabled !== savedSettings.enabled || settings.externalProcessingAllowed !== savedSettings.externalProcessingAllowed)); + useEffect(() => { currentTranslation.current = t; }, [t]); + useEffect(() => { + if (!loading && settings && retryFocus.current) { + if (document.activeElement === document.body || document.activeElement === retryFocus.current) body.current?.focus(); + retryFocus.current = null; + } + }, [loading, settings]); useEffect(() => { - let active = true; + const owner = session.current; + owner.mounted = true; + const generation = ++owner.generation; + const context = getSessionContext(); + const active = () => owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context; api.get("/ai/settings") - .then((response) => { if (active) setSettings(response.data); }) - .catch(() => { if (active) setFailed(true); }); - return () => { active = false; }; - }, []); + .then((response) => { if (active()) { setSettings(response.data); setSavedSettings(response.data); } }) + .catch(() => { if (active()) setFailed(true); }) + .finally(() => { if (active()) setLoading(false); }); + return () => { owner.mounted = false; owner.generation += 1; }; + }, [readAttempt]); const save = async () => { - if (!settings) return; + checkSessionCookie(); + const owner = session.current; + if (!settings || !owner.mounted || owner.saving || isSessionInvalidated()) return; + const submitted = settings; + const context = getSessionContext(); + const generation = owner.generation; + const active = () => owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context; + owner.saving = true; + if (body.current?.contains(document.activeElement)) body.current.focus(); setSaving(true); setFailed(false); try { @@ -37,19 +66,21 @@ export default function AiPrivacySettingsCard() { enabled: settings.enabled, externalProcessingAllowed: settings.externalProcessingAllowed, }); - setSettings(response.data); - toast(t("settingsAiSaved"), "success"); + if (!active()) return; + setSavedSettings(response.data); + setSettings(current => current === submitted || !current ? response.data : { ...response.data, enabled: current.enabled, externalProcessingAllowed: current.externalProcessingAllowed }); + toast(currentTranslation.current("settingsAiSaved"), "success"); } catch { - setFailed(true); + if (active()) setFailed(true); } finally { - setSaving(false); + if (active()) { owner.saving = false; setSaving(false); } } }; return ( - {failed ? {t("settingsAiPrivacyUnavailable")} : null} - {!settings ? : + {failed ? { retryFocus.current = document.activeElement as HTMLElement; setFailed(false); setLoading(true); setReadAttempt(value => value + 1); }}>{t("retry")} : undefined}>{t(settings ? "settingsAiSaveFailed" : "settingsAiPrivacyUnavailable")} : null} + {!settings ? loading ? : null : setSettings({ ...settings, enabled: event.target.checked })} />} label={t("settingsAiEnabled")} @@ -67,7 +98,10 @@ export default function AiPrivacySettingsCard() { ? t("settingsAiExternalAvailable") : t("settingsAiExternalLocalOnly")} - + {dirty ? {t("assetsUnsavedChanges")} : null} + + {dirty ? : null} + } ); diff --git a/job-tracker-ui/src/components/AiUsageCard.tsx b/job-tracker-ui/src/components/AiUsageCard.tsx index 64de68b..36ebfa3 100644 --- a/job-tracker-ui/src/components/AiUsageCard.tsx +++ b/job-tracker-ui/src/components/AiUsageCard.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Alert, Box, Button, LinearProgress, Skeleton, Typography } from "@mui/material"; import { PageSection } from "../layout/PageLayout"; import { api } from "../api"; import { useI18n } from "../i18n/I18nProvider"; +import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; type Usage = { currentMonth: { calls: number; estimatedTokens: number }; @@ -15,73 +16,111 @@ type Usage = { type BillingStatus = { enabled: boolean; canCheckout: boolean; canManage: boolean }; -function formatBytes(bytes: number) { - if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`; - if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; - return `${(bytes / 1_000_000_000).toFixed(1)} GB`; +function formatBytes(bytes: number, locale: string) { + if (bytes < 1_000_000) return `${Math.round(bytes / 1_000).toLocaleString(locale)} KB`; + const options = { minimumFractionDigits: 1, maximumFractionDigits: 1 }; + if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toLocaleString(locale, options)} MB`; + return `${(bytes / 1_000_000_000).toLocaleString(locale, options)} GB`; } export default function AiUsageCard() { - const { t } = useI18n(); + const { t, language } = useI18n(); + const locale = language === "nb" ? "nb-NO" : "en"; const [usage, setUsage] = useState(null); const [billing, setBilling] = useState(null); const [billingBusy, setBillingBusy] = useState(false); const [billingFailed, setBillingFailed] = useState(false); const [failed, setFailed] = useState(false); + const [billingReadFailed, setBillingReadFailed] = useState(false); + const [usageAttempt, setUsageAttempt] = useState(0); + const [billingAttempt, setBillingAttempt] = useState(0); + const owner = useRef({ mounted: false, generation: 0, billingBusy: false }); + const body = useRef(null); + + useEffect(() => { + const lifetime = owner.current; + lifetime.mounted = true; + lifetime.generation += 1; + return () => { lifetime.mounted = false; lifetime.generation += 1; }; + }, []); useEffect(() => { let active = true; + const context = getSessionContext(); + const current = () => active && !isSessionInvalidated() && getSessionContext() === context; api.get("/ai/usage") - .then((response) => { if (active) setUsage(response.data); }) - .catch(() => { if (active) setFailed(true); }); - api.get("/billing/status") - .then((response) => { if (active) setBilling(response.data); }) - .catch(() => { /* Billing is optional; usage remains available. */ }); + .then((response) => { if (current()) setUsage(response.data); }) + .catch(() => { if (current()) setFailed(true); }); return () => { active = false; }; - }, []); + }, [usageAttempt]); + + useEffect(() => { + let active = true; + const context = getSessionContext(); + const current = () => active && !isSessionInvalidated() && getSessionContext() === context; + api.get("/billing/status") + .then((response) => { if (current()) setBilling(response.data); }) + .catch(() => { if (current()) setBillingReadFailed(true); }); + return () => { active = false; }; + }, [billingAttempt]); const openBilling = async (path: "checkout" | "portal") => { + checkSessionCookie(); + const lifetime = owner.current; + if (!lifetime.mounted || lifetime.billingBusy || isSessionInvalidated()) return; + const generation = lifetime.generation; + const context = getSessionContext(); + const active = () => lifetime.mounted && lifetime.generation === generation && !isSessionInvalidated() && getSessionContext() === context; + lifetime.billingBusy = true; setBillingBusy(true); setBillingFailed(false); try { const response = await api.post<{ url: string }>(`/billing/${path}`); + checkSessionCookie(); + if (!active()) return; window.location.assign(response.data.url); } catch { + checkSessionCookie(); + if (!active()) return; setBillingFailed(true); setBillingBusy(false); + lifetime.billingBusy = false; } }; - if (failed) return {t("settingsUsageUnavailable")}; - if (!usage) return ; - - const callsPercent = usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0; - const tokensPercent = usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0; - const storagePercent = Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100); + const callsPercent = usage && usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0; + const tokensPercent = usage && usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0; + const storagePercent = usage && usage.storageLimitBytes > 0 ? Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100) : 0; return ( - {t("settingsUsagePlan", { plan: usage.plan })}}> + {t("settingsUsagePlan", { plan: usage.plan })} : undefined}> + + {failed ? { if (body.current?.contains(document.activeElement)) body.current.focus(); setFailed(false); setUsageAttempt(value => value + 1); }}>{t("retry")}}>{t("settingsUsageUnavailable")} : !usage ? : null} + {usage ? <> {usage.monthlyCallLimit === 0 ? {t("settingsUsageNoAi")} : <> - {t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })} + {t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(locale), limit: usage.monthlyCallLimit.toLocaleString(locale) })} - {t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })} + {t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(locale), limit: usage.monthlyTokenLimit.toLocaleString(locale) })} } - {t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })} + {t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes, locale), limit: formatBytes(usage.storageLimitBytes, locale) })} {t("settingsUsageReset")} + : null} + {billingReadFailed ? { if (body.current?.contains(document.activeElement)) body.current.focus(); setBillingReadFailed(false); setBillingAttempt(value => value + 1); }}>{t("retry")}}>{t("settingsBillingUnavailable")} : !billing ? : null} {billing?.canCheckout ? : null} {billing?.canManage ? : null} {billingFailed ? {t("settingsBillingUnavailable")} : null} - {usage.plan === "free" && billing?.enabled === false ? ( + {usage?.plan === "free" && billing?.enabled === false ? ( {t("settingsBillingNotConfigured")} ) : null} + ); } diff --git a/job-tracker-ui/src/components/BackupCard.tsx b/job-tracker-ui/src/components/BackupCard.tsx index 529347d..176d3ad 100644 --- a/job-tracker-ui/src/components/BackupCard.tsx +++ b/job-tracker-ui/src/components/BackupCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Alert, Box, Button, Divider, Typography } from "@mui/material"; import { PageSection } from "../layout/PageLayout"; @@ -7,6 +7,7 @@ import { clearAuthClientState } from "../auth"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { usePrompt } from "../prompt"; +import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; type AccountLifecycleStatus = { deletionEnabled: boolean; @@ -23,14 +24,37 @@ export default function BackupCard() { const [exportingAccount, setExportingAccount] = useState(false); const [deletingAccount, setDeletingAccount] = useState(false); const [lifecycle, setLifecycle] = useState(null); + const [lifecycleFailed, setLifecycleFailed] = useState(false); + const [loadingLifecycle, setLoadingLifecycle] = useState(true); + const [readAttempt, setReadAttempt] = useState(0); + const session = useRef({ mounted: true, generation: 0, busy: false }); + const currentTranslation = useRef(t); + const body = useRef(null); + useEffect(() => { currentTranslation.current = t; }, [t]); + useEffect(() => { + const owner=session.current; owner.mounted=true; owner.generation += 1; + return () => { owner.mounted=false; owner.generation += 1; }; + }, []); + + const beginAction = () => { + checkSessionCookie(); + const owner=session.current; + if (!owner.mounted || owner.busy || isSessionInvalidated()) return null; + owner.busy=true; + if (body.current?.contains(document.activeElement)) body.current.focus(); + const context=getSessionContext(); const generation=owner.generation; + return () => { checkSessionCookie(); return owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context; }; + }; useEffect(() => { let active = true; + const context=getSessionContext(); api.get("/account-lifecycle/status") - .then((response) => { if (active) setLifecycle(response.data); }) - .catch(() => { if (active) setLifecycle(null); }); + .then((response) => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLifecycle(response.data); }) + .catch(() => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLifecycleFailed(true); }) + .finally(() => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLoadingLifecycle(false); }); return () => { active = false; }; - }, []); + }, [readAttempt]); const downloadBlob = (blob: Blob, contentDisposition: string | undefined, fallbackName: string) => { const url = URL.createObjectURL(blob); @@ -45,63 +69,74 @@ export default function BackupCard() { }; const downloadAccountExport = async () => { + const active=beginAction(); if (!active) return; setExportingAccount(true); try { const res = await api.post("/export/account", null, { responseType: "blob" }); + if (!active()) return; downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobjakt-account-export-${new Date().toISOString().slice(0, 10)}.zip`); - toast(t("accountExportDownloaded"), "success"); + toast(currentTranslation.current("accountExportDownloaded"), "success"); } catch (error: any) { - toast(getApiErrorMessage(error, t("accountExportFailed")), "error"); + if (active()) toast(getApiErrorMessage(error, currentTranslation.current("accountExportFailed")), "error"); } finally { - setExportingAccount(false); + session.current.busy=false; + if (active()) setExportingAccount(false); } }; const downloadEncrypted = async () => { + const active=beginAction(); if (!active) return; setDownloading(true); try { const res = await api.post("/backup/encrypted", null, { responseType: "blob" }); + if (!active()) return; downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobtracker_backup_${new Date().toISOString().slice(0, 10)}.jtbackup`); - toast(t("backupDownloaded"), "success"); + toast(currentTranslation.current("backupDownloaded"), "success"); } catch (error: any) { - toast(getApiErrorMessage(error, t("backupFailed")), "error"); + if (active()) toast(getApiErrorMessage(error, currentTranslation.current("backupFailed")), "error"); } finally { - setDownloading(false); + session.current.busy=false; + if (active()) setDownloading(false); } }; const deleteAccount = async () => { if (!lifecycle?.deletionEnabled || lifecycle.deletionStatus !== "active") return; - const confirmation = await prompt({ - title: t("accountDeleteTitle"), - message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }), - confirmLabel: t("accountDeleteButton"), - cancelLabel: t("cancel"), - }); - if (confirmation === null) return; - if (confirmation.trim() !== lifecycle.requiredConfirmation) { - toast(t("accountDeleteConfirmationMismatch"), "error"); - return; - } + const active=beginAction(); if (!active) return; setDeletingAccount(true); try { + const confirmation = await prompt({ + title: t("accountDeleteTitle"), + message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }), + confirmLabel: t("accountDeleteButton"), + cancelLabel: t("cancel"), + }); + if (!active() || confirmation === null) return; + if (confirmation.trim() !== lifecycle.requiredConfirmation) { + toast(currentTranslation.current("accountDeleteConfirmationMismatch"), "error"); + return; + } const response = await api.post("/account-lifecycle/delete", { confirmation }); + if (!active()) return; setLifecycle((current) => current ? { ...current, deletionStatus: "pending", request: response.data } : current); clearAuthClientState(); window.location.assign("/login"); } catch (error: any) { - toast(getApiErrorMessage(error, t("accountDeleteFailed")), "error"); - setDeletingAccount(false); + if (active()) toast(getApiErrorMessage(error, currentTranslation.current("accountDeleteFailed")), "error"); + } finally { + session.current.busy=false; + if (active()) setDeletingAccount(false); } }; return ( + {t("accountExportBody")} - @@ -112,24 +147,25 @@ export default function BackupCard() { {t("backupBody")} - {t("accountDeleteTitle")} - {lifecycle?.deletionStatus === "pending" ? ( + {loadingLifecycle ? {t("loading")} : lifecycleFailed ? { body.current?.focus(); setLifecycleFailed(false); setLoadingLifecycle(true); setReadAttempt(value => value+1); }}>{t("retry")}}>{t("accountLifecycleUnavailable")} : lifecycle?.deletionStatus === "pending" ? ( {t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })} ) : lifecycle?.deletionEnabled ? ( {t("accountDeleteWarning")} - ) : ( {t("accountDeleteDisabled")} )} + ); } diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 86dd77d..5edd3fc 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1238,6 +1238,7 @@ export const translations = { settingsAiExternalLocalOnly: "This deployment is local-only. External processing cannot be enabled.", settingsAiSave: "Save AI privacy settings", settingsAiSaved: "AI privacy settings saved.", + settingsAiSaveFailed: "AI privacy settings could not be saved. Your changes are retained; review them before saving again.", settingsUsageTitle: "Account usage", settingsUsageUnavailable: "Account usage is temporarily unavailable.", settingsUsagePlan: "{plan} plan", @@ -2287,6 +2288,7 @@ export const translations = { accountDeleteFailed: "Account deletion could not be started.", accountDeletePending: "Account deletion is in progress ({stage}). Access has already been disabled.", accountDeleteDisabled: "Account deletion is not enabled yet. The deletion workflow remains safely off until retention, restore, and production cache-purge safeguards are approved.", + accountLifecycleUnavailable: "Account deletion availability could not be loaded. Retry checks availability only.", authStatusTitle: "Authentication", authStatusNotSignedIn: "Not signed in.", authStatusRoles: "Roles: {roles}", @@ -4008,6 +4010,7 @@ export const translations = { settingsAiExternalLocalOnly: "Denne installasjonen bruker bare lokal KI. Ekstern behandling kan ikke aktiveres.", settingsAiSave: "Lagre KI-personvern", settingsAiSaved: "Innstillinger for KI-personvern er lagret.", + settingsAiSaveFailed: "Kunne ikke lagre KI-personverninnstillingene. Endringene er beholdt; se gjennom dem før du lagrer på nytt.", settingsUsageTitle: "Kontobruk", settingsUsageUnavailable: "Kontobruk er midlertidig utilgjengelig.", settingsUsagePlan: "{plan}-abonnement", @@ -5057,6 +5060,7 @@ export const translations = { accountDeleteFailed: "Kontosletting kunne ikke startes.", accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.", accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.", + accountLifecycleUnavailable: "Kunne ikke hente status for kontosletting. Prøv igjen for å kontrollere tilgjengeligheten.", authStatusTitle: "Autentisering", authStatusNotSignedIn: "Ikke logget inn.", authStatusRoles: "Roller: {roles}",