From 2797478ac9a5f2228b3b27731262b1c536dac1d9 Mon Sep 17 00:00:00 2001 From: imneov Date: Mon, 12 Oct 2026 21:43:49 -0700 Subject: [PATCH] fix(shell): show who is signed in in the avatar menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu drew one line, "admin · 管理员", and with server.auth.disabled it drew only that: the change-password and sign-out entries were dropped with no replacement, so the menu read as broken instead of as an install that has no login. The header now carries the avatar, the name, the role and — when console has a User CRD to read it from — the email, and userMenuState() decides the entries instead of an inline !me.authDisabled. Without a login the state also shows in the bar, so it is known before anything is opened, and the bar gains no element: the user button itself turns amber (icon, name, background and a dot on the avatar) and its tooltip carries 登录已关闭,当前为管理员身份 (Sign-in off — acting as the administrator). In that mode the menu draws no entries at all — console can honour neither — and carries the same sentence in their place; signed-in installs keep both entries live. The text names the state rather than the reader: the mode makes every request the administrator, it does not make every account one. --- CHANGELOG.md | 1 + web/src/shell/Layout.tsx | 74 ++++++++++++++++++-------- web/src/shell/locales/shell.en-US.json | 4 +- web/src/shell/locales/shell.zh-CN.json | 4 +- web/src/shell/user-menu.test.ts | 39 ++++++++++++++ web/src/shell/user-menu.ts | 36 +++++++++++++ 6 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 web/src/shell/user-menu.test.ts create mode 100644 web/src/shell/user-menu.ts 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, + }; +}