feat: add password visibility toggle
This commit is contained in:
@@ -73,7 +73,7 @@ describe("AuthGate", () => {
|
|||||||
render(<AuthGate />);
|
render(<AuthGate />);
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: /sign in to thothii/i })).toBeInTheDocument();
|
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 () => {
|
test("presents a forbidden /me response explicitly", async () => {
|
||||||
|
|||||||
@@ -42,12 +42,61 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked();
|
expect(screen.getByRole("checkbox", { name: /remember me/i })).not.toBeChecked();
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
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 }));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/i);
|
||||||
expect(submittedPassword).toBe("not-a-real-password");
|
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 () => {
|
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.click(screen.getByRole("checkbox", { name: /remember me/i }));
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
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 });
|
const submit = screen.getByRole("button", { name: /sign in/i });
|
||||||
await Promise.all([userEvent.click(submit), userEvent.click(submit)]);
|
await Promise.all([userEvent.click(submit), userEvent.click(submit)]);
|
||||||
|
|
||||||
expect(requests).toBe(1);
|
expect(requests).toBe(1);
|
||||||
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ subject: "admin-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", () => {
|
test("shows OIDC only when public configuration enables it", () => {
|
||||||
@@ -180,7 +229,7 @@ describe("LoginPage", () => {
|
|||||||
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
|
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} onRetry={retry} />);
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
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 userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/temporarily unavailable/i);
|
||||||
@@ -197,9 +246,9 @@ describe("LoginPage", () => {
|
|||||||
vi.spyOn(Storage.prototype, method));
|
vi.spyOn(Storage.prototype, method));
|
||||||
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
render(<LoginPage config={localConfig} onAuthenticated={vi.fn()} />);
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "alice");
|
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 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) expect(write).not.toHaveBeenCalled();
|
||||||
for (const write of storageWrites) write.mockRestore();
|
for (const write of storageWrites) write.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -214,10 +263,10 @@ describe("LoginPage", () => {
|
|||||||
try {
|
try {
|
||||||
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
|
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={onAuthenticated} /></StrictMode>);
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
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 userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledOnce());
|
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(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -231,10 +280,10 @@ describe("LoginPage", () => {
|
|||||||
try {
|
try {
|
||||||
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
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 }));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/invalid username or password/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(screen.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||||
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/not wrapped in act/i);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -259,7 +308,7 @@ describe("LoginPage", () => {
|
|||||||
try {
|
try {
|
||||||
const view = render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
const view = render(<StrictMode><LoginPage config={localConfig} onAuthenticated={vi.fn()} /></StrictMode>);
|
||||||
await userEvent.type(screen.getByLabelText(/username/i), "admin");
|
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 }));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
view.unmount();
|
view.unmount();
|
||||||
release();
|
release();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { FormEvent } 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 { ApiError } from "../api/client";
|
||||||
import { beginOidcLogin, loginLocal } from "../api/auth";
|
import { beginOidcLogin, loginLocal } from "../api/auth";
|
||||||
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
|
import type { AuthenticatedUser, AuthPublicConfig } from "../api/types";
|
||||||
@@ -31,6 +31,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
|||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
const attemptRef = useRef(0);
|
const attemptRef = useRef(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<{ message: string; retry: boolean }>();
|
const [error, setError] = useState<{ message: string; retry: boolean }>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +51,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
|||||||
submittingRef.current = true;
|
submittingRef.current = true;
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
setShowPassword(false);
|
||||||
try {
|
try {
|
||||||
const user = await loginLocal(username, password, remember);
|
const user = await loginLocal(username, password, remember);
|
||||||
if (mountedRef.current && attemptRef.current === attempt) onAuthenticated(user);
|
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,
|
// Passwords never enter React state and are cleared after every attempt,
|
||||||
// including operational failures and successful authentication.
|
// including operational failures and successful authentication.
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setShowPassword(false);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
passwordRef.current?.focus();
|
passwordRef.current?.focus();
|
||||||
}
|
}
|
||||||
@@ -116,15 +119,32 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
|
<label htmlFor="thothii-password" className="text-sm font-semibold">Password</label>
|
||||||
<input
|
<div className="relative">
|
||||||
ref={passwordRef}
|
<input
|
||||||
id="thothii-password"
|
ref={passwordRef}
|
||||||
name="password"
|
id="thothii-password"
|
||||||
type="password"
|
name="password"
|
||||||
autoComplete="current-password"
|
type={showPassword ? "text" : "password"}
|
||||||
required
|
autoComplete="current-password"
|
||||||
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"
|
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>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />
|
<input name="remember" type="checkbox" className="size-4 accent-[oklch(var(--primary))]" />
|
||||||
|
|||||||
Reference in New Issue
Block a user