This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 92574ae feat: move the account menu to the top-right header
92574ae is described below
commit 92574ae06525a55ee749d3b9dfb02c7f1bea8e6d
Author: Yang Luo <[email protected]>
AuthorDate: Wed Aug 19 01:49:38 2026 +0800
feat: move the account menu to the top-right header
---
web/src/App.tsx | 33 ++++++-
web/src/components/layout/Header.tsx | 158 ++++++++++++++++++++++++++++++++++
web/src/components/layout/Sidebar.tsx | 145 +++----------------------------
3 files changed, 198 insertions(+), 138 deletions(-)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2ffa1e1..32bacca 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -19,6 +19,7 @@ import * as AccountBackend from "@/backend/AccountBackend";
import * as Conf from "@/Conf";
import * as Setting from "@/Setting";
import {Footer} from "@/components/layout/Footer";
+import {Header} from "@/components/layout/Header";
import {Sidebar} from "@/components/layout/Sidebar";
import {PageSpinner} from "@/components/ui/spinner";
import {TooltipProvider} from "@/components/ui/tooltip";
@@ -51,11 +52,24 @@ const DashboardPage = React.lazy(() =>
import("@/pages/DashboardPage"));
Setting.initCasdoorSdk(Conf.AuthConfig);
+const collapsedKey = "sidebarCollapsed";
+
export default function App() {
// undefined while the account request is in flight, null when signed out.
const [account, setAccount] = React.useState<Account | null |
undefined>(undefined);
+ const [collapsed, setCollapsed] = React.useState(
+ () => localStorage.getItem(collapsedKey) === "true",
+ );
+ const [drawerOpen, setDrawerOpen] = React.useState(false);
const location = useLocation();
+ const toggleCollapsed = () => {
+ setCollapsed(value => {
+ localStorage.setItem(collapsedKey, String(!value));
+ return !value;
+ });
+ };
+
const getAccount = React.useCallback(() => {
AccountBackend.getAccount().then(res => {
const user = res.data;
@@ -120,10 +134,21 @@ export default function App() {
return (
<TooltipProvider delayDuration={200}>
<div className="flex min-h-screen">
- <Sidebar account={account} onSignout={signout} />
- {/* pt-14 leaves room for the fixed mobile bar the sidebar renders;
- min-w-0 keeps a wide table from stretching the whole layout. */}
- <div className="flex min-w-0 flex-1 flex-col pt-14 md:pt-0">
+ <Sidebar
+ account={account}
+ collapsed={collapsed}
+ drawerOpen={drawerOpen}
+ onDrawerOpenChange={setDrawerOpen}
+ />
+ {/* min-w-0 keeps a wide table from stretching the whole layout. */}
+ <div className="flex min-w-0 flex-1 flex-col">
+ <Header
+ account={account}
+ collapsed={collapsed}
+ onToggleCollapsed={toggleCollapsed}
+ onOpenDrawer={() => setDrawerOpen(true)}
+ onSignout={signout}
+ />
<main className="flex-1">
<React.Suspense fallback={<PageSpinner />}>
<Routes>
diff --git a/web/src/components/layout/Header.tsx
b/web/src/components/layout/Header.tsx
new file mode 100644
index 0000000..fbf6867
--- /dev/null
+++ b/web/src/components/layout/Header.tsx
@@ -0,0 +1,158 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Link, useNavigate} from "react-router-dom";
+import {
+ LogOut,
+ Menu as MenuIcon,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Settings,
+} from "lucide-react";
+import i18next from "i18next";
+import {useTranslation} from "react-i18next";
+
+import * as Setting from "@/Setting";
+import type {Account} from "@/types";
+import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
+import {Button} from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {LanguageSelect} from "@/components/layout/LanguageSelect";
+
+function AccountMenu({
+ account,
+ onSignout,
+}: {
+ account: Account | null | undefined;
+ onSignout: () => void;
+}) {
+ const navigate = useNavigate();
+
+ if (account === undefined) {
+ return null;
+ }
+ if (account === null) {
+ return (
+ <Button asChild size="sm">
+ <a href={Setting.getSigninUrl() || "/signin"}>{i18next.t("account:Sign
In")}</a>
+ </Button>
+ );
+ }
+
+ const openProfile = () => {
+ const profileUrl = Setting.getMyProfileUrl(account);
+ if (profileUrl === "") {
+ navigate("/account");
+ } else {
+ Setting.openLink(profileUrl);
+ }
+ };
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <button className="flex items-center gap-2 rounded-md px-1.5 py-1
transition-colors hover:bg-accent">
+ <Avatar className="h-8 w-8 shrink-0">
+ {account.avatar ? <AvatarImage src={account.avatar}
alt={account.name} /> : null}
+ <AvatarFallback style={{backgroundColor:
Setting.getAvatarColor(account.name)}}>
+ <span className="text-white">
+ {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
+ </span>
+ </AvatarFallback>
+ </Avatar>
+ <span className="hidden max-w-[140px] truncate text-sm font-medium
sm:inline">
+ {Setting.getShortName(account.displayName || account.name)}
+ </span>
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem onClick={openProfile}>
+ <Settings />
+ {i18next.t("account:My Account")}
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={onSignout}>
+ <LogOut />
+ {i18next.t("account:Sign Out")}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+/**
+ * The top bar of the content column: navigation controls on the left, and
+ * everything about the viewer themselves on the right. It sticks so the
account
+ * menu stays reachable on a long list page.
+ */
+export function Header({
+ account,
+ collapsed,
+ onToggleCollapsed,
+ onOpenDrawer,
+ onSignout,
+}: {
+ account: Account | null | undefined;
+ collapsed: boolean;
+ onToggleCollapsed: () => void;
+ onOpenDrawer: () => void;
+ onSignout: () => void;
+}) {
+ // The labels are read eagerly through i18next.t, so the header has to
+ // re-render when the language changes.
+ useTranslation();
+
+ return (
+ <header className="sticky top-0 z-30 flex h-14 shrink-0 items-center
justify-between gap-2 border-b bg-background/95 px-3 backdrop-blur">
+ <div className="flex min-w-0 items-center gap-2">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={onToggleCollapsed}
+ aria-label={i18next.t("general:Toggle sidebar")}
+ className="hidden md:inline-flex"
+ >
+ {collapsed ? (
+ <PanelLeftOpen className="h-5 w-5" />
+ ) : (
+ <PanelLeftClose className="h-5 w-5" />
+ )}
+ </Button>
+
+ <Button variant="ghost" size="icon" aria-label="Menu"
onClick={onOpenDrawer} className="md:hidden">
+ <MenuIcon className="h-5 w-5" />
+ </Button>
+ {/* The sidebar, which carries the logo on desktop, is hidden here. */}
+ <Link to="/" className="flex items-center md:hidden">
+ <img
+ src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+ alt="Casbin Gateway"
+ className="h-6 w-auto"
+ />
+ </Link>
+ </div>
+
+ <div className="flex items-center gap-1">
+ <LanguageSelect />
+ <AccountMenu account={account} onSignout={onSignout} />
+ </div>
+ </header>
+ );
+}
diff --git a/web/src/components/layout/Sidebar.tsx
b/web/src/components/layout/Sidebar.tsx
index 305301a..3367797 100644
--- a/web/src/components/layout/Sidebar.tsx
+++ b/web/src/components/layout/Sidebar.tsx
@@ -13,7 +13,7 @@
// limitations under the License.
import * as React from "react";
-import {Link, useLocation, useNavigate} from "react-router-dom";
+import {Link, useLocation} from "react-router-dom";
import {
Bot,
ChartColumn,
@@ -23,11 +23,7 @@ import {
Globe,
LayoutDashboard,
ListFilter,
- LogOut,
- Menu as MenuIcon,
MessageSquare,
- PanelLeftClose,
- PanelLeftOpen,
Plug,
ScrollText,
Server,
@@ -41,19 +37,9 @@ import {useTranslation} from "react-i18next";
import * as Setting from "@/Setting";
import {cn} from "@/lib/utils";
import type {Account} from "@/types";
-import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {Button} from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import {Tooltip} from "@/components/ui/tooltip";
-import {LanguageSelect} from "@/components/layout/LanguageSelect";
-const collapsedKey = "sidebarCollapsed";
const advancedOpenKey = "sidebarAdvancedOpen";
interface MenuEntry {
@@ -116,19 +102,20 @@ function readFlag(key: string, fallback: boolean) {
export function Sidebar({
account,
- onSignout,
+ collapsed,
+ drawerOpen,
+ onDrawerOpenChange,
}: {
account: Account | null | undefined;
- onSignout: () => void;
+ collapsed: boolean;
+ drawerOpen: boolean;
+ onDrawerOpenChange: (open: boolean) => void;
}) {
// Subscribing to the language keeps the menu labels, which are read eagerly
// through i18next.t, re-rendering when the language changes.
useTranslation();
const location = useLocation();
- const navigate = useNavigate();
- const [collapsed, setCollapsed] = React.useState(() =>
readFlag(collapsedKey, false));
const [advancedOpen, setAdvancedOpen] = React.useState(() =>
readFlag(advancedOpenKey, false));
- const [drawerOpen, setDrawerOpen] = React.useState(false);
const isAdmin = Setting.isAdminUser(account);
const mainEntries = getMainEntries().filter(entry => !entry.adminOnly ||
isAdmin);
@@ -137,15 +124,8 @@ export function Sidebar({
// The drawer covers the page, so leaving it open across a navigation would
// hide whatever the viewer just asked for.
React.useEffect(() => {
- setDrawerOpen(false);
- }, [location.pathname]);
-
- const toggleCollapsed = () => {
- setCollapsed(value => {
- localStorage.setItem(collapsedKey, String(!value));
- return !value;
- });
- };
+ onDrawerOpenChange(false);
+ }, [location.pathname, onDrawerOpenChange]);
const toggleAdvanced = () => {
setAdvancedOpen(value => {
@@ -154,15 +134,6 @@ export function Sidebar({
});
};
- const openProfile = () => {
- const profileUrl = Setting.getMyProfileUrl(account);
- if (profileUrl === "") {
- navigate("/account");
- } else {
- Setting.openLink(profileUrl);
- }
- };
-
/** `iconOnly` is the collapsed desktop rail; the drawer is always full
width. */
const renderEntry = (entry: MenuEntry, iconOnly: boolean, indented = false)
=> {
const link = (
@@ -230,59 +201,6 @@ export function Sidebar({
</nav>
);
- const renderAccount = (iconOnly: boolean) => {
- if (account === undefined) {
- return null;
- }
- if (account === null) {
- return (
- <Button asChild size="sm" className={cn("w-full", iconOnly && "px-0")}>
- <a href={Setting.getSigninUrl() || "/signin"}>
- {iconOnly ? <LogOut className="h-4 w-4" /> :
i18next.t("account:Sign In")}
- </a>
- </Button>
- );
- }
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <button
- className={cn(
- "flex w-full items-center gap-2 rounded-md px-2 py-1.5
hover:bg-accent",
- iconOnly && "justify-center px-0",
- )}
- >
- <Avatar className="h-8 w-8 shrink-0">
- {account.avatar ? <AvatarImage src={account.avatar}
alt={account.name} /> : null}
- <AvatarFallback style={{backgroundColor:
Setting.getAvatarColor(account.name)}}>
- <span className="text-white">
- {Setting.getShortName(account.name).slice(0,
2).toUpperCase()}
- </span>
- </AvatarFallback>
- </Avatar>
- {iconOnly ? null : (
- <span className="min-w-0 flex-1 truncate text-left text-sm
font-medium">
- {Setting.getShortName(account.displayName || account.name)}
- </span>
- )}
- </button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start" side="top" className="w-48">
- <DropdownMenuItem onClick={openProfile}>
- <Settings />
- {i18next.t("account:My Account")}
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={onSignout}>
- <LogOut />
- {i18next.t("account:Sign Out")}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- );
- };
-
const renderPanel = (iconOnly: boolean) => (
<>
<div
@@ -301,26 +219,6 @@ export function Sidebar({
</div>
{renderNav(iconOnly)}
-
- <div className={cn("shrink-0 border-t p-2", iconOnly && "flex flex-col
items-center gap-1")}>
- {renderAccount(iconOnly)}
- <div className={cn("mt-1 flex items-center", iconOnly ? "flex-col" :
"justify-between")}>
- <LanguageSelect />
- <Button
- variant="ghost"
- size="icon"
- onClick={toggleCollapsed}
- aria-label={i18next.t("general:Toggle sidebar")}
- className="hidden md:inline-flex"
- >
- {iconOnly ? (
- <PanelLeftOpen className="h-5 w-5" />
- ) : (
- <PanelLeftClose className="h-5 w-5" />
- )}
- </Button>
- </div>
- </div>
</>
);
@@ -335,32 +233,11 @@ export function Sidebar({
{renderPanel(collapsed)}
</aside>
- {/* Fixed rather than in flow, so that the flex row this sits in stays a
- sidebar-plus-content layout at every width. The content column leaves
- room for it with its own top padding. */}
- <header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center
gap-2 border-b bg-background/95 px-3 backdrop-blur md:hidden">
- <Button
- variant="ghost"
- size="icon"
- aria-label="Menu"
- onClick={() => setDrawerOpen(true)}
- >
- <MenuIcon className="h-5 w-5" />
- </Button>
- <Link to="/" className="flex items-center">
- <img
- src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
- alt="Casbin Gateway"
- className="h-6 w-auto"
- />
- </Link>
- </header>
-
{drawerOpen ? (
<div className="fixed inset-0 z-50 md:hidden">
<div
className="absolute inset-0 bg-black/50"
- onClick={() => setDrawerOpen(false)}
+ onClick={() => onDrawerOpenChange(false)}
aria-hidden
/>
<div className="absolute inset-y-0 left-0 flex w-64 flex-col
border-r bg-background">
@@ -369,7 +246,7 @@ export function Sidebar({
size="icon"
aria-label="Close"
className="absolute right-2 top-2 z-10"
- onClick={() => setDrawerOpen(false)}
+ onClick={() => onDrawerOpenChange(false)}
>
<X className="h-5 w-5" />
</Button>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]