diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69be14c..c9c19fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ chart's `appVersion`; the image and chart are released together under it.
### Fixed
- Overlay panels keep their edge. The site-wide "no focus ring" rule zeroed the `ring-1` that draws the border of menus, popovers, selects, comboboxes and dialogs, so an open white panel on the white page showed only a faint shadow — the top-bar avatar menu read as a line of text floating under the header.
+- The top-bar avatar menu names the signed-in user — avatar, name, role and, when console has one to read, the email — instead of a single `name · role` line. With `server.auth.disabled` the user button itself turns amber and the avatar carries a dot, so the state shows without opening anything and the bar gains no element; the menu carries 登录已关闭,当前为管理员身份 (*Sign-in off — acting as the administrator*) in place of change-password and sign-out, which that mode cannot honour.
## [0.1.2] - 2026-10-08
diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx
index ac27236..1a04054 100644
--- a/web/src/shell/Layout.tsx
+++ b/web/src/shell/Layout.tsx
@@ -1,8 +1,9 @@
-import { useEffect, useState, type ReactNode } from "react";
+import { Fragment, useEffect, useState, type ReactNode } from "react";
import { NavLink, useLocation } from "react-router";
import {
Avatar,
AvatarFallback,
+ Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -17,6 +18,7 @@ import { usePermissions } from "@/shell/permissions";
import { ChangePasswordDialog } from "@/shell/ChangePasswordDialog";
import { PreferencesPanel } from "@/shell/PreferencesPanel";
import { useLayout } from "@/shell/preferences";
+import { userMenuIdentity, userMenuState } from "@/shell/user-menu";
import { activeGroupTitle, navGroups, topbarItems, type ConsoleModule, type NavGroup, type NavItem } from "@/shell/module";
import { LOCALES, navLabel, useLocale, useT } from "@/shell/i18n";
@@ -95,6 +97,10 @@ export function Layout({ modules, children }: { modules: ConsoleModule[]; childr
// Global's default chrome is a dark header except in minimal, where it is fixed
// to white on the board. Dark values are Global's measured .dark header.
+//
+// chipWarn is the auth-disabled state on the user button itself: no extra element
+// in the bar, the state rides on the identity. chipDot colours the dot's ring to
+// whatever is behind the avatar, so it reads as a badge on both tones.
const TONE = {
light: {
bar: "border-b bg-card text-[var(--shell-foreground)]",
@@ -102,6 +108,8 @@ const TONE = {
logo: "text-primary",
hover: "hover:bg-accent",
avatar: "bg-primary/10 text-primary",
+ chipWarn: "bg-warning/10 text-warning shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-warning)_45%,transparent)]",
+ chipDot: "ring-card",
},
dark: {
bar: "border-b border-[var(--shell-header-dark-border)] bg-[var(--shell-header-dark)] text-[var(--shell-header-dark-foreground)]",
@@ -109,6 +117,8 @@ const TONE = {
logo: "text-[var(--shell-header-dark-primary)]",
hover: "hover:bg-white/8",
avatar: "bg-white/10 text-white",
+ chipWarn: "bg-warning/10 text-warning shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-warning)_45%,transparent)]",
+ chipDot: "ring-[var(--shell-header-dark)]",
},
};
@@ -127,6 +137,8 @@ function TopBar({
const tr = useT("shell");
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const initial = me?.name?.[0]?.toUpperCase() ?? "?";
+ const menu = userMenuState(me);
+ const identity = userMenuIdentity(me);
const t = TONE[tone];
return (
@@ -167,36 +179,56 @@ function TopBar({
>
+ {/* The state belongs in the bar, not only behind the avatar: with no
+ login every request is the administrator, and that is worth knowing
+ before opening anything. It rides on the user button itself, so the
+ bar gains no element. */}
-
-
- {initial}
-
+
+
+
+ {initial}
+
+ {menu.noLogin && }
+
{me?.name}
-
+
-
- {me?.name}
- {me?.isAdmin ? ` · ${tr("layout.admin")}` : ""}
+
+
+ {initial}
+
+
+ {identity.name}
+ {tr(`layout.${identity.role}`)}
+ {identity.email && {identity.email}}
+
- {/* With auth disabled there is no session to end and no stored
- password to change; both entries would fail if offered. */}
- {!me?.authDisabled && (
+ {menu.noLogin && (
<>
- setChangePasswordOpen(true)}>
-
- {tr("layout.changePassword")}
-
-
-
-
- {tr("layout.logout")}
-
+
+ {tr("layout.noLogin")}
+
>
)}
+ {menu.entries.map((entry) => {
+ const Icon = entry === "changePassword" ? KeyRound : LogOut;
+ return (
+
+
+ setChangePasswordOpen(true) : logout}>
+
+ {tr(entry === "changePassword" ? "layout.changePassword" : "layout.logout")}
+
+
+ );
+ })}
diff --git a/web/src/shell/locales/shell.en-US.json b/web/src/shell/locales/shell.en-US.json
index 6474c5b..44641a4 100644
--- a/web/src/shell/locales/shell.en-US.json
+++ b/web/src/shell/locales/shell.en-US.json
@@ -8,8 +8,10 @@
"preferences": "Preferences",
"language": "Language",
"admin": "Administrator",
+ "member": "Member",
"changePassword": "Change password",
- "logout": "Sign out"
+ "logout": "Sign out",
+ "noLogin": "Sign-in off — acting as the administrator"
},
"home": {
"title": "Inference Overview",
diff --git a/web/src/shell/locales/shell.zh-CN.json b/web/src/shell/locales/shell.zh-CN.json
index 7e19405..de86487 100644
--- a/web/src/shell/locales/shell.zh-CN.json
+++ b/web/src/shell/locales/shell.zh-CN.json
@@ -5,8 +5,10 @@
"preferences": "偏好设置",
"language": "语言",
"admin": "管理员",
+ "member": "普通用户",
"changePassword": "修改密码",
- "logout": "退出登录"
+ "logout": "退出登录",
+ "noLogin": "登录已关闭,当前为管理员身份"
},
"home": {
"title": "推理运行概览",
diff --git a/web/src/shell/user-menu.test.ts b/web/src/shell/user-menu.test.ts
new file mode 100644
index 0000000..15ceba1
--- /dev/null
+++ b/web/src/shell/user-menu.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { userMenuIdentity, userMenuState } from "@/shell/user-menu";
+import type { Me } from "@/shell/api";
+
+const me = (over: Partial = {}): Me => ({
+ name: "admin",
+ groups: [],
+ isAdmin: true,
+ permissions: ["*"],
+ requirePasswordReset: false,
+ ...over,
+});
+
+describe("userMenuState", () => {
+ it("offers both entries when console logs people in", () => {
+ expect(userMenuState(me())).toEqual({ entries: ["changePassword", "logout"], noLogin: false });
+ });
+
+ it("draws no entries and says why when console runs without a login", () => {
+ expect(userMenuState(me({ authDisabled: true }))).toEqual({ entries: [], noLogin: true });
+ });
+
+ it("keeps the entries when there is no session to read yet", () => {
+ expect(userMenuState(null)).toEqual({ entries: ["changePassword", "logout"], noLogin: false });
+ });
+});
+
+describe("userMenuIdentity", () => {
+ it("carries the name and the role", () => {
+ expect(userMenuIdentity(me())).toEqual({ name: "admin", role: "admin", email: undefined });
+ expect(userMenuIdentity(me({ name: "li", isAdmin: false }))).toEqual({ name: "li", role: "member", email: undefined });
+ expect(userMenuIdentity(null)).toEqual({ name: "", role: "member", email: undefined });
+ });
+
+ it("carries the email only when console has one to read", () => {
+ expect(userMenuIdentity(me({ email: "admin@example.com" })).email).toBe("admin@example.com");
+ expect(userMenuIdentity(me({ authDisabled: true })).email).toBeUndefined();
+ });
+});
diff --git a/web/src/shell/user-menu.ts b/web/src/shell/user-menu.ts
new file mode 100644
index 0000000..be94411
--- /dev/null
+++ b/web/src/shell/user-menu.ts
@@ -0,0 +1,36 @@
+import type { Me } from "@/shell/api";
+
+// What the avatar menu draws, apart from how: the shell is the only place that
+// knows both the identity and the auth mode.
+export type UserMenuEntry = "changePassword" | "logout";
+
+export interface UserMenuState {
+ // Auth disabled: console can honour neither entry — there is no session to end
+ // and no stored password to change — so the notice below stands in for them
+ // and no dead rows are drawn.
+ entries: UserMenuEntry[];
+ // Console running without a login: every request is the synthetic
+ // administrator, and the menu says so.
+ noLogin: boolean;
+}
+
+export function userMenuState(me: Me | null): UserMenuState {
+ const noLogin = me?.authDisabled === true;
+ return { entries: noLogin ? [] : ["changePassword", "logout"], noLogin };
+}
+
+export interface UserMenuIdentity {
+ name: string;
+ role: "admin" | "member";
+ email?: string;
+}
+
+// The synthetic administrator of an auth-disabled install is not a User CRD, so
+// it has no email to read.
+export function userMenuIdentity(me: Me | null): UserMenuIdentity {
+ return {
+ name: me?.name ?? "",
+ role: me?.isAdmin ? "admin" : "member",
+ email: me?.email || undefined,
+ };
+}