diff --git a/web/src/auth/login-page.test.tsx b/web/src/auth/login-page.test.tsx index dd1ad84..0cf8a3c 100644 --- a/web/src/auth/login-page.test.tsx +++ b/web/src/auth/login-page.test.tsx @@ -12,6 +12,7 @@ function tree() { } /> objects landing} /> + object detail} /> ); } @@ -34,3 +35,34 @@ test("invalid credentials show an inline error", async () => { expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(), ); }); + +test("shows the session-expired notice when reason=expired", async () => { + renderApp(tree(), { route: "/login?reason=expired" }); + expect(await screen.findByText(/session expired/i)).toBeInTheDocument(); +}); + +test("returns to the from path on success", async () => { + renderApp(tree(), { route: "/login?from=%2Fobjects%2F123" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("object detail")).toBeInTheDocument(); +}); + +test("rejects an off-site from and falls back to /objects", async () => { + renderApp(tree(), { route: "/login?from=%2F%2Fevil.com" }); + await userEvent.type(screen.getByLabelText(/email/i), "editor@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "pw-editor-123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(await screen.findByText("objects landing")).toBeInTheDocument(); +}); + +test("disables submit until both fields are filled", async () => { + renderApp(tree(), { route: "/login" }); + const button = screen.getByRole("button", { name: /sign in/i }); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/email/i), "a@b.se"); + expect(button).toBeDisabled(); + await userEvent.type(screen.getByLabelText(/password/i), "pw"); + expect(button).toBeEnabled(); +}); diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx index 3de4927..1eedfeb 100644 --- a/web/src/auth/login-page.tsx +++ b/web/src/auth/login-page.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useLogin } from "../api/queries"; @@ -8,10 +8,19 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +/** Accept only a single-leading-slash local path; reject protocol-relative + * ("//host") and absolute URLs to avoid an open redirect. */ +function safeFrom(raw: string | null): string { + if (!raw) return "/objects"; + return /^\/(?!\/)/.test(raw) ? raw : "/objects"; +} + export function LoginPage() { const { t } = useTranslation(); const { app_name } = useConfig(); const navigate = useNavigate(); + const [params] = useSearchParams(); + const sessionExpired = params.get("reason") === "expired"; const login = useLogin(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -24,7 +33,7 @@ export function LoginPage() { event.preventDefault(); login.mutate( { email, password }, - { onSuccess: () => navigate("/objects", { replace: true }) }, + { onSuccess: () => navigate(safeFrom(params.get("from")), { replace: true }) }, ); }; @@ -38,6 +47,9 @@ export function LoginPage() {
{t("auth.sessionExpired")}