diff --git a/services/webmail/src/components/AccountSwitcher.tsx b/services/webmail/src/components/AccountSwitcher.tsx
new file mode 100644
index 0000000..fc32fcd
--- /dev/null
+++ b/services/webmail/src/components/AccountSwitcher.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
+import { useTranslations } from "../i18n/provider";
+import { Avatar } from "./Avatar";
+
+interface SignedInAccount {
+ id: string;
+ email: string;
+ active: boolean;
+}
+
+/**
+ * The webmail accounts signed in on this browser, and the way to move between
+ * them without signing out. The auth service keeps one session per account;
+ * switching only changes which one is active.
+ */
+export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
+ const t = useTranslations();
+ const [accounts, setAccounts] = useState
([]);
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+
+ const load = useCallback(() => {
+ fetch("/api/v1/auth/accounts")
+ .then((res) => (res.ok ? res.json() : { accounts: [] }))
+ .then((data) => setAccounts(data.accounts ?? []))
+ .catch(() => undefined);
+ }, []);
+
+ useEffect(load, [load]);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = (event: MouseEvent | KeyboardEvent) => {
+ if (event instanceof KeyboardEvent) {
+ if (event.key === "Escape") setOpen(false);
+ } else if (root.current && !root.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", close);
+ document.addEventListener("keydown", close);
+ return () => {
+ document.removeEventListener("mousedown", close);
+ document.removeEventListener("keydown", close);
+ };
+ }, [open]);
+
+ const active = accounts.find((a) => a.active);
+
+ // A reload rather than a client-side update: every screen holds the previous
+ // account's mail in state, and none of it should survive the switch.
+ const switchTo = async (id: string) => {
+ const res = await fetch("/api/v1/auth/switch", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id }),
+ });
+ if (res.ok) window.location.href = "/user/mail/inbox";
+ };
+
+ const signOut = async (all: boolean) => {
+ const res = await fetch("/api/v1/auth/logout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ all }),
+ }).catch(() => null);
+ const data = res?.ok ? await res.json().catch(() => ({})) : {};
+ window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login";
+ };
+
+ const item =
+ "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]";
+
+ return (
+
+
+
+ {open && (
+
+ {accounts.map((account) => (
+
+ ))}
+
+
+ {t("accounts.addAccount")}
+
+
+
+
+ {t("accounts.settings")}
+
+
+ {accounts.length > 1 && (
+
+ )}
+
+ )}
+
+ );
+}