feat(web): login reason banner + return-to + empty-field guard (#48)
This commit is contained in:
@@ -12,6 +12,7 @@ function tree() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/objects" element={<div>objects landing</div>} />
|
||||
<Route path="/objects/:id" element={<div>object detail</div>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{app_name}</h1>
|
||||
{sessionExpired && (
|
||||
<p className="text-sm text-muted-foreground">{t("auth.sessionExpired")}</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
@@ -63,7 +75,7 @@ export function LoginPage() {
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={login.isPending}>
|
||||
<Button type="submit" className="w-full" disabled={login.isPending || !email.trim() || !password}>
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches", "language": "Language", "skipToContent": "Skip to content" },
|
||||
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
|
||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server", "sessionExpired": "Your session expired — please sign in again.", "signingOut": "Signing out…" },
|
||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
|
||||
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" },
|
||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar", "language": "Språk", "skipToContent": "Hoppa till innehåll" },
|
||||
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
|
||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern", "sessionExpired": "Din session har gått ut — logga in igen.", "signingOut": "Loggar ut…" },
|
||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
|
||||
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" },
|
||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||
|
||||
Reference in New Issue
Block a user