diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 00705ee..6dd2891 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -20,6 +20,7 @@
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
},
"search": {
+ "headerPlaceholder": "Search…",
"placeholder": "Search the collection…",
"all": "All",
"prompt": "Type to search",
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index 8157ddf..247f64d 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -20,6 +20,7 @@
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
},
"search": {
+ "headerPlaceholder": "Sök…",
"placeholder": "Sök i samlingen…",
"all": "Alla",
"prompt": "Skriv för att söka",
diff --git a/web/src/shell/header-search.test.tsx b/web/src/shell/header-search.test.tsx
new file mode 100644
index 0000000..99b9c91
--- /dev/null
+++ b/web/src/shell/header-search.test.tsx
@@ -0,0 +1,47 @@
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Routes, Route, useLocation } from "react-router-dom";
+import { renderApp } from "../test/render";
+import { HeaderSearch } from "./header-search";
+
+function LocationProbe() {
+ const location = useLocation();
+ return
{location.pathname + location.search}
;
+}
+
+function tree() {
+ return (
+
+
+
+
+ >
+ }
+ />
+ } />
+
+ );
+}
+
+test("submitting a query navigates to /search?q=", async () => {
+ renderApp(tree());
+
+ const input = await screen.findByRole("searchbox", { name: "Search" });
+ await userEvent.type(input, "amphora{Enter}");
+
+ expect(await screen.findByTestId("location")).toHaveTextContent("/search?q=amphora");
+});
+
+test("submitting an empty query does not navigate", async () => {
+ renderApp(tree());
+
+ const input = await screen.findByRole("searchbox", { name: "Search" });
+ await userEvent.type(input, " {Enter}");
+
+ expect(screen.getByTestId("location")).toHaveTextContent("/");
+ expect(screen.getByTestId("location")).not.toHaveTextContent("/search");
+});
diff --git a/web/src/shell/header-search.tsx b/web/src/shell/header-search.tsx
new file mode 100644
index 0000000..c0b50ca
--- /dev/null
+++ b/web/src/shell/header-search.tsx
@@ -0,0 +1,37 @@
+import { Search } from "lucide-react";
+import { useState, type FormEvent } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { Input } from "@/components/ui/input";
+
+export function HeaderSearch() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [q, setQ] = useState("");
+
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const query = q.trim();
+ if (query) navigate(`/search?q=${encodeURIComponent(query)}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/shell/user-menu.test.tsx b/web/src/shell/user-menu.test.tsx
new file mode 100644
index 0000000..70085b9
--- /dev/null
+++ b/web/src/shell/user-menu.test.tsx
@@ -0,0 +1,35 @@
+import { expect, test } from "vitest";
+import { screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { server } from "../test/server";
+import { renderApp } from "../test/render";
+import { UserMenu } from "./user-menu";
+
+test("shows the signed-in email on the trigger", async () => {
+ renderApp();
+ expect(await screen.findByText("editor@example.com")).toBeInTheDocument();
+});
+
+test("opens the menu showing email + role and signs out", async () => {
+ let loggedOut = false;
+ server.use(
+ http.post("/api/admin/logout", () => {
+ loggedOut = true;
+ return new HttpResponse(null, { status: 204 });
+ }),
+ );
+
+ renderApp();
+
+ const trigger = await screen.findByRole("button", { name: /editor@example.com/ });
+ await userEvent.click(trigger);
+
+ // Menu content renders in a portal on document.body.
+ const menu = within(document.body);
+ expect(await menu.findByText("editor")).toBeInTheDocument();
+ const signOut = await menu.findByText("Sign out");
+
+ await userEvent.click(signOut);
+ await waitFor(() => expect(loggedOut).toBe(true));
+});
diff --git a/web/src/shell/user-menu.tsx b/web/src/shell/user-menu.tsx
new file mode 100644
index 0000000..a8779be
--- /dev/null
+++ b/web/src/shell/user-menu.tsx
@@ -0,0 +1,42 @@
+import { CircleUser } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { useLogout, useMe } from "../api/queries";
+import { Button } from "@/components/ui/button";
+import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
+
+export function UserMenu() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { data: me } = useMe();
+ const logout = useLogout();
+
+ const onSignOut = () =>
+ logout.mutate(undefined, {
+ onSuccess: () => navigate("/login", { replace: true }),
+ });
+
+ if (!me) return null;
+
+ return (
+
+ );
+}