feat: add password visibility toggle
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))]" />
|
||||
|
||||
Reference in New Issue
Block a user