diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index 7786a906..5da6dcb0 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -7,6 +7,10 @@ domain, covers host-level detail for one hive.
## What it shows
+The signed-in user's avatar sits at the right end of the header on every page.
+It opens a menu with the user's name and links to authelia's settings and
+sign-out pages; the hive dashboard and the per-agent page have the same menu.
+
| route | what |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md
index de91478f..59dc82bf 100644
--- a/docs/web-ui/agent.md
+++ b/docs/web-ui/agent.md
@@ -70,6 +70,13 @@ at a given viewport width. Two columns:
unprivileged process can't make directly).
- **Inbox / todos pills** (`π¬ inbox Β· N` / `π todos Β· N`): hidden
when empty; selecting it opens the matching flyout in the side panel.
+ - **Profile avatar**: the signed-in user's initial, opening a popover with
+ their name and links to authelia's settings and sign-out pages, as on the
+ dashboard (`AgentUserMenu.tsx`). It shows only behind the gateway, where
+ the page shares the dashboard's origin and session; opened on the agent's
+ own port there is no sign-in and no avatar. The name comes from the
+ dashboard's `/api/whoami`, authelia's URL from the dashboard's
+ `/api/state`.
- **Links badge** (`π`): opens a popover listing `β dashboard`
(back-link to the host dashboard, `${dashboardBase}`)
followed by this agent's `agent_links()`-sourced navigation (stats,
diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md
index a305e2b3..780e5aa9 100644
--- a/docs/web-ui/dashboard.md
+++ b/docs/web-ui/dashboard.md
@@ -24,6 +24,15 @@ dashboard tab strip.
comes from `state.swarm_public_url`, which hive-c0re reads from
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
with no domain set there is no swarm entry. The standalone pages link back to `/`.
+- **Profile menu**: the signed-in user's avatar at the right end of the
+ chrome (`user-menu.js`, the same component as the swarm UI's and the agent
+ page's). It opens a popover with the user's name and links to authelia's
+ settings and sign-out pages. The name comes from `/api/whoami`, which the
+ dashboard's nginx vhost proxies to authelia, and the links from
+ `state.authelia_public_url`, which hive-c0re reads from
+ `HIVE_AUTHELIA_PUBLIC_URL` (set from `services.hyperhive.swarm.authelia.url`).
+ If the name doesn't load the avatar shows a question mark, and without the URL the
+ popover has no links.
- **Tab strip**: `β 4G3NTS β`, `β R3BU1LD QU3U3 β`, `β M3T4 1NPUTS β`,
`β BUILD L0GS β`, `β K3PT ST4T3 β`. In-page tabs only β ST4TS
lives on its own page, reached from the pages menu. Count pills on
diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx
index f4eec760..831e97ec 100644
--- a/frontend/packages/agent/src/Root.tsx
+++ b/frontend/packages/agent/src/Root.tsx
@@ -11,6 +11,7 @@ import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { Header } from "./components/Header.js";
import { MetaNav } from "./components/MetaNav.js";
+import { AgentUserMenu } from "./components/AgentUserMenu.js";
import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js";
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
import { StatusChips } from "./components/StatusChips.js";
@@ -176,6 +177,11 @@ export function Root() {
+ {state ? (
+
+ ) : null}
>
);
// Kept mounted regardless of `openPanel` (open/closed toggles just the
diff --git a/frontend/packages/agent/src/components/AgentUserMenu.tsx b/frontend/packages/agent/src/components/AgentUserMenu.tsx
new file mode 100644
index 00000000..d0a566fa
--- /dev/null
+++ b/frontend/packages/agent/src/components/AgentUserMenu.tsx
@@ -0,0 +1,44 @@
+// β the profile menu (`@hive/shared/user-menu.js`) in this
+// page's header. Only behind the gateway: the page is then on the dashboard's
+// own origin, where the session cookie is valid and nginx proxies
+// `/api/whoami` to authelia. Opened directly on the agent's own port there is
+// no sign-in to show, so the page has no menu.
+//
+// Authelia's URL, for the settings and sign-out links, comes from the
+// dashboard's `/api/state` (`authelia_public_url`), the one place hive-c0re
+// states it.
+import { useEffect, useState } from "preact/hooks";
+import { UserMenu } from "@hive/shared/user-menu.js";
+import "@hive/shared/user-menu.css";
+import { isBehindGateway } from "../lib/dashboardBase.js";
+
+export function AgentUserMenu({ dashboardBase }: { dashboardBase: string }) {
+ const behind = isBehindGateway();
+ const [autheliaUrl, setAutheliaUrl] = useState(null);
+
+ useEffect(() => {
+ if (!behind) return;
+ let cancelled = false;
+ fetch(`${dashboardBase}api/state`)
+ .then((r) =>
+ r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`)),
+ )
+ .then((state: { authelia_public_url?: string | null }) => {
+ if (!cancelled) setAutheliaUrl(state.authelia_public_url ?? null);
+ })
+ .catch(() => {
+ /* cosmetic only: the menu then shows the name without the links */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [behind, dashboardBase]);
+
+ if (!behind) return null;
+ return (
+
+ );
+}
diff --git a/frontend/packages/agent/src/lib/dashboardBase.ts b/frontend/packages/agent/src/lib/dashboardBase.ts
index 61d31446..a4622608 100644
--- a/frontend/packages/agent/src/lib/dashboardBase.ts
+++ b/frontend/packages/agent/src/lib/dashboardBase.ts
@@ -4,8 +4,13 @@
// a relative path. Behind the gateway this page lives at
// `/agent//` on the dashboard's own origin (`location.origin`);
// accessed directly, it's the same host on `dashboardPort`.
+/** Whether this page is served through the gateway, on the dashboard's origin. */
+export function isBehindGateway(): boolean {
+ return location.pathname.startsWith("/agent/");
+}
+
export function resolveDashboardBase(dashboardPort: number): string {
- return location.pathname.startsWith("/agent/")
+ return isBehindGateway()
? location.origin + "/"
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
}
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index e11e27bd..d9487002 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -7,6 +7,7 @@
@import "./system-sections.css";
@import "@hive/shared/jobq-rollup.css";
@import "@hive/shared/jobq-graph.css";
+@import "@hive/shared/user-menu.css";
/* βββ tabbed dashboard chrome ββββββββββββββββββββββββββββββββββββββ
Top-of-page sticky header with banner + tab strip. SSE stays
@@ -42,6 +43,12 @@ body.dashboard-shell {
padding: 0.3em 1em;
margin: 0 0 1em;
}
+/* Profile menu (user-menu.js): the avatar at the right end of the chrome.
+ Its own look is `@hive/shared/user-menu.css`'s. */
+.user-menu-slot {
+ flex: none;
+ margin-left: auto;
+}
/* Pages menu (links-menu.js): one icon button at the left of the chrome
that opens a popover of the standalone pages and the swarm UI. Same
shape as the swarm UI's LinksMenu β quiet until hovered or open. The
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html
index 9587234d..a50f5234 100644
--- a/frontend/packages/dashboard/src/index.html
+++ b/frontend/packages/dashboard/src/index.html
@@ -86,6 +86,8 @@
menu at the left of this strip, not tabs β the strip holds only
in-page tabs. -->
+
+