diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index f3d5a5f..45583e2 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -2,6 +2,8 @@ ## Continuation — 20 October 2026 +Remaining P7-09 data presentation **8af9168014631633e38e8ba7cd22899fdf1092e2** was normally pushed and directly verified **07:58:49 UTC**, with clean HEAD=origin=remote development. P7-09 implemented scope is CONDITIONAL GO; qualifications remain. [P7-10 Users](p7-10/USERS.md) now has18/5 affected frontend,25backend and finalnative3/type/lint/build PASS; final hygiene/publication follows. Six new frontend cases derive861/99, three native cases derive342 browser tests; these are inventories, not fresh whole-suite results. Audit/System remain pending. + 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. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 7eed1b5..ae6b4ee 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -1,5 +1,7 @@ # Phase 7 — Initial preservation matrix +P7-10 [Users](p7-10/USERS.md) adds bounded PRES-A01/E09 proof without changing any group disposition: native administrator create/role persistence, target-session revocation, non-admin denial, last-admin conflict and deletion-disabled response, plus retained self-demotion/exact-email guards. Failed reads are distinct from empty; committed-write recovery does not replay writes; submitted form and immutable session disposal are tested. Desktop grid scrolling remains intentional and all action rows fit. Seven scans report zero violations with28incomplete nodes retained. Audit/System, shared accessibility qualification and all human/provider/production requirements remain open;57groups/33routes unchanged. + ## Import continuation — 20 October 2026 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. diff --git a/docs/redesign/phase-7/p7-10/README.md b/docs/redesign/phase-7/p7-10/README.md new file mode 100644 index 0000000..58fd4db --- /dev/null +++ b/docs/redesign/phase-7/p7-10/README.md @@ -0,0 +1,13 @@ +# P7-10 — Administration + +Entry: P7-09 **8af9168014631633e38e8ba7cd22899fdf1092e2**, normally pushed and directly verified **20 October 2026 07:58:49 UTC**, clean development HEAD=origin=remote. It completes the implemented Settings scope with 47/6 affected frontend, 22 existing backend contracts and 23 final native cases; manual/provider/output/production qualifications remain. + +Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md): exactly three separately validated and pushed boundaries, **Users → Audit → System**. Reuse existing routes, MUI/DataGrid, read/mutation owners and shared page/state components. No aggregate Admin dashboard or new API/schema contract. + +| Boundary | State | +|---|---| +| [Users](USERS.md) | Implemented CONDITIONAL GO; final hygiene/publication receipt follows | +| Audit | Pending; preserve the exact supported Undo allowlist and ordinary owner-filtered Restore distinction | +| System | Pending; preserve independent SMTP/probe/test/maintenance scopes, write-only secret semantics and truthful diagnostic availability | + +All 57 preservation groups retain **56 PARTIAL / C11 DEFERRED**, with 33 routes. P5-13 is withheld; followMode remains deferred. No Phase 8 implementation. Production readiness remains NO-GO; the independently owner-authorized main release is still unverified after run831 failed with browser crashes/malformed Playwright JavaScript. Do not infer admin recent-auth enforcement: the current Users/Audit/System actions do not implement that separate guard. Preserve actual account-lifecycle/export recent-auth checks and record this gap rather than inventing a security protocol during presentation migration. diff --git a/docs/redesign/phase-7/p7-10/USERS.md b/docs/redesign/phase-7/p7-10/USERS.md new file mode 100644 index 0000000..0391d82 --- /dev/null +++ b/docs/redesign/phase-7/p7-10/USERS.md @@ -0,0 +1,25 @@ +# P7-10 Users + +**Implemented CONDITIONAL GO; final hygiene and publication pending.** This bounded change preserves `/admin/users` and the existing authenticated administrator API contract. The shell retains the route h1; the owner uses the shared h2 header, h3 create section and existing desktop DataGrid/mobile cards. + +## Findings and changes + +The previous list catch converted every error to an empty list. Creation serialized a ProblemDetails object as `[object Object]`, and pending actions had no synchronous admission or disposal guard. A successful create could clear newer form values or start another private list read after unmount. Five new ordinary regressions failed against the prior owner while all four original cases passed. + +The page now uses existing `useViewResource`/`ViewStateNotice` for distinct loading, unavailable/denied and actual empty states. Retry only reloads the list. A native create form retains the exact email/password/Admin payload. All mutations share synchronous admission and current-session/lifetime checks, current-language feedback and a named pending focus target. Creation clears only submitted fields that have not changed; a committed create followed by failed refresh offers read recovery without replay. Accepted deletion HTTP202 is labelled queued, not completed. Server last-admin/self-demotion/exact-email/delete availability remain authoritative. + +Shared section/surface/density tokens replace owner-specific decorative surfaces. Mobile identities wrap. Desktop two-dimensional grid scrolling is intentional, named and keyboard reachable, with root overflow still checked. Final visual review exposed clipped wrapped action buttons; a strengthened browser assertion failed ordinarily before adding content-sized DataGrid rows. It now verifies every row action fits vertically and a horizontally scrolled action receives visible keyboard focus. Sorting, pagination and row identity remain unchanged. + +## Evidence + +- Frontend **18/18 cases in five suites PASS**: ten Users cases, including six additions, plus eight shared confirmation/dialog cases. Original self-demotion warning, other-admin warning, last-admin disable and exact-email confirmation remain ordinary passing cases. Queued-deletion feedback is tested with a mocked202, not an actual deletion. +- Existing backend **25/25 PASS**, zero skipped, covering Users controllers, API administrator authorization and session-context middleware with the unchanged Release binary. +- Final native **3/3 PASS**, zero skips/flaky/retries, **13:07:56–13:08:20 UTC**, export **`f6H1YeLZWstdE-yoN5W8B`**. Real isolated HTTP verifies administrator creation/role readback, revoked target sessions, non-admin403, last-admin409, deletion-disabled503 with retained account, GET-only503 recovery and keyboard creation. No reset email or actual account deletion runs. +- English/Bokmål light/dark samples at320,390,768,1024,1440,1920; seven all-enabled axe scans, zero violations,28 incomplete nodes. These are finite paired samples, not the full Cartesian product. +- App/browser TypeScript, lint and production build PASS. The intermediate compiler-memoization lint failure and its correction remain retained. [Portable results](evidence/users-results.json) record command/result hashes and explicitly post-run source identities. + +Both final320px Bokmål dark and1440px English light PNGs were actually inspected after the row correction. The other five images are retained automated evidence. Earlier candidate images and clipped-row RED remain historical evidence. Incompletes include invisible badges/overlapping backgrounds, the inherited roleless shell version badge and MUI DataGrid sort-tooltip wrapper. These are retained accessibility qualifications for the shared-owner integration; no axe rule or application assertion is disabled. Human screen-reader, physical touch/software keyboard, actual zoom and production output checks remain pending. + +## Preservation and remaining work + +PRES-A01/E09 and affected shared interaction contracts gain bounded proof without changing the 57-group disposition or33routes. No backend authorization, tenant data, lifecycle flag, CV renderer/PDF/revision or AI safeguard changes. Current admin actions lack a separate recent-auth guard; this change does not claim otherwise. Actual SMTP/reset delivery, accepted deletion completion, provider/model/system operations and production remain unverified. Audit and System follow as separate commits. Phase 8 remains outside scope. diff --git a/docs/redesign/phase-7/p7-10/evidence/users-results.json b/docs/redesign/phase-7/p7-10/evidence/users-results.json new file mode 100644 index 0000000..c4e8fc2 --- /dev/null +++ b/docs/redesign/phase-7/p7-10/evidence/users-results.json @@ -0,0 +1,259 @@ +{ + "observedUtc": "2026-10-20T13:08:49.381408+00:00", + "sourceScope": "Post-run observations, not pre-run captures", + "sources": { + "src/views/AdminUsersPage.tsx": "fc02891601b6dd5593210f872db86d18447e848955d60019350e1cda622761f0", + "src/admin-users-page.test.tsx": "ad167fc0f457ac082335edd23f729bce35d2246217337891f1f07fb9ccae9f71", + "src/i18n/translations.ts": "69ef9e733534731206a98b717e586a61ce1b78f6ed7b1b847b46b60b71ce0c32", + "e2e/phase7-admin-users.spec.ts": "53e2f7daa55627b66c20fc969ce553a6a73ea027da644be2531bcd467063c8f8" + }, + "gates": { + "typecheck": { + "path": ".artifacts/phase7/current/ui/typecheck-command.json", + "sha256": "92b1a8a58e4f2a5b49dbcc17bb1f7a164b437b75a77ce29dbd06a6ed5d00ffaf", + "name": "typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "typecheck" + ], + "startedUtc": "2026-10-20T13:07:13.238238+00:00", + "finishedUtc": "2026-10-20T13:07:23.292112+00:00", + "exitCode": 0 + }, + "lint": { + "path": ".artifacts/phase7/current/ui/lint-command.json", + "sha256": "e380e1fe5db37416bfae32f67fed85bb4827973220144fd7abdff63d842f012d", + "name": "lint", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "lint" + ], + "startedUtc": "2026-10-20T13:07:28.787691+00:00", + "finishedUtc": "2026-10-20T13:07:48.996027+00:00", + "exitCode": 0 + }, + "browser-typecheck": { + "path": ".artifacts/phase7/current/ui/browser-typecheck-command.json", + "sha256": "1916ff75a302812d544bb7d0095efe82000d3b192d890dabdfa0e8708d742a87", + "name": "browser-typecheck", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "exec", + "tsc", + "--", + "--project", + "../.artifacts/phase7/e2e-tsconfig.json", + "--noEmit" + ], + "startedUtc": "2026-10-20T13:07:23.294714+00:00", + "finishedUtc": "2026-10-20T13:07:28.785676+00:00", + "exitCode": 0 + }, + "build": { + "path": ".artifacts/phase7/current/ui/build-command.json", + "sha256": "fd1802d73fe5c992f3e2adac0e0f35fe28221efbc4e3b7f61a793e6dcd5e70d6", + "name": "build", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "run", + "build" + ], + "startedUtc": "2026-10-20T13:07:48.997996+00:00", + "finishedUtc": "2026-10-20T13:07:56.404637+00:00", + "exitCode": 0 + }, + "admin-users": { + "path": ".artifacts/phase7/current/ui/admin-users-command.json", + "sha256": "7fed5787b7d48faf52b0cf3a1fb65e0f8244177678f8738fa023cf50aefe6473", + "name": "admin-users", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-admin-users.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\admin-users-results" + ], + "startedUtc": "2026-10-20T13:07:56.410189+00:00", + "finishedUtc": "2026-10-20T13:08:20.400526+00:00", + "exitCode": 0 + } + }, + "images": [ + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-actual-get-recovery-en-light-390.png", + "sha256": "cbb227fbd4691b63f9f972ba868244456cc635895b3a5c9a2695f170e1449912" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-320-nb-dark.png", + "sha256": "42ebc78505ec0a0e937188b65bb23f6a15959a19ba307c4f9bce68b0a1b51249" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-390-en-light.png", + "sha256": "9d2d8c5c52f877c17d1e61cecfc9324865a02b00fde225768d9cca6cb470b26d" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-768-nb-light.png", + "sha256": "e7e47a6095da25a23902bf202e802f5f3ba89e4f8c7770b3a729b44ee5b681b1" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1024-en-dark.png", + "sha256": "ac5b41db31027a8873c825a91363ad11114e674573606ad8b7a1a386165c19de" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1440-en-light.png", + "sha256": "d80d2b1aa402d53429e1dc71c87a244cfb1d1af1d11fbf19916ab64523fb3c91" + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1920-nb-dark.png", + "sha256": "84a780ab684ebb9c6530e845bbc5cf70711d388881db12a4280af5214fdd05d1" + } + ], + "scans": [ + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-actual-get-recovery-en-light-390-axe.json", + "violations": 0, + "incompleteNodes": 3, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-320-nb-dark-axe.json", + "violations": 0, + "incompleteNodes": 3, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-390-en-light-axe.json", + "violations": 0, + "incompleteNodes": 3, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-768-nb-light-axe.json", + "violations": 0, + "incompleteNodes": 4, + "incompleteRules": [ + "aria-prohibited-attr", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1024-en-dark-axe.json", + "violations": 0, + "incompleteNodes": 5, + "incompleteRules": [ + "aria-prohibited-attr", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1440-en-light-axe.json", + "violations": 0, + "incompleteNodes": 5, + "incompleteRules": [ + "aria-prohibited-attr", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-10/users-final-visuals/users-long-identity-1920-nb-dark-axe.json", + "violations": 0, + "incompleteNodes": 5, + "incompleteRules": [ + "aria-prohibited-attr", + "color-contrast" + ] + } + ], + "frontend": { + "path": ".artifacts/phase7/current/unit/admin-users-final-20261020T130341Z-command.json", + "sha256": "4b5c069699c156d6a49fdc2b42cb0d6478f02950b1a1d6414bee0be1ba5157d8", + "command": [ + "rtk", + "proxy", + "npm.cmd", + "test", + "--", + "--runInBand", + "--watchAll=false", + "--json", + "--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\admin-users-final-20261020T130341Z.json", + "src/admin-users-page.test.tsx", + "src/confirm.test.tsx", + "src/confirm-flow.test.tsx", + "src/dialog-flow.test.tsx", + "src/modal-state.test.tsx" + ], + "startedUtc": "2026-10-20T13:03:41.037736+00:00", + "finishedUtc": "2026-10-20T13:03:51.672970+00:00", + "exitCode": 0, + "counts": { + "numPassedTests": 18, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "numPassedTestSuites": 5, + "numFailedTestSuites": 0 + } + }, + "backend": { + "path": ".artifacts/phase7/current/backend-career/p710-users-entry-20261020T125635Z/command.json", + "sha256": "70d503a6ff1c27f6b1361d388020ad13406c87b5e8079b96a428dddd436d3b2f", + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-build", + "--filter", + "FullyQualifiedName~UsersControllerTests|FullyQualifiedName~ApiAuthorizationPostureTests|FullyQualifiedName~SessionContextMiddlewareTests", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p710-users-entry-20261020T125635Z" + ], + "startedUtc": "2026-10-20T12:56:35.309094+00:00", + "finishedUtc": "2026-10-20T12:56:38.915911+00:00", + "exitCode": 0, + "logSha256": "d9e8e89d9b46ba4575d4f2fd52a63ff317897e698356cd499dad7d8616c893f5", + "counts": { + "total": 25, + "passed": 25, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "677e93e67549b1076d5a0dc804d8f0c86779d1a845cd09c79b4958edd8afc7ef" + }, + "buildId": "f6H1YeLZWstdE-yoN5W8B", + "nativeStats": { + "startTime": "2026-10-20T13:07:56.934Z", + "duration": 23435.509000000002, + "expected": 3, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "nativeResultSha256": "7afbc369f3a9e628074fefaf75eab31c45139adba59f03d78687ec74541c3654" +} diff --git a/docs/redesign/phase-7/p7-10/evidence/users-static-checks.json b/docs/redesign/phase-7/p7-10/evidence/users-static-checks.json new file mode 100644 index 0000000..79c7218 --- /dev/null +++ b/docs/redesign/phase-7/p7-10/evidence/users-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-10", + "status": "PASS", + "checkedUtc": "2026-10-20T13:09:54.281127+00:00", + "commands": [ + { + "name": "secret-scan", + "command": [ + "rtk", + "proxy", + "C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", + "-X", + "utf8", + "scripts/supply-chain.py", + "scan-secrets" + ], + "startedUtc": "2026-10-20T13:09:50.288246+00:00", + "finishedUtc": "2026-10-20T13:09:53.446671+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T130950Z/secret-scan.log", + "logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-20T13:09:53.447162+00:00", + "finishedUtc": "2026-10-20T13:09:53.545286+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T130950Z/working-whitespace.log", + "logSha256Raw": "fc361bb530dd5610a4744d4fd10476a3ff92609526be469ce0c32eb4ac9ea3bb" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-20T13:09:53.545789+00:00", + "finishedUtc": "2026-10-20T13:09:53.621382+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T130950Z/staged-whitespace.log", + "logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-20T13:09:53.621382+00:00", + "finishedUtc": "2026-10-20T13:09:53.864111+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T130950Z/phase7-whitespace.log", + "logSha256Raw": "296a8d67450c089ffaa00fc6935bdd1b2009396bc7edd24a6f693de0fd20e51e" + } + ], + "untrackedSecretScan": { + "files": 4, + "findings": 0 + }, + "documentation": { + "files": 32, + "relativeLinks": 626, + "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/job-tracker-ui/e2e/phase7-admin-users.spec.ts b/job-tracker-ui/e2e/phase7-admin-users.spec.ts new file mode 100644 index 0000000..fccbc4a --- /dev/null +++ b/job-tracker-ui/e2e/phase7-admin-users.spec.ts @@ -0,0 +1,133 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { translations } from '../src/i18n/translations'; + +const apiUrl = 'http://localhost:5302/api'; +const password = 'E2ePassword123!'; +type Language = 'en' | 'nb'; +type Headers = Record; +type Owner = { id: string; email: string; headers: Headers }; +type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number }; +type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] }; +const boundary = 'Actual isolated API/Identity/CSRF admin and ordinary accounts exercise Users create/read/role persistence, target-session revocation, final-admin conflict, non-admin denial and deletion-disabled 503 without deleting an account. Only the explicitly named first GET503 is controlled. Read Retry does not replay writes. No password-reset mail, provider/model/billing/restore/probe/maintenance, production or Phase8 action occurs. Google Fonts css2 stylesheets are aborted and all other nonlocal requests rejected. Desktop grid horizontal scrolling is intentional and its action reachability is keyboard checked. Chromium viewport/keyboard evidence does not establish physical touch, real zoom or human screen-reader acceptance.'; +function copy(key: string, language: Language = 'en') { return (translations[language] as Record)[key] ?? key; } +function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); } +async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) { + const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data }); + const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result; +} +async function json(response: Pick, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } +async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise { + expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200); + const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me')); + const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); + return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; +} +async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) { + const email = `p710-admin-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; + expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email; +} +const test = base.extend<{ session: Session }>({ + session: async ({ page, context, playwright }, provide, info) => { + expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); + const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext(); + context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); }); + context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); }); + await context.route('**/*', route => { + const request = route.request(); const url = new URL(request.url()); + if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); } + const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); + const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname); + const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings'; + if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } + return route.continue(); + }); + await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); + try { + const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email); + expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false); + await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]); + } finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); } + }, +}); +async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); } +async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); } +async function capture(page: Page, info: TestInfo, name: string) { + const geometry = await page.getByRole('main').evaluate(main => { + const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root')); + // MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets. + // The named desktop grid intentionally scrolls in two dimensions; its clipped cells are checked by keyboard separately. + return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => !node.closest('.MuiDataGrid-root') && node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; + }); + await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); + await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); + const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]); +} + +function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method()===method && new URL(value.url()).pathname===`/api${path}`); } + +test('[P7-10 Users A01 E09] real admin creation/role persistence revoke sessions while final-admin and disabled deletion remain protected', async ({ page, playwright, session }) => { + const administrator=await login(session.requests, page.request, 'e2e@example.test'); + expect((await call(session.requests, page.request, 'PUT', `/users/${administrator.id}/roles`, administrator.headers, { roles: [] })).status()).toBe(409); + await start(page, '/admin/users', 390); + const email=`p710-created-${Date.now()}@example.test`; + await page.getByRole('textbox', { name: copy('profileEmail'), exact: true }).fill(email); + await page.getByLabel(copy('profileNewPassword'), { exact: true }).fill(password); + const createdResponse=response(page, 'POST', '/users'); + await page.getByLabel(copy('profileNewPassword'), { exact: true }).press('Enter'); + const created=await json<{ id: string; email: string; roles: string[] }>(await createdResponse); + expect(created.email).toBe(email); expect(created.roles).toEqual([]); + await expect(page.getByText(email, { exact: true }).first()).toBeVisible(); + const other=await playwright.request.newContext(); + try { + const before=await login(session.requests, other, email); + expect((await call(session.requests, other, 'GET', '/users')).status()).toBe(403); + expect((await call(session.requests, other, 'PUT', `/users/${administrator.id}/roles`, before.headers, { roles: [] })).status()).toBe(403); + const card=page.locator('.MuiPaper-root').filter({ has: page.getByText(email, { exact: true }).first() }).last(); + const changed=response(page, 'PUT', `/users/${created.id}/roles`); + await keyboard(card.getByRole('button', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204); + await expect.poll(async () => (await json>(await call(session.requests, page.request, 'GET', '/users'))).find(value => value.id===created.id)?.roles).toEqual(['Admin']); + expect((await call(session.requests, other, 'GET', '/auth/me')).status()).toBe(401); + expect((await page.request.delete(`${apiUrl}/users/${created.id}`, { headers: { ...administrator.headers, 'X-Confirm-Account-Deletion': email } })).status()).toBe(503); + expect((await json>(await call(session.requests, page.request, 'GET', '/users'))).some(value => value.id===created.id)).toBe(true); + } finally { await other.dispose(); } +}); + +test('[P7-10 Users A01 E08] unavailable user list recovers by GET without fabricating empty state', async ({ page, session }, info) => { + await login(session.requests, page.request, 'e2e@example.test'); let failed=false; + await page.route(`${apiUrl}/users`, route => { if (route.request().method()==='GET' && !failed) { failed=true; return route.fulfill({ status: 503, json: { title: 'Controlled Users read failure' } }); } return route.continue(); }); + await start(page, '/admin/users', 390); + await expect(page.getByRole('alert').filter({ hasText: copy('adminUsersLoadFailed') })).toBeVisible(); + await expect(page.getByText(copy('adminUsersNoUsers'), { exact: true })).toHaveCount(0); + const before=session.requests.filter(value => value.channel==='browser' && value.method!=='GET').length; + await keyboard(page.getByRole('button', { name: copy('retry'), exact: true })); + await expect(page.getByRole('group', { name: copy('adminUsersTitle'), exact: true })).toBeFocused(); + await expect(page.getByText('e2e@example.test', { exact: true }).first()).toBeVisible(); + expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toHaveLength(before); + await capture(page, info, 'users-actual-get-recovery-en-light-390'); +}); + +test('[P7-10 Users A01] finite bilingual light/dark widths retain long identities and protected admin controls', async ({ page, session }, info) => { + test.setTimeout(180_000); const administrator=await login(session.requests, page.request, 'e2e@example.test'); + const email=`long-administrative-identity-${Date.now()}@example.test`; + expect((await call(session.requests, page.request, 'POST', '/users', administrator.headers, { email, password, roles: [] })).status()).toBe(200); + const samples=[{ width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'nb', mode: 'dark' }] as const; + for (const sample of samples) { + expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', administrator.headers, { language: sample.language==='nb' ? 'nb-NO' : 'en' })).status()).toBe(204); + await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample); + await start(page, '/admin/users', sample.width); + await expect(page.getByRole('heading', { level: 3, name: copy('adminUsersCreateUser', sample.language), exact: true })).toBeVisible(); + await expect(page.getByText(email, { exact: true }).first()).toBeVisible(); + if (sample.width>=768) { + const region=page.getByRole('region', { name: copy('adminUsersTitle', sample.language), exact: true }); await expect(region).toHaveAttribute('tabindex', '0'); + const action=region.getByRole('button', { name: copy('adminUsersSendReset', sample.language), exact: true }).first(); await action.focus(); await expect(action).toBeFocused(); + const box=await action.boundingBox(); expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x+box!.width).toBeLessThanOrEqual(sample.width); + const clipped=await region.locator('.MuiDataGrid-row').evaluateAll(rows => rows.flatMap(row => { + const bounds=row.getBoundingClientRect(); return Array.from(row.querySelectorAll('button')).filter(button => { const rect=button.getBoundingClientRect(); return rect.top < bounds.top-1 || rect.bottom > bounds.bottom+1; }).map(button => button.textContent); + })); expect(clipped).toEqual([]); + } + await capture(page, info, `users-long-identity-${sample.width}-${sample.language}-${sample.mode}`); + } + expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toEqual([]); +}); + diff --git a/job-tracker-ui/src/admin-users-page.test.tsx b/job-tracker-ui/src/admin-users-page.test.tsx index e2a4740..6be5621 100644 --- a/job-tracker-ui/src/admin-users-page.test.tsx +++ b/job-tracker-ui/src/admin-users-page.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { CssVarsProvider } from "@mui/material/styles"; import { api } from "./api"; @@ -13,7 +13,7 @@ import AdminUsersPage from "./views/AdminUsersPage"; jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() }, - getApiErrorMessage: (_error: unknown, fallback: string) => fallback, + getApiErrorMessage: (error: any, fallback: string) => error?.response?.data?.detail || fallback, })); const mockedApi = api as jest.Mocked; @@ -22,7 +22,7 @@ function renderPage(users: unknown[]) { mockedApi.get.mockResolvedValue({ data: users } as any); mockedApi.put.mockResolvedValue({ data: null } as any); mockedApi.delete.mockResolvedValue({ data: null } as any); - render( + return render( @@ -39,6 +39,7 @@ function renderPage(users: unknown[]) { beforeEach(() => { jest.clearAllMocks(); + mockedApi.get.mockReset(); mockedApi.post.mockReset(); window.localStorage.clear(); Object.defineProperty(window, "matchMedia", { configurable: true, value: jest.fn().mockImplementation(() => ({ @@ -51,6 +52,38 @@ beforeEach(() => { }); }); +function held() { let resolve!: (value: T) => void; const promise=new Promise(yes => { resolve=yes; }); return { promise, resolve }; } +function createDraft(email='new@example.test') { fireEvent.change(screen.getByLabelText('Email'), { target: { value: email } }); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'TestPassword123!' } }); } + +test('failed users read stays unavailable and Retry only reads', async () => { + mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]); + const retry=await screen.findByRole('button', { name: 'Retry' }); + expect(screen.queryByText('No users.')).not.toBeInTheDocument(); fireEvent.click(retry); + expect(await screen.findByText('No users.')).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test('create failure presents structured feedback and retains the entered fields', async () => { + mockedApi.post.mockRejectedValue({ response: { status: 400, data: { detail: 'The email is already registered.' } } }); renderPage([]); await screen.findByText('No users.'); createDraft(); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); expect(await screen.findByText('The email is already registered.')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toHaveValue('new@example.test'); +}); + +test('create admission serializes competing clicks and acknowledgement retains newer form values', async () => { + const pending=held(); mockedApi.post.mockReturnValue(pending.promise); renderPage([]); await screen.findByText('No users.'); createDraft(); + const create=screen.getByRole('button', { name: 'Create' }); act(() => { create.click(); create.click(); }); expect(mockedApi.post).toHaveBeenCalledTimes(1); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'next@example.test' } }); + await act(async () => pending.resolve({ data: { id: 'created' } })); expect(screen.getByLabelText('Email')).toHaveValue('next@example.test'); +}); + +test('successful create followed by failed list refresh keeps read recovery without replaying create', async () => { + mockedApi.post.mockResolvedValue({ data: { id: 'created' } }); renderPage([]); await screen.findByText('No users.'); mockedApi.get.mockRejectedValueOnce(new Error('offline')); createDraft(); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); const retry=await screen.findByRole('button', { name: 'Retry' }); fireEvent.click(retry); await screen.findByText('No users.'); expect(mockedApi.post).toHaveBeenCalledTimes(1); +}); + +test('disposed create acknowledgement cannot start a new private list read', async () => { + const pending=held(); mockedApi.post.mockReturnValue(pending.promise); const view=renderPage([]); await screen.findByText('No users.'); createDraft(); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); view.unmount(); await act(async () => pending.resolve({ data: { id: 'created' } })); expect(mockedApi.get).toHaveBeenCalledTimes(1); +}); + test("requires explicit confirmation before removing your own admin role", async () => { renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: true }]); @@ -96,3 +129,12 @@ test("sends the exact account email after confirming deletion", async () => { headers: { "X-Confirm-Account-Deletion": "other@example.com" }, })); }); + +test('accepted deletion is reported as queued rather than completed', async () => { + renderPage([{ id: 'other', email: 'other@example.com', userName: 'other', roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); + mockedApi.delete.mockResolvedValueOnce({ status: 202, data: { status: 'pending' } } as any); + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); + expect(await screen.findByText('Account deletion request queued. Deletion has not completed yet.')).toBeInTheDocument(); + expect(screen.queryByText('User deleted.')).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 5edd3fc..a271663 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1798,6 +1798,7 @@ export const translations = { adminUsersResetFailed: "Failed to send reset.", adminUsersDeleteConfirmTitle: "Delete user", adminUsersDeleted: "User deleted.", + adminUsersDeletionQueued: "Account deletion request queued. Deletion has not completed yet.", adminUsersDeleteFailed: "Failed to delete user.", adminUsersCreated: "User created.", adminUsersCreateFailed: "Failed to create user.", @@ -4570,6 +4571,7 @@ export const translations = { adminUsersResetFailed: "Kunne ikke sende tilbakestilling.", adminUsersDeleteConfirmTitle: "Slett bruker", adminUsersDeleted: "Bruker slettet.", + adminUsersDeletionQueued: "Forespørselen om kontosletting er satt i kø. Slettingen er ikke fullført ennå.", adminUsersDeleteFailed: "Kunne ikke slette bruker.", adminUsersCreated: "Bruker opprettet.", adminUsersCreateFailed: "Kunne ikke opprette bruker.", diff --git a/job-tracker-ui/src/views/AdminUsersPage.tsx b/job-tracker-ui/src/views/AdminUsersPage.tsx index 3350e8d..7534b17 100644 --- a/job-tracker-ui/src/views/AdminUsersPage.tsx +++ b/job-tracker-ui/src/views/AdminUsersPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, @@ -20,6 +20,10 @@ import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; +import { useViewResource } from "../hooks/useViewResource"; +import ViewStateNotice from "../components/ViewStateNotice"; +import { PageHeader, PageSection } from "../layout/PageLayout"; +import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary"; type UserDto = { id: string; @@ -36,30 +40,47 @@ export default function AdminUsersPage() { const { toast } = useToast(); const { confirmAction } = useDialogActions(); const { t } = useI18n(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const owner = useRef({ mounted: true, generation: 0, busy: false }); + const body = useRef(null); + const currentTranslation = useRef(t); + useEffect(() => { currentTranslation.current=t; }, [t]); + useEffect(() => { const lifetime=owner.current; lifetime.mounted=true; lifetime.generation+=1; return () => { lifetime.mounted=false; lifetime.generation+=1; }; }, []); + const userResource=useViewResource(async () => (await api.get("/users")).data ?? [], { initialData: [], errorMessage: t("adminUsersLoadFailed") }); + const users=userResource.data; + const loading=userResource.loading || userResource.refreshing; + const load=userResource.reload; const [newEmail, setNewEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); const [newIsAdmin, setNewIsAdmin] = useState(false); + const canCreate = newEmail.trim().length > 3 && newPassword.length >= 6; - async function load() { - setLoading(true); - try { - const res = await api.get("/users"); - setUsers(res.data ?? []); - } catch { - setUsers([]); - } finally { - setLoading(false); - } - } - - useEffect(() => { - void load(); + const beginAction=useCallback(() => { + checkSessionCookie(); const lifetime=owner.current; + if (!lifetime.mounted || lifetime.busy || isSessionInvalidated()) return null; + lifetime.busy=true; setBusy(true); + if (body.current?.contains(document.activeElement)) body.current.focus(); + const context=getSessionContext(); const generation=lifetime.generation; + return () => { checkSessionCookie(); return lifetime.mounted && lifetime.generation===generation && !isSessionInvalidated() && getSessionContext()===context; }; }, []); + const finishAction=useCallback((active: () => boolean) => { owner.current.busy=false; if (active()) setBusy(false); }, []); - const canCreate = useMemo(() => newEmail.trim().length > 3 && newPassword.length >= 6, [newEmail, newPassword]); + const createUser=async () => { + if (!canCreate) return; + const active=beginAction(); if (!active) return; + const submitted={ email: newEmail, password: newPassword, isAdmin: newIsAdmin }; + try { + await api.post("/users", { email: submitted.email, password: submitted.password, roles: submitted.isAdmin ? ["Admin"] : [] }); + if (!active()) return; + setNewEmail(current => current===submitted.email ? "" : current); + setNewPassword(current => current===submitted.password ? "" : current); + setNewIsAdmin(current => current===submitted.isAdmin ? false : current); + toast(currentTranslation.current("adminUsersCreated"), "success"); + await load(); + } catch (error) { if (active()) toast(getApiErrorMessage(error, currentTranslation.current("adminUsersCreateFailed")), "error"); } + finally { finishAction(active); } + }; const rows = useMemo(() => users.map((u) => ({ id: u.id, @@ -71,6 +92,8 @@ export default function AdminUsersPage() { })), [users]); const setAdminRole = useCallback(async (u: UserDto, grantAdmin: boolean) => { + const active=beginAction(); if (!active) return; + try { if (!grantAdmin) { const name = u.userName || u.email || u.id; const confirmed = await confirmAction( @@ -83,44 +106,50 @@ export default function AdminUsersPage() { destructive: true, }, ); - if (!confirmed) return; + if (!active() || !confirmed) return; } - try { const roles = grantAdmin ? Array.from(new Set([...(u.roles || []), "Admin"])) : (u.roles || []).filter((role) => role.toLowerCase() !== "admin"); await api.put(`/users/${u.id}/roles`, { roles }); - toast(t("adminUsersRolesUpdated"), "success"); + if (!active()) return; + toast(currentTranslation.current("adminUsersRolesUpdated"), "success"); await load(); } catch (e) { - toast(getApiErrorMessage(e, t("adminUsersRolesUpdateFailed")), "error"); + if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersRolesUpdateFailed")), "error"); } - }, [confirmAction, t, toast]); + finally { finishAction(active); } + }, [beginAction, confirmAction, finishAction, load, t, toast]); const sendReset = useCallback(async (u: UserDto) => { + const active=beginAction(); if (!active) return; try { await api.post(`/users/${u.id}/send-password-reset`); - toast(t("adminUsersResetSent"), "success"); + if (active()) toast(currentTranslation.current("adminUsersResetSent"), "success"); } catch (e: any) { - toast(getApiErrorMessage(e, t("adminUsersResetFailed")), "error"); + if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersResetFailed")), "error"); } - }, [t, toast]); + finally { finishAction(active); } + }, [beginAction, finishAction, toast]); const remove = useCallback(async (u: UserDto) => { + const active=beginAction(); if (!active) return; + try { const name = u.userName || u.email || u.id; const message = u.isCurrentUser ? t("adminUsersDeleteSelfWarning") : t("adminUsersDeleteConfirmNamed", { name }); - if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return; - try { - await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } }); - toast(t("adminUsersDeleted"), "info"); + if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true })) || !active()) return; + const response=await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } }); + if (!active()) return; + toast(currentTranslation.current(response.status===202 ? "adminUsersDeletionQueued" : "adminUsersDeleted"), "info"); await load(); } catch (e) { - toast(getApiErrorMessage(e, t("adminUsersDeleteFailed")), "error"); + if (active()) toast(getApiErrorMessage(e, currentTranslation.current("adminUsersDeleteFailed")), "error"); } - }, [confirmAction, t, toast]); + finally { finishAction(active); } + }, [beginAction, confirmAction, finishAction, load, t, toast]); const columns = useMemo(() => [ { @@ -173,33 +202,30 @@ export default function AdminUsersPage() { - - - + ); }, }, - ], [remove, sendReset, setAdminRole, t]); + ], [busy, remove, sendReset, setAdminRole, t]); return ( - - - {t("adminUsersTitle")} - - {t("adminUsersSubtitle")} + + {t("adminUsersSafetyNotice")} - - {t("adminUsersCreateUser")} + + { event.preventDefault(); void createUser(); }}> setNewEmail(e.target.value)} fullWidth /> setNewPassword(e.target.value)} fullWidth /> @@ -208,28 +234,19 @@ export default function AdminUsersPage() { setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} /> - + + - {isMobile ? ( + { body.current?.focus(); await load(); }} /> + + {!userResource.error ? isMobile ? ( {!loading && rows.length === 0 ? ( {t("adminUsersNoUsers")} @@ -238,7 +255,7 @@ export default function AdminUsersPage() { const user = row.raw as UserDto; const isAdmin = user.roles.includes("Admin"); return ( - + @@ -259,13 +276,13 @@ export default function AdminUsersPage() { - + - - + @@ -274,9 +291,10 @@ export default function AdminUsersPage() { })} ) : ( - + "auto"} rows={rows} columns={columns} disableRowSelectionOnClick @@ -301,7 +319,7 @@ export default function AdminUsersPage() { {t("adminUsersNoUsers")} ) : null} - )} - + ) : null} + ); }