Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/user-menu/UserMenu.tsx
iris fd51cbd19d swarm-ui: pages that only display start from the answer they last saw, then refresh behind it
usePolled takes a cache key: it saves each answer in localStorage and starts
from the saved one, so opening the agents, alerts, builds, review runs or hives
view shows the last data at once and the refresh spinner marks the update. A
saved answer is dropped after a day, when it was saved by another build of the
UI, at log out (a new onLogout of the shared user menu) and when the gateway
ends the login (a new onSessionEnded of the session guard). Pages with an
editor (schedules, notification flows, broadcast) do not use it.
2026-11-05 23:14:25 +01:00

129 lines
4.1 KiB
TypeScript

// <UserMenu> — the profile affordance in a 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. The swarm UI, the
// hive dashboard and the per-agent page all mount this one, so they look and
// behave the same. Same icon-button + popover chrome as `SettingsMenu`.
//
// Who: `auth_request` only answers yes or no, never *who*. The name comes from
// authelia's `GET /api/user/info`, which each UI's nginx vhost proxies at
// `/api/whoami` on its own origin, so the browser sends the session cookie
// and no CORS is involved. A fetch that fails leaves the avatar as a "?".
//
// Avatar: authelia has no photo field, so it is generated from the initials
// (no third-party call: gravatar would leak a hashed e-mail on every load).
//
// Styles live in `@hive/shared/user-menu.css`, which the mounting page
// imports or `@import`s itself: the dashboard's JS bundle loads `.css`
// imports as text, so a component that imported it would not style there.
import { useEffect, useRef, useState } from "preact/hooks";
import {
accountLinks,
initialOf,
labelOf,
parseWhoAmI,
pickAccent,
type WhoAmI,
} from "./identity.js";
export function UserMenu({
whoamiUrl,
autheliaUrl,
onLogout,
}: {
// Where the signed-in user is asked for, on this page's own origin.
whoamiUrl: string;
// Authelia's base URL, for the settings and sign-out links; none when it
// is not known, and the menu then shows only the name.
autheliaUrl: string | null;
// Runs when "log out" is chosen, for a page to forget what it kept of this
// session.
onLogout?: () => void;
}) {
const [who, setWho] = useState<WhoAmI | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
fetch(whoamiUrl)
.then((r) =>
r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`)),
)
.then((body) => {
if (!cancelled) setWho(parseWhoAmI(body));
})
.catch(() => {
if (!cancelled) setWho(null);
});
return () => {
cancelled = true;
};
}, [whoamiUrl]);
// Close on an outside click or Escape, as `SettingsMenu` does.
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 = labelOf(who);
const links = accountLinks(autheliaUrl);
return (
<div class="user-menu" ref={rootRef}>
<button
type="button"
class="user-menu-button"
style={{ background: pickAccent(label ?? "operator") }}
aria-haspopup="true"
aria-expanded={open}
aria-label={label ? `signed in as ${label}` : "account"}
onClick={() => setOpen((v) => !v)}
>
{initialOf(label)}
</button>
{open ? (
<div class="user-menu-popover" role="menu">
{label ? <div class="user-menu-name">{label}</div> : null}
{links ? (
<>
<a
class="user-menu-item"
href={links.settings}
target="_blank"
rel="noreferrer"
role="menuitem"
onClick={() => setOpen(false)}
>
authelia settings
</a>
<a
class="user-menu-item"
href={links.logout}
role="menuitem"
onClick={() => {
onLogout?.();
setOpen(false);
}}
>
log out
</a>
</>
) : null}
</div>
) : null}
</div>
);
}