diff --git a/web/src/auth/login-page.test.tsx b/web/src/auth/login-page.test.tsx new file mode 100644 index 0000000..dd1ad84 --- /dev/null +++ b/web/src/auth/login-page.test.tsx @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Routes, Route } from "react-router-dom"; +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { LoginPage } from "./login-page"; + +function tree() { + return ( + + } /> + objects landing} /> + + ); +} + +test("successful login navigates to /objects", async () => { + renderApp(tree(), { route: "/login" }); + 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("invalid credentials show an inline error", async () => { + server.use(http.post("/api/admin/login", () => new HttpResponse(null, { status: 401 }))); + renderApp(tree(), { route: "/login" }); + await userEvent.type(screen.getByLabelText(/email/i), "x@y.se"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await waitFor(() => + expect(screen.getByText(/invalid email or password/i)).toBeInTheDocument(), + ); +}); diff --git a/web/src/auth/login-page.tsx b/web/src/auth/login-page.tsx new file mode 100644 index 0000000..32b9e18 --- /dev/null +++ b/web/src/auth/login-page.tsx @@ -0,0 +1,66 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { useLogin } from "../api/queries"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function LoginPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const login = useLogin(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + login.mutate( + { email, password }, + { onSuccess: () => navigate("/objects", { replace: true }) }, + ); + }; + + const errorKey = login.error + ? login.error.message === "invalid" + ? "auth.invalid" + : "auth.networkError" + : null; + + return ( +
+
+

{t("app.name")}

+
+ + setEmail(event.target.value)} + autoComplete="username" + /> +
+
+ + setPassword(event.target.value)} + autoComplete="current-password" + /> +
+ {errorKey && ( +

+ {t(errorKey)} +

+ )} + +
+
+ ); +} diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 0000000..9bd5a25 --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/web/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx new file mode 100644 index 0000000..f162996 --- /dev/null +++ b/web/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +