From e2b88ce3c25a0662e079850634a7046e159196b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 18:25:27 +0200 Subject: [PATCH] feat: add password visibility toggle --- frontend/src/auth/AuthGate.test.tsx | 2 +- frontend/src/auth/LoginPage.test.tsx | 73 +++++++++++++++++++++++----- frontend/src/auth/LoginPage.tsx | 40 +++++++++++---- 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/frontend/src/auth/AuthGate.test.tsx b/frontend/src/auth/AuthGate.test.tsx index 3a022ce4..7e8cd05c 100644 --- a/frontend/src/auth/AuthGate.test.tsx +++ b/frontend/src/auth/AuthGate.test.tsx @@ -73,7 +73,7 @@ describe("AuthGate", () => { render(); expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); }); test("presents a forbidden /me response explicitly", async () => { diff --git a/frontend/src/auth/LoginPage.test.tsx b/frontend/src/auth/LoginPage.test.tsx index faddf437..b99a6aed 100644 --- a/frontend/src/auth/LoginPage.test.tsx +++ b/frontend/src/auth/LoginPage.test.tsx @@ -42,12 +42,61 @@ describe("LoginPage", () => { expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked(); await userEvent.type(screen.getByLabelText(/username/i), "alice"); - await userEvent.type(screen.getByLabelText(/password/i), "not-a-real-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "not-a-real-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i); expect(submittedPassword).toBe("not-a-real-password"); - expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); + }); + + test("toggles password visibility without changing the entered value", async () => { + render(); + const password = screen.getByLabelText(/^password$/i); + + expect(password).toHaveAttribute("type", "password"); + await userEvent.type(password, "locally-visible-value"); + await userEvent.click(screen.getByRole("button", { name: /show password/i })); + + expect(password).toHaveAttribute("type", "text"); + expect(password).toHaveValue("locally-visible-value"); + + await userEvent.click(screen.getByRole("button", { name: /hide password/i })); + + expect(password).toHaveAttribute("type", "password"); + expect(password).toHaveValue("locally-visible-value"); + }); + + test("masks a visible password as soon as sign-in starts", async () => { + let releaseRequest!: () => void; + let requestStarted!: () => void; + const requestGate = new Promise((resolve) => { releaseRequest = resolve; }); + const started = new Promise((resolve) => { requestStarted = resolve; }); + server.use(http.post("/api/auth/local/login", async () => { + requestStarted(); + await requestGate; + return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 }); + })); + + render(); + const password = screen.getByLabelText(/^password$/i); + await userEvent.type(screen.getByLabelText(/username/i), "alice"); + await userEvent.type(password, "locally-visible-value"); + await userEvent.click(screen.getByRole("button", { name: /show password/i })); + expect(password).toHaveAttribute("type", "text"); + + try { + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await started; + + expect(password).toHaveAttribute("type", "password"); + expect(screen.getByRole("button", { name: /show password/i })).toBeDisabled(); + } finally { + releaseRequest(); + } + + expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i); + expect(password).toHaveValue(""); }); test("submits a remembered local login once and returns the safe user", async () => { @@ -67,13 +116,13 @@ describe("LoginPage", () => { await userEvent.click(screen.getByRole("checkbox", { name: /remember me/i })); await userEvent.type(screen.getByLabelText(/username/i), "admin"); - await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password"); const submit = screen.getByRole("button", { name: /sign in/i }); await Promise.all([userEvent.click(submit), userEvent.click(submit)]); expect(requests).toBe(1); await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-1" }))); - expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); }); test("shows OIDC only when public configuration enables it", () => { @@ -180,7 +229,7 @@ describe("LoginPage", () => { render(); await userEvent.type(screen.getByLabelText(/username/i), "alice"); - await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i); @@ -197,9 +246,9 @@ describe("LoginPage", () => { vi.spyOn(Storage.prototype, method)); render(); await userEvent.type(screen.getByLabelText(/username/i), "alice"); - await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); - await vi.waitFor(() => expect(screen.getByLabelText(/password/i)).toHaveValue("")); + await vi.waitFor(() => expect(screen.getByLabelText(/^password$/i)).toHaveValue("")); for (const write of storageWrites) expect(write).not.toHaveBeenCalled(); for (const write of storageWrites) write.mockRestore(); }); @@ -214,10 +263,10 @@ describe("LoginPage", () => { try { render(); await userEvent.type(screen.getByLabelText(/username/i), "admin"); - await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "correct-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce()); - expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled(); expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i); } finally { @@ -231,10 +280,10 @@ describe("LoginPage", () => { try { render(); await userEvent.type(screen.getByLabelText(/username/i), "admin"); - await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "wrong-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i); - expect(screen.getByLabelText(/password/i)).toHaveValue(""); + expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); expect(screen.getByRole("button", { name: /sign in/i })).toBeEnabled(); expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i); } finally { @@ -259,7 +308,7 @@ describe("LoginPage", () => { try { const view = render(); await userEvent.type(screen.getByLabelText(/username/i), "admin"); - await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.type(screen.getByLabelText(/^password$/i), "wrong-password"); await userEvent.click(screen.getByRole("button", { name: /sign in/i })); view.unmount(); release(); diff --git a/frontend/src/auth/LoginPage.tsx b/frontend/src/auth/LoginPage.tsx index 7449a9a6..f5645739 100644 --- a/frontend/src/auth/LoginPage.tsx +++ b/frontend/src/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; -import { AlertTriangle, ArrowRight, LockKeyhole } from "lucide-react"; +import { AlertTriangle, ArrowRight, Eye, EyeOff, LockKeyhole } from "lucide-react"; import { ApiError } from "../api/client"; import { beginOidcLogin, loginLocal } from "../api/auth"; import type { AuthenticatedUser, AuthPublicConfig } from "../api/types"; @@ -31,6 +31,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) const submittingRef = useRef(false); const attemptRef = useRef(0); const [submitting, setSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState<{ message: string; retry: boolean }>(); useEffect(() => { @@ -50,6 +51,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) submittingRef.current = true; setError(undefined); setSubmitting(true); + setShowPassword(false); try { const user = await loginLocal(username, password, remember); if (mountedRef.current && attemptRef.current === attempt) onAuthenticated(user); @@ -61,6 +63,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps) // Passwords never enter React state and are cleared after every attempt, // including operational failures and successful authentication. form.reset(); + setShowPassword(false); setSubmitting(false); passwordRef.current?.focus(); } @@ -116,15 +119,32 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
- +
+ + +