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, + }; +}