diff --git a/CHANGELOG.md b/CHANGELOG.md index baa5183..68e4165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added an authenticated account profile route with safe account metadata and + password-change handling. - Implemented an explicit browser-cookie auth client mode with in-memory CSRF handling. - Enabled live owned-incident listing against authenticated diff --git a/README.md b/README.md index 94ed249..36f88e6 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,6 @@ Planned account portal work includes: - public landing and pricing/account-entry pages - payment-gated account creation - login/logout -- account profile page -- password change flow - session status and session revocation flows - account billing status display - subscription/payment status handling @@ -264,13 +262,18 @@ do not imply production readiness or public `/v1` API readiness. The server currently confirms bearer session auth, browser-cookie auth routes, `POST /v1/auth/register`, `POST /v1/auth/email/verify`, `GET /v1/account`, -owner-scoped incident list/detail routes, contact public-key routes, -sharing-grant routes, and wrapped-key routes. Current `open-proofline/server` -documents authenticated +`POST /v1/account/password`, owner-scoped incident list/detail routes, contact +public-key routes, sharing-grant routes, and wrapped-key routes. Current +`open-proofline/server` documents authenticated `GET /v1/incidents`, and this client parses that response shape in live mode. Mock mode uses prototype incident records only and must not be treated as backend truth. +Authenticated users can open the account profile route to review safe account +metadata and change their password through `POST /v1/account/password`. The +server keeps the active session usable after a successful password change and +revokes other sessions for the account. + Public registration is controlled by the server's `SAFE_ACCOUNT_REGISTRATION_MODE`. `disabled` and `admin_only` reject public registration with `registration_disabled`; `open` creates a diff --git a/docs/api-client.md b/docs/api-client.md index 3ef00da..2286405 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -36,6 +36,7 @@ From current `open-proofline/server` docs and route registration: - `POST /v1/auth/web/logout` - `GET /v1/auth/web/csrf` - `GET /v1/account` +- `POST /v1/account/password` - `POST /v1/incidents` - `GET /v1/incidents` - `GET /v1/incidents/{incident_id}` @@ -56,6 +57,18 @@ The web client calls `GET /v1/incidents` in live mode and parses the still returns typed prototype incident records for browser smoke tests and UI review, but those records are not backend truth. +## Account Profile And Password Change + +The account profile route reads safe account metadata from `GET /v1/account`. +Password changes call authenticated `POST /v1/account/password` with +`current_password` and `new_password`, parse the returned `{ "account": ... }` +body, and keep the current browser session active. Current server behavior +revokes other sessions for the account after a successful password change. + +The UI maps password-change failures to fixed safe messages and does not log or +persist passwords, request bodies, session tokens, Authorization headers, +browser session cookies, or CSRF token values. + ## Frontend Metadata Boundary Incident detail parsing keeps browser state focused on public-safe metadata. diff --git a/docs/architecture.md b/docs/architecture.md index 6da6b5f..57e284a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,8 +13,8 @@ flowchart LR ## Boundaries -- The app handles account login, public registration, email verification, and - incident metadata review. +- The app handles account login, public registration, email verification, + account profile/password management, and incident metadata review. - The live API client supports explicit bearer-token and browser-cookie auth modes. Cookie mode uses server-managed HttpOnly cookies, in-memory CSRF tokens, and `credentials: "include"` only for cookie-authenticated requests. diff --git a/docs/security-model.md b/docs/security-model.md index 51b721a..40d2186 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -12,6 +12,8 @@ This web client is experimental and not production-ready. - Expired or malformed loaded sessions are cleared before authenticating the UI. - Loaded sessions are cleared when the configured API mode or auth mode no longer matches the stored session metadata. +- Password changes use the authenticated account route, keep the active session + usable after success, and rely on the server to revoke other account sessions. - API responses are parsed with Zod before use where route shapes are known. - UI states avoid showing raw tokens, Authorization headers, request bodies, plaintext, raw keys, wrapped-key ciphertext, stored paths, or object keys. diff --git a/src/App.test.tsx b/src/App.test.tsx index 16d7700..fb79993 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -600,6 +600,341 @@ test("renders authenticated mock incident detail metadata sections", async () => expect(screen.getByText("No key delivery")).toBeInTheDocument(); }); +test("redirects unauthenticated account profile visits to login", async () => { + renderRoute("/account"); + + expect( + await screen.findByRole("heading", { name: "Sign in" }), + ).toBeInTheDocument(); +}); + +test("changes a live account password and keeps the current session active", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + let resolvePasswordChange!: () => void; + const passwordRequest = vi.fn(); + server.use( + http.get("*/v1/account", () => + HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + email: "live-user@example.invalid", + email_verified_at: "2026-06-01T00:20:00Z", + account_state: "active", + role: "user", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:30:00Z", + password_changed_at: "2026-06-01T00:15:00Z", + }, + }), + ), + http.post("*/v1/account/password", async ({ request }) => { + passwordRequest(); + expect(request.credentials).toBe("omit"); + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + current_password: "current-password", + new_password: "replacement-password", + }); + await new Promise((resolve) => { + resolvePasswordChange = resolve; + }); + return HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + email: "live-user@example.invalid", + email_verified_at: "2026-06-01T00:20:00Z", + account_state: "active", + role: "user", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:50:00Z", + password_changed_at: "2026-06-01T00:50:00Z", + }, + }); + }), + ); + + renderRoute("/account"); + + expect( + await screen.findByRole("heading", { name: "Account profile" }), + ).toBeInTheDocument(); + expect(await screen.findByText("live-user")).toBeInTheDocument(); + expect(screen.getByText("Password changed")).toBeInTheDocument(); + expect(screen.getByText("2026-06-01T00:15:00Z")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Current password"), { + target: { value: "current-password" }, + }); + fireEvent.change(screen.getByLabelText("New password"), { + target: { value: "replacement-password" }, + }); + fireEvent.change(screen.getByLabelText("Confirm new password"), { + target: { value: "replacement-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Change password" })); + + expect( + await screen.findByRole("button", { name: "Changing password" }), + ).toBeDisabled(); + expect(passwordRequest).toHaveBeenCalledTimes(1); + resolvePasswordChange(); + + expect(await screen.findByRole("status")).toHaveTextContent( + "This browser session remains active; other sessions were revoked by the server.", + ); + expect(screen.getAllByText("2026-06-01T00:50:00Z")).toHaveLength(2); + expect(screen.getByLabelText("Current password")).toHaveValue(""); + expect(screen.getByLabelText("New password")).toHaveValue(""); + expect(screen.getByLabelText("Confirm new password")).toHaveValue(""); + expect(screen.queryByText("current-password")).toBeNull(); + expect(screen.queryByText("replacement-password")).toBeNull(); +}); + +test("does not show stale account metadata after switching sessions", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + let resolveBobAccount!: () => void; + server.use( + http.post("*/v1/auth/login", async ({ request }) => { + const credentials = (await request.json()) as { username: string }; + return HttpResponse.json({ + session_id: + credentials.username === "bob-user" ? "ses_bob" : "ses_alice", + account: { + id: credentials.username === "bob-user" ? "acct_bob" : "acct_alice", + username: credentials.username, + role: "user", + }, + token: + credentials.username === "bob-user" ? "token-bob" : "token-alice", + created_at: "2026-06-01T00:00:00Z", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + }), + http.post("*/v1/auth/logout", () => HttpResponse.json({ revoked: true })), + http.get("*/v1/account", async ({ request }) => { + const authorization = request.headers.get("authorization"); + if (authorization === "Bearer token-bob") { + await new Promise((resolve) => { + resolveBobAccount = resolve; + }); + return HttpResponse.json({ + account: { + id: "acct_bob", + username: "bob-user", + role: "user", + }, + }); + } + return HttpResponse.json({ + account: { + id: "acct_alice", + username: "alice-user", + role: "user", + }, + }); + }), + ); + + renderRoute("/login"); + + fireEvent.change(await screen.findByLabelText("Username"), { + target: { value: "alice-user" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "alice-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect( + await screen.findByRole("heading", { name: "Account overview" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("link", { name: "Account" })[0]!); + expect( + await screen.findByRole("heading", { name: "Account profile" }), + ).toBeInTheDocument(); + expect(await screen.findByText("alice-user")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Account menu")); + fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" })); + expect( + await screen.findByRole("heading", { name: "Sign in" }), + ).toBeInTheDocument(); + + fireEvent.change(await screen.findByLabelText("Username"), { + target: { value: "bob-user" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "bob-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect( + await screen.findByRole("heading", { name: "Account overview" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("link", { name: "Account" })[0]!); + + expect( + await screen.findByText("Loading account metadata."), + ).toBeInTheDocument(); + expect(screen.queryByText("alice-user")).toBeNull(); + + resolveBobAccount(); + expect(await screen.findByText("bob-user")).toBeInTheDocument(); + expect(screen.queryByText("alice-user")).toBeNull(); +}); + +test("blocks password-change validation failures before calling the API", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + const passwordRequest = vi.fn(); + server.use( + http.get("*/v1/account", () => + HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + role: "user", + }, + }), + ), + http.post("*/v1/account/password", () => { + passwordRequest(); + return HttpResponse.json({ account: { id: "acct_live" } }); + }), + ); + + renderRoute("/account"); + + await screen.findByRole("heading", { name: "Account profile" }); + fireEvent.change(screen.getByLabelText("Current password"), { + target: { value: "current-password" }, + }); + fireEvent.change(screen.getByLabelText("New password"), { + target: { value: "short" }, + }); + fireEvent.change(screen.getByLabelText("Confirm new password"), { + target: { value: "different-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Change password" })); + + expect( + await screen.findByText("Password must be at least 12 bytes."), + ).toBeInTheDocument(); + expect(screen.getByText("New passwords must match.")).toBeInTheDocument(); + expect(passwordRequest).toHaveBeenCalledTimes(0); +}); + +test("maps invalid current password errors to safe account UI text", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/account", () => + HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + role: "user", + }, + }), + ), + http.post("*/v1/account/password", () => + HttpResponse.json( + { + error: { + code: "invalid_credentials", + message: "current password is invalid", + }, + }, + { status: 401 }, + ), + ), + ); + + renderRoute("/account"); + + await submitPasswordChange(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Current password was not accepted.", + ); +}); + +test("maps unauthorized password-change errors to safe account UI text", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/account", () => + HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + role: "user", + }, + }), + ), + http.post("*/v1/account/password", () => + HttpResponse.json( + { + error: { + code: "authentication_required", + message: "authentication is required", + }, + }, + { status: 401 }, + ), + ), + ); + + renderRoute("/account"); + + await submitPasswordChange(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Sign in again before changing your password.", + ); +}); + +test("keeps generic password-change failures generic", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/account", () => + HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + role: "user", + }, + }), + ), + http.post("*/v1/account/password", () => + HttpResponse.json( + { + error: { + code: "unavailable", + message: "backend private account detail", + }, + }, + { status: 503 }, + ), + ), + ); + + renderRoute("/account"); + + await submitPasswordChange(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Password could not be changed.", + ); + expect(screen.queryByText("backend private account detail")).toBeNull(); +}); + test("renders live incident list records without displaying private fields", async () => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); saveLiveSession(); @@ -742,6 +1077,20 @@ test("shows generic dependent metadata errors on incident detail", async () => { ).toBeInTheDocument(); }); +async function submitPasswordChange() { + await screen.findByRole("heading", { name: "Account profile" }); + fireEvent.change(screen.getByLabelText("Current password"), { + target: { value: "current-password" }, + }); + fireEvent.change(screen.getByLabelText("New password"), { + target: { value: "replacement-password" }, + }); + fireEvent.change(screen.getByLabelText("Confirm new password"), { + target: { value: "replacement-password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Change password" })); +} + function saveLiveSession() { saveTestSession("live", "live-user"); } diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 81600de..dfcfdfc 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -133,6 +133,90 @@ test("uses cookie credentials without authorization headers for authenticated re }); }); +test("changes account passwords with bearer authentication", async () => { + server.use( + http.post("*/v1/account/password", async ({ request }) => { + expect(request.credentials).toBe("omit"); + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + current_password: "current-password", + new_password: "replacement-password", + }); + return HttpResponse.json({ + account: { + id: "acct_live", + username: "live-user", + role: "user", + password_changed_at: "2026-06-01T00:45:00Z", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + await expect( + client.changePassword({ + currentPassword: "current-password", + newPassword: "replacement-password", + }), + ).resolves.toMatchObject({ + id: "acct_live", + username: "live-user", + password_changed_at: "2026-06-01T00:45:00Z", + }); +}); + +test("attaches cookie CSRF headers to unsafe password-change requests", async () => { + server.use( + http.get("*/v1/auth/web/csrf", ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + return HttpResponse.json({ + csrf_token: "csrf-token", + header_name: "X-CSRF-Token", + }); + }), + http.post("*/v1/account/password", async ({ request }) => { + expect(request.credentials).toBe("include"); + expect(request.headers.get("authorization")).toBeNull(); + expect(request.headers.get("x-csrf-token")).toBe("csrf-token"); + await expect(request.json()).resolves.toEqual({ + current_password: "current-password", + new_password: "replacement-password", + }); + return HttpResponse.json({ + account: { + id: "acct_cookie", + username: "cookie-user", + role: "user", + password_changed_at: "2026-06-01T00:45:00Z", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + authMode: "cookie", + }); + + await expect( + client.changePassword({ + currentPassword: "current-password", + newPassword: "replacement-password", + }), + ).resolves.toMatchObject({ + id: "acct_cookie", + password_changed_at: "2026-06-01T00:45:00Z", + }); +}); + test("attaches cookie CSRF headers to unsafe cookie logout requests", async () => { server.use( http.get("*/v1/auth/web/csrf", ({ request }) => { diff --git a/src/api/client.ts b/src/api/client.ts index 02dc231..d98c033 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -47,6 +47,11 @@ type VerifyAccountEmailRequest = { token: string; }; +type ChangePasswordRequest = { + currentPassword: string; + newPassword: string; +}; + type RequestOptions = { includeAuth?: boolean; includeCredentials?: boolean; @@ -69,7 +74,7 @@ export class CredentialModeError extends Error { } export const prooflineQueryKeys = { - account: ["account"] as const, + account: (sessionId: string) => ["account", sessionId] as const, incidents: ["incidents"] as const, incident: (incidentId: string) => ["incident", incidentId] as const, contactPublicKeys: ["contact-public-keys"] as const, @@ -373,6 +378,27 @@ export class ProoflineApiClient { .account; } + async changePassword(request: ChangePasswordRequest): Promise { + if (this.mode === "mock") { + const changedAt = new Date().toISOString(); + return { + ...mockAccount, + updated_at: changedAt, + password_changed_at: changedAt, + }; + } + + return accountResponseSchema.parse( + await this.request("/v1/account/password", { + method: "POST", + body: JSON.stringify({ + current_password: request.currentPassword, + new_password: request.newPassword, + }), + }), + ).account; + } + async listOwnedIncidents(): Promise { if (this.mode === "mock") { return mockIncidents; diff --git a/src/auth/use-auth.ts b/src/auth/use-auth.ts index 02289db..9a4e89a 100644 --- a/src/auth/use-auth.ts +++ b/src/auth/use-auth.ts @@ -14,7 +14,7 @@ import { type ProoflineApiClient, } from "../api/client"; import { ApiError, safeErrorMessage } from "../api/errors"; -import type { Session } from "../api/schemas"; +import type { Account, Session } from "../api/schemas"; import { clearSession, loadSession, @@ -34,6 +34,10 @@ type AuthContextValue = { | { ok: false; code?: "email_verification_required"; message: string } >; logout: () => Promise; + changePassword: (request: { + currentPassword: string; + newPassword: string; + }) => Promise; }; const AuthContext = createContext(null); @@ -105,6 +109,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [apiClient]); + const changePassword = useCallback( + async (request: { currentPassword: string; newPassword: string }) => { + const account = await apiClient.changePassword(request); + setSession((currentSession) => { + if (!currentSession) { + return currentSession; + } + const nextSession = { ...currentSession, account }; + saveSession(nextSession); + return nextSession; + }); + return account; + }, + [apiClient], + ); + const value = useMemo( () => ({ apiClient, @@ -112,8 +132,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: Boolean(session), login, logout, + changePassword, }), - [apiClient, session, login, logout], + [apiClient, session, login, logout, changePassword], ); return createElement(AuthContext.Provider, { value }, children); diff --git a/src/components/proofline/AppShell.tsx b/src/components/proofline/AppShell.tsx index 5e57bdf..1b361dc 100644 --- a/src/components/proofline/AppShell.tsx +++ b/src/components/proofline/AppShell.tsx @@ -11,6 +11,7 @@ import { useAuth } from "../../auth/use-auth"; const navigation = [ { to: "/", label: "Overview" }, { to: "/incidents", label: "Records" }, + { to: "/account", label: "Account" }, ]; export function AppShell() { diff --git a/src/components/proofline/ProfileMenu.tsx b/src/components/proofline/ProfileMenu.tsx index 54e22e9..9f46ec8 100644 --- a/src/components/proofline/ProfileMenu.tsx +++ b/src/components/proofline/ProfileMenu.tsx @@ -77,17 +77,27 @@ export function ProfileMenu({ session, onLogout }: ProfileMenuProps) {
{isSignedIn ? ( - + <> + setIsOpen(false)} + > + Account profile + + + ) : ( <> maxPasswordBytes) { + return `Password must be at most ${maxPasswordBytes} bytes.`; + } + return null; +} + +function passwordChangeErrorMessage(error: unknown): string { + if (!(error instanceof ApiError)) { + return "Password could not be changed."; + } + + switch (error.code) { + case "invalid_credentials": + return "Current password was not accepted."; + case "invalid_password": + return "Use a new password that meets the server requirements."; + case "authentication_required": + return "Sign in again before changing your password."; + default: + return "Password could not be changed."; + } +} + +function AccountPage() { + const { isAuthenticated, apiClient, changePassword, session } = useAuth(); + const queryClient = useQueryClient(); + const accountQueryKey = prooflineQueryKeys.account( + session?.sessionId ?? "signed-out", + ); + const account = useQuery({ + queryKey: accountQueryKey, + queryFn: () => apiClient.getCurrentAccount(), + enabled: isAuthenticated && session !== null, + }); + const passwordChange = useMutation({ + mutationFn: changePassword, + onSuccess: (updatedAccount) => { + queryClient.setQueryData(accountQueryKey, updatedAccount); + }, + }); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [currentPasswordError, setCurrentPasswordError] = useState< + string | null + >(null); + const [newPasswordError, setNewPasswordError] = useState(null); + const [confirmPasswordError, setConfirmPasswordError] = useState< + string | null + >(null); + const [result, setResult] = useState({ + state: "idle", + }); + + if (!isAuthenticated) { + return ; + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setResult({ state: "idle" }); + + const currentValidation = + currentPassword === "" ? "Enter your current password." : null; + const passwordValidation = passwordValidationMessage(newPassword); + const confirmationValidation = + confirmPassword !== newPassword ? "New passwords must match." : null; + + setCurrentPasswordError(currentValidation); + setNewPasswordError(passwordValidation); + setConfirmPasswordError(confirmationValidation); + if ( + currentValidation !== null || + passwordValidation !== null || + confirmationValidation !== null + ) { + return; + } + + try { + await passwordChange.mutateAsync({ + currentPassword, + newPassword, + }); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setResult({ + state: "success", + message: + "Password changed. This browser session remains active; other sessions were revoked by the server.", + }); + } catch (error) { + setResult({ + state: "error", + message: passwordChangeErrorMessage(error), + }); + } + } + + const displayedAccount = account.data; + + return ( +
+ +

+ Review account metadata returned by the authenticated server API. +

+

+ Changing your password keeps this browser session active and + revokes other sessions for the account. +

+ + } + /> + + + ) : null + } + > + {account.isError ? ( + + Account metadata could not be loaded. + + ) : account.isLoading ? ( +

+ Loading account metadata. +

+ ) : displayedAccount ? ( + + ) : null} +
+ + +
+ + + + { + setCurrentPassword(event.target.value); + if (currentPasswordError !== null) { + setCurrentPasswordError( + event.target.value === "" + ? "Enter your current password." + : null, + ); + } + }} + required + /> + {currentPasswordError !== null ? ( + {currentPasswordError} + ) : null} + + + + {passwordRequirements} + { + setNewPassword(event.target.value); + if (newPasswordError !== null) { + setNewPasswordError( + passwordValidationMessage(event.target.value), + ); + } + }} + required + /> + {newPasswordError !== null ? ( + {newPasswordError} + ) : null} + + + + { + setConfirmPassword(event.target.value); + if (confirmPasswordError !== null) { + setConfirmPasswordError( + event.target.value === newPassword + ? null + : "New passwords must match.", + ); + } + }} + required + /> + {confirmPasswordError !== null ? ( + {confirmPasswordError} + ) : null} + + + + {result.state === "success" ? ( + {result.message} + ) : null} + {result.state === "error" ? ( + + {result.message} + + ) : null} + + +
+
+
+ ); +} + +export const accountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account", + component: AccountPage, +}); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index a966237..de6e34f 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -119,3 +119,35 @@ test("navigates internal incident routes without full page reloads", async ({ await expect(page.getByText("Experimental")).toBeVisible(); await expectNoHorizontalOverflow(page); }); + +test("opens the account profile and changes a mock password", async ({ + page, +}) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect( + page.getByRole("heading", { name: "Account overview" }), + ).toBeVisible(); + await page.getByRole("link", { name: "Account" }).click(); + await expect(page).toHaveURL(/\/account$/); + await expect( + page.getByRole("heading", { name: "Account profile" }), + ).toBeVisible(); + await expect(page.getByText("prototype-user")).toBeVisible(); + + await page.getByLabel("Current password").fill("prototype-password"); + await page + .getByLabel("New password", { exact: true }) + .fill("replacement-password"); + await page.getByLabel("Confirm new password").fill("replacement-password"); + await page.getByRole("button", { name: "Change password" }).click(); + + await expect(page.getByRole("status")).toContainText("Password changed."); + await expect(page.getByLabel("Current password")).toHaveValue(""); + await expect(page.getByLabel("New password", { exact: true })).toHaveValue( + "", + ); + await expect(page.getByLabel("Confirm new password")).toHaveValue(""); + await expectNoHorizontalOverflow(page); +});