// — 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" both build off the *same* base URL // `LinksMenu` already shows for "Authelia" (`GET /api/links`), not a // second source of the domain — `/settings` and `/logout` are appended // client-side, both real authelia routes (mara confirmed `/settings` is // the real path after the plain domain-root link landed short of it). 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 (
{open ? ( ) : null}
); }