feat(web): login reason banner + return-to + empty-field guard (#48)
This commit is contained in:
@@ -12,6 +12,7 @@ function tree() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/objects" element={<div>objects landing</div>} />
|
<Route path="/objects" element={<div>objects landing</div>} />
|
||||||
|
<Route path="/objects/:id" element={<div>object detail</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,3 +35,34 @@ test("invalid credentials show an inline error", async () => {
|
|||||||
expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(),
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useLogin } from "../api/queries";
|
import { useLogin } from "../api/queries";
|
||||||
@@ -8,10 +8,19 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { app_name } = useConfig();
|
const { app_name } = useConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const sessionExpired = params.get("reason") === "expired";
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -24,7 +33,7 @@ export function LoginPage() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
login.mutate(
|
login.mutate(
|
||||||
{ email, password },
|
{ 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">
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
|
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
|
||||||
<h1 className="text-2xl font-semibold">{app_name}</h1>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -63,7 +75,7 @@ export function LoginPage() {
|
|||||||
{t(errorKey)}
|
{t(errorKey)}
|
||||||
</p>
|
</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")}
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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" },
|
"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" },
|
"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)" },
|
"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" },
|
"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" },
|
"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" },
|
"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" },
|
"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)" },
|
"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" },
|
"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" },
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
|
|||||||
Reference in New Issue
Block a user