feat: add password visibility toggle

This commit is contained in:
Codex
2026-08-30 18:25:27 +02:00
parent cb40c09d9a
commit e2b88ce3c2
3 changed files with 92 additions and 23 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ describe("AuthGate", () => {
render(<AuthGate />);
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 () => {
+61 -12
View File
@@ -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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
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<void>((resolve) => { releaseRequest = resolve; });
const started = new Promise<void>((resolve) => { requestStarted = resolve; });
server.use(http.post("/api/auth/local/login", async () => {
requestStarted();
await requestGate;
return HttpResponse.json({ code: "invalid_credentials" }, { status: 401 });
}));
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
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(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
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(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
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(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
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(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
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();
+30 -10
View File
@@ -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)
</div>
<div className="grid gap-1.5">
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
<input
ref={passwordRef}
id="thothii-password"
name="password"
type="password"
autoComplete="current-password"
required
className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
/>
<div className="relative">
<input
ref={passwordRef}
id="thothii-password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
className="h-10 w-full rounded-md border border-input bg-background px-3 pr-11 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-controls="thothii-password"
title={showPassword ? "Hide password" : "Show password"}
disabled={submitting}
onClick={() => setShowPassword((visible) => !visible)}
>
{showPassword
? <EyeOff aria-hidden="true" />
: <Eye aria-hidden="true" />}
</Button>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />