From dabd0fc8233a0c5b80ac08063101ba0ef79230b0 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 24 Aug 2026 00:00:13 +0200 Subject: [PATCH] =?UTF-8?q?swarm-ui:=20header=20profile=20menu=20=E2=80=94?= =?UTF-8?q?=20initials=20avatar,=20authelia=20settings=20+=20logout=20(#35?= =?UTF-8?q?70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "/api/whoami" same-origin nginx proxy to authelia's own GET /api/user/info (session-cookie authenticated, no swarm-controller code needed) and a new UserMenu header component: a generated initials avatar (first letter of display name, coloured from the same seven base16 chromatic slots the nav accent already cycles through) opening a popover with the signed-in name, a link to authelia settings, and log out — both reusing the existing "Authelia" entry from GET /api/links rather than a second source of the domain. Per mara's call on the open avatar-mechanism question: initials now, a real uploaded photo (authelia's settings UI implies pics are settable) is an explicit future item, not blocking this. --- .../packages/swarm-ui/src/shell/Shell.tsx | 2 + .../packages/swarm-ui/src/shell/UserMenu.css | 67 ++++++++ .../packages/swarm-ui/src/shell/UserMenu.tsx | 160 ++++++++++++++++++ nix/host-modules/swarm-ui.nix | 20 +++ 4 files changed, 249 insertions(+) create mode 100644 frontend/packages/swarm-ui/src/shell/UserMenu.css create mode 100644 frontend/packages/swarm-ui/src/shell/UserMenu.tsx 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 ( +
+ + {open ? ( + + ) : null} +
+ ); +} 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 = ''