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 (
+
+ );
+}
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 (
+
+ )
+}
+
+export { Label }