Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 53 additions & 21 deletions web/src/shell/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -95,20 +97,28 @@ 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)]",
icon: "text-muted-foreground hover:bg-accent hover:text-foreground",
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)]",
icon: "text-white/55 hover:bg-white/8 hover:text-white",
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)]",
},
};

Expand All @@ -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 (
Expand Down Expand Up @@ -167,36 +179,56 @@ function TopBar({
>
<Palette className="h-4 w-4" />
</button>
{/* 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. */}
<DropdownMenu>
<DropdownMenuTrigger className={`flex items-center gap-2 rounded-md px-2 py-1 transition-colors ${t.hover}`}>
<Avatar className="h-7 w-7">
<AvatarFallback className={`text-[13px] font-semibold ${t.avatar}`}>{initial}</AvatarFallback>
</Avatar>
<DropdownMenuTrigger
title={menu.noLogin ? tr("layout.noLogin") : undefined}
className={`flex items-center gap-2 rounded-md px-2 py-1 transition-colors ${menu.noLogin ? t.chipWarn : t.hover}`}
>
<span className="relative flex shrink-0">
<Avatar className="h-7 w-7">
<AvatarFallback className={`text-[13px] font-semibold ${menu.noLogin ? "bg-warning/20 text-warning" : t.avatar}`}>{initial}</AvatarFallback>
</Avatar>
{menu.noLogin && <span aria-hidden className={`absolute -top-0.5 -right-0.5 size-2.5 rounded-full bg-warning ring-2 ${t.chipDot}`} />}
</span>
<span className="hidden text-[13px] font-medium sm:inline">{me?.name}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel>
{me?.name}
{me?.isAdmin ? ` · ${tr("layout.admin")}` : ""}
<DropdownMenuLabel className="flex items-center gap-2.5 px-1.5 py-2 font-normal">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-[13px] font-semibold text-primary">{initial}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-foreground">{identity.name}</span>
<span className="block truncate text-xs text-muted-foreground">{tr(`layout.${identity.role}`)}</span>
{identity.email && <span className="block truncate text-xs text-muted-foreground">{identity.email}</span>}
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
{/* 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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setChangePasswordOpen(true)}>
<KeyRound className="mr-2 h-4 w-4" />
{tr("layout.changePassword")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<LogOut className="mr-2 h-4 w-4" />
{tr("layout.logout")}
</DropdownMenuItem>
<Badge variant="warning" className="h-auto w-full justify-start px-2 py-1 text-left leading-snug whitespace-normal">
{tr("layout.noLogin")}
</Badge>
</>
)}
{menu.entries.map((entry) => {
const Icon = entry === "changePassword" ? KeyRound : LogOut;
return (
<Fragment key={entry}>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={entry === "changePassword" ? () => setChangePasswordOpen(true) : logout}>
<Icon className="mr-2 h-4 w-4" />
{tr(entry === "changePassword" ? "layout.changePassword" : "layout.logout")}
</DropdownMenuItem>
</Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
4 changes: 3 additions & 1 deletion web/src/shell/locales/shell.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion web/src/shell/locales/shell.zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"preferences": "偏好设置",
"language": "语言",
"admin": "管理员",
"member": "普通用户",
"changePassword": "修改密码",
"logout": "退出登录"
"logout": "退出登录",
"noLogin": "登录已关闭,当前为管理员身份"
},
"home": {
"title": "推理运行概览",
Expand Down
39 changes: 39 additions & 0 deletions web/src/shell/user-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
36 changes: 36 additions & 0 deletions web/src/shell/user-menu.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading