diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx
index a87fab36..39b0cf79 100644
--- a/frontend/packages/swarm-ui/src/shell/Shell.tsx
+++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx
@@ -31,6 +31,7 @@ import type { ComponentChildren } from 'preact';
import { Link, useLocation } from 'wouter-preact';
import { LinksMenu } from './LinksMenu.js';
import { SettingsMenu } from './SettingsMenu.js';
+import { UserMenu } from './UserMenu.js';
import { useApplyThemeOverride } from '../lib/theme-apply.js';
import { useApplyMotionOverride } from '../lib/motion-apply.js';
import './Shell.css';
@@ -284,6 +285,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
{/* Keyed by route so it remounts (and replays its entrance
diff --git a/frontend/packages/swarm-ui/src/shell/UserMenu.css b/frontend/packages/swarm-ui/src/shell/UserMenu.css
new file mode 100644
index 00000000..85233cad
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/shell/UserMenu.css
@@ -0,0 +1,67 @@
+/* — same header icon-button + popover chrome as
+ `LinksMenu`/`SettingsMenu`, except the trigger is a round, accent-
+ coloured initials avatar rather than a glyph-on-transparent button —
+ this is the "who is this" affordance, the other two are quiet chrome.
+ Kept as its own stylesheet for the same reason `SettingsMenu.css`
+ gives: three small, independently-evolving popovers that happen to
+ look alike today, not one component with three skins. */
+.user-menu {
+ position: relative;
+}
+.user-menu-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.75em;
+ height: 2.75em;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 50%;
+ color: #fff;
+ font-size: 1em;
+ font-weight: 600;
+ line-height: 1;
+ cursor: pointer;
+ text-shadow: 0 0.05em 0.15em rgba(0, 0, 0, 0.35);
+}
+.user-menu-button:hover,
+.user-menu-button[aria-expanded='true'] {
+ border-color: var(--fg);
+}
+.user-menu-popover {
+ position: absolute;
+ top: calc(100% + 0.4em);
+ right: 0;
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ min-width: 12em;
+ padding: 0.4em;
+ border: 1px solid var(--border);
+ border-radius: 0.5em;
+ background: var(--bg-elev);
+ box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
+}
+.user-menu-name {
+ padding: 0.4em 0.6em 0.5em;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 0.3em;
+ color: var(--muted);
+ font-size: 0.85em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.user-menu-item {
+ display: flex;
+ align-items: center;
+ min-height: 2.75em;
+ padding: 0.4em 0.6em;
+ border-radius: 0.35em;
+ color: var(--fg);
+ text-decoration: none;
+ white-space: nowrap;
+}
+.user-menu-item:hover {
+ background: var(--bg);
+}
diff --git a/frontend/packages/swarm-ui/src/shell/UserMenu.tsx b/frontend/packages/swarm-ui/src/shell/UserMenu.tsx
new file mode 100644
index 00000000..a8c55d42
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/shell/UserMenu.tsx
@@ -0,0 +1,160 @@
+// — the profile affordance in the header's top-right corner:
+// an initials avatar button opening a popover with the signed-in name,
+// a link to Authelia's own account settings, and a sign-out link. Same
+// icon-button + popover chrome as `SettingsMenu`/`LinksMenu` (own
+// stylesheet — see `SettingsMenu.css`'s note on why these stay separate).
+//
+// Identity: `auth_request` only ever answers yes/no, never *who*. This
+// learns "who" from authelia's own `GET /api/user/info`, proxied
+// same-origin at `/api/whoami` (see `swarm-ui.nix`) — no
+// swarm-controller code, a pure pass-through.
+//
+// Avatar: authelia has no photo field. Ships as a generated initials
+// avatar (self-contained, no third-party CDN call — gravatar was the
+// other option, rejected for leaking a hashed email on every load); a
+// real uploaded photo is a follow-up, not blocking this. Colour is a
+// deterministic pick from the same seven base16 chromatic slots the nav
+// accent cycles through (`AVATAR_ACCENTS`).
+//
+// "Authelia settings" / "log out" reuse the *same* URL `LinksMenu`
+// already shows for "Authelia" (`GET /api/links`), not a second source
+// of the domain; `/logout` is appended client-side — authelia's own
+// documented sign-out route.
+import { useEffect, useRef, useState } from 'preact/hooks';
+import './UserMenu.css';
+
+interface WhoAmI {
+ displayName: string | null;
+ email: string | null;
+}
+
+interface ServiceLink {
+ label: string;
+ icon: string;
+ url: string;
+}
+
+// Same seven chromatic slots `Shell.tsx`'s `NAV_ITEMS` cycle through —
+// not imported from there directly: that list is route accents, this is
+// a name→colour pick, different domains that happen to share a palette.
+const AVATAR_ACCENTS = [
+ 'var(--red)',
+ 'var(--yellow)',
+ 'var(--green)',
+ 'var(--cyan)',
+ 'var(--blue)',
+ 'var(--purple)',
+ 'var(--pink)',
+];
+
+function pickAccent(seed: string): string {
+ let hash = 0;
+ for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
+ return AVATAR_ACCENTS[Math.abs(hash) % AVATAR_ACCENTS.length];
+}
+
+// Lenient parse — authelia wraps its API responses in a `{status, data}`
+// envelope; this is a cosmetic-only display (same "a fetch failure just
+// falls back" rule `Shell.tsx`'s own swarm-name fetch follows), so an
+// unexpected shape degrades to no name rather than a thrown error.
+function parseWhoAmI(body: unknown): WhoAmI {
+ const record = (body ?? {}) as Record;
+ const data = (record.data ?? record) as Record;
+ const displayName = typeof data.display_name === 'string' ? data.display_name : null;
+ const emails = Array.isArray(data.emails) ? data.emails : null;
+ const email = emails && typeof emails[0] === 'string' ? (emails[0] as string) : null;
+ return { displayName, email };
+}
+
+export function UserMenu() {
+ const [who, setWho] = useState(null);
+ const [autheliaUrl, setAutheliaUrl] = useState(null);
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ fetch('/api/whoami')
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`))))
+ .then((body) => setWho(parseWhoAmI(body)))
+ .catch(() => setWho(null)); // cosmetic only — see file-top comment
+ }, []);
+
+ // Reuses `LinksMenu`'s own data source rather than a second endpoint —
+ // see file-top comment.
+ useEffect(() => {
+ fetch('/api/links')
+ .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(`http ${r.status}`))))
+ .then((links) => {
+ const authelia = links.find((l) => l.label === 'Authelia');
+ if (authelia) setAutheliaUrl(authelia.url);
+ })
+ .catch(() => {
+ /* cosmetic only — the menu just renders with no links */
+ });
+ }, []);
+
+ // Close on an outside click or Escape — identical pattern to
+ // `LinksMenu`/`SettingsMenu`.
+ useEffect(() => {
+ if (!open) return;
+ function onPointerDown(e: MouseEvent) {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
+ }
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') setOpen(false);
+ }
+ document.addEventListener('pointerdown', onPointerDown);
+ document.addEventListener('keydown', onKeyDown);
+ return () => {
+ document.removeEventListener('pointerdown', onPointerDown);
+ document.removeEventListener('keydown', onKeyDown);
+ };
+ }, [open]);
+
+ const label = who?.displayName || who?.email || null;
+ const initial = label ? label.trim().charAt(0).toUpperCase() : '?';
+ const accent = pickAccent(label ?? 'operator');
+ // Trailing slash normalised off so `/logout` appends cleanly
+ // regardless of whether the contributed link kept one (the `Authelia`
+ // entry does — see `swarm-authelia.nix` — but this isn't the only
+ // conceivable shape a future contributor's own URL could take).
+ const base = autheliaUrl?.replace(/\/+$/, '') ?? null;
+
+ return (
+
+ );
+}
diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix
index 9f1b08f5..662d39b7 100644
--- a/nix/host-modules/swarm-ui.nix
+++ b/nix/host-modules/swarm-ui.nix
@@ -217,6 +217,26 @@ in
return 301 /api/docs/;
'';
};
+ # Session identity for the header's profile menu: initials
+ # avatar + "who is this" line. `auth_request` above
+ # only ever answers yes/no — it never forwards *who* — so the
+ # frontend has no other way to learn this. Same-origin proxy
+ # straight to authelia's own `GET /api/user/info`
+ # (session-cookie authenticated) rather than new
+ # swarm-controller code: the cookie is already valid here (the
+ # session cookie's domain is the swarm's, shared across every
+ # vhost under it — see swarm-authelia.nix), so this is a pure
+ # pass-through with nothing for a daemon to add.
+ #
+ # `=` exact match, not a prefix, so proxy_pass's own URI part
+ # (`/api/user/info`) REPLACES the matched request URI rather
+ # than being appended to it — same substitution shape as
+ # `/__hive_authelia` below, just not `internal` since the
+ # frontend calls this one directly.
+ "= /api/whoami" = {
+ proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/user/info";
+ extraConfig = swarmAuthRequest;
+ };
"/api/docs/" = {
alias = "${gatewayCfg.swaggerUiTheme}/";
extraConfig = ''