feat(auth): migrate public authentication presentation

This commit is contained in:
cesnimda
2026-10-11 20:50:14 +02:00
parent 27c54146ad
commit 8ad720da5d
18 changed files with 7595 additions and 127 deletions
+2 -2
View File
@@ -2,11 +2,11 @@
Every completed checkpoint or coherent repair is committed and pushed normally to `codex/ui-redesign-phase4`, with immediate direct remote=HEAD verification. Production main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`; no force push, main push or automatic deployment.
Entry is the completed Phase6 successor receipt `b5319981ad3d1bd635daff861453d23c0de5320b`. Its [historical ledger](../phase-6/COMMITS.md) retains P6-05 `4669fb42e604c4728e8c420846ba78f298a8d90c` and original runtime provenance. Application source is unchanged from P6-03 `bd47214bdcd74336788dca0f294b8ddb9403cac1`; measured export `2LDblja_XnSBWSuY6ys_V`.
Entry is the completed Phase6 successor receipt `b5319981ad3d1bd635daff861453d23c0de5320b`. Its [historical ledger](../phase-6/COMMITS.md) retains P6-05 `4669fb42e604c4728e8c420846ba78f298a8d90c` and original runtime provenance. At P7-00 entry, application source was unchanged from P6-03 `bd47214bdcd74336788dca0f294b8ddb9403cac1`; inherited measured export `2LDblja_XnSBWSuY6ys_V`. Later migration rows record their own changed source/build identity.
| Logical unit | Commit | Validation / push receipt |
|---|---|---|
| P7-00 inventory, preservation/debt and unchanged-source baseline | PENDING — coordinator records actual hash after normal push | [P7-00](p7-00/README.md), [fresh finite baseline](p7-00/evidence/baseline.json); inherited runtime explicitly separated. P7-01 page changes wait for this push/remote verification |
| P7-00 inventory, preservation/debt and unchanged-source baseline | `27c54146ad6f9059295d169b533db7b1b6689210` | [P7-00](p7-00/README.md), [fresh finite baseline](p7-00/evidence/baseline.json) and [static checks](p7-00/evidence/static-checks.json); inherited runtime explicitly separated. Normal development push verified2026-10-11 14:44:45UTC: localHEAD=origin=live development27c5414; local/origin/live main unchanged27657f3; clean worktree. P7-01 entry dependency satisfied |
Hashes and remote receipts are appended by a successor to avoid self-referential commit hashes. Each row records actual commands/results, source scope, remaining conditions, normal push and immediate direct remote identity; partial or blocked evidence is recorded without inventing success. Subsequent receipt metadata is a separately reviewable documentation unit, with its own hash reported after push rather than an endless receipt chain.
+90
View File
@@ -0,0 +1,90 @@
# P7-01 — Authentication and account-security presentation
**Status: public authentication sub-slice CONDITIONAL GO; account-security implementation/acceptance PENDING.** This is the bounded execution/acceptance plan from the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md). P7-00 `27c54146ad6f9059295d169b533db7b1b6689210` is pushed and direct development/localHEAD/origin equality verified at2026-10-11 14:44:45UTC; main unchanged and worktree clean at entry. Its [baseline](../p7-00/evidence/baseline.json) and [ledger](../COMMITS.md) satisfy entry. No new routes, identity/provider protocols or capability activation are included. Public source/results below are measured before their commit; the parent records its push receipt separately. This sub-slice does not complete P7-01.
## Baseline and bounded commit scope
The inherited accepted runtime baseline is backend1,033, frontend521/85, ordinary browser159, sidecar49 and separate existing security/HTTP/fidelity lanes. P7-00 performed fresh identity/routes/matrix/static checks only. New affected runtime evidence begins here; every count delta must identify actual added/removed cases and avoid adding focused subsets to full totals.
| Coherent commit boundary within P7-01 | Existing source owners | Allowed presentation scope / exclusions |
|---|---|---|
| Public authentication | [Login/Register](../../../../job-tracker-ui/src/views/LoginPage.tsx), [Forgot](../../../../job-tracker-ui/src/views/ForgotPasswordPage.tsx), [Reset](../../../../job-tracker-ui/src/views/ResetPasswordPage.tsx), [Verify/email-change](../../../../job-tracker-ui/src/views/VerifyEmailPage.tsx), [Microsoft relink](../../../../job-tracker-ui/src/views/MicrosoftLegacyRelinkPage.tsx) | Reuse existing shared page/forms/state/error patterns; coherent headings/names/labels/focus, responsive layout and translated feedback. Preserve safe from/query/hash/state, remember-me, registration/verification/token/Turnstile/2FA and provider exchange semantics |
| Account identity/security | [Profile](../../../../job-tracker-ui/src/views/ProfilePage.tsx), [AuthStatus](../../../../job-tracker-ui/src/components/AuthStatusCard.tsx), [2FA](../../../../job-tracker-ui/src/components/TwoFactorSettingsCard.tsx), [Sessions](../../../../job-tracker-ui/src/components/SessionsSettingsCard.tsx) | Shared theme/section hierarchy, honest status/error/retry, dialog-local failure, locale dates and long metadata reflow. Preserve identity-only profile payload/local preference ownership, avatar/crop/remove, email confirmation/password, recovery/trusted-device/current-session/revoke semantics |
| Shared provider consumers affected by either boundary | [Google](../../../../job-tracker-ui/src/components/GoogleAuthCard.tsx), [Microsoft](../../../../job-tracker-ui/src/components/MicrosoftAuthCard.tsx) | Real consumers are LoginPage sign-in presentation and ProfilePage account-link presentation. Prove both after shared changes; preserve link password, popup origin/state, cancellation/no-exchange, safe return and unlink guards. Mailbox grants remain separate |
The exact checkpoint remains P7-01; these are reviewable commit boundaries, not additional phases. Account changes follow the public-auth commit to avoid conflicting shared-provider edits. Keep existing save/fetch/security owners; no dead-code cleanup or backend concurrency/provider redesign hidden in presentation.
## Read-only source findings to reproduce before repair
| Observation at entry | Bounded correction candidate / required proof |
|---|---|
| TwoFactorSettingsCard status GET catches failure into null; null then selects Enable2FA action as though disabled | Distinct pending/known/failed status and deliberate retry; no misleading enabled/disabled assertion or setup action while unknown. Request counts, known enabled/disabled branch and failure→retry readback tests |
| Sessions revoke-others failure sets the global error rendered outside the still-open confirmation dialog | Durable alert inside that dialog, retained confirmation/retry/cancel and focus; no background-isolation workaround or automatic resend/revoke |
| AuthStatus signed-in sentence is a literal English sentence; account/security/provider dates use host-default locale | Existing i18n interpolation/natural EN/NB wording and explicit UI-locale date formatting; test actual two locales with fixed timestamps and shared provider presentations |
| Account child card headings default from typography variant; shell already owns page h1 and Profile has explicit contextual headings | Use existing section primitives and explicit contextual levels/names; prove loaded full hierarchy and preserve field/action names. Do not add a second h1/main |
| Profile load/avatar/remove/save/password errors still use raw response/message stringification, while email-change paths use shared helper | Reuse getApiErrorMessage with translated caller fallback and structured server explanations where reproduced; retained draft/local state and deliberate successful recovery. Do not change payload/save semantics |
| Trusted-device/session metadata uses absolute secondary-action list layout | Verify long device names/dates at320/390 and keyboard/zoom before any reflow repair; source layout alone is not measured overflow evidence |
Installed MUI Dialog generates aria-labelledby and DialogTitle inherits its ID. Missing explicit ID props alone do not establish an unnamed-dialog defect; actual named-dialog/description/focus/error tests determine whether a repair is required. Preserve recovery confirmation before dismissing codes, password/code clearing and no automatic sensitive action on entry.
## Required automated and native evidence
| Scope | Existing anchors / additions needed |
|---|---|
| Local auth and safe navigation | [Login tests](../../../../job-tracker-ui/src/login-page.test.tsx), [Verify tests](../../../../job-tracker-ui/src/verify-email-page.test.tsx), [native auth return/expiry](../../../../job-tracker-ui/e2e/analytics-auth-repair.spec.ts). Exercise login/register/reset/verify/relink loaded/error/expired/used-token states, remember-me, recovery/rate-limit and unsafe/nested return rejection without route/protocol changes |
| Account identity versus Career | [Profile tests](../../../../job-tracker-ui/src/profile-page.test.tsx), [native account isolation](../../../../job-tracker-ui/e2e/account-isolation.spec.ts). Add identity-only save payload/readback, no canonical Career/CV/sibling write, local-only vs external read-only rules, pending email request/cancel/password failure/retry and owner-switch disposal |
| 2FA status/confirm/recovery/devices | [2FA tests](../../../../job-tracker-ui/src/two-factor-settings.test.tsx) currently cover enable→recovery and wrong-password disable. Add unknown/error/retry, known status, recovery acknowledgment/clear/cancel, trusted-device error/revoke and actual named modal trap/return; no real authenticator qualification claim |
| Sessions and sign-out | Add focused real Sessions/AuthStatus tests for load failure/retry, remote vs current revoke, canceled revoke-others/no-write, failed confirm within modal and deliberate retry/readback. Preserve clearAuthClientState/private-tree disposal with [session-boundary tests](../../../../job-tracker-ui/src/session-boundary.test.ts) |
| Shared providers | Current Login tests exercise real Google/Microsoft components under mocked external exchange; Profile tests mock Google. Add real account-link/unlink presentation/negative-path coverage so Login success cannot stand in for Profile compatibility. No actual provider call without separate authority |
| Responsive/localised/accessibility | EN/NB, light/dark,320/360–390/768/1024/1280/1440/wide representative states, long labels/device names, actual relevant zoom, keyboard/focus/errors/loading/retry and dialog state. Capture DOM names, root overflow, screenshot and axe contexts; use current theme/page/i18n owners |
Use owned synthetic fixtures and actual mutation/no-write/readback assertions. Run appropriate affected unit/native tests, app/browser TypeScript, lint/build and finite docs/secret/whitespace checks. A broader full runtime gate is required at P7-12; extra runs here need a new change/failure/unresolved concern. Preserve ordinary P2-D01, existing auth/tenant/AI/frozen/output guards and original PDF oracles.
## Acceptance and remaining gates
- Public/auth and account commits have bounded diffs, real consumer regression, exact commands/results/count deltas, source identity and retained intermediate failures. Commit/push each completed unit and record immediate direct remote=HEAD in the ledger.
- Existing32 routes/aliases/search/history/state remain; no weakened authentication/authorization, canonical mutation, duplicate provider exchange/save owner or silent sensitive-action semantics change.
- EN/NB/theme/responsive names/headings/error/retry/focus behavior is verified in actual representative native states, with axe incomplete/violation contexts retained rather than disabled.
- Applicable E01–E06/E09 and shared E07/E08 plus S03 boundaries remain in the [57 matrix](../PRESERVATION.md). Finite scoped repairs do not upgrade whole groups without missing mandatory evidence.
- Actual mail/OAuth/Turnstile/authenticator/device/reader results and M01–M12 remain explicit when unavailable. M13/P5-13 remains deferred do-not-execute. Keep all [debt](../DEBT.md), production NO-GO and main unchanged; no Phase8 customization.
## Public authentication implementation and acceptance
The [public evidence manifest](evidence/public-auth-results.json) records the fresh final commands, source/export identity, all 61 affected unit assertions, 22 native assertions, two isolated SMTP workflows, 22 all-enabled axe attachments, exact geometry, image hashes and retained intermediate attempts. Public UI runtime files are copied to the immutable `.artifacts/phase7/p7-01/public-final/` snapshot before account runs reuse the runner lanes; affected-unit/backend artifacts retain their unique original paths. Only manifest file references were rebased: raw reports retain original absolute execution/attachment paths internally, and commands, times, results and measured SHA256 values are unchanged. Portable PNG references resolve to the snapshot. The six public source files use UTF-8/LF normalization so their hashes remain stable when Git stages LF. The manifest is written with LF; no commit hash is inferred from its own contents.
The five existing view owners now use [AuthPageFrame](../../../../job-tracker-ui/src/layout/AuthPageFrame.tsx), the existing PageHeader/theme spacing and a bounded 520px form width. Each public page owns one named main landmark/H1. Recovery forms have durable page feedback, labelled local validation, focus on the failed field, retained input and explicit resubmission. Verification/email-change loading and success have named dynamic status; invalid/missing token pages retain a usable recovery action. Long action labels wrap at narrow widths. Existing route, command, token, Turnstile, remember-me, provider and safe-return owners remain in place.
Visual review of the first passing export found registration describing its password as “Current password.” Two ordinary password-intent unit cases reproduced the mismatch; registration now uses the existing “New password”/“Nytt passord” key while preserving `new-password` autocomplete. The final export and native/recovery runs below include that repair. No new translation key or hard-coded product string was required.
| Fresh bounded evidence | Observed final result | Scope / count explanation |
|---|---|---|
| Affected public Jest files | **61/61**, four suites, zero failure/pending | Existing Login33 and Verify4 retained; Microsoft relink1→2; new public-presentation22. Compared with the inherited affected38 cases, +23 cases/+1 suite. This is not a fresh full frontend run |
| Ordinary public native pack | **22/22**, zero skip/flaky/unexpected/retry | Fifteen finite presentation samples, two keyboard recovery/prefill/validation workflows, three real invalid-token HTTP400 workflows and two failed-login→explicit-success/return-context workflows. Fresh2026-10-11 18:40:40–18:41:10UTC |
| Isolated actual Identity + loopback SMTP pack | **2/2**, zero skip/flaky/unexpected/retry | Missing-token/no-write plus real registration202, delivered verification link204, invalid link400, password-reset delivery/reset204/replay400, old password/session rejection and protected return. Fresh18:41:10–18:41:23UTC; separate from the22 ordinary cases |
| Loopback SMTP fixture helper | **6/6**, zero fail/cancel/skip/todo | Fragmented commands/dot stuffing, envelope reset, oversize rejection, quoted-printable UTF-8, folded base64 and unsupported-MIME handling. Separate tooling cases |
| Backend auth/security subset | **87/87**, zero failed/not-executed | Auth/session/local identity/context/2FA/email-ownership/authorization contracts at unchanged backend source; a subset of inherited1,033, not an additive total |
| App and browser TypeScript, lint, production frontend build | **All exit0** | Final sequence18:40:04–18:40:39UTC; lint retains zero-warning policy. Browser config contents and command/log hashes are portable in the manifest |
Authoritative final build ID is `rd_SQeVLwVwER9VMCc6XL`. Canonical public six-file source digest is `b330b233508ad5202a1f622ade4464103f7a986ee53648c8f16047f2a1d32586`; the manifest records each file and its precise ordered digest algorithm. Static export74 files have measured raw-file digest `dff031a52740a09359715a31456c9cec70f5aae6f9932c018af078215e973d67`. These identify the tested public working tree, not a newly deployed or remotely pushed identity. P7-00 remains the recorded entry commit until a successor push receipt is recorded in the ledger.
### Responsive, localisation and visual evidence
The native pack actually covers EN/NB and light/dark across320,360,390,768,1024,1280,1440 and1920px; the 1280×320 registration sample exercises short landscape height. It includes ready, registration-disabled, missing-link, local-validation, actual HTTP400 and actual HTTP401 states. Each of22 scans records one H1, named main and exact visible-control geometry; root/main overflow is0 throughout and visible controls remain within viewport tolerance. Public forms have no density control, so Comfortable/Compact is explicitly not applicable here. This is a finite sampling plan, not every route×theme×language×breakpoint combination, physical orientation or browser zoom acceptance.
The parent opened the **eight exact final presentation-matrix PNGs** with `view_image`: login NB/dark320; registration NB/light390 and NB/dark1280×320; forgot EN/dark360; reset EN/light320; verify NB/dark390; email-change EN/dark1024; Microsoft relink EN/dark1440. Final registration images visibly say “Nytt passord.” The reviewed samples have calm shared framing and reachable/wrapped labels/actions, with no new observed app-chrome overflow. The manifest records those eight exact raw PNG hashes. Other workflow screenshots were captured and hashed; individual visual inspection is not claimed for them.
The22 all-enabled axe scans contain **0 violation nodes and31 incomplete nodes**, all `color-contrast`. Every retained check says the background could not be determined because another element overlaps it. Contexts include outlined empty/filled form inputs and two primary-button nodes in failed-login states. Exact targets, HTML, check IDs/messages/data, context and geometry are retained in the manifest; complete raw attachments remain embedded in the hashed native report. Reflow and zero reported violations do not prove contrast. **M11** must qualify contextual contrast/forced colors; M09 must qualify spoken names/errors/state and possible duplicate route/error announcements. Browser zoom, human screen reader and physical/software-keyboard qualification remain unverified. No axe tag/rule was suppressed to obtain these results.
Frontend-owned copy uses the existing EN/NB keys. Actual invalid-token responses include existing server-authored English “Invalid or expired link.”/“Invalid email or token.” in NB samples; this is retained as a localisation qualification, not presented as translated server feedback. Actual external OAuth, Turnstile and production email delivery remain unqualified; the SMTP run uses the real application mail/Identity path against an owned local capture server, not an external service.
### Retained failures and preservation
The first SMTP attempt passed0/2 because strict alert selectors also matched the established route announcer. The second passed1/2 because the fixture expected direct login where the protected-route handoff actually opens the existing Landing page. Scoped-main alert assertions and following that actual Landing handoff fixed the test assumptions; no product change was made for these failures. The third passed2/2 before the label repair, and the fresh final run passed2/2 after it. Their reports/logs/command records and whole output folders are retained under `.artifacts/phase7/p7-01/public-final/attempts/`; the manifest resolves archived attachment paths to their retained folders rather than later output files.
Intermediate public unit attempts are also retained, including the59-case run with one failure, the later59/59 run, and the password-label diagnostic RED with1 pass/1 failure/20 unselected pending cases. The final61-case affected run has zero pending/failure; diagnostic `testNamePattern` selection is not used for final acceptance. The first passing22-case public native run remains historical evidence and did not excuse the visually identified label defect.
The finite public checks preserve E01–E06/E09 and E07/E08/S03 boundaries, exact safe return/query/hash handling, real token failure/replay and old-session rejection. Backend routes/protocols and established link/exchange guards are retained. All **57 groups remain56 PARTIAL + C11 DEFERRED**, and all32 routes remain retained with their existing qualifications. No whole-group upgrade or fresh full frontend/backend/browser/fidelity acceptance is inferred from these subsets. Ordinary P2-D01 remains required by the inherited baseline and future integrated gate; it was not rerun in this public pack.
Public sub-slice acceptance is **CONDITIONAL GO** for its completed development commit, with M01–M12, server-error language/reader/contrast/provider qualifications and inherited debt carried. **Production remains NO-GO**, main unchanged; M13/P5-13 remains deferred do-not-execute. Commit/push/remote receipt and finite hygiene checks are recorded by the parent in the ledger before account product work proceeds.
Account/security acceptance is **PENDING**: five new account-presentation RED cases and12 real-provider cases are prepared (eight existing provider behaviors pass; four provider presentation cases fail), plus six meaningful native account cases not yet executed. The parent owns those product repairs and the later fresh affected build/native evidence. This public result neither completes P7-01 nor changes the approved checkpoint order.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
{
"schemaVersion": 1,
"checkpoint": "p7-01",
"status": "PASS",
"checkedUtc": "2026-10-11T18:48:17.428310+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-11T18:48:14.595748+00:00",
"finishedUtc": "2026-10-11T18:48:16.864213+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261011T184814Z/secret-scan.log",
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
},
{
"name": "working-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check"
],
"startedUtc": "2026-10-11T18:48:16.864713+00:00",
"finishedUtc": "2026-10-11T18:48:16.957533+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261011T184814Z/working-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "staged-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--cached",
"--check"
],
"startedUtc": "2026-10-11T18:48:16.958040+00:00",
"finishedUtc": "2026-10-11T18:48:17.040996+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261011T184814Z/staged-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
},
{
"name": "phase7-whitespace",
"command": [
"rtk",
"proxy",
"git",
"diff",
"--check",
"b5319981ad3d1bd635daff861453d23c0de5320b"
],
"startedUtc": "2026-10-11T18:48:17.041545+00:00",
"finishedUtc": "2026-10-11T18:48:17.143326+00:00",
"exitCode": 0,
"log": ".artifacts/phase7/current/static/20261011T184814Z/phase7-whitespace.log",
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
}
],
"untrackedSecretScan": {
"files": 3,
"findings": 0
},
"documentation": {
"files": 8,
"relativeLinks": 285,
"errors": []
},
"preservation": {
"groups": 57,
"statuses": {
"PARTIAL": 56,
"PASS": 0,
"FAIL": 0,
"DEFERRED": 1
},
"originalRoutesPreserved": true,
"currentConcreteRoutes": 32
},
"production": {
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
"status": "NO-GO",
"qualification": "Local fetched refs; direct push observation recorded separately."
},
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
"limits": [
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
"Local links check target existence, not URL/heading anchors.",
"Static evidence is not runtime/manual/device/provider/production acceptance."
]
}
@@ -0,0 +1,140 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const mailUrl = `http://127.0.0.1:${process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303"}`;
const t = translations.en;
async function actualMailLink(request: APIRequestContext, recipient: string, pathname: string) {
let messages: Array<{ sequence: number; body: string }> = [];
await expect.poll(async () => {
const response = await request.get(`${mailUrl}/messages`, { params: { recipient } });
expect(response.status()).toBe(200);
messages = await response.json();
return messages.some((message) => message.body.includes(`http://localhost:3300${pathname}?`));
}, { message: "The configured local SMTP sender delivers the actual recovery email." }).toBe(true);
const body = messages.findLast((message) => message.body.includes(`http://localhost:3300${pathname}?`))!.body;
const value = body.match(/http:\/\/localhost:3300\/[^\s]+/)?.[0];
if (!value) throw new Error("Local recovery email has no application link.");
const link = new URL(value);
expect(link.origin).toBe("http://localhost:3300");
expect(link.pathname).toBe(pathname);
expect(link.searchParams.has("token")).toBe(true);
return link;
}
async function login(page: Page, email: string, password: string, expectedStatus: number) {
await page.getByLabel(t.usernameOrEmail, { exact: true }).fill(email);
await page.getByLabel(t.profileCurrentPassword, { exact: true }).fill(password);
const response = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/login` && item.request().method() === "POST");
await page.getByRole("button", { name: t.signInTitle, exact: true }).click();
expect((await response).status()).toBe(expectedStatus);
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("uiLanguage", "en"));
});
test("[PRES-E02] incomplete recovery links explain the problem without submitting tokens", async ({ page }) => {
const submissions: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && /\/auth\/(reset-password|verify-email)$/.test(request.url())) submissions.push(request.url());
});
await page.goto("/reset-password?email=incomplete%40example.test");
await expect(page.getByRole("main").getByRole("alert")).toContainText(t.missingResetLinkInfo);
await expect(page.getByRole("button", { name: t.updatePassword, exact: true })).toBeDisabled();
await page.goto("/verify-email");
await expect(page.getByRole("main").getByRole("alert")).toContainText(t.missingVerifyLinkInfo);
await expect(page.getByRole("button", { name: t.backToLogin, exact: true })).toBeVisible();
expect(submissions).toEqual([]);
});
test("[PRES-E02 PRES-E05 PRES-E09] real registration, SMTP verification and password recovery preserve protected return and revoke the old session", async ({ page, browser, request }, testInfo) => {
const email = `recovery-${randomUUID()}@example.test`;
const initialPassword = "RecoveryBefore123!";
const newPassword = "RecoveryAfter456!";
const replayPassword = "RecoveryReplay789!";
const config = await request.get(`${apiUrl}/auth/config`);
expect(config.status()).toBe(200);
expect(await config.json()).toMatchObject({ requireAuth: true, allowRegistration: true, requireEmailVerification: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false });
await page.goto("/register");
await page.getByLabel(t.profileEmail, { exact: true }).fill(email);
await page.getByLabel(t.profileNewPassword, { exact: true }).fill(initialPassword);
await page.getByLabel(t.confirmPassword, { exact: true }).fill(initialPassword);
const registered = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/register` && item.request().method() === "POST");
await page.getByRole("button", { name: t.createAccount, exact: true }).click();
const registration = await registered;
expect(registration.status()).toBe(202);
expect(await registration.json()).toMatchObject({ verificationRequired: true });
await expect(page.getByRole("main").getByRole("alert").filter({ hasText: t.emailNotVerified })).toBeVisible();
const verification = await actualMailLink(request, email, "/verify-email");
// The product's fixed auth-email policy remains five requests/IP per 15min.
// These are the five submissions in this isolated run: invalid verification,
// valid verification, request reset, valid reset, replay reset.
const invalidVerification = new URL(verification);
invalidVerification.searchParams.set("token", "not-a-valid-identity-token");
const invalid = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/verify-email` && item.request().method() === "POST");
await page.goto(invalidVerification.toString());
expect((await invalid).status()).toBe(400);
await expect(page.getByRole("main").getByRole("alert")).toContainText("Invalid or expired link.");
const verified = page.waitForResponse((item) => item.url() === `${apiUrl}/auth/verify-email` && item.request().method() === "POST");
await page.goto(verification.toString());
expect((await verified).status()).toBe(204);
await expect(page.getByRole("status").filter({ hasText: t.verifyEmailSuccess })).toBeVisible();
const protectedPath = "/jobs?search=recovery-return";
await page.goto(protectedPath);
await expect(page).toHaveURL("http://localhost:3300/");
await page.getByRole("button", { name: t.signInTitle, exact: true }).first().click();
await expect(page).toHaveURL(/\/login$/);
await login(page, email, initialPassword, 200);
await expect(page).toHaveURL(new RegExp("/jobs\\?search=recovery-return$"));
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(200);
const recoveryContext = await browser.newContext();
try {
await recoveryContext.addInitScript(() => localStorage.setItem("uiLanguage", "en"));
const recovery = await recoveryContext.newPage();
await recovery.goto(`http://localhost:3300/forgot-password?email=${encodeURIComponent(email)}`);
await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toHaveValue(email);
const requested = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/request-password-reset` && item.request().method() === "POST");
await recovery.getByRole("button", { name: t.forgotPasswordSubmit, exact: true }).click();
expect((await requested).status()).toBe(204);
await expect(recovery.getByRole("status").filter({ hasText: t.passwordResetRequestSent })).toBeVisible();
const resetLink = await actualMailLink(request, email, "/reset-password");
expect(resetLink.searchParams.get("email")).toBe(email);
await recovery.goto(resetLink.toString());
await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toHaveValue(email);
await expect(recovery.getByLabel(t.profileEmail, { exact: true })).toBeDisabled();
await recovery.getByLabel(t.profileNewPassword, { exact: true }).fill(newPassword);
await recovery.getByLabel(t.confirmPassword, { exact: true }).fill(newPassword);
const reset = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/reset-password` && item.request().method() === "POST");
await recovery.getByRole("button", { name: t.updatePassword, exact: true }).click();
expect((await reset).status()).toBe(204);
await expect(recovery).toHaveURL(/\/login$/);
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
await login(recovery, email, initialPassword, 401);
await expect(recovery).toHaveURL(/\/login$/);
await expect(recovery.getByRole("main").getByRole("alert")).toBeVisible();
await recovery.goto(resetLink.toString());
await recovery.getByLabel(t.profileNewPassword, { exact: true }).fill(replayPassword);
await recovery.getByLabel(t.confirmPassword, { exact: true }).fill(replayPassword);
const replay = recovery.waitForResponse((item) => item.url() === `${apiUrl}/auth/reset-password` && item.request().method() === "POST");
await recovery.getByRole("button", { name: t.updatePassword, exact: true }).click();
expect((await replay).status()).toBe(400);
await expect(recovery.getByRole("main").getByRole("alert")).toBeVisible();
await recovery.getByRole("button", { name: t.backToLogin, exact: true }).click();
await login(recovery, email, replayPassword, 401);
await login(recovery, email, newPassword, 200);
await expect(recovery).toHaveURL(/\/dashboard$/);
expect((await recovery.request.get(`${apiUrl}/auth/me`)).status()).toBe(200);
await testInfo.attach("verified-recovery-complete", { body: await recovery.screenshot(), contentType: "image/png" });
} finally {
await recoveryContext.close();
}
});
@@ -0,0 +1,224 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type APIRequestContext, 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 Mode = 'light' | 'dark';
type Sample = { path: string; title: keyof typeof translations.en; language: Language; mode: Mode; width: number; height: number; screenshot?: boolean };
// Finite presentation samples, not a Cartesian route/viewport certification.
// Public forms have no density control; compact/comfortable belongs to the account shell pack.
const samples: Sample[] = [
{ path: '/login', title: 'signInTitle', language: 'nb', mode: 'dark', width: 320, height: 740, screenshot: true },
{ path: '/login', title: 'signInTitle', language: 'en', mode: 'light', width: 1280, height: 900 },
{ path: '/register', title: 'createAccount', language: 'nb', mode: 'light', width: 390, height: 844, screenshot: true },
{ path: '/register', title: 'createAccount', language: 'en', mode: 'dark', width: 1440, height: 900 },
{ path: '/forgot-password', title: 'forgotPasswordTitle', language: 'en', mode: 'dark', width: 360, height: 740, screenshot: true },
{ path: '/forgot-password', title: 'forgotPasswordTitle', language: 'nb', mode: 'light', width: 1024, height: 768 },
{ path: '/reset-password', title: 'resetPasswordTitle', language: 'en', mode: 'light', width: 320, height: 740, screenshot: true },
{ path: '/reset-password', title: 'resetPasswordTitle', language: 'nb', mode: 'dark', width: 768, height: 1024 },
{ path: '/verify-email', title: 'verifyEmailTitle', language: 'en', mode: 'light', width: 1920, height: 1080 },
{ path: '/verify-email', title: 'verifyEmailTitle', language: 'nb', mode: 'dark', width: 390, height: 844, screenshot: true },
{ path: '/confirm-email-change', title: 'confirmEmailChangeTitle', language: 'en', mode: 'dark', width: 1024, height: 768, screenshot: true },
{ path: '/confirm-email-change', title: 'confirmEmailChangeTitle', language: 'nb', mode: 'light', width: 768, height: 1024 },
{ path: '/microsoft-legacy-relink', title: 'microsoftLegacyRelinkTitle', language: 'nb', mode: 'light', width: 1280, height: 900 },
{ path: '/microsoft-legacy-relink', title: 'microsoftLegacyRelinkTitle', language: 'en', mode: 'dark', width: 1440, height: 900, screenshot: true },
{ path: '/register', title: 'createAccount', language: 'nb', mode: 'dark', width: 1280, height: 320, screenshot: true },
];
test.beforeEach(async ({ context, page }) => {
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await page.emulateMedia({ reducedMotion: 'reduce' });
});
async function configure(page: Page, sample: Pick<Sample, 'language' | 'mode' | 'width' | 'height'>) {
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.addInitScript(({ language, mode }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', mode);
}, sample);
}
async function audit(page: Page, info: TestInfo, name: string, context: object, screenshot = true) {
// Inspect natural document origin even when autofocus scrolled a short viewport.
await page.evaluate(() => window.scrollTo(0, 0));
const geometry = await page.evaluate(() => {
const main = document.querySelector('main')!;
const heading = main.querySelector('h1')!;
const controls = Array.from(main.querySelectorAll<HTMLElement>('input,button,select,textarea,[role="button"]')).flatMap(element => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
? [{ tag: element.tagName, type: element.getAttribute('type'), text: element.textContent?.trim(), left: rect.left, right: rect.right, width: rect.width, height: rect.height }]
: [];
});
return { viewportWidth: innerWidth, viewportHeight: innerHeight, pageOverflow: document.documentElement.scrollWidth - innerWidth,
mainOverflow: main.scrollWidth - main.clientWidth, headingTop: heading.getBoundingClientRect().top,
mainName: main.getAttribute('aria-labelledby'), h1Count: document.querySelectorAll('h1').length, controls };
});
// No tags, exclusions or disabled rules: retain incomplete nodes for manual triage.
const axe = await new AxeBuilder({ page }).analyze();
await info.attach(`${name}-all-enabled-axe`, { body: JSON.stringify({ context, geometry, axe }, null, 2), contentType: 'application/json' });
if (screenshot) {
const path = info.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
await info.attach(name, { path, contentType: 'image/png' });
}
expect(geometry.h1Count).toBe(1);
expect(geometry.pageOverflow).toBeLessThanOrEqual(1);
expect(geometry.mainOverflow).toBeLessThanOrEqual(1);
expect(geometry.headingTop).toBeGreaterThanOrEqual(0);
for (const control of geometry.controls) {
expect(control.left, `${control.tag} ${control.text ?? control.type}`).toBeGreaterThanOrEqual(-1);
expect(control.right, `${control.tag} ${control.text ?? control.type}`).toBeLessThanOrEqual(geometry.viewportWidth + 1);
}
expect(axe.violations).toEqual([]);
}
for (const sample of samples) {
test(`[P7-01 public/auth] ${sample.path} ${sample.language}/${sample.mode} ${sample.width}x${sample.height}: landmarks, labels and reflow`, async ({ page }, info) => {
const t = translations[sample.language];
await configure(page, sample);
await page.goto(sample.path);
const main = page.getByRole('main', { name: t[sample.title], exact: true });
await expect(main).toBeVisible();
await expect(main.getByRole('heading', { name: t[sample.title], level: 1, exact: true })).toBeVisible();
await expect(page.getByRole('main')).toHaveCount(1);
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode);
if (sample.path === '/login' || sample.path === '/register') {
await expect(main.getByText(t.authRequired, { exact: true })).toBeVisible();
await expect(main.getByLabel(sample.path === '/login' ? t.usernameOrEmail : t.profileEmail, { exact: true })).toBeVisible();
await expect(main.getByLabel(sample.path === '/login' ? t.profileCurrentPassword : t.profileNewPassword, { exact: true })).toHaveAttribute('autocomplete', sample.path === '/login' ? 'current-password' : 'new-password');
if (sample.path === '/register') {
await expect(main.getByRole('button', { name: t.createAccount, exact: true })).toBeDisabled();
await expect(main.getByRole('alert')).toContainText(t.registrationUnavailable);
await expect(main.getByLabel(t.confirmPassword, { exact: true })).toBeVisible();
}
}
const missing = sample.path === '/reset-password' ? t.missingResetLinkInfo
: sample.path === '/verify-email' ? t.missingVerifyLinkInfo
: sample.path === '/confirm-email-change' ? t.missingEmailChangeLinkInfo
: sample.path === '/microsoft-legacy-relink' ? t.microsoftLegacyRelinkMissing : undefined;
if (missing) await expect(main.getByRole('alert')).toHaveText(missing);
await audit(page, info, 'public-auth-presentation', { ...sample, state: missing ? 'missing-link' : sample.path === '/register' ? 'registration-disabled' : 'ready', density: 'not-applicable', browserZoom: 'not-measured' }, Boolean(sample.screenshot));
});
}
for (const variant of [{ language: 'en', mode: 'light', width: 1280, height: 900 }, { language: 'nb', mode: 'dark', width: 390, height: 844 }] as const) {
test(`[P7-01 public/auth] ${variant.language} keyboard recovery entry preserves the prefill, validates locally and returns focus to sign-in`, async ({ page }, info) => {
const t = translations[variant.language];
await configure(page, variant);
await page.goto('/login');
const username = page.getByLabel(t.usernameOrEmail, { exact: true });
await expect(username).toBeFocused();
await username.fill('prefill+proof@example.test');
await page.getByRole('button', { name: t.forgotPassword, exact: true }).press('Enter');
await expect(page).toHaveURL(/\/forgot-password\?email=prefill%2Bproof%40example\.test$/);
const email = page.getByLabel(t.profileEmail, { exact: true });
await expect(email).toHaveValue('prefill+proof@example.test');
await expect(email).toBeFocused();
const commands: string[] = [];
const record = (request: import('@playwright/test').Request) => { if (request.method() === 'POST' && request.url().endsWith('/auth/request-password-reset')) commands.push(request.url()); };
page.on('request', record);
await email.fill('');
await page.getByRole('button', { name: t.forgotPasswordSubmit, exact: true }).press('Enter');
await expect(email).toBeFocused();
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toHaveAccessibleDescription(t.emailRequired);
expect(commands).toEqual([]);
page.off('request', record);
await audit(page, info, 'public-auth-reset-request-validation', { ...variant, route: '/forgot-password', state: 'client-validation', requests: commands });
await page.getByRole('button', { name: t.backToLogin, exact: true }).press('Enter');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByLabel(t.usernameOrEmail, { exact: true })).toBeFocused();
});
}
// Three real token-error calls stay within the unchanged auth-email policy's five-call budget.
for (const row of [
{ language: 'en', mode: 'light', width: 1280, height: 900, path: '/verify-email?userId=p7-nonexistent-user&token=invalid-proof', endpoint: '/auth/verify-email', title: 'verifyEmailTitle' },
{ language: 'nb', mode: 'dark', width: 390, height: 844, path: '/confirm-email-change?userId=p7-nonexistent-user&email=unused%40example.test&token=invalid-proof', endpoint: '/auth/email-change/confirm', title: 'confirmEmailChangeTitle' },
{ language: 'nb', mode: 'light', width: 768, height: 1024, path: '/reset-password?email=p7-nonexistent-account%40example.test&token=invalid-proof', endpoint: '/auth/reset-password', title: 'resetPasswordTitle' },
] as const) {
test(`[P7-01 public/auth] real ${row.endpoint} invalid-token rejection retains a usable page`, async ({ page }, info) => {
const t = translations[row.language];
await configure(page, row);
const response = page.waitForResponse(reply => new URL(reply.url()).pathname === `/api${row.endpoint}` && reply.request().method() === 'POST');
await page.goto(row.path);
const main = page.getByRole('main', { name: t[row.title], exact: true });
if (row.endpoint === '/auth/reset-password') {
await main.getByLabel(t.profileNewPassword, { exact: true }).fill('NewPassword123!');
await main.getByLabel(t.confirmPassword, { exact: true }).fill('NewPassword123!');
await main.getByRole('button', { name: t.updatePassword, exact: true }).press('Enter');
}
const reply = await response;
expect(reply.status()).toBe(400);
const error = main.getByRole('alert');
await expect(error).toBeVisible();
await expect(error).not.toBeEmpty();
await expect(page).toHaveURL(`http://localhost:3300${row.path}`);
if (row.endpoint === '/auth/reset-password') {
await expect(main.getByLabel(t.profileNewPassword, { exact: true })).toHaveValue('NewPassword123!');
await expect(main.getByLabel(t.confirmPassword, { exact: true })).toHaveValue('NewPassword123!');
await expect(main.getByRole('button', { name: t.updatePassword, exact: true })).toBeEnabled();
}
await audit(page, info, 'public-auth-invalid-token', { ...row, state: 'actual-http-400', errorText: await error.innerText(), errorSource: 'existing server response; no frontend translation claim for server-authored text', persistedRecovery: 'covered by separate isolated SMTP pack' });
await main.getByRole('button', { name: t.backToLogin, exact: true }).press('Enter');
await expect(page.getByLabel(t.usernameOrEmail, { exact: true })).toBeFocused();
});
}
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await api.get(`${apiUrl}/auth/me`);
expect(me.status()).toBe(200);
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': (await me.json()).sessionContext as string };
}
for (const variant of [{ language: 'en', mode: 'dark', width: 1280, height: 900 }, { language: 'nb', mode: 'light', width: 320, height: 740 }] as const) {
test(`[P7-01 public/auth] ${variant.language} rejected sign-in retains credentials and the exact protected return route`, async ({ playwright, page }, info) => {
const t = translations[variant.language];
const admin = await playwright.request.newContext();
const owner = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p7-public-auth-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const ownerHeaders = await login(owner, email);
expect((await owner.put(`${apiUrl}/auth/preferences/language`, { headers: ownerHeaders, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await configure(page, variant);
const requested = '/analytics?months=%36#return-proof';
await page.goto(requested);
await expect(page).toHaveURL('http://localhost:3300/');
await page.getByRole('button', { name: t.signInTitle, exact: true }).first().press('Enter');
const username = page.getByLabel(t.usernameOrEmail, { exact: true });
const currentPassword = page.getByLabel(t.profileCurrentPassword, { exact: true });
await expect(username).toBeFocused();
await username.fill(email);
await currentPassword.fill('IncorrectPassword123!');
const rejected = page.waitForResponse(response => response.url() === `${apiUrl}/auth/login` && response.request().method() === 'POST');
await page.getByRole('button', { name: t.signInTitle, exact: true }).press('Enter');
expect((await rejected).status()).toBe(401);
await expect(page.getByRole('main').getByRole('alert')).toBeVisible();
await expect(username).toHaveValue(email);
await expect(currentPassword).toHaveValue('IncorrectPassword123!');
await expect(page).toHaveURL('http://localhost:3300/login');
await audit(page, info, 'public-auth-sign-in-error', { ...variant, requested, state: 'actual-http-401', provider: 'local' });
await currentPassword.fill(password);
await page.getByRole('button', { name: t.signInTitle, exact: true }).press('Enter');
await expect(page).toHaveURL(`http://localhost:3300${requested}`);
await expect(page.getByRole('heading', { name: t.analytics, level: 1, exact: true })).toBeVisible();
const me = await page.request.get(`${apiUrl}/auth/me`);
expect(me.status()).toBe(200);
expect((await me.json()).email).toBe(email);
await info.attach('public-auth-protected-return', { body: JSON.stringify({ ...variant, requested, returned: new URL(page.url()).pathname + new URL(page.url()).search + new URL(page.url()).hash, actualAuthenticatedEmail: email }), contentType: 'application/json' });
} finally { await owner.dispose(); await admin.dispose(); }
});
}
@@ -0,0 +1,66 @@
import { defineConfig } from "@playwright/test";
import os from "node:os";
import path from "node:path";
import base from "./playwright.config";
// Run sequentially after the ordinary native suite. Real Identity + SMTP, fresh
// synthetic SQLite and keys; no provider calls or test-only product endpoints.
if (!Array.isArray(base.webServer)) throw new Error("Native server definitions are required.");
const [backend, app] = base.webServer;
const smtpPort = process.env.AUTH_RECOVERY_SMTP_PORT ?? "2527";
const mailPort = process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303";
const nodeCommand = `"${process.execPath.replaceAll('"', '\\"')}"`;
const captureCommand = `"${path.resolve("scripts/localSMTPfixture.mjs").replaceAll('"', '\\"')}"`;
export default defineConfig({
testDir: "./auth-tests",
workers: 1,
fullyParallel: false,
retries: 0,
timeout: 120_000,
use: { ...base.use, baseURL: "http://localhost:3300" },
projects: base.projects,
webServer: [
{
command: `${nodeCommand} ${captureCommand}`,
url: `http://127.0.0.1:${mailPort}/health`,
timeout: 20_000,
reuseExistingServer: false,
env: { AUTH_RECOVERY_SMTP_PORT: smtpPort, AUTH_RECOVERY_MAIL_PORT: mailPort },
},
{
...backend,
reuseExistingServer: false,
env: {
...backend.env,
Data__Root: path.join(os.tmpdir(), `jobtracker-auth-recovery-${process.pid}-${Date.now().toString(36)}`),
App__PublicBaseUrl: "http://localhost:3300",
Auth__AllowRegistration: "true",
Auth__RequireEmailVerification: "true",
Auth__GoogleClientId: "",
Auth__MicrosoftClientId: "",
Turnstile__SiteKey: "",
Turnstile__SecretKey: "",
Email__Enabled: "true",
Email__SmtpHost: "127.0.0.1",
Email__SmtpPort: smtpPort,
Email__SmtpUser: "",
Email__SmtpPassword: "",
Email__From: "capture@example.test",
Email__FromName: "Local recovery test",
Email__SmtpEnableSsl: "false",
Email__SmtpTimeoutMs: "5000",
Ai__BaseUrl: "http://127.0.0.1:1",
Stripe__SecretKey: "",
Backups__Enabled: "false",
Workers__AiOperationsEnabled: "false",
Workers__RulesEnabled: "false",
Workers__FollowUpRemindersEnabled: "false",
Workers__DailyExportEnabled: "false",
Workers__JobEnrichmentEnabled: "false",
Workers__SavedJobSearchAlertsEnabled: "false",
},
},
{ ...app, reuseExistingServer: false },
],
});
+125
View File
@@ -0,0 +1,125 @@
// Test-only mail capture. Never imported by the application.
import { createServer as createSMTPServer } from "node:net";
import { createServer as createHTTPServer } from "node:http";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const maxMailBytes = 128 * 1024;
export async function startLocalSMTPFixture({ smtpPort = 0, controlPort = 0 } = {}) {
const messages = [];
const sockets = new Set();
const smtp = createSMTPServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => socket.destroy());
socket.setEncoding("latin1");
socket.write("220 localhost test capture ESMTP\r\n");
let buffer = "";
let sender = "";
let recipients = [];
let data = null;
let dataBytes = 0;
const reset = () => { sender = ""; recipients = []; data = null; dataBytes = 0; };
socket.on("data", (chunk) => {
buffer += chunk;
if (buffer.length + dataBytes > maxMailBytes) {
reset();
socket.end("552 Fixture message size exceeded\r\n");
return;
}
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n");
const line = buffer.slice(0, end).replace(/\r$/, "");
buffer = buffer.slice(end + 1);
if (data !== null) {
if (line === ".") {
try {
if (messages.length >= 100) throw new Error("Fixture mail capacity exceeded.");
messages.push({ sequence: messages.length + 1, recipients: [...recipients], body: decodePlainMessage(data.join("\r\n") + "\r\n") });
socket.write("250 Captured locally\r\n");
} catch {
socket.write("554 Unsupported fixture mail\r\n");
}
reset();
} else {
dataBytes += line.length + 2;
data.push(line.startsWith("..") ? line.slice(1) : line);
}
continue;
}
if (/^(EHLO|HELO)(\s|$)/i.test(line)) socket.write(`250-localhost\r\n250 SIZE ${maxMailBytes}\r\n`);
else if (/^MAIL FROM:/i.test(line)) {
reset();
sender = /^MAIL FROM:\s*<([^>]+)>/i.exec(line)?.[1] ?? "";
socket.write(sender ? "250 Sender accepted\r\n" : "501 Invalid sender\r\n");
} else if (/^RCPT TO:/i.test(line)) {
const recipient = /^RCPT TO:\s*<([^>]+)>/i.exec(line)?.[1]?.toLowerCase() ?? "";
if (!sender) socket.write("503 Sender required\r\n");
else if (!/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) socket.write("550 Only synthetic .test recipients are accepted\r\n");
else { recipients.push(recipient); socket.write("250 Recipient accepted\r\n"); }
} else if (/^DATA$/i.test(line)) {
if (!sender || !recipients.length) socket.write("503 Envelope required\r\n");
else { data = []; dataBytes = 0; socket.write("354 End with dot on its own line\r\n"); }
} else if (/^RSET$/i.test(line)) { reset(); socket.write("250 Reset\r\n"); }
else if (/^NOOP$/i.test(line)) socket.write("250 OK\r\n");
else if (/^QUIT$/i.test(line)) { socket.end("221 Closing\r\n"); break; }
else socket.write("502 Command unavailable in local fixture\r\n");
}
});
});
const control = createHTTPServer((request, response) => {
response.setHeader("Cache-Control", "no-store");
response.setHeader("Content-Type", "application/json; charset=utf-8");
if (request.method !== "GET" || request.headers.origin) { response.writeHead(403).end("{}"); return; }
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/health") { response.end('{"status":"local-fixture"}'); return; }
const recipient = (url.searchParams.get("recipient") ?? "").toLowerCase();
if (url.pathname !== "/messages" || !/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) { response.writeHead(400).end("{}"); return; }
response.end(JSON.stringify(messages.filter((message) => message.recipients.includes(recipient)).map(({ sequence, body }) => ({ sequence, body }))));
});
const listen = (server, port) => new Promise((accept, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => { server.off("error", reject); accept(); });
});
await listen(smtp, smtpPort);
try { await listen(control, controlPort); }
catch (error) { smtp.close(); throw error; }
return {
smtpPort: smtp.address().port,
controlPort: control.address().port,
close: async () => {
sockets.forEach((socket) => socket.destroy());
control.closeAllConnections();
await Promise.all([smtp, control].map((server) => new Promise((accept) => server.close(accept))));
},
};
}
export function decodePlainMessage(raw) {
const separator = /\r?\n\r?\n/.exec(raw);
if (!separator) throw new Error("Mail has no MIME header boundary.");
const headers = raw.slice(0, separator.index).replace(/\r?\n[ \t]+/g, " ");
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headers)?.[1] ?? "text/plain";
if (!/^text\/plain(?:;|$)/i.test(contentType)) throw new Error("Only text/plain fixture mail is supported.");
const charset = /charset=["']?([^;\s"']+)/i.exec(contentType)?.[1] ?? "utf-8";
if (!/^(utf-8|us-ascii)$/i.test(charset)) throw new Error("Unsupported fixture mail charset.");
const encoding = /^content-transfer-encoding:\s*([^\r\n]+)/im.exec(headers)?.[1].trim().toLowerCase() ?? "7bit";
let body = raw.slice(separator.index + separator[0].length);
if (encoding === "base64") return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
if (encoding === "quoted-printable") {
body = body.replace(/=\r?\n/g, "").replace(/=([\da-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
} else if (encoding !== "7bit" && encoding !== "8bit") {
throw new Error("Unsupported fixture mail transfer encoding.");
}
return Buffer.from(body, "latin1").toString("utf8");
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const fixture = await startLocalSMTPFixture({
smtpPort: Number.parseInt(process.env.AUTH_RECOVERY_SMTP_PORT ?? "2527", 10),
controlPort: Number.parseInt(process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303", 10),
});
process.stdout.write(`Local SMTP fixture ready on 127.0.0.1:${fixture.smtpPort}; control 127.0.0.1:${fixture.controlPort}\n`);
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void fixture.close(); });
}
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createConnection } from "node:net";
import { decodePlainMessage, startLocalSMTPFixture } from "./localSMTPfixture.mjs";
async function client(port) {
const socket = createConnection({ host: "127.0.0.1", port });
socket.setEncoding("utf8");
let buffer = "";
const lines = [];
const waiters = [];
socket.on("data", (chunk) => {
buffer += chunk;
while (buffer.includes("\n")) {
const end = buffer.indexOf("\n");
const line = buffer.slice(0, end).replace(/\r$/, "");
buffer = buffer.slice(end + 1);
if (waiters.length) waiters.shift()(line);
else lines.push(line);
}
});
const readLine = () => lines.length ? Promise.resolve(lines.shift()) : new Promise((resolve) => waiters.push(resolve));
const reply = async () => {
let line;
do { line = await readLine(); } while (/^\d{3}-/.test(line));
return Number.parseInt(line, 10);
};
assert.equal(await reply(), 220);
return { socket, reply, command: async (line) => { socket.write(line + "\r\n"); return reply(); } };
}
test("loopback SMTP captures fragmented commands and dot-stuffed MIME for the envelope recipient only", { timeout: 5000 }, async (t) => {
const fixture = await startLocalSMTPFixture();
t.after(() => fixture.close());
assert.ok(fixture.smtpPort > 0);
const smtp = await client(fixture.smtpPort);
t.after(() => smtp.socket.destroy());
smtp.socket.write("EH");
await new Promise((resolve) => setImmediate(resolve));
smtp.socket.write("LO fixture\r\n");
assert.equal(await smtp.reply(), 250);
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
assert.equal(await smtp.command("RCPT TO:<outside@example.com>"), 550);
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
assert.equal(await smtp.command("DATA"), 354);
smtp.socket.write("Content-Type: text/plain; charset=utf-8\r\n\r\n..leading dot\r\nmessage\r\n.\r\n");
assert.equal(await smtp.reply(), 250);
assert.equal(await smtp.command("QUIT"), 221);
const response = await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("access-control-allow-origin"), null);
assert.deepEqual(await response.json(), [{ sequence: 1, body: ".leading dot\r\nmessage\r\n" }]);
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=other%40example.test`)).json(), []);
});
test("SMTP reset discards the previous envelope and rejects DATA without a recipient", { timeout: 5000 }, async (t) => {
const fixture = await startLocalSMTPFixture();
t.after(() => fixture.close());
assert.ok(fixture.smtpPort > 0);
const smtp = await client(fixture.smtpPort);
t.after(() => smtp.socket.destroy());
assert.equal(await smtp.command("DATA"), 503);
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
assert.equal(await smtp.command("RSET"), 250);
assert.equal(await smtp.command("DATA"), 503);
assert.equal(await smtp.command("QUIT"), 221);
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`)).json(), []);
});
test("SMTP oversized messages are rejected without retaining a partial recovery body", { timeout: 5000 }, async (t) => {
const fixture = await startLocalSMTPFixture();
t.after(() => fixture.close());
assert.ok(fixture.smtpPort > 0);
const smtp = await client(fixture.smtpPort);
t.after(() => smtp.socket.destroy());
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
assert.equal(await smtp.command("DATA"), 354);
smtp.socket.write("Content-Type: text/plain\r\n\r\n" + "x".repeat(132000) + "\r\n.\r\n");
assert.equal(await smtp.reply(), 552);
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`)).json(), []);
});
test("quoted-printable SMTP mail retains folded reset URLs and UTF-8 copy", () => {
const raw = "Subject: Verify email\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nBekreft =C3=B8\r\nhttp://localhost:3300/verify-email?userId=3Done&to=\r\nken=3DA%2BB\r\n";
assert.equal(decodePlainMessage(raw), "Bekreft ø\r\nhttp://localhost:3300/verify-email?userId=one&token=A%2BB\r\n");
});
test("base64 SMTP mail is decoded after header unfolding", () => {
assert.equal(decodePlainMessage("Content-Type: text/plain;\r\n charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n"), "Hello, world!");
});
test("unsupported MIME does not silently produce a recovery URL", () => {
assert.throws(() => decodePlainMessage("Content-Type: multipart/mixed; boundary=x\r\n\r\n--x\r\n"), /text\/plain/);
});
@@ -0,0 +1,27 @@
import React, { useId } from "react";
import { Box, Paper } from "@mui/material";
import { PageHeader } from "./PageLayout";
// Public auth pages own their forms, commands and token lifecycles.
export default function AuthPageFrame({ title, description, children }: {
title: string;
description?: string;
children: React.ReactNode;
}) {
const titleId = useId();
return (
<Box component="main" aria-labelledby={titleId} sx={{
minHeight: "100svh", display: "grid", placeItems: "center", boxSizing: "border-box",
p: "var(--app-page-gutter, 16px)", bgcolor: "background.default",
}}>
<Paper elevation={0} sx={{
width: "100%", maxWidth: 520, minWidth: 0, boxSizing: "border-box",
p: "var(--app-card-padding, 16px)", border: "1px solid", borderColor: "divider",
}}>
<PageHeader title={title} titleId={titleId} description={description} />
<Box sx={{ mt: "var(--app-section-gap, 24px)", minWidth: 0 }}>{children}</Box>
</Paper>
</Box>
);
}
+1 -1
View File
@@ -213,7 +213,7 @@ describe('LoginPage', () => {
renderLoginPage('register');
await userEvent.type(await screen.findByLabelText('Email'), 'pending@example.com');
await userEvent.type(screen.getByLabelText('Current password'), 'password1');
await userEvent.type(screen.getByLabelText('New password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm password'), 'password1');
await click(screen.getByRole('button', { name: 'Create account' }));
@@ -1,6 +1,6 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
@@ -42,3 +42,25 @@ test("legacy Microsoft recovery requires the emailed proof and a fresh matching
}));
expect(await screen.findByText("Microsoft sign-in was relinked. You can now sign in normally.")).toBeInTheDocument();
});
test("pending Microsoft recovery keeps a named action and announces progress before the real confirmation boundary", async () => {
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "test-client";
let resolvePopup!: (result: { idToken: string }) => void;
const popup = new Promise<{ idToken: string }>(resolve => { resolvePopup = resolve; });
(getMicrosoftMsalInstance as jest.Mock).mockReturnValue({
initialize: jest.fn().mockResolvedValue(undefined),
loginPopup: jest.fn().mockReturnValue(popup),
});
(api.post as jest.Mock).mockReset().mockResolvedValue({ data: {} });
window.history.replaceState({}, "", "/microsoft-legacy-relink?userId=user-1&tenantId=tenant-1&objectId=object-1&token=recovery-token");
render(<MemoryRouter><I18nProvider><MicrosoftLegacyRelinkPage /></I18nProvider></MemoryRouter>);
fireEvent.click(screen.getByRole("button", { name: "Continue with Microsoft" }));
expect(screen.getByRole("button", { name: "Continue with Microsoft" })).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("Loading...");
expect(api.post).not.toHaveBeenCalled();
await act(async () => resolvePopup({ idToken: "fresh-microsoft-token" }));
expect(await screen.findByRole("status")).toHaveTextContent("Microsoft sign-in was relinked.");
expect(api.post).toHaveBeenCalledWith("/auth/microsoft/legacy-relink/confirm", {
userId: "user-1", tenantId: "tenant-1", objectId: "object-1", recoveryToken: "recovery-token", microsoftToken: "fresh-microsoft-token",
});
});
@@ -0,0 +1,241 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { api } from './api';
import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import ForgotPasswordPage from './views/ForgotPasswordPage';
import LoginPage from './views/LoginPage';
import MicrosoftLegacyRelinkPage from './views/MicrosoftLegacyRelinkPage';
import ResetPasswordPage from './views/ResetPasswordPage';
import VerifyEmailPage from './views/VerifyEmailPage';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const mockedApi = api as jest.Mocked<typeof api>;
function mount(view: React.ReactNode, path: string) {
window.history.replaceState({}, '', path);
return render(<MemoryRouter initialEntries={[path]}><I18nProvider><ToastProvider>{view}</ToastProvider></I18nProvider></MemoryRouter>);
}
function submit() {
fireEvent.submit(document.querySelector('form')!);
}
function deferred() {
let resolve!: (value: any) => void;
const promise = new Promise<any>(done => { resolve = done; });
return { promise, resolve };
}
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
mockedApi.post.mockReset();
mockNavigate.mockReset();
mockedApi.get.mockReset().mockImplementation((url: string) => Promise.resolve({ data: url === '/auth/config'
? { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true }
: { email: 'person@example.test', roles: [] } } as any));
});
describe('public auth presentation', () => {
it.each([
{ mode: 'login' as const, label: 'Current password', autocomplete: 'current-password', absent: 'New password' },
{ mode: 'register' as const, label: 'New password', autocomplete: 'new-password', absent: 'Current password' },
])('names the password field for $mode intent and retains its autocomplete contract', async ({ mode, label, autocomplete, absent }) => {
mount(<LoginPage initialMode={mode} />, mode === 'register' ? '/register' : '/login');
await screen.findByText('Authentication is required to use this app.');
expect(screen.getByLabelText(label, { exact: true })).toHaveAttribute('autocomplete', autocomplete);
expect(screen.getByLabelText(label, { exact: true })).toHaveAttribute('type', 'password');
expect(screen.queryByLabelText(absent, { exact: true })).not.toBeInTheDocument();
});
it.each([
{ name: 'password request', view: <ForgotPasswordPage />, path: '/forgot-password', title: 'Request a password reset' },
{ name: 'password reset', view: <ResetPasswordPage />, path: '/reset-password', title: 'Reset password' },
{ name: 'email verification', view: <VerifyEmailPage />, path: '/verify-email', title: 'Verify your email' },
{ name: 'email change', view: <VerifyEmailPage emailChange />, path: '/confirm-email-change', title: 'Confirm your new email' },
{ name: 'Microsoft recovery', view: <MicrosoftLegacyRelinkPage />, path: '/microsoft-legacy-relink', title: 'Recover Microsoft sign-in' },
])('provides one named main landmark and H1 for $name', async ({ view, path, title }) => {
mount(view, path);
const heading = await screen.findByRole('heading', { level: 1, name: title });
expect(screen.getAllByRole('main')).toHaveLength(1);
expect(screen.getByRole('main', { name: title })).toContainElement(heading);
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
});
it.each([
{ emailChange: false, path: '/verify-email?userId=user-1&token=pending', text: 'Verifying your email...' },
{ emailChange: true, path: '/confirm-email-change?userId=user-1&email=new%40example.test&token=pending', text: 'Confirming your new email...' },
])('announces the pending confirmation without an unnamed progressbar: $emailChange', async ({ emailChange, path, text }) => {
const request = deferred();
mockedApi.post.mockReturnValueOnce(request.promise);
mount(<VerifyEmailPage emailChange={emailChange} />, path);
expect(screen.getByRole('status')).toHaveTextContent(text);
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
await act(async () => request.resolve({ data: {} }));
expect(await screen.findByRole('status')).toHaveTextContent(emailChange ? 'Your email has changed.' : 'Your email has been verified.');
});
it('retains the entered reset email and a durable error in the page when the command fails', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 503, data: 'Reset delivery unavailable.' } });
mount(<ForgotPasswordPage />, '/forgot-password?email=prefilled%40example.test');
const email = screen.getByLabelText('Email');
fireEvent.change(email, { target: { value: 'person@example.test' } });
await act(async () => fireEvent.submit(email.closest('form')!));
const error = await within(screen.getByRole('main')).findByRole('alert');
expect(error).toHaveTextContent('Reset delivery unavailable.');
expect(email).toHaveValue('person@example.test');
expect(mockedApi.post).toHaveBeenCalledWith('/auth/request-password-reset', { email: 'person@example.test' });
expect(mockNavigate).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Send reset link' })).toBeEnabled();
});
it('retains both new-password fields and the decoded recovery token after a rejected reset', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Reset token expired.' } });
mount(<ResetPasswordPage />, '/reset-password?email=person%40example.test&token=encoded%2Btoken%2Fproof%3D');
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'NewPassword123!' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'NewPassword123!' } });
await act(async () => fireEvent.submit(screen.getByLabelText('New password').closest('form')!));
expect(await within(screen.getByRole('main')).findByRole('alert')).toHaveTextContent('Reset token expired.');
expect(screen.getByLabelText('New password')).toHaveValue('NewPassword123!');
expect(screen.getByLabelText('Confirm password')).toHaveValue('NewPassword123!');
expect(mockedApi.post).toHaveBeenCalledWith('/auth/reset-password', {
email: 'person@example.test', token: 'encoded+token/proof=', newPassword: 'NewPassword123!',
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it('keeps a rejected local sign-in beside the retained fields without navigating', async () => {
mockedApi.post.mockRejectedValueOnce({ response: { status: 401, data: 'Invalid credentials.' } });
mount(<LoginPage />, '/login');
fireEvent.change(await screen.findByLabelText('Username or email'), { target: { value: 'person' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'wrong-password' } });
await act(async () => submit());
expect(await within(screen.getByRole('main')).findByRole('alert')).toHaveTextContent('Invalid credentials.');
expect(screen.getByLabelText('Username or email')).toHaveValue('person');
expect(screen.getByLabelText('Current password')).toHaveValue('wrong-password');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('keeps the reset-request action name while announcing an in-flight command', async () => {
const request = deferred();
mockedApi.post.mockReturnValueOnce(request.promise);
mount(<ForgotPasswordPage />, '/forgot-password?email=person%40example.test');
fireEvent.submit(screen.getByLabelText('Email').closest('form')!);
expect(screen.getByRole('button', { name: 'Send reset link' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Sending reset link...');
await act(async () => request.resolve({ data: {} }));
expect(await within(screen.getByRole('main')).findByRole('status')).toHaveTextContent('If that account exists, a reset link has been sent.');
});
it('announces an in-flight local sign-in while preserving its action name', async () => {
const request = deferred();
mockedApi.post.mockReturnValueOnce(request.promise);
mount(<LoginPage />, '/login');
fireEvent.change(await screen.findByLabelText('Username or email'), { target: { value: 'person' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'Password123!' } });
submit();
expect(screen.getByRole('button', { name: 'Sign in' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
await act(async () => request.resolve({ data: {} }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
});
it('announces a pending password save without changing its payload or successful destination', async () => {
const request = deferred();
mockedApi.post.mockReturnValueOnce(request.promise);
mount(<ResetPasswordPage />, '/reset-password?email=person%40example.test&token=good-token');
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'NewPassword123!' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'NewPassword123!' } });
submit();
expect(screen.getByRole('button', { name: 'Update password' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Saving…');
await act(async () => request.resolve({ data: {} }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }));
expect(mockedApi.post).toHaveBeenCalledWith('/auth/reset-password', { email: 'person@example.test', token: 'good-token', newPassword: 'NewPassword123!' });
});
it('focuses the first invalid local-login field and associates its explanation', async () => {
mount(<LoginPage />, '/login');
const email = await screen.findByLabelText('Username or email');
act(() => screen.getByLabelText('Current password').focus());
submit();
expect(email).toHaveFocus();
expect(email).toHaveAccessibleDescription('Username or email is required');
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('focuses the invalid new password without losing confirmation input', () => {
mount(<ResetPasswordPage />, '/reset-password?email=person%40example.test&token=good-token');
const password = screen.getByLabelText('New password');
fireEvent.change(password, { target: { value: 'short' } });
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'retained' } });
act(() => screen.getByLabelText('Confirm password').focus());
submit();
expect(password).toHaveFocus();
expect(password).toHaveAttribute('aria-invalid', 'true');
expect(password).toHaveAccessibleDescription('Password must be at least 8 characters, with a number and a lowercase letter.');
expect(screen.getByLabelText('Confirm password')).toHaveValue('retained');
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('focuses and explains the invalid reset-request email without sending a command', () => {
mount(<ForgotPasswordPage />, '/forgot-password');
const email = screen.getByLabelText('Email');
act(() => email.blur());
fireEvent.submit(email.closest('form')!);
expect(email).toHaveFocus();
expect(email).toHaveAccessibleDescription('Enter your email address.');
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('replaces previous reset-request success when a later request fails', async () => {
mockedApi.post.mockResolvedValueOnce({ data: {} } as any)
.mockRejectedValueOnce({ response: { status: 503, data: 'Reset delivery unavailable.' } });
mount(<ForgotPasswordPage />, '/forgot-password?email=person%40example.test');
await act(async () => submit());
expect(within(screen.getByRole('main')).getByRole('status')).toHaveTextContent('If that account exists, a reset link has been sent.');
await act(async () => submit());
expect(within(screen.getByRole('main')).getByRole('alert')).toHaveTextContent('Reset delivery unavailable.');
expect(within(screen.getByRole('main')).queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByLabelText('Email')).toHaveValue('person@example.test');
});
it.each(['login', 'register'] as const)('does not assert optional authentication while %s configuration is unresolved', async (initialMode) => {
const configuration = deferred();
mockedApi.get.mockReturnValueOnce(configuration.promise);
mount(<LoginPage initialMode={initialMode} />, initialMode === 'register' ? '/register' : '/login');
expect(screen.queryByText('Authentication is optional in this environment.')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
await act(async () => configuration.resolve({ data: {
requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: false,
} }));
expect(screen.getByText('Authentication is optional in this environment.')).toBeInTheDocument();
});
it('offers a read-only configuration retry while retaining entered sign-in fields', async () => {
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } });
mount(<LoginPage />, '/login');
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/config'));
fireEvent.change(screen.getByLabelText('Username or email'), { target: { value: 'retained-user' } });
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'retained-password' } });
expect(screen.queryByText('Authentication is optional in this environment.')).not.toBeInTheDocument();
expect(within(screen.getByRole('main')).getByRole('alert')).toHaveTextContent('An unexpected error occurred while loading this page.');
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Retry' })));
expect(screen.getByText('Authentication is required to use this app.')).toBeInTheDocument();
expect(screen.getByLabelText('Username or email')).toHaveValue('retained-user');
expect(screen.getByLabelText('Current password')).toHaveValue('retained-password');
expect(mockedApi.get.mock.calls.filter(([path]) => path === '/auth/config')).toHaveLength(2);
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
+19 -28
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, TextField, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ForgotPasswordPage() {
const { toast } = useToast();
@@ -17,6 +18,8 @@ export default function ForgotPasswordPage() {
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [emailError, setEmailError] = useState<string | undefined>(undefined);
const [submitError, setSubmitError] = useState<string | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
useEffect(() => {
@@ -25,25 +28,7 @@ export default function ForgotPasswordPage() {
}, []);
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("forgotPasswordTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("forgotPasswordBody")}
</Typography>
<AuthPageFrame title={t("forgotPasswordTitle")} description={t("forgotPasswordBody")}>
<Box
component="form"
onSubmit={(e) => {
@@ -51,13 +36,17 @@ export default function ForgotPasswordPage() {
const trimmed = email.trim();
if (!trimmed) {
setEmailError(t("emailRequired"));
emailRef.current?.focus();
return;
}
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("invalidEmail"));
emailRef.current?.focus();
return;
}
setEmailError(undefined);
setSubmitError(null);
setSubmitted(false);
setLoading(true);
api
.post("/auth/request-password-reset", { email: email.trim() })
@@ -66,13 +55,14 @@ export default function ForgotPasswordPage() {
toast(t("passwordResetRequestSent"), "success");
})
.catch((error: any) => {
toast(getApiErrorMessage(error, t("passwordResetRequestFailed")), "error");
setSubmitError(getApiErrorMessage(error, t("passwordResetRequestFailed")));
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitted ? <Alert severity="success">{t("passwordResetRequestSent")}</Alert> : null}
{submitted ? <Alert severity="success" role="status">{t("passwordResetRequestSent")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
<TextField
label={t("profileEmail")}
type="email"
@@ -80,21 +70,22 @@ export default function ForgotPasswordPage() {
onChange={(e) => { setEmail(e.target.value); setEmailError(undefined); }}
autoComplete="email"
autoFocus
inputRef={emailRef}
error={Boolean(emailError)}
helperText={emailError}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 1, mt: 1, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
{t("backToLogin")}
</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? t("passwordResetRequestSending") : t("forgotPasswordSubmit")}
<Button type="submit" variant="contained" disabled={loading} aria-busy={loading}>
{t("forgotPasswordSubmit")}
</Button>
</Box>
{loading ? <Typography role="status" aria-live="polite" variant="body2" color="text.secondary">{t("passwordResetRequestSending")}</Typography> : null}
</Box>
</Paper>
</Box>
</AuthPageFrame>
);
}
+39 -33
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Paper, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, TextField, Typography } from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
@@ -12,6 +12,7 @@ import TwoFactorChallenge from "../components/TwoFactorChallenge";
import TurnstileWidget from "../components/TurnstileWidget";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import AuthPageFrame from "../layout/AuthPageFrame";
type AuthConfig = {
requireAuth: boolean;
@@ -31,6 +32,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const location = useLocation() as any;
const [cfg, setCfg] = useState<AuthConfig | null>(null);
const [configLoading, setConfigLoading] = useState(true);
const [configFailed, setConfigFailed] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -44,6 +47,10 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
const [registerMode, setRegisterMode] = useState(initialMode === "register");
const [turnstileToken, setTurnstileToken] = useState("");
const [submitError, setSubmitError] = useState<string | null>(null);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -56,13 +63,18 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
state: { from: contextPath, ...(Number.isSafeInteger(fromState.focusJobId) && fromState.focusJobId > 0 ? { focusJobId: fromState.focusJobId } : {}) },
} : { replace: true };
useEffect(() => {
api
const loadAuthConfig = useCallback(() => {
setConfigLoading(true);
setConfigFailed(false);
return api
.get<AuthConfig>("/auth/config")
.then((r) => setCfg(r.data))
.catch(() => setCfg(null));
.catch(() => { setCfg(null); setConfigFailed(true); })
.finally(() => setConfigLoading(false));
}, []);
useEffect(() => { void loadAuthConfig(); }, [loadAuthConfig]);
async function completeLogin() {
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
@@ -83,12 +95,16 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
}
setFieldErrors(errors);
if (errors.email) emailRef.current?.focus();
else if (errors.password) passwordRef.current?.focus();
else if (errors.confirmPassword) confirmPasswordRef.current?.focus();
return Object.keys(errors).length === 0;
}
async function submit(mode: "login" | "register") {
if (!validate(mode)) return;
setLoading(true);
setSubmitError(null);
setEmailNotVerified(false);
setVerificationResent(false);
try {
@@ -109,7 +125,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
if (mode === "login" && e?.response?.data?.error === "email_not_verified") {
setEmailNotVerified(true);
} else {
toast(getApiErrorMessage(e, t("loginFailed")), "error");
setSubmitError(getApiErrorMessage(e, t("loginFailed")));
}
} finally {
setLoading(false);
@@ -118,12 +134,13 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
async function resendVerification() {
setResendingVerification(true);
setSubmitError(null);
try {
await api.post("/auth/resend-verification-email", { email });
setVerificationResent(true);
toast(t("verificationEmailResent"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("verifyEmailFailed")), "error");
setSubmitError(getApiErrorMessage(e, t("verifyEmailFailed")));
} finally {
setResendingVerification(false);
}
@@ -132,26 +149,11 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
const allowReg = cfg?.allowRegistration ?? false;
return (
<Box
component="main"
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography component="h1" variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{registerMode ? t("createAccount") : t("signInTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
<AuthPageFrame title={registerMode ? t("createAccount") : t("signInTitle")} description={cfg ? (cfg.requireAuth ? t("authRequired") : t("authOptional")) : undefined}>
{configLoading ? <Typography role="status" aria-live="polite" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("loading")}</Typography> : null}
{configFailed ? <Alert severity="error" sx={{ mb: 2 }} action={<Button type="button" color="inherit" size="small" onClick={() => void loadAuthConfig()}>{t("retry")}</Button>}>
{t("appErrorBody")}
</Alert> : null}
{new URLSearchParams(location.search).has("sessionChanged") ? <Alert severity="info" sx={{ mb: 2 }}>{t("sessionChangedCleared")}</Alert> : null}
{pendingToken ? (
@@ -167,6 +169,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
{registerMode && cfg && !allowReg ? <Alert severity="info">{t("registrationUnavailable")}</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
@@ -187,15 +190,17 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
onChange={(e) => { setEmail(e.target.value); setFieldErrors((f) => ({ ...f, email: undefined })); }}
autoComplete={registerMode ? "email" : "username"}
autoFocus
inputRef={emailRef}
error={Boolean(fieldErrors.email)}
helperText={fieldErrors.email}
fullWidth
/>
<TextField
label={t("profileCurrentPassword")}
label={registerMode ? t("profileNewPassword") : t("profileCurrentPassword")}
value={password}
onChange={(e) => { setPassword(e.target.value); setFieldErrors((f) => ({ ...f, password: undefined, confirmPassword: undefined })); }}
autoComplete={registerMode ? "new-password" : "current-password"}
inputRef={passwordRef}
type="password"
error={Boolean(fieldErrors.password)}
helperText={fieldErrors.password || (registerMode ? t("passwordTooShort") : undefined)}
@@ -207,6 +212,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setFieldErrors((f) => ({ ...f, confirmPassword: undefined })); }}
autoComplete="new-password"
inputRef={confirmPasswordRef}
type="password"
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
@@ -251,16 +257,17 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
size="small"
disableRipple
disabled={loading}
onClick={() => { if (initialMode === "register") navigate("/login"); else setRegisterMode((v) => !v); setFieldErrors({}); }}
sx={{ px: 0, minWidth: 0, fontWeight: 700 }}
onClick={() => { if (initialMode === "register") navigate("/login"); else setRegisterMode((v) => !v); setFieldErrors({}); setSubmitError(null); }}
sx={{ px: 0, minWidth: 0, fontWeight: 700, maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }}
>
{registerMode ? t("backToLogin") : t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg) || Boolean(cfg?.turnstileEnabled && !turnstileToken)} sx={{ ml: "auto" }}>
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg) || Boolean(cfg?.turnstileEnabled && !turnstileToken)} aria-busy={loading} sx={{ ml: "auto", maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" }}>
{registerMode ? t("createAccount") : t("signInTitle")}
</Button>
</Box>
{loading || resendingVerification ? <Typography role="status" aria-live="polite" variant="body2" color="text.secondary">{t("loading")}</Typography> : null}
</Box>
{!registerMode && (cfg?.googleEnabled || cfg?.microsoftEnabled) ? (
@@ -274,7 +281,6 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
) : null}
</>
)}
</Paper>
</Box>
</AuthPageFrame>
);
}
@@ -1,11 +1,12 @@
import React, { useState } from "react";
import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { getMicrosoftMsalInstance } from "../components/MicrosoftAuthCard";
import { useI18n } from "../i18n/I18nProvider";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function MicrosoftLegacyRelinkPage() {
const { t } = useI18n();
@@ -45,22 +46,19 @@ export default function MicrosoftLegacyRelinkPage() {
}
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", p: 2 }}>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("microsoftLegacyRelinkTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mt: 1 }}>{t("microsoftLegacyRelinkBody")}</Typography>
<AuthPageFrame title={t("microsoftLegacyRelinkTitle")} description={t("microsoftLegacyRelinkBody")}>
{missing ? <Alert severity="error" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkMissing")}</Alert> : null}
{error ? <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert> : null}
{success ? <Alert severity="success" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkSuccess")}</Alert> : null}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 3 }}>
{success ? <Alert severity="success" role="status" sx={{ mt: 2 }}>{t("microsoftLegacyRelinkSuccess")}</Alert> : null}
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 1, mt: 3, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button onClick={() => navigate("/login")}>{t("backToLogin")}</Button>
{!success ? (
<Button variant="contained" disabled={missing || working} onClick={() => void confirm()}>
{working ? <CircularProgress size={20} /> : t("continueWithMicrosoft")}
<Button variant="contained" disabled={missing || working} aria-busy={working} startIcon={working ? <CircularProgress size={20} aria-hidden="true" /> : undefined} onClick={() => void confirm()}>
{t("continueWithMicrosoft")}
</Button>
) : null}
</Box>
</Paper>
</Box>
{working ? <Typography role="status" aria-live="polite" variant="body2" color="text.secondary" sx={{ mt: 1.5 }}>{t("loading")}</Typography> : null}
</AuthPageFrame>
);
}
+20 -27
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, TextField, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import AuthPageFrame from "../layout/AuthPageFrame";
export default function ResetPasswordPage() {
const { toast } = useToast();
@@ -19,6 +20,9 @@ export default function ResetPasswordPage() {
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmPassword?: string }>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -29,25 +33,7 @@ export default function ResetPasswordPage() {
const missingResetInfo = !email || !token;
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("resetPasswordTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("resetPasswordBody")}
</Typography>
<AuthPageFrame title={t("resetPasswordTitle")} description={t("resetPasswordBody")}>
<Box
component="form"
onSubmit={(e) => {
@@ -60,8 +46,12 @@ export default function ResetPasswordPage() {
if (!newPassword || newPassword.length < 8) errors.newPassword = t("passwordTooShort");
if (confirmPassword !== newPassword) errors.confirmPassword = t("passwordsDoNotMatch");
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
if (Object.keys(errors).length > 0) {
(errors.newPassword ? passwordRef : confirmPasswordRef).current?.focus();
return;
}
setSubmitError(null);
setLoading(true);
api
.post("/auth/reset-password", { email, token, newPassword })
@@ -70,13 +60,14 @@ export default function ResetPasswordPage() {
navigate("/login", { replace: true });
})
.catch((e2: any) => {
toast(getApiErrorMessage(e2, t("resetFailed")), "error");
setSubmitError(getApiErrorMessage(e2, t("resetFailed")));
})
.finally(() => setLoading(false));
}}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{missingResetInfo ? <Alert severity="warning">{t("missingResetLinkInfo")}</Alert> : null}
{submitError ? <Alert severity="error">{submitError}</Alert> : null}
<TextField label={t("profileEmail")} type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth />
<TextField
label={t("profileNewPassword")}
@@ -84,6 +75,7 @@ export default function ResetPasswordPage() {
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setFieldErrors((f) => ({ ...f, newPassword: undefined, confirmPassword: undefined })); }}
autoComplete="new-password"
inputRef={passwordRef}
error={Boolean(fieldErrors.newPassword)}
helperText={fieldErrors.newPassword || t("passwordTooShort")}
fullWidth
@@ -94,21 +86,22 @@ export default function ResetPasswordPage() {
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setFieldErrors((f) => ({ ...f, confirmPassword: undefined })); }}
autoComplete="new-password"
inputRef={confirmPasswordRef}
error={Boolean(fieldErrors.confirmPassword)}
helperText={fieldErrors.confirmPassword}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 1, mt: 1, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button type="button" variant="outlined" onClick={() => navigate("/login")} disabled={loading}>
{t("backToLogin")}
</Button>
<Button type="submit" variant="contained" disabled={loading || missingResetInfo}>
<Button type="submit" variant="contained" disabled={loading || missingResetInfo} aria-busy={loading}>
{t("updatePassword")}
</Button>
</Box>
{loading ? <Typography role="status" aria-live="polite" variant="body2" color="text.secondary">{t("saving")}</Typography> : null}
</Box>
</Paper>
</Box>
</AuthPageFrame>
);
}
+9 -24
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import AuthPageFrame from "../layout/AuthPageFrame";
type Status = "verifying" | "success" | "error";
@@ -38,39 +39,23 @@ export default function VerifyEmailPage({ emailChange = false }: { emailChange?:
}, [emailChange, t]);
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
background:
"radial-gradient(1200px 700px at 20% 0%, rgba(79,140,255,0.14), transparent 55%), radial-gradient(900px 600px at 80% 20%, rgba(245,158,11,0.10), transparent 55%)",
}}
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t(emailChange ? "confirmEmailChangeTitle" : "verifyEmailTitle")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 2 }}>
<AuthPageFrame title={t(emailChange ? "confirmEmailChangeTitle" : "verifyEmailTitle")}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{status === "verifying" && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={20} />
<Box role="status" aria-live="polite" sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={20} aria-hidden="true" />
<Typography sx={{ color: "text.secondary" }}>{t(emailChange ? "confirmEmailChangeVerifying" : "verifyEmailVerifying")}</Typography>
</Box>
)}
{status === "success" && <Alert severity="success">{t(emailChange ? "confirmEmailChangeSuccess" : "verifyEmailSuccess")}</Alert>}
{status === "success" && <Alert severity="success" role="status">{t(emailChange ? "confirmEmailChangeSuccess" : "verifyEmailSuccess")}</Alert>}
{status === "error" && <Alert severity="error">{errorMessage}</Alert>}
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", justifyContent: "flex-end", mt: 1, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button variant="contained" onClick={() => navigate("/login")}>
{t("backToLogin")}
</Button>
</Box>
</Box>
</Paper>
</Box>
</AuthPageFrame>
);
}