You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/20 16:31:52 UTC
(casbin-gateway) 01/01: feat(web): rebuild the UI on shadcn/ui
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
commit 6fdcea6485b005c9680c5d3e643d4e5298eea372
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Aug 21 00:31:26 2026 +0800
feat(web): rebuild the UI on shadcn/ui
---
.claude/launch.json | 11 +
web/.gitignore | 1 +
web/package.json | 12 +-
web/postcss.config.js | 6 -
web/src/App.tsx | 160 ++++--
web/src/Setting.tsx | 35 +-
web/src/components/DataTable.tsx | 302 -----------
web/src/components/EnvSnippet.tsx | 6 +-
web/src/components/FormRow.tsx | 59 --
web/src/components/NodeTable.tsx | 14 +-
web/src/components/Result.tsx | 65 ---
web/src/components/layout/Footer.tsx | 39 --
web/src/components/layout/Header.tsx | 158 ------
web/src/components/layout/LanguageSelect.tsx | 48 --
web/src/components/layout/Sidebar.tsx | 259 ---------
web/src/components/rules/CompoundRule.tsx | 4 +-
web/src/components/rules/IpRateRuleTable.tsx | 6 +-
web/src/components/rules/IpRuleTable.tsx | 4 +-
web/src/components/rules/RowActions.tsx | 14 +-
web/src/components/rules/SiteRuleTable.tsx | 4 +-
web/src/components/rules/UaRuleTable.tsx | 4 +-
web/src/components/rules/WafRuleTable.tsx | 4 +-
web/src/components/shared/app-header.tsx | 194 +++++++
web/src/components/shared/app-sidebar.tsx | 202 +++++++
web/src/components/shared/breadcrumb-bar.tsx | 78 +++
web/src/components/shared/confirm-dialog.tsx | 98 ++++
web/src/components/shared/data-table.tsx | 430 +++++++++++++++
web/src/components/shared/empty-state.tsx | 44 ++
.../{FormDialog.tsx => shared/form-dialog.tsx} | 63 ++-
web/src/components/shared/loading.tsx | 75 +++
web/src/components/shared/misc.tsx | 246 +++++++++
web/src/components/shared/number-input.tsx | 85 +++
web/src/components/shared/page-header.tsx | 84 +++
web/src/components/shared/password-input.tsx | 42 ++
web/src/components/shared/simple-select.tsx | 181 +++++++
web/src/components/shared/stat-card.tsx | 83 +++
web/src/components/shared/status-badge.tsx | 102 ++++
web/src/components/ui/alert-dialog.tsx | 170 +++---
web/src/components/ui/alert.tsx | 128 +++--
web/src/components/ui/avatar.tsx | 71 +--
web/src/components/ui/badge.tsx | 50 +-
web/src/components/ui/breadcrumb.tsx | 103 ++++
web/src/components/ui/button.tsx | 80 ++-
web/src/components/ui/card.tsx | 103 ++--
web/src/components/ui/combobox.tsx | 97 ----
web/src/components/ui/command.tsx | 157 +++---
web/src/components/ui/confirm-button.tsx | 62 ---
web/src/components/ui/dialog.tsx | 161 ++++--
web/src/components/ui/dropdown-menu.tsx | 255 +++++++--
web/src/components/ui/input.tsx | 26 +-
web/src/components/ui/label.tsx | 38 +-
web/src/components/ui/number-input.tsx | 67 ---
web/src/components/ui/password-input.tsx | 30 --
web/src/components/ui/popover.tsx | 69 ++-
web/src/components/ui/progress.tsx | 57 ++
web/src/components/ui/select.tsx | 244 +++++----
web/src/components/ui/separator.tsx | 40 ++
web/src/components/ui/skeleton.tsx | 23 +
web/src/components/ui/sonner.tsx | 36 +-
web/src/components/ui/spinner.tsx | 16 -
web/src/components/ui/switch.tsx | 55 +-
web/src/components/ui/table.tsx | 121 +++--
web/src/components/ui/tabs.tsx | 54 ++
web/src/components/ui/textarea.tsx | 45 +-
web/src/components/ui/tooltip.tsx | 112 ++--
web/src/index.css | 248 ++++++---
web/src/lib/agents.ts | 9 +-
web/src/locales/en/data.json | 13 +-
web/src/locales/zh/data.json | 13 +-
web/src/nav.ts | 102 ++++
web/src/pages/AccountPage.tsx | 146 +++--
web/src/pages/AgentDashboardPage.tsx | 153 +++---
web/src/pages/AgentDetailPage.tsx | 32 +-
web/src/pages/AgentRecordsPage.tsx | 308 ++++-------
web/src/pages/AgentSessionsPage.tsx | 48 +-
web/src/pages/AgentsPage.tsx | 78 ++-
web/src/pages/AuthCallback.tsx | 16 +-
web/src/pages/CertEditPage.tsx | 238 ++++----
web/src/pages/CertListPage.tsx | 35 +-
web/src/pages/ChannelEditPage.tsx | 263 +++++----
web/src/pages/ChannelListPage.tsx | 101 ++--
web/src/pages/DashboardPage.tsx | 6 +-
web/src/pages/NodeEditPage.tsx | 106 ++--
web/src/pages/NodeListPage.tsx | 38 +-
web/src/pages/RecordEditPage.tsx | 53 +-
web/src/pages/RecordListPage.tsx | 35 +-
web/src/pages/RuleEditPage.tsx | 143 ++---
web/src/pages/RuleListPage.tsx | 37 +-
web/src/pages/SigninPage.tsx | 94 ++--
web/src/pages/SiteEditPage.tsx | 340 ++++++------
web/src/pages/SiteListPage.tsx | 82 +--
web/src/types.ts | 3 +
web/tailwind.config.js | 84 ---
web/tsconfig.app.tsbuildinfo | 1 -
web/tsconfig.node.tsbuildinfo | 1 -
web/vite.config.ts | 12 +-
web/yarn.lock | 598 +++++++++++----------
97 files changed, 5495 insertions(+), 3633 deletions(-)
diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..013c265
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "web",
+ "runtimeExecutable": "yarn",
+ "runtimeArgs": ["--cwd", "web", "start"],
+ "port": 16002
+ }
+ ]
+}
diff --git a/web/.gitignore b/web/.gitignore
index 9657599..41daa5b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -3,3 +3,4 @@ build
dist
.env.local
*.log
+*.tsbuildinfo
diff --git a/web/package.json b/web/package.json
index 4a69047..6b6a692 100644
--- a/web/package.json
+++ b/web/package.json
@@ -18,11 +18,14 @@
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
+ "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
+ "@tanstack/react-table": "^8.21.2",
"casdoor-js-sdk": "^0.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -37,10 +40,10 @@
"react-router-dom": "^6.30.0",
"recharts": "^2.15.0",
"sonner": "^2.0.1",
- "tailwind-merge": "^2.6.0",
- "tailwindcss-animate": "^1.0.7"
+ "tailwind-merge": "^3.0.1"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.0.6",
"@types/file-saver": "^2.0.7",
"@types/node": "^22.13.5",
"@types/react": "^18.3.18",
@@ -48,12 +51,11 @@
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.4",
- "autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2",
- "postcss": "^8.5.3",
- "tailwindcss": "^3.4.17",
+ "tailwindcss": "^4.0.6",
+ "tw-animate-css": "^1.2.4",
"typescript": "^5.7.3",
"vite": "^6.2.0"
}
diff --git a/web/postcss.config.js b/web/postcss.config.js
deleted file mode 100644
index 2aa7205..0000000
--- a/web/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 17d42fc..e64ff51 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -20,11 +20,12 @@ import i18next from "i18next";
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 {findGroupOf, selectedKeyOf} from "@/nav";
+import {AppHeader} from "@/components/shared/app-header";
+import {AppSidebar, persistOpenKeys, readSavedOpenKeys} from "@/components/shared/app-sidebar";
+import {Loading} from "@/components/shared/loading";
import {TooltipProvider} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
import AccountPage from "@/pages/AccountPage";
import AgentDashboardPage from "@/pages/AgentDashboardPage";
import AgentDetailPage from "@/pages/AgentDetailPage";
@@ -45,7 +46,7 @@ import RuleListPage from "@/pages/RuleListPage";
import SigninPage from "@/pages/SigninPage";
import SiteEditPage from "@/pages/SiteEditPage";
import SiteListPage from "@/pages/SiteListPage";
-import type {Account} from "@/types";
+import type {Account, ThemeAlgorithm} from "@/types";
// The gateway analytics page is the only one that draws charts, and the
// charting runtime is by far the largest dependency here, so it is fetched only
@@ -54,26 +55,82 @@ const DashboardPage = React.lazy(() => import("@/pages/DashboardPage"));
Setting.initCasdoorSdk(Conf.AuthConfig);
-const collapsedKey = "sidebarCollapsed";
+const collapsedKey = "siderCollapsed";
export default function App() {
// Pages call i18next.t() directly, so re-render the whole tree on a language change.
useTranslation();
// 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 [themeAlgorithm, setThemeAlgorithm] = React.useState<ThemeAlgorithm>(() => {
+ const stored = Setting.readThemeAlgorithm();
+ // Applied before the first paint so a dark-mode reload never flashes the
+ // light palette.
+ Setting.applyThemeAlgorithm(stored);
+ return stored;
+ });
+ const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem(collapsedKey) === "true");
const location = useLocation();
- const toggleCollapsed = () => {
+ const selectedKey = selectedKeyOf(location.pathname);
+ const wasCollapsedRef = React.useRef(false);
+ const [openKeys, setOpenKeys] = React.useState<string[]>(() => {
+ if (localStorage.getItem(collapsedKey) === "true") {
+ return [];
+ }
+ const saved = new Set(readSavedOpenKeys());
+ const group = findGroupOf(selectedKey);
+ if (group) {
+ saved.add(group.key);
+ }
+ return [...saved];
+ });
+
+ // Navigating into a collapsed group opens it, and expanding the rail restores
+ // whatever the reader last had open rather than the defaults.
+ React.useEffect(() => {
+ if (collapsed) {
+ wasCollapsedRef.current = true;
+ setOpenKeys([]);
+ return;
+ }
+ const justExpanded = wasCollapsedRef.current;
+ wasCollapsedRef.current = false;
+ const group = findGroupOf(selectedKey);
+
+ setOpenKeys(previous => {
+ if (justExpanded) {
+ const restored = new Set(readSavedOpenKeys());
+ if (group) {
+ restored.add(group.key);
+ }
+ return [...restored];
+ }
+ if (group && !previous.includes(group.key)) {
+ return [...previous, group.key];
+ }
+ return previous;
+ });
+ }, [selectedKey, collapsed]);
+
+ React.useEffect(() => {
+ if (!collapsed) {
+ persistOpenKeys(openKeys);
+ }
+ }, [openKeys, collapsed]);
+
+ const toggleSidebar = () => {
setCollapsed(value => {
localStorage.setItem(collapsedKey, String(!value));
return !value;
});
};
+ const changeTheme = (next: ThemeAlgorithm) => {
+ setThemeAlgorithm(next);
+ Setting.saveThemeAlgorithm(next);
+ };
+
const getAccount = React.useCallback(() => {
AccountBackend.getAccount().then(res => {
const user = res.data;
@@ -119,7 +176,7 @@ export default function App() {
/** Wraps a page that only makes sense for a signed-in user. */
const requireSignin = (render: (user: Account) => React.ReactNode) => {
if (account === undefined) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
if (account === null) {
sessionStorage.setItem("from", location.pathname);
@@ -135,28 +192,49 @@ export default function App() {
return <>{element}</>;
};
+ // The sign-in screen is its own full-page layout: no rail, no header.
+ if (location.pathname === "/signin" || location.pathname === "/callback") {
+ return (
+ <TooltipProvider>
+ <Routes>
+ <Route path="/callback" element={<AuthCallback />} />
+ <Route path="/signin" element={redirectHomeIfSignedIn(<SigninPage />)} />
+ </Routes>
+ </TooltipProvider>
+ );
+ }
+
return (
- <TooltipProvider delayDuration={200}>
- <div className="flex min-h-screen">
- <Sidebar
- account={account}
+ <TooltipProvider>
+ <div className="bg-muted/30 min-h-screen">
+ <AppSidebar
collapsed={collapsed}
- drawerOpen={drawerOpen}
- onDrawerOpenChange={setDrawerOpen}
+ selectedKey={selectedKey}
+ openKeys={openKeys}
+ onOpenKeysChange={setOpenKeys}
+ isAdmin={Setting.isAdminUser(account)}
/>
- {/* 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}
+
+ <div
+ className={cn(
+ "flex min-h-screen flex-col transition-[margin] duration-200",
+ collapsed ? "ml-16" : "ml-64",
+ )}
+ >
+ <AppHeader
collapsed={collapsed}
- onToggleCollapsed={toggleCollapsed}
- onOpenDrawer={() => setDrawerOpen(true)}
+ onToggleSidebar={toggleSidebar}
+ uri={location.pathname}
+ account={account}
+ themeAlgorithm={themeAlgorithm}
+ onThemeChange={changeTheme}
onSignout={signout}
/>
- <main className="flex-1">
- <React.Suspense fallback={<PageSpinner />}>
+
+ {/* min-w-0 keeps a wide table from stretching the whole layout. */}
+ <main className="flex min-w-0 flex-1 flex-col">
+ <React.Suspense fallback={<Loading type="page" />}>
<Routes>
- <Route path="/callback" element={<AuthCallback />} />
<Route
path="/"
element={requireSignin(user =>
@@ -170,11 +248,7 @@ export default function App() {
),
)}
/>
- <Route path="/signin" element={redirectHomeIfSignedIn(<SigninPage />)} />
- <Route
- path="/account"
- element={requireSignin(user => <AccountPage account={user} />)}
- />
+ <Route path="/account" element={requireSignin(user => <AccountPage account={user} />)} />
<Route path="/agents" element={requireSignin(user => <AgentsPage account={user} />)} />
<Route
path="/agents/:agentId"
@@ -197,17 +271,11 @@ export default function App() {
/>
<Route path="/certs" element={requireSignin(user => <CertListPage account={user} />)} />
<Route path="/certs/:owner/:certName" element={requireSignin(() => <CertEditPage />)} />
- <Route
- path="/records"
- element={requireSignin(user => <RecordListPage account={user} />)}
- />
+ <Route path="/records" element={requireSignin(user => <RecordListPage account={user} />)} />
<Route path="/records/:owner/:id" element={requireSignin(() => <RecordEditPage />)} />
<Route path="/rules" element={requireSignin(user => <RuleListPage account={user} />)} />
<Route path="/rules/:owner/:ruleName" element={requireSignin(() => <RuleEditPage />)} />
- <Route
- path="/channels"
- element={requireSignin(user => <ChannelListPage account={user} />)}
- />
+ <Route path="/channels" element={requireSignin(user => <ChannelListPage account={user} />)} />
<Route
path="/channels/:owner/:channelName"
element={requireSignin(() => <ChannelEditPage />)}
@@ -216,7 +284,17 @@ export default function App() {
</Routes>
</React.Suspense>
</main>
- <Footer />
+
+ <footer className="text-muted-foreground flex items-center justify-center gap-2 border-t py-5 text-sm">
+ {i18next.t("general:Powered by")}
+ <a target="_blank" rel="noreferrer" href="https://github.com/apache/casbin-gateway">
+ <img
+ className="h-[30px] w-auto"
+ alt="Casbin"
+ src={`${Setting.StaticBaseUrl}/img/casbin_logo_1024x256.png`}
+ />
+ </a>
+ </footer>
</div>
</div>
</TooltipProvider>
diff --git a/web/src/Setting.tsx b/web/src/Setting.tsx
index 1cf46fd..3787c9c 100644
--- a/web/src/Setting.tsx
+++ b/web/src/Setting.tsx
@@ -17,7 +17,7 @@ import Sdk from "casdoor-js-sdk";
import {toast} from "sonner";
import * as Conf from "@/Conf";
-import type {Account, ApiResponse} from "@/types";
+import type {Account, ApiResponse, ThemeAlgorithm} from "@/types";
/**
* Where the REST API lives. It stays empty on purpose: the dev server proxies
@@ -193,6 +193,39 @@ export function getLanguage() {
return i18next.language;
}
+// Dark mode is a class on <html> because that is what the Tailwind `dark:`
+// variant keys off; the data-theme attribute is kept for the plain CSS rules
+// and third-party widgets that read it.
+export function isDarkTheme(themeAlgorithm: ThemeAlgorithm | undefined) {
+ return Array.isArray(themeAlgorithm) && themeAlgorithm.includes("dark");
+}
+
+export function applyThemeAlgorithm(themeAlgorithm: ThemeAlgorithm) {
+ const dark = isDarkTheme(themeAlgorithm);
+ document.documentElement.classList.toggle("dark", dark);
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
+}
+
+export function readThemeAlgorithm(): ThemeAlgorithm {
+ try {
+ const raw = localStorage.getItem("themeAlgorithm");
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ return parsed as ThemeAlgorithm;
+ }
+ }
+ } catch {
+ // A corrupt value is not worth failing startup over.
+ }
+ return ["default"];
+}
+
+export function saveThemeAlgorithm(themeAlgorithm: ThemeAlgorithm) {
+ localStorage.setItem("themeAlgorithm", JSON.stringify(themeAlgorithm));
+ applyThemeAlgorithm(themeAlgorithm);
+}
+
export function setLanguage(language: string) {
localStorage.setItem("language", language);
i18next.changeLanguage(language);
diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx
deleted file mode 100644
index 2759933..0000000
--- a/web/src/components/DataTable.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-// 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 * as React from "react";
-import {ChevronDown, ChevronRight, ChevronsUpDown, ChevronUp} from "lucide-react";
-import i18next from "i18next";
-
-import {cn} from "@/lib/utils";
-import {Button} from "@/components/ui/button";
-import {Spinner} from "@/components/ui/spinner";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-
-export type SortOrder = "ascend" | "descend" | undefined;
-
-export interface Column<T> {
- title: React.ReactNode;
- key: string;
- dataIndex?: string;
- /** A CSS width, e.g. "120px". Columns without one share the leftover space. */
- width?: string;
- className?: string;
- /**
- * A comparator sorts the rows in the browser. `true` means the server sorts,
- * and the click is forwarded to `onSort` instead.
- */
- sorter?: ((a: T, b: T) => number) | true;
- render?: (value: any, record: T, index: number) => React.ReactNode;
-}
-
-export interface ServerPagination {
- page: number;
- pageSize: number;
- total: number;
- onChange: (page: number, pageSize: number) => void;
-}
-
-export interface DataTableProps<T> {
- columns: Column<T>[];
- data: T[] | null | undefined;
- rowKey: (record: T, index: number) => string;
- loading?: boolean;
- /** Heading shown above the table, next to `toolbar`. */
- title?: React.ReactNode;
- toolbar?: React.ReactNode;
- /** Rows per page when the browser paginates. 0 shows everything at once. */
- pageSize?: number;
- /** Set when the server paginates: `data` is then already the current page. */
- serverPagination?: ServerPagination;
- onSort?: (field: string, order: SortOrder) => void;
- emptyText?: React.ReactNode;
- expandedRowRender?: (record: T) => React.ReactNode;
- rowClassName?: (record: T, index: number) => string | undefined;
-}
-
-const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
-
-function getValue<T>(record: T, column: Column<T>) {
- if (!column.dataIndex) {
- return undefined;
- }
- return (record as Record<string, any>)[column.dataIndex];
-}
-
-export function DataTable<T>({
- columns,
- data,
- rowKey,
- loading = false,
- title,
- toolbar,
- pageSize = 10,
- serverPagination,
- onSort,
- emptyText,
- expandedRowRender,
- rowClassName,
-}: DataTableProps<T>) {
- const [sortKey, setSortKey] = React.useState<string>("");
- const [sortOrder, setSortOrder] = React.useState<SortOrder>(undefined);
- const [page, setPage] = React.useState(1);
- const [localPageSize, setLocalPageSize] = React.useState(pageSize);
- const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
-
- // Memoised so that a page passing `data={undefined}` does not hand the sort
- // below a fresh array on every render.
- const rows = React.useMemo(() => data ?? [], [data]);
-
- // A shrinking result set (a delete, a new filter) can leave the viewer on a
- // page that no longer exists, which would render as an empty table.
- const localPageCount = localPageSize > 0 ? Math.max(1, Math.ceil(rows.length / localPageSize)) : 1;
- React.useEffect(() => {
- if (!serverPagination && page > localPageCount) {
- setPage(localPageCount);
- }
- }, [localPageCount, page, serverPagination]);
-
- const toggleSort = (column: Column<T>) => {
- if (!column.sorter) {
- return;
- }
-
- const nextOrder: SortOrder =
- sortKey !== column.key ? "ascend" : sortOrder === "ascend" ? "descend" : undefined;
- setSortKey(nextOrder === undefined ? "" : column.key);
- setSortOrder(nextOrder);
-
- if (column.sorter === true) {
- onSort?.(column.dataIndex ?? column.key, nextOrder);
- }
- };
-
- const sorted = React.useMemo(() => {
- const column = columns.find(item => item.key === sortKey);
- if (!column || typeof column.sorter !== "function" || sortOrder === undefined) {
- return rows;
- }
-
- const comparator = column.sorter;
- const copy = [...rows];
- copy.sort((a, b) => {
- const result = comparator(a, b);
- return sortOrder === "ascend" ? result : -result;
- });
- return copy;
- }, [columns, rows, sortKey, sortOrder]);
-
- const pageRows = React.useMemo(() => {
- if (serverPagination || localPageSize <= 0) {
- return sorted;
- }
- const start = (page - 1) * localPageSize;
- return sorted.slice(start, start + localPageSize);
- }, [localPageSize, page, serverPagination, sorted]);
-
- const currentPage = serverPagination ? serverPagination.page : page;
- const currentPageSize = serverPagination ? serverPagination.pageSize : localPageSize;
- const total = serverPagination ? serverPagination.total : sorted.length;
- const pageCount = currentPageSize > 0 ? Math.max(1, Math.ceil(total / currentPageSize)) : 1;
-
- const goTo = (nextPage: number, nextPageSize = currentPageSize) => {
- if (serverPagination) {
- serverPagination.onChange(nextPage, nextPageSize);
- return;
- }
- setPage(nextPage);
- setLocalPageSize(nextPageSize);
- };
-
- const showPager = currentPageSize > 0 && (total > currentPageSize || currentPage > 1);
- const columnCount = columns.length + (expandedRowRender ? 1 : 0);
-
- return (
- <div className="rounded-xl border bg-card shadow-sm">
- {(title || toolbar) && (
- <div className="flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2.5">
- <div className="flex items-center gap-3 text-sm font-semibold">
- {title}
- {loading ? <Spinner className="text-muted-foreground" /> : null}
- </div>
- <div className="flex items-center gap-2">{toolbar}</div>
- </div>
- )}
- <Table>
- <TableHeader>
- <TableRow className="bg-muted/40 hover:bg-muted/40">
- {expandedRowRender ? <TableHead className="w-8" /> : null}
- {columns.map(column => (
- <TableHead
- key={column.key}
- style={column.width ? {width: column.width, minWidth: column.width} : undefined}
- className={cn(column.sorter && "cursor-pointer select-none", column.className)}
- onClick={() => toggleSort(column)}
- >
- <span className="inline-flex items-center gap-1">
- {column.title}
- {column.sorter ? (
- sortKey === column.key && sortOrder === "ascend" ? (
- <ChevronUp className="h-3 w-3" />
- ) : sortKey === column.key && sortOrder === "descend" ? (
- <ChevronDown className="h-3 w-3" />
- ) : (
- <ChevronsUpDown className="h-3 w-3 opacity-40" />
- )
- ) : null}
- </span>
- </TableHead>
- ))}
- </TableRow>
- </TableHeader>
- <TableBody>
- {pageRows.length === 0 ? (
- <TableRow className="hover:bg-transparent">
- <TableCell colSpan={columnCount} className="h-24 text-center text-muted-foreground">
- {loading ? <Spinner className="mx-auto" /> : (emptyText ?? i18next.t("general:No data"))}
- </TableCell>
- </TableRow>
- ) : (
- pageRows.map((record, index) => {
- const key = rowKey(record, index);
- const isOpen = expanded[key] === true;
- return (
- <React.Fragment key={key}>
- <TableRow className={rowClassName?.(record, index)}>
- {expandedRowRender ? (
- <TableCell className="w-8 align-top">
- <button
- type="button"
- aria-label={isOpen ? "Collapse row" : "Expand row"}
- className="text-muted-foreground hover:text-foreground"
- onClick={() => setExpanded(current => ({...current, [key]: !isOpen}))}
- >
- {isOpen ? (
- <ChevronDown className="h-4 w-4" />
- ) : (
- <ChevronRight className="h-4 w-4" />
- )}
- </button>
- </TableCell>
- ) : null}
- {columns.map(column => (
- <TableCell
- key={column.key}
- style={column.width ? {width: column.width, minWidth: column.width} : undefined}
- className={column.className}
- >
- {column.render
- ? column.render(getValue(record, column), record, index)
- : (getValue(record, column) as React.ReactNode)}
- </TableCell>
- ))}
- </TableRow>
- {isOpen && expandedRowRender ? (
- <TableRow className="hover:bg-transparent">
- <TableCell colSpan={columnCount} className="bg-muted/30 p-0">
- {expandedRowRender(record)}
- </TableCell>
- </TableRow>
- ) : null}
- </React.Fragment>
- );
- })
- )}
- </TableBody>
- </Table>
- {showPager && (
- <div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 text-sm text-muted-foreground">
- <span>{i18next.t("general:{total} in total").replace("{total}", String(total))}</span>
- <div className="flex items-center gap-2">
- <select
- className="h-8 rounded-md border border-input bg-transparent px-2 text-sm"
- value={currentPageSize}
- onChange={event => goTo(1, Number(event.target.value))}
- >
- {PAGE_SIZE_OPTIONS.map(option => (
- <option key={option} value={option}>
- {option} / page
- </option>
- ))}
- </select>
- <Button
- variant="outline"
- size="sm"
- disabled={currentPage <= 1}
- onClick={() => goTo(currentPage - 1)}
- >
- {"<"}
- </Button>
- <span className="tabular-nums">
- {currentPage} / {pageCount}
- </span>
- <Button
- variant="outline"
- size="sm"
- disabled={currentPage >= pageCount}
- onClick={() => goTo(currentPage + 1)}
- >
- {">"}
- </Button>
- </div>
- </div>
- )}
- </div>
- );
-}
diff --git a/web/src/components/EnvSnippet.tsx b/web/src/components/EnvSnippet.tsx
index 25bf32a..24e9a28 100644
--- a/web/src/components/EnvSnippet.tsx
+++ b/web/src/components/EnvSnippet.tsx
@@ -19,7 +19,7 @@ import i18next from "i18next";
import * as Setting from "@/Setting";
import {Button} from "@/components/ui/button";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import {envSnippet, shells, type Shell} from "@/lib/channels";
@@ -58,7 +58,7 @@ export function EnvSnippet({
</button>
))}
</div>
- <Tooltip title={i18next.t("general:Copy")}>
+ <SimpleTooltip title={i18next.t("general:Copy")}>
<Button
variant="ghost"
size="icon"
@@ -71,7 +71,7 @@ export function EnvSnippet({
>
<Copy className="h-3.5 w-3.5" />
</Button>
- </Tooltip>
+ </SimpleTooltip>
</div>
<pre className="overflow-x-auto rounded-md bg-muted p-2 text-xs">{snippet}</pre>
</div>
diff --git a/web/src/components/FormRow.tsx b/web/src/components/FormRow.tsx
deleted file mode 100644
index 6b532bd..0000000
--- a/web/src/components/FormRow.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-// 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 * as React from "react";
-
-import {cn} from "@/lib/utils";
-
-/**
- * FormRow is the label-beside-field layout the antd edit pages built out of
- * `Row`/`Col span={2}/span={22}`. It stacks on narrow screens instead.
- */
-export function FormRow({
- label,
- children,
- hint,
- className,
-}: {
- label: React.ReactNode;
- children: React.ReactNode;
- hint?: React.ReactNode;
- className?: string;
-}) {
- return (
- <div className={cn("grid gap-2 py-3 md:grid-cols-[180px_minmax(0,1fr)] md:gap-4", className)}>
- <div className="pt-2 text-sm font-medium text-muted-foreground">{label}</div>
- <div className="min-w-0">
- {children}
- {hint ? <div className="mt-1 text-xs text-muted-foreground">{hint}</div> : null}
- </div>
- </div>
- );
-}
-
-/** The heading strip an edit page puts above its card, with its save button. */
-export function PageHeader({
- title,
- children,
-}: {
- title: React.ReactNode;
- children?: React.ReactNode;
-}) {
- return (
- <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
- <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
- <div className="flex items-center gap-2">{children}</div>
- </div>
- );
-}
diff --git a/web/src/components/NodeTable.tsx b/web/src/components/NodeTable.tsx
index 08df2b7..eef2f39 100644
--- a/web/src/components/NodeTable.tsx
+++ b/web/src/components/NodeTable.tsx
@@ -17,7 +17,7 @@ import {CircleCheck, CircleMinus, RefreshCw} from "lucide-react";
import i18next from "i18next";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -29,7 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import type {Account, Node, SiteNode} from "@/types";
function StatusBadge({status}: {status: string}) {
@@ -38,7 +38,7 @@ function StatusBadge({status}: {status: string}) {
}
if (status === "In Progress") {
return (
- <Badge variant="processing">
+ <Badge variant="info">
<RefreshCw className="h-3 w-3 animate-spin" />
{status}
</Badge>
@@ -54,7 +54,7 @@ function StatusBadge({status}: {status: string}) {
}
if (status === "Stopped") {
return (
- <Badge variant="error">
+ <Badge variant="danger">
<CircleMinus className="h-3 w-3" />
{status}
</Badge>
@@ -146,9 +146,9 @@ export function NodeTable({
return null;
}
return (
- <Tooltip title={<pre className="max-h-96 overflow-auto whitespace-pre-wrap text-xs">{text}</pre>}>
+ <SimpleTooltip title={<pre className="max-h-96 overflow-auto whitespace-pre-wrap text-xs">{text}</pre>}>
<span className="cursor-help">{Setting.getShortText(text)}</span>
- </Tooltip>
+ </SimpleTooltip>
);
},
},
@@ -199,7 +199,7 @@ export function NodeTable({
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/Result.tsx b/web/src/components/Result.tsx
deleted file mode 100644
index ca1283c..0000000
--- a/web/src/components/Result.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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 * as React from "react";
-import {CircleAlert, CircleX, SearchX, ShieldX} from "lucide-react";
-import i18next from "i18next";
-
-import {Button} from "@/components/ui/button";
-
-const icons = {
- "403": <ShieldX className="h-14 w-14 text-destructive" />,
- "404": <SearchX className="h-14 w-14 text-muted-foreground" />,
- error: <CircleX className="h-14 w-14 text-destructive" />,
- warning: <CircleAlert className="h-14 w-14 text-warning" />,
-};
-
-export function Result({
- status = "error",
- title,
- subTitle,
- extra,
-}: {
- status?: keyof typeof icons;
- title: React.ReactNode;
- subTitle?: React.ReactNode;
- extra?: React.ReactNode;
-}) {
- return (
- <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-6 text-center">
- {icons[status]}
- <div className="text-xl font-semibold">{title}</div>
- {subTitle ? <div className="max-w-xl text-sm text-muted-foreground">{subTitle}</div> : null}
- {extra ? <div className="flex gap-2">{extra}</div> : null}
- </div>
- );
-}
-
-/** The 403 every admin-only page falls back to, matching the antd build's wording. */
-export function UnauthorizedResult() {
- return (
- <Result
- status="403"
- title="403 Unauthorized"
- subTitle={i18next.t(
- "general:Sorry, you do not have permission to access this page or logged in status invalid.",
- )}
- extra={
- <Button asChild>
- <a href="/">{i18next.t("general:Back Home")}</a>
- </Button>
- }
- />
- );
-}
diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx
deleted file mode 100644
index f79434b..0000000
--- a/web/src/components/layout/Footer.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-// 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 i18next from "i18next";
-
-import * as Setting from "@/Setting";
-
-export function Footer() {
- return (
- <footer className="mt-auto border-t py-4">
- <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
- {i18next.t("general:Powered by")}
- <a
- target="_blank"
- rel="noreferrer"
- href="https://github.com/apache/casbin-gateway"
- className="inline-flex items-center"
- >
- <img
- className="h-5 w-auto"
- alt="Casbin"
- src={`${Setting.StaticBaseUrl}/img/casbin_logo_1024x256.png`}
- />
- </a>
- </div>
- </footer>
- );
-}
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
deleted file mode 100644
index fbf6867..0000000
--- a/web/src/components/layout/Header.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-// 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/LanguageSelect.tsx b/web/src/components/layout/LanguageSelect.tsx
deleted file mode 100644
index c79c2a1..0000000
--- a/web/src/components/layout/LanguageSelect.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-// 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 {Globe} from "lucide-react";
-
-import * as Setting from "@/Setting";
-import {Button} from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-
-export function LanguageSelect() {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" aria-label="Language">
- <Globe className="h-5 w-5" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {Setting.Countries.map(country => (
- <DropdownMenuItem key={country.key} onClick={() => Setting.setLanguage(country.key)}>
- <img
- src={`${Setting.StaticBaseUrl}/flag-icons/${country.country}.svg`}
- alt={country.alt}
- className="h-4 w-6 rounded-sm object-cover"
- />
- {country.label}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx
deleted file mode 100644
index 3367797..0000000
--- a/web/src/components/layout/Sidebar.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-// 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 * as React from "react";
-import {Link, useLocation} from "react-router-dom";
-import {
- Bot,
- ChartColumn,
- ChevronDown,
- ChevronRight,
- FileSearch,
- Globe,
- LayoutDashboard,
- ListFilter,
- MessageSquare,
- Plug,
- ScrollText,
- Server,
- Settings,
- ShieldCheck,
- X,
-} from "lucide-react";
-import i18next from "i18next";
-import {useTranslation} from "react-i18next";
-
-import * as Setting from "@/Setting";
-import {cn} from "@/lib/utils";
-import type {Account} from "@/types";
-import {Button} from "@/components/ui/button";
-import {Tooltip} from "@/components/ui/tooltip";
-
-const advancedOpenKey = "sidebarAdvancedOpen";
-
-interface MenuEntry {
- key: string;
- label: string;
- icon: React.ReactNode;
- adminOnly?: boolean;
-}
-
-/** The agent tools this product is built around, in the order they are used. */
-function getMainEntries(): MenuEntry[] {
- return [
- {key: "/", label: i18next.t("general:Dashboard"), icon: <LayoutDashboard />},
- {key: "/agents", label: i18next.t("agent:Agents"), icon: <Bot />, adminOnly: true},
- {
- key: "/agent-sessions",
- label: i18next.t("agent:Agent Sessions"),
- icon: <MessageSquare />,
- adminOnly: true,
- },
- {
- key: "/agent-records",
- label: i18next.t("agent:Agent Records"),
- icon: <FileSearch />,
- adminOnly: true,
- },
- {key: "/channels", label: i18next.t("channel:Channels"), icon: <Plug />},
- ];
-}
-
-/**
- * The reverse-proxy gateway pages. They are a full feature set, but an
- * individual running Gateway to manage the agents on their own machine never
- * needs them, so they live behind one collapsed group instead of competing for
- * attention with the agent pages above.
- */
-function getAdvancedEntries(): MenuEntry[] {
- return [
- {key: "/sites", label: i18next.t("general:Sites"), icon: <Globe />},
- {key: "/certs", label: i18next.t("general:Certs"), icon: <ShieldCheck />},
- {key: "/rules", label: i18next.t("general:Rules"), icon: <ListFilter />},
- {key: "/nodes", label: i18next.t("general:Nodes"), icon: <Server />},
- {key: "/records", label: i18next.t("general:Records"), icon: <ScrollText />},
- {key: "/dashboard", label: i18next.t("general:Gateway Analytics"), icon: <ChartColumn />},
- ];
-}
-
-/**
- * The dashboard is at "/", which every other path also starts with, so it only
- * counts as selected on an exact match.
- */
-function isSelected(pathname: string, key: string) {
- return key === "/" ? pathname === "/" : pathname.startsWith(key);
-}
-
-function readFlag(key: string, fallback: boolean) {
- const stored = localStorage.getItem(key);
- return stored === null ? fallback : stored === "true";
-}
-
-export function Sidebar({
- account,
- collapsed,
- drawerOpen,
- onDrawerOpenChange,
-}: {
- account: Account | null | undefined;
- 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 [advancedOpen, setAdvancedOpen] = React.useState(() => readFlag(advancedOpenKey, false));
-
- const isAdmin = Setting.isAdminUser(account);
- const mainEntries = getMainEntries().filter(entry => !entry.adminOnly || isAdmin);
- const advancedEntries = getAdvancedEntries().filter(entry => !entry.adminOnly || isAdmin);
-
- // The drawer covers the page, so leaving it open across a navigation would
- // hide whatever the viewer just asked for.
- React.useEffect(() => {
- onDrawerOpenChange(false);
- }, [location.pathname, onDrawerOpenChange]);
-
- const toggleAdvanced = () => {
- setAdvancedOpen(value => {
- localStorage.setItem(advancedOpenKey, String(!value));
- return !value;
- });
- };
-
- /** `iconOnly` is the collapsed desktop rail; the drawer is always full width. */
- const renderEntry = (entry: MenuEntry, iconOnly: boolean, indented = false) => {
- const link = (
- <Link
- key={entry.key}
- to={entry.key}
- className={cn(
- "flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
- iconOnly && "justify-center px-0",
- indented && !iconOnly && "ml-3",
- isSelected(location.pathname, entry.key)
- ? "bg-primary/10 text-primary"
- : "text-muted-foreground hover:bg-accent hover:text-foreground",
- )}
- >
- <span className="[&>svg]:h-[18px] [&>svg]:w-[18px]">{entry.icon}</span>
- {iconOnly ? null : <span className="truncate">{entry.label}</span>}
- </Link>
- );
-
- if (!iconOnly) {
- return link;
- }
- return (
- <Tooltip key={entry.key} title={entry.label} side="right">
- {link}
- </Tooltip>
- );
- };
-
- const renderNav = (iconOnly: boolean) => (
- <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
- {mainEntries.map(entry => renderEntry(entry, iconOnly))}
-
- {advancedEntries.length > 0 ? (
- <div className="mt-2 border-t pt-2">
- {iconOnly ? (
- <Tooltip title={i18next.t("general:Advanced")} side="right">
- <button
- onClick={toggleAdvanced}
- className="flex w-full items-center justify-center rounded-md px-0 py-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
- aria-label={i18next.t("general:Advanced")}
- >
- <Settings className="h-[18px] w-[18px]" />
- </button>
- </Tooltip>
- ) : (
- <button
- onClick={toggleAdvanced}
- className="flex w-full items-center gap-1 rounded-md px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
- >
- {advancedOpen ? (
- <ChevronDown className="h-3.5 w-3.5" />
- ) : (
- <ChevronRight className="h-3.5 w-3.5" />
- )}
- {i18next.t("general:Advanced")}
- </button>
- )}
- {advancedOpen
- ? advancedEntries.map(entry => renderEntry(entry, iconOnly, true))
- : null}
- </div>
- ) : null}
- </nav>
- );
-
- const renderPanel = (iconOnly: boolean) => (
- <>
- <div
- className={cn(
- "flex h-14 shrink-0 items-center gap-2 border-b px-3",
- iconOnly && "justify-center px-0",
- )}
- >
- <Link to="/" className="flex min-w-0 items-center">
- <img
- src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
- alt="Casbin Gateway"
- className={cn("w-auto", iconOnly ? "h-5" : "h-6")}
- />
- </Link>
- </div>
-
- {renderNav(iconOnly)}
- </>
- );
-
- return (
- <>
- <aside
- className={cn(
- "sticky top-0 hidden h-screen shrink-0 flex-col border-r bg-background md:flex",
- collapsed ? "w-16" : "w-60",
- )}
- >
- {renderPanel(collapsed)}
- </aside>
-
- {drawerOpen ? (
- <div className="fixed inset-0 z-50 md:hidden">
- <div
- className="absolute inset-0 bg-black/50"
- onClick={() => onDrawerOpenChange(false)}
- aria-hidden
- />
- <div className="absolute inset-y-0 left-0 flex w-64 flex-col border-r bg-background">
- <Button
- variant="ghost"
- size="icon"
- aria-label="Close"
- className="absolute right-2 top-2 z-10"
- onClick={() => onDrawerOpenChange(false)}
- >
- <X className="h-5 w-5" />
- </Button>
- {renderPanel(false)}
- </div>
- </div>
- ) : null}
- </>
- );
-}
diff --git a/web/src/components/rules/CompoundRule.tsx b/web/src/components/rules/CompoundRule.tsx
index 14e080e..a1e9d7b 100644
--- a/web/src/components/rules/CompoundRule.tsx
+++ b/web/src/components/rules/CompoundRule.tsx
@@ -17,7 +17,7 @@ import i18next from "i18next";
import * as RuleBackend from "@/backend/RuleBackend";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {useExpressionTable, type ExpressionTableProps} from "@/components/rules/useExpressionTable";
import {Button} from "@/components/ui/button";
@@ -134,7 +134,7 @@ export function CompoundRule({
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/rules/IpRateRuleTable.tsx b/web/src/components/rules/IpRateRuleTable.tsx
index 55624fd..eb7703d 100644
--- a/web/src/components/rules/IpRateRuleTable.tsx
+++ b/web/src/components/rules/IpRateRuleTable.tsx
@@ -14,11 +14,11 @@
import i18next from "i18next";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {useExpressionTable, type ExpressionTableProps} from "@/components/rules/useExpressionTable";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
-import {NumberInput} from "@/components/ui/number-input";
+import {NumberInput} from "@/components/shared/number-input";
import type {RuleExpression} from "@/types";
// The server keeps both numbers as strings in the generic expression row, so
@@ -72,7 +72,7 @@ export function IpRateRuleTable({title, table, onUpdateTable}: ExpressionTablePr
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/rules/IpRuleTable.tsx b/web/src/components/rules/IpRuleTable.tsx
index 9a66f75..ceca6fc 100644
--- a/web/src/components/rules/IpRuleTable.tsx
+++ b/web/src/components/rules/IpRuleTable.tsx
@@ -14,7 +14,7 @@
import i18next from "i18next";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {useExpressionTable, type ExpressionTableProps} from "@/components/rules/useExpressionTable";
import {Button} from "@/components/ui/button";
@@ -110,7 +110,7 @@ export function IpRuleTable({title, table, onUpdateTable}: ExpressionTableProps)
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/rules/RowActions.tsx b/web/src/components/rules/RowActions.tsx
index 377ed55..d0323da 100644
--- a/web/src/components/rules/RowActions.tsx
+++ b/web/src/components/rules/RowActions.tsx
@@ -16,7 +16,7 @@ import {ArrowDown, ArrowUp, Trash2} from "lucide-react";
import i18next from "i18next";
import {Button} from "@/components/ui/button";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
/** The move-up / move-down / delete trio every editable sub-table carries. */
export function RowActions({
@@ -34,21 +34,21 @@ export function RowActions({
}) {
return (
<div className="flex items-center gap-1">
- <Tooltip title={i18next.t("general:Up")}>
+ <SimpleTooltip title={i18next.t("general:Up")}>
<Button variant="outline" size="icon-sm" disabled={index === 0} onClick={onUp}>
<ArrowUp />
</Button>
- </Tooltip>
- <Tooltip title={i18next.t("general:Down")}>
+ </SimpleTooltip>
+ <SimpleTooltip title={i18next.t("general:Down")}>
<Button variant="outline" size="icon-sm" disabled={index === length - 1} onClick={onDown}>
<ArrowDown />
</Button>
- </Tooltip>
- <Tooltip title={i18next.t("general:Delete")}>
+ </SimpleTooltip>
+ <SimpleTooltip title={i18next.t("general:Delete")}>
<Button variant="outline" size="icon-sm" onClick={onDelete}>
<Trash2 />
</Button>
- </Tooltip>
+ </SimpleTooltip>
</div>
);
}
diff --git a/web/src/components/rules/SiteRuleTable.tsx b/web/src/components/rules/SiteRuleTable.tsx
index 0a695df..01a3c89 100644
--- a/web/src/components/rules/SiteRuleTable.tsx
+++ b/web/src/components/rules/SiteRuleTable.tsx
@@ -16,7 +16,7 @@ import * as React from "react";
import i18next from "i18next";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {Button} from "@/components/ui/button";
import {
@@ -110,7 +110,7 @@ export function SiteRuleTable({
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/rules/UaRuleTable.tsx b/web/src/components/rules/UaRuleTable.tsx
index 6cf0fa9..cd39ebd 100644
--- a/web/src/components/rules/UaRuleTable.tsx
+++ b/web/src/components/rules/UaRuleTable.tsx
@@ -14,7 +14,7 @@
import i18next from "i18next";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {useExpressionTable, type ExpressionTableProps} from "@/components/rules/useExpressionTable";
import {Button} from "@/components/ui/button";
@@ -115,7 +115,7 @@ export function UaRuleTable({
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/rules/WafRuleTable.tsx b/web/src/components/rules/WafRuleTable.tsx
index 1e2062e..203b0dc 100644
--- a/web/src/components/rules/WafRuleTable.tsx
+++ b/web/src/components/rules/WafRuleTable.tsx
@@ -14,7 +14,7 @@
import i18next from "i18next";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {RowActions} from "@/components/rules/RowActions";
import {useExpressionTable, type ExpressionTableProps} from "@/components/rules/useExpressionTable";
import {Button} from "@/components/ui/button";
@@ -90,7 +90,7 @@ export function WafRuleTable({title, table, onUpdateTable}: ExpressionTableProps
return (
<DataTable
columns={columns}
- data={rows}
+ dataSource={rows}
rowKey={(_record, index) => String(index)}
pageSize={0}
title={title}
diff --git a/web/src/components/shared/app-header.tsx b/web/src/components/shared/app-header.tsx
new file mode 100644
index 0000000..16e6082
--- /dev/null
+++ b/web/src/components/shared/app-header.tsx
@@ -0,0 +1,194 @@
+// 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 * as React from "react";
+import {useNavigate} from "react-router-dom";
+import {Globe, LogOut, Moon, PanelLeft, PanelLeftClose, Sun, User} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import type {Account, ThemeAlgorithm} 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 {SimpleTooltip} from "@/components/ui/tooltip";
+import {BreadcrumbBar} from "@/components/shared/breadcrumb-bar";
+
+function ThemeToggle({
+ themeAlgorithm,
+ onChange,
+}: {
+ themeAlgorithm: ThemeAlgorithm;
+ onChange: (next: ThemeAlgorithm) => void;
+}) {
+ const isDark = Setting.isDarkTheme(themeAlgorithm);
+ return (
+ <SimpleTooltip title={isDark ? i18next.t("general:Light") : i18next.t("general:Dark")}>
+ <Button
+ variant="ghost"
+ size="icon-sm"
+ onClick={() => onChange(isDark ? ["default"] : ["dark"])}
+ aria-label="Toggle theme"
+ >
+ {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
+ </Button>
+ </SimpleTooltip>
+ );
+}
+
+function LanguageSelect() {
+ const [, forceRender] = React.useReducer((count: number) => count + 1, 0);
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon-sm" aria-label="Change language">
+ <Globe className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {Setting.Countries.map(country => (
+ <DropdownMenuItem
+ key={country.key}
+ onClick={() => {
+ Setting.setLanguage(country.key);
+ // The shell reads i18next.t directly, which does not re-render on
+ // a language change by itself. Forcing a render here is what makes
+ // the switch take effect immediately.
+ forceRender();
+ }}
+ >
+ <img
+ src={`${Setting.StaticBaseUrl}/flag-icons/${country.country}.svg`}
+ alt={country.alt}
+ className="h-4 w-6 rounded-xs object-cover"
+ />
+ {country.label}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+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 name = account.displayName || account.name || "";
+ const openProfile = () => {
+ const profileUrl = Setting.getMyProfileUrl(account);
+ if (profileUrl === "") {
+ navigate("/account");
+ } else {
+ Setting.openLink(profileUrl);
+ }
+ };
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <button
+ type="button"
+ className="hover:bg-accent flex items-center gap-2 rounded-md px-1.5 py-1 transition-colors"
+ >
+ <Avatar>
+ {account.avatar ? <AvatarImage src={account.avatar} alt={name} /> : null}
+ <AvatarFallback style={{backgroundColor: Setting.getAvatarColor(account.name), color: "#fff"}}>
+ {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <span className="hidden max-w-[120px] truncate text-sm font-medium sm:inline">{name}</span>
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem onClick={openProfile}>
+ <User />
+ {i18next.t("account:My Account")}
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem variant="destructive" onClick={onSignout}>
+ <LogOut />
+ {i18next.t("account:Sign Out")}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
+
+/**
+ * The top bar of the content column: where you are 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 AppHeader({
+ collapsed,
+ onToggleSidebar,
+ uri,
+ account,
+ themeAlgorithm,
+ onThemeChange,
+ onSignout,
+}: {
+ collapsed: boolean;
+ onToggleSidebar: () => void;
+ uri: string;
+ account: Account | null | undefined;
+ themeAlgorithm: ThemeAlgorithm;
+ onThemeChange: (next: ThemeAlgorithm) => void;
+ onSignout: () => void;
+}) {
+ return (
+ <header className="bg-background/95 supports-[backdrop-filter]:bg-background/80 sticky top-0 z-30 flex h-13 shrink-0 items-center justify-between gap-2 border-b px-2 backdrop-blur">
+ <div className="flex min-w-0 items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon-sm"
+ onClick={onToggleSidebar}
+ aria-label={i18next.t("general:Toggle navigation")}
+ >
+ {collapsed ? <PanelLeft className="size-4" /> : <PanelLeftClose className="size-4" />}
+ </Button>
+ <BreadcrumbBar uri={uri} />
+ </div>
+
+ <div className="flex items-center gap-0.5 pr-1">
+ <ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange} />
+ <LanguageSelect />
+ <AccountMenu account={account} onSignout={onSignout} />
+ </div>
+ </header>
+ );
+}
diff --git a/web/src/components/shared/app-sidebar.tsx b/web/src/components/shared/app-sidebar.tsx
new file mode 100644
index 0000000..5345649
--- /dev/null
+++ b/web/src/components/shared/app-sidebar.tsx
@@ -0,0 +1,202 @@
+// 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} from "react-router-dom";
+import {ChevronDown, type LucideIcon} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {cn} from "@/lib/utils";
+import {navGroups, type NavGroup} from "@/nav";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+
+const OPEN_KEYS_STORAGE = "siderMenuOpenKeys";
+const DEFAULT_OPEN_KEYS = navGroups.filter(group => group.children).map(group => group.key);
+
+export function readSavedOpenKeys(): string[] {
+ try {
+ const raw = localStorage.getItem(OPEN_KEYS_STORAGE);
+ if (!raw) {
+ return DEFAULT_OPEN_KEYS;
+ }
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter((key: unknown) => typeof key === "string") : DEFAULT_OPEN_KEYS;
+ } catch {
+ return DEFAULT_OPEN_KEYS;
+ }
+}
+
+export function persistOpenKeys(keys: string[]) {
+ try {
+ localStorage.setItem(OPEN_KEYS_STORAGE, JSON.stringify(keys));
+ } catch {
+ // Private-mode storage failures must not take the navigation down.
+ }
+}
+
+function NavLink({
+ to,
+ active,
+ collapsed,
+ icon: Icon,
+ label,
+ nested = false,
+}: {
+ to: string;
+ active: boolean;
+ collapsed: boolean;
+ icon?: LucideIcon;
+ label: string;
+ nested?: boolean;
+}) {
+ const content = (
+ <Link
+ to={to}
+ className={cn(
+ "flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors",
+ collapsed && "justify-center px-0",
+ nested && !collapsed && "pl-9 text-[13px]",
+ active
+ ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
+ : "text-sidebar-foreground/70 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
+ )}
+ >
+ {Icon ? <Icon className="size-4 shrink-0" /> : null}
+ {!collapsed ? <span className="truncate">{label}</span> : null}
+ </Link>
+ );
+
+ return collapsed ? (
+ <SimpleTooltip title={label} side="right">
+ {content}
+ </SimpleTooltip>
+ ) : (
+ content
+ );
+}
+
+/**
+ * Fixed navigation rail. When collapsed it drops to icons only and the groups
+ * stop expanding in place — an accordion inside a 64px column is unreadable, so
+ * the icon links straight to the group's first page instead.
+ */
+export function AppSidebar({
+ collapsed,
+ selectedKey,
+ openKeys,
+ onOpenKeysChange,
+ isAdmin,
+}: {
+ collapsed: boolean;
+ selectedKey: string;
+ openKeys: string[];
+ onOpenKeysChange: (keys: string[]) => void;
+ isAdmin: boolean;
+}) {
+ const toggleGroup = (key: string) => {
+ onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !== key) : [...openKeys, key]);
+ };
+
+ const visible = (entry: {adminOnly?: boolean}) => !entry.adminOnly || isAdmin;
+ const groups: NavGroup[] = navGroups
+ .filter(visible)
+ .map(group => (group.children ? {...group, children: group.children.filter(visible)} : group))
+ .filter(group => !group.children || group.children.length > 0);
+
+ return (
+ <aside
+ className={cn(
+ "bg-sidebar fixed inset-y-0 left-0 z-40 flex flex-col border-r transition-[width] duration-200",
+ collapsed ? "w-16" : "w-64",
+ )}
+ >
+ <div className={cn("flex h-13 shrink-0 items-center border-b", collapsed ? "justify-center px-0" : "px-5")}>
+ <Link to="/" className="flex items-center overflow-hidden">
+ <img
+ src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+ alt="Casbin Gateway"
+ className={cn("w-auto object-contain transition-all", collapsed ? "h-5" : "h-7 max-w-[150px]")}
+ />
+ </Link>
+ </div>
+
+ <nav className="scrollbar-thin flex-1 space-y-0.5 overflow-y-auto p-2">
+ {groups.map(group => {
+ if (!group.children) {
+ return (
+ <NavLink
+ key={group.key}
+ to={group.path ?? group.key}
+ active={selectedKey === group.key}
+ collapsed={collapsed}
+ icon={group.icon}
+ label={i18next.t(group.label)}
+ />
+ );
+ }
+
+ const groupLabel = i18next.t(group.label);
+ const isOpen = openKeys.includes(group.key);
+ const hasActiveChild = group.children.some(child => child.key === selectedKey);
+
+ if (collapsed) {
+ return (
+ <NavLink
+ key={group.key}
+ to={group.children[0].path}
+ active={hasActiveChild}
+ collapsed
+ icon={group.icon}
+ label={groupLabel}
+ />
+ );
+ }
+
+ return (
+ <div key={group.key}>
+ <button
+ type="button"
+ onClick={() => toggleGroup(group.key)}
+ className={cn(
+ "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors",
+ hasActiveChild ? "text-sidebar-foreground font-medium" : "text-sidebar-foreground/70",
+ "hover:bg-sidebar-accent/60 hover:text-sidebar-foreground",
+ )}
+ aria-expanded={isOpen}
+ >
+ {group.icon ? <group.icon className="size-4 shrink-0" /> : null}
+ <span className="flex-1 truncate text-left">{groupLabel}</span>
+ <ChevronDown className={cn("size-3.5 shrink-0 transition-transform", isOpen && "rotate-180")} />
+ </button>
+ {isOpen ? (
+ <div className="mt-0.5 space-y-0.5">
+ {group.children.map(child => (
+ <NavLink
+ key={child.key}
+ to={child.path}
+ active={selectedKey === child.key}
+ collapsed={false}
+ label={i18next.t(child.label)}
+ nested
+ />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ );
+ })}
+ </nav>
+ </aside>
+ );
+}
diff --git a/web/src/components/shared/breadcrumb-bar.tsx b/web/src/components/shared/breadcrumb-bar.tsx
new file mode 100644
index 0000000..5551420
--- /dev/null
+++ b/web/src/components/shared/breadcrumb-bar.tsx
@@ -0,0 +1,78 @@
+// 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} from "react-router-dom";
+import i18next from "i18next";
+
+import {findLeaf} from "@/nav";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+
+/**
+ * Derives the trail from the URL against the shared nav tree, so a page never
+ * has to declare its own breadcrumb. A path whose first segment is not in the
+ * tree renders nothing rather than an invented label.
+ */
+export function BreadcrumbBar({uri}: {uri: string}) {
+ const segments = (uri || "").split("/").filter(Boolean);
+ if (segments.length === 0) {
+ return null;
+ }
+
+ const rootLeaf = findLeaf(`/${segments[0]}`);
+ if (!rootLeaf) {
+ return null;
+ }
+ const rootLabel = i18next.t(rootLeaf.label);
+
+ const lastSegment = segments[segments.length - 1];
+ const lastLeaf = segments.length > 1 ? findLeaf(`/${lastSegment}`) : null;
+ const lastLabel = lastLeaf ? i18next.t(lastLeaf.label) : decodeURIComponent(lastSegment);
+
+ return (
+ <Breadcrumb>
+ <BreadcrumbList className="text-xs sm:gap-1.5">
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link to="/">{i18next.t("general:Home")}</Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ {segments.length === 1 ? (
+ <BreadcrumbItem>
+ <BreadcrumbPage>{rootLabel}</BreadcrumbPage>
+ </BreadcrumbItem>
+ ) : (
+ <>
+ <BreadcrumbItem>
+ <BreadcrumbLink asChild>
+ <Link to={`/${segments[0]}`}>{rootLabel}</Link>
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage className="max-w-[240px] truncate">{lastLabel}</BreadcrumbPage>
+ </BreadcrumbItem>
+ </>
+ )}
+ </BreadcrumbList>
+ </Breadcrumb>
+ );
+}
diff --git a/web/src/components/shared/confirm-dialog.tsx b/web/src/components/shared/confirm-dialog.tsx
new file mode 100644
index 0000000..8fad78a
--- /dev/null
+++ b/web/src/components/shared/confirm-dialog.tsx
@@ -0,0 +1,98 @@
+// 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 * as React from "react";
+import i18next from "i18next";
+import type {VariantProps} from "class-variance-authority";
+
+import type {buttonVariants} from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+
+/**
+ * The guard in front of every destructive row action. The trigger is passed as
+ * children so a call site stays a single element:
+ *
+ * <ConfirmDialog title="Delete agent?" onConfirm={...}>
+ * <Button variant="outline" size="sm">Delete</Button>
+ * </ConfirmDialog>
+ */
+export function ConfirmDialog({
+ children,
+ title,
+ description,
+ confirmText,
+ cancelText,
+ variant = "destructive",
+ onConfirm,
+ disabled = false,
+ open,
+ onOpenChange,
+}: {
+ children: React.ReactNode;
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ confirmText?: React.ReactNode;
+ cancelText?: React.ReactNode;
+ variant?: VariantProps<typeof buttonVariants>["variant"];
+ onConfirm: () => void | Promise<unknown>;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const [pending, setPending] = React.useState(false);
+
+ const handleConfirm = async(event: React.MouseEvent) => {
+ // The dialog closes itself on action; awaiting first would let it close
+ // before an async handler reports a failure, so errors are surfaced by the
+ // handler's own toast instead.
+ event.preventDefault();
+ setPending(true);
+ try {
+ await onConfirm();
+ } finally {
+ setPending(false);
+ onOpenChange?.(false);
+ }
+ };
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ <AlertDialogTrigger asChild disabled={disabled}>
+ {children}
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>{title}</AlertDialogTitle>
+ {description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>{cancelText ?? i18next.t("general:Cancel")}</AlertDialogCancel>
+ <AlertDialogAction variant={variant} onClick={handleConfirm} disabled={pending}>
+ {confirmText ?? i18next.t("general:OK")}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ );
+}
diff --git a/web/src/components/shared/data-table.tsx b/web/src/components/shared/data-table.tsx
new file mode 100644
index 0000000..1822cbf
--- /dev/null
+++ b/web/src/components/shared/data-table.tsx
@@ -0,0 +1,430 @@
+// 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 * as React from "react";
+import {
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+ type SortingState,
+} from "@tanstack/react-table";
+import {ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown, Search} from "lucide-react";
+import i18next from "i18next";
+
+import {cn} from "@/lib/utils";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Skeleton} from "@/components/ui/skeleton";
+import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
+import {EmptyState} from "@/components/shared/empty-state";
+
+export type SortOrder = "ascend" | "descend" | undefined;
+
+export interface Column<T> {
+ key: string;
+ title: React.ReactNode;
+ /** May be a dotted path. A column without one is a display column (actions). */
+ dataIndex?: string;
+ render?: (value: any, record: T, index: number) => React.ReactNode;
+ /** A CSS width, e.g. "120px". Columns without one share the leftover space. */
+ width?: string;
+ align?: "left" | "right" | "center";
+ /**
+ * A comparator sorts the rows in the browser. `true` means the server sorts,
+ * and the click is forwarded to `onSort` instead.
+ */
+ sorter?: ((a: T, b: T) => number) | true;
+ ellipsis?: boolean;
+ className?: string;
+ headerClassName?: string;
+}
+
+export interface ServerPagination {
+ page: number;
+ pageSize: number;
+ total: number;
+ onChange: (page: number, pageSize: number) => void;
+}
+
+function readPath(row: any, path?: string) {
+ if (path === undefined || path === null) {
+ return undefined;
+ }
+ if (!path.includes(".")) {
+ return row?.[path];
+ }
+ return path.split(".").reduce((acc, part) => (acc === null || acc === undefined ? acc : acc[part]), row);
+}
+
+function resolveRowKey<T>(rowKey: string | ((record: T, index: number) => string), record: T, index: number) {
+ if (typeof rowKey === "function") {
+ return rowKey(record, index);
+ }
+ const value = readPath(record, rowKey);
+ return value === undefined || value === null ? String(index) : String(value);
+}
+
+function SortIcon({state}: {state: false | "asc" | "desc"}) {
+ if (state === "asc") {
+ return <ArrowUp className="size-3.5" />;
+ }
+ if (state === "desc") {
+ return <ArrowDown className="size-3.5" />;
+ }
+ return <ChevronsUpDown className="size-3.5 opacity-40" />;
+}
+
+export interface DataTableProps<T> {
+ columns: Column<T>[];
+ dataSource: T[] | null | undefined;
+ rowKey?: string | ((record: T, index: number) => string);
+ loading?: boolean;
+ /** Heading shown on the left of the table header. */
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ /** Node rendered on the right of the table header. */
+ toolbar?: React.ReactNode;
+ /** Renders a filter box that matches across every cell of a row. */
+ searchable?: boolean;
+ searchPlaceholder?: string;
+ /** Rows per page when the browser paginates. 0 shows everything at once. */
+ pageSize?: number;
+ /** Set when the server paginates: `dataSource` is then already the current page. */
+ serverPagination?: ServerPagination;
+ onSort?: (field: string, order: SortOrder) => void;
+ emptyText?: React.ReactNode;
+ emptyIcon?: React.ComponentType<{className?: string}>;
+ onRowClick?: (record: T) => void;
+ expandable?: {
+ rowExpandable?: (record: T) => boolean;
+ expandedRowRender: (record: T) => React.ReactNode;
+ };
+ /** Tighter row padding for tables embedded in a card or a row. */
+ dense?: boolean;
+ className?: string;
+ tableClassName?: string;
+}
+
+/**
+ * The list-page workhorse. Every resource screen renders the same thing: an
+ * optional toolbar, a bordered table, and pagination that only appears once the
+ * data actually overflows a page.
+ */
+export function DataTable<T>({
+ columns,
+ dataSource,
+ rowKey = "name",
+ loading = false,
+ title,
+ description,
+ toolbar,
+ searchable = false,
+ searchPlaceholder,
+ pageSize = 20,
+ serverPagination,
+ onSort,
+ emptyText,
+ emptyIcon,
+ onRowClick,
+ expandable,
+ dense = false,
+ className,
+ tableClassName,
+}: DataTableProps<T>) {
+ const [sorting, setSorting] = React.useState<SortingState>([]);
+ const [globalFilter, setGlobalFilter] = React.useState("");
+ const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
+
+ const data = React.useMemo(() => dataSource ?? [], [dataSource]);
+
+ // A column that says the server sorts is never sorted here: the click is
+ // reported through onSort and the backend sends the next page back.
+ const serverSorted = React.useMemo(() => columns.some(column => column.sorter === true), [columns]);
+
+ const columnDefs = React.useMemo<ColumnDef<T>[]>(
+ () =>
+ columns.map((column, columnIndex) => {
+ const id = column.key ?? column.dataIndex ?? `col-${columnIndex}`;
+ const shared = {
+ id: String(id),
+ header: () => column.title,
+ enableSorting: Boolean(column.sorter) && column.dataIndex !== undefined,
+ sortingFn:
+ typeof column.sorter === "function"
+ ? (rowA: any, rowB: any) => (column.sorter as (a: T, b: T) => number)(rowA.original, rowB.original)
+ : undefined,
+ meta: column,
+ };
+
+ if (column.dataIndex === undefined) {
+ return {
+ ...shared,
+ cell: ({row}: any) => (column.render ? column.render(undefined, row.original, row.index) : null),
+ } as ColumnDef<T>;
+ }
+
+ return {
+ ...shared,
+ accessorFn: (row: T) => readPath(row, column.dataIndex),
+ cell: ({row, getValue}: any) =>
+ column.render ? column.render(getValue(), row.original, row.index) : getValue(),
+ } as ColumnDef<T>;
+ }),
+ [columns],
+ );
+
+ const browserPaginates = pageSize > 0 && !serverPagination;
+
+ const table = useReactTable({
+ data,
+ columns: columnDefs,
+ state: {sorting, globalFilter},
+ onSortingChange: updater => {
+ const next = typeof updater === "function" ? updater(sorting) : updater;
+ setSorting(next);
+ if (serverSorted && onSort) {
+ const entry = next[0];
+ const column = entry ? columns.find(item => item.key === entry.id) : undefined;
+ onSort(column?.dataIndex ?? entry?.id ?? "", entry ? (entry.desc ? "descend" : "ascend") : undefined);
+ }
+ },
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: browserPaginates ? getPaginationRowModel() : undefined,
+ manualSorting: serverSorted,
+ getRowId: (record, index) => resolveRowKey(rowKey, record, index),
+ // The default filter only sees accessor values, which would silently skip
+ // display columns. Matching the whole record keeps a search for a nested
+ // field working the way a reader expects.
+ globalFilterFn: (row, _columnId, value) =>
+ JSON.stringify(row.original ?? {})
+ .toLowerCase()
+ .includes(String(value).toLowerCase()),
+ initialState: browserPaginates ? {pagination: {pageSize}} : undefined,
+ });
+
+ const rows = table.getRowModel().rows;
+ const showHeader = Boolean(title || description || toolbar || searchable);
+ const totalRows = table.getFilteredRowModel().rows.length;
+
+ const currentPage = serverPagination ? serverPagination.page : table.getState().pagination.pageIndex + 1;
+ const currentPageSize = serverPagination ? serverPagination.pageSize : table.getState().pagination.pageSize;
+ const total = serverPagination ? serverPagination.total : totalRows;
+ const pageCount = serverPagination
+ ? Math.max(1, Math.ceil(serverPagination.total / Math.max(1, serverPagination.pageSize)))
+ : table.getPageCount();
+ const showPagination = serverPagination ? total > currentPageSize || currentPage > 1 : browserPaginates && total > pageSize;
+
+ const goTo = (nextPage: number) => {
+ if (serverPagination) {
+ serverPagination.onChange(nextPage, serverPagination.pageSize);
+ return;
+ }
+ table.setPageIndex(nextPage - 1);
+ };
+
+ return (
+ <div
+ data-slot="data-table"
+ data-loading={loading ? "true" : "false"}
+ className={cn("bg-card flex flex-col overflow-hidden rounded-xl border shadow-sm", className)}
+ >
+ {showHeader && (
+ <div
+ data-slot="data-table-header"
+ className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
+ >
+ <div className="min-w-0">
+ {title ? <h2 className="truncate text-sm font-semibold">{title}</h2> : null}
+ {description ? <p className="text-muted-foreground mt-0.5 truncate text-xs">{description}</p> : null}
+ </div>
+ <div className="flex flex-wrap items-center gap-2">
+ {searchable && (
+ <div className="relative">
+ <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
+ <Input
+ value={globalFilter}
+ onChange={event => setGlobalFilter(event.target.value)}
+ placeholder={searchPlaceholder ?? i18next.t("general:Search")}
+ className="h-8 w-44 pl-8 text-xs lg:w-56"
+ />
+ </div>
+ )}
+ {toolbar}
+ </div>
+ </div>
+ )}
+
+ <Table className={tableClassName} containerClassName="scrollbar-thin">
+ <TableHeader className="bg-muted/40">
+ {table.getHeaderGroups().map(headerGroup => (
+ <TableRow key={headerGroup.id} className="hover:bg-transparent">
+ {expandable ? <TableHead className="w-8" /> : null}
+ {headerGroup.headers.map(header => {
+ const meta = (header.column.columnDef.meta ?? {}) as Column<T>;
+ const canSort = header.column.getCanSort();
+ return (
+ <TableHead
+ key={header.id}
+ style={meta.width ? {width: meta.width, minWidth: meta.width} : undefined}
+ className={cn(
+ meta.align === "right" && "text-right",
+ meta.align === "center" && "text-center",
+ meta.headerClassName,
+ )}
+ >
+ {canSort ? (
+ <button
+ type="button"
+ onClick={header.column.getToggleSortingHandler()}
+ className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
+ >
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ <SortIcon state={header.column.getIsSorted()} />
+ </button>
+ ) : (
+ flexRender(header.column.columnDef.header, header.getContext())
+ )}
+ </TableHead>
+ );
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+
+ <TableBody>
+ {loading && rows.length === 0 ? (
+ Array.from({length: 5}).map((_, rowIndex) => (
+ <TableRow key={`skeleton-${rowIndex}`} className="hover:bg-transparent">
+ {expandable ? <TableCell /> : null}
+ {columns.map((column, columnIndex) => (
+ <TableCell key={column.key ?? columnIndex}>
+ <Skeleton className="h-4 w-full max-w-[160px]" />
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : rows.length === 0 ? (
+ <TableRow className="hover:bg-transparent">
+ <TableCell colSpan={columns.length + (expandable ? 1 : 0)} className="p-0">
+ <EmptyState icon={emptyIcon} title={emptyText ?? i18next.t("general:No data")} />
+ </TableCell>
+ </TableRow>
+ ) : (
+ rows.map(row => {
+ const isExpanded = Boolean(expanded[row.id]);
+ const canExpand = expandable
+ ? expandable.rowExpandable
+ ? expandable.rowExpandable(row.original)
+ : true
+ : false;
+ return (
+ <React.Fragment key={row.id}>
+ <TableRow
+ data-row-key={row.id}
+ data-state={isExpanded ? "selected" : undefined}
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
+ className={cn(onRowClick && "cursor-pointer")}
+ >
+ {expandable ? (
+ <TableCell className="w-8 pr-0">
+ {canExpand ? (
+ <Button
+ variant="ghost"
+ size="icon-xs"
+ aria-label={isExpanded ? "Collapse row" : "Expand row"}
+ onClick={event => {
+ event.stopPropagation();
+ setExpanded(prev => ({...prev, [row.id]: !prev[row.id]}));
+ }}
+ >
+ <ChevronRight className={cn("size-3.5 transition-transform", isExpanded && "rotate-90")} />
+ </Button>
+ ) : null}
+ </TableCell>
+ ) : null}
+ {row.getVisibleCells().map(cell => {
+ const meta = (cell.column.columnDef.meta ?? {}) as Column<T>;
+ return (
+ <TableCell
+ key={cell.id}
+ style={meta.width ? {width: meta.width, minWidth: meta.width} : undefined}
+ className={cn(
+ dense && "py-1.5",
+ meta.align === "right" && "text-right",
+ meta.align === "center" && "text-center",
+ meta.ellipsis && "max-w-0 truncate",
+ meta.className,
+ )}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ );
+ })}
+ </TableRow>
+ {canExpand && isExpanded && expandable ? (
+ <TableRow className="hover:bg-transparent">
+ <TableCell colSpan={columns.length + 1} className="bg-muted/30 p-0">
+ {expandable.expandedRowRender(row.original)}
+ </TableCell>
+ </TableRow>
+ ) : null}
+ </React.Fragment>
+ );
+ })
+ )}
+ </TableBody>
+ </Table>
+
+ {showPagination && (
+ <div className="flex items-center justify-between border-t px-4 py-2.5">
+ <span className="text-muted-foreground text-xs">
+ {(currentPage - 1) * currentPageSize + 1}
+ {"–"}
+ {Math.min(currentPage * currentPageSize, total)} of {total}
+ </span>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="outline"
+ size="icon-sm"
+ onClick={() => goTo(currentPage - 1)}
+ disabled={currentPage <= 1}
+ aria-label="Previous page"
+ >
+ <ChevronLeft className="size-4" />
+ </Button>
+ <span className="text-muted-foreground px-2 text-xs tabular-nums">
+ {currentPage} / {pageCount}
+ </span>
+ <Button
+ variant="outline"
+ size="icon-sm"
+ onClick={() => goTo(currentPage + 1)}
+ disabled={currentPage >= pageCount}
+ aria-label="Next page"
+ >
+ <ChevronRight className="size-4" />
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/web/src/components/shared/empty-state.tsx b/web/src/components/shared/empty-state.tsx
new file mode 100644
index 0000000..d556764
--- /dev/null
+++ b/web/src/components/shared/empty-state.tsx
@@ -0,0 +1,44 @@
+// 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 * as React from "react";
+import {Inbox} from "lucide-react";
+
+import {cn} from "@/lib/utils";
+
+export function EmptyState({
+ icon,
+ title = "No data",
+ description,
+ action,
+ className,
+}: {
+ icon?: React.ComponentType<{className?: string}>;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+ className?: string;
+}) {
+ const Icon = icon ?? Inbox;
+ return (
+ <div className={cn("flex flex-col items-center justify-center gap-2 px-6 py-14 text-center", className)}>
+ <div className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full">
+ <Icon className="size-5" />
+ </div>
+ <p className="text-sm font-medium">{title}</p>
+ {description ? <p className="text-muted-foreground max-w-sm text-xs">{description}</p> : null}
+ {action ? <div className="mt-2">{action}</div> : null}
+ </div>
+ );
+}
diff --git a/web/src/components/FormDialog.tsx b/web/src/components/shared/form-dialog.tsx
similarity index 51%
rename from web/src/components/FormDialog.tsx
rename to web/src/components/shared/form-dialog.tsx
index f77e491..31cc4bf 100644
--- a/web/src/components/FormDialog.tsx
+++ b/web/src/components/shared/form-dialog.tsx
@@ -14,36 +14,60 @@
import * as React from "react";
import i18next from "i18next";
+import type {VariantProps} from "class-variance-authority";
import {cn} from "@/lib/utils";
-import {Button} from "@/components/ui/button";
+import {Button, buttonVariants} from "@/components/ui/button";
import {
Dialog,
DialogContent,
+ DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {Label} from "@/components/ui/label";
-/** The modal every list page opens to create an object: chrome here, fields in the caller. */
+/**
+ * The "open a modal, fill a short form, POST it" shape that most create flows
+ * reduce to. It owns the chrome — title, footer, submit state, Enter to submit —
+ * so a page only writes its fields.
+ */
export function FormDialog({
open,
onOpenChange,
title,
+ description,
children,
onSubmit,
submitText,
+ cancelText,
submitting = false,
+ submitDisabled = false,
+ submitVariant = "default",
+ size = "default",
+ footer,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
+ description?: React.ReactNode;
children: React.ReactNode;
onSubmit: () => void;
submitText?: React.ReactNode;
+ cancelText?: React.ReactNode;
submitting?: boolean;
+ submitDisabled?: boolean;
+ submitVariant?: VariantProps<typeof buttonVariants>["variant"];
+ size?: "default" | "lg" | "xl";
+ footer?: React.ReactNode;
}) {
+ const sizeClass = {
+ default: "sm:max-w-lg",
+ lg: "sm:max-w-2xl",
+ xl: "sm:max-w-4xl",
+ }[size];
+
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
onSubmit();
@@ -51,19 +75,26 @@ export function FormDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-lg">
+ <DialogContent className={cn("max-h-[90vh] overflow-hidden", sizeClass)}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
+ {description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-col gap-4">
- <div className="-mx-1 grid max-h-[60vh] gap-4 overflow-y-auto px-1 py-0.5">{children}</div>
+ <div className="scrollbar-thin -mx-1 max-h-[60vh] overflow-y-auto px-1 py-0.5">
+ <div className="grid gap-4">{children}</div>
+ </div>
<DialogFooter>
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button type="submit" disabled={submitting}>
- {submitText ?? i18next.t("general:OK")}
- </Button>
+ {footer ?? (
+ <>
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ {cancelText ?? i18next.t("general:Cancel")}
+ </Button>
+ <Button type="submit" variant={submitVariant} loading={submitting} disabled={submitDisabled}>
+ {submitText ?? i18next.t("general:OK")}
+ </Button>
+ </>
+ )}
</DialogFooter>
</form>
</DialogContent>
@@ -71,7 +102,11 @@ export function FormDialog({
);
}
-/** One labelled control inside a FormDialog. */
+/**
+ * A labelled control with optional hint and error text. Deliberately
+ * uncontrolled about the input itself so it wraps anything — Input, Textarea,
+ * SimpleSelect, a custom editor.
+ */
export function Field({
label,
htmlFor,
@@ -94,12 +129,12 @@ export function Field({
{label ? (
<Label htmlFor={htmlFor}>
{label}
- {required ? <span className="ml-0.5 text-destructive">*</span> : null}
+ {required ? <span className="text-destructive">*</span> : null}
</Label>
) : null}
{children}
- {hint && !error ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
- {error ? <p className="text-xs text-destructive">{error}</p> : null}
+ {hint && !error ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
+ {error ? <p className="text-destructive text-xs">{error}</p> : null}
</div>
);
}
diff --git a/web/src/components/shared/loading.tsx b/web/src/components/shared/loading.tsx
new file mode 100644
index 0000000..c1553d7
--- /dev/null
+++ b/web/src/components/shared/loading.tsx
@@ -0,0 +1,75 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+
+const DOT_COUNT = 3;
+
+/** The bouncing-dot indicator this UI uses in place of a spinner. */
+export function AiDots({
+ size = "medium",
+ className,
+}: {
+ size?: "small" | "medium" | "large";
+ className?: string;
+}) {
+ const dotClass = {small: "size-1", medium: "size-2", large: "size-3"}[size];
+ const gapClass = {small: "gap-1", medium: "gap-1.5", large: "gap-2.5"}[size];
+
+ return (
+ <span className={cn("inline-flex items-center", gapClass, className)}>
+ {Array.from({length: DOT_COUNT}).map((_, index) => (
+ <span
+ key={index}
+ className={cn("bg-foreground inline-block rounded-full", dotClass)}
+ style={{animation: "dot-bounce 1.4s ease-in-out infinite", animationDelay: `${index * 0.16}s`}}
+ />
+ ))}
+ </span>
+ );
+}
+
+export function Loading({
+ spinning = true,
+ tip,
+ type = "section",
+ className,
+}: {
+ spinning?: boolean;
+ tip?: React.ReactNode;
+ type?: "page" | "section" | "small";
+ className?: string;
+}) {
+ if (!spinning) {
+ return null;
+ }
+ const isPage = type === "page";
+ const isSmall = type === "small";
+
+ return (
+ <div
+ className={cn(
+ "flex flex-col items-center justify-center",
+ isPage && "h-[calc(100vh-160px)] w-full",
+ type === "section" && "py-12",
+ className,
+ )}
+ >
+ <AiDots size={isSmall ? "small" : isPage ? "large" : "medium"} />
+ {tip && !isSmall ? <div className="text-muted-foreground mt-3.5 text-xs tracking-wide">{tip}</div> : null}
+ </div>
+ );
+}
diff --git a/web/src/components/shared/misc.tsx b/web/src/components/shared/misc.tsx
new file mode 100644
index 0000000..37b3c81
--- /dev/null
+++ b/web/src/components/shared/misc.tsx
@@ -0,0 +1,246 @@
+// 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 * as React from "react";
+import {Check, Copy, HelpCircle} from "lucide-react";
+import i18next from "i18next";
+
+import {cn} from "@/lib/utils";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+
+/** Label followed by a question mark that explains it. */
+export function LabelWithTip({
+ text,
+ tooltip,
+ className,
+}: {
+ text: React.ReactNode;
+ tooltip?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <span className={cn("inline-flex items-center gap-1", className)}>
+ <span>{text}</span>
+ <SimpleTooltip title={tooltip}>
+ <HelpCircle className="text-muted-foreground size-3.5 cursor-help" />
+ </SimpleTooltip>
+ </span>
+ );
+}
+
+/** Copies text to the clipboard and confirms it in place for a moment. */
+export function CopyButton({
+ value,
+ className,
+ size = "icon-xs",
+ label,
+}: {
+ value?: React.ReactNode;
+ className?: string;
+ size?: "icon-xs" | "icon-sm" | "icon";
+ label?: string;
+}) {
+ const [copied, setCopied] = React.useState(false);
+ const buttonLabel = label ?? i18next.t("general:Copy");
+
+ React.useEffect(() => {
+ if (!copied) {
+ return undefined;
+ }
+ const timer = setTimeout(() => setCopied(false), 1600);
+ return () => clearTimeout(timer);
+ }, [copied]);
+
+ const handleCopy = async() => {
+ try {
+ await navigator.clipboard.writeText(String(value ?? ""));
+ setCopied(true);
+ } catch {
+ // Clipboard access is denied in some embedded contexts; failing quietly
+ // is better than a toast the reader cannot act on.
+ }
+ };
+
+ return (
+ <SimpleTooltip title={copied ? i18next.t("general:Copied") : buttonLabel}>
+ <Button
+ type="button"
+ variant="ghost"
+ size={size}
+ className={className}
+ onClick={handleCopy}
+ aria-label={buttonLabel}
+ >
+ {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
+ </Button>
+ </SimpleTooltip>
+ );
+}
+
+/** Monospace value with a copy affordance — tokens, hostnames, agent ids. */
+export function CodeText({
+ children,
+ className,
+ copyable = false,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+ copyable?: boolean;
+}) {
+ return (
+ <span className={cn("inline-flex max-w-full items-center gap-1", className)}>
+ <code className="bg-muted truncate rounded px-1.5 py-0.5 font-mono text-xs">{children}</code>
+ {copyable ? <CopyButton value={children} /> : null}
+ </span>
+ );
+}
+
+/** Scrollable pre block for payloads, prompts and command output. */
+export function CodeBlock({
+ children,
+ className,
+ maxHeight = "24rem",
+ copyable = false,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+ maxHeight?: string;
+ copyable?: boolean;
+}) {
+ return (
+ <div className={cn("bg-muted/60 relative overflow-hidden rounded-lg border", className)}>
+ {copyable ? <CopyButton value={children} className="absolute top-1.5 right-1.5 z-10" /> : null}
+ <pre className="scrollbar-thin overflow-auto p-3 font-mono text-xs leading-relaxed" style={{maxHeight}}>
+ {children}
+ </pre>
+ </div>
+ );
+}
+
+export interface DescriptionItem {
+ key?: string;
+ label: React.ReactNode;
+ value?: React.ReactNode;
+}
+
+/** Key/value rows, for the detail panes above a table. */
+export function DescriptionList({
+ items,
+ columns = 1,
+ className,
+}: {
+ // A row is often written as `condition && {...}`, so anything falsy is a row
+ // the caller decided not to render.
+ items: (DescriptionItem | false | null | undefined | "" | 0)[];
+ columns?: 1 | 2 | 3;
+ className?: string;
+}) {
+ return (
+ <dl
+ className={cn(
+ "grid gap-x-6 gap-y-3 text-sm",
+ columns === 2 && "sm:grid-cols-2",
+ columns === 3 && "sm:grid-cols-3",
+ className,
+ )}
+ >
+ {(items ?? []).filter(Boolean).map((item, index) => {
+ const entry = item as DescriptionItem;
+ return (
+ <div key={entry.key ?? index} className="grid gap-0.5">
+ <dt className="text-muted-foreground text-xs">{entry.label}</dt>
+ <dd className="break-words">{entry.value ?? "—"}</dd>
+ </div>
+ );
+ })}
+ </dl>
+ );
+}
+
+/** Full-page status screen — 404s and permission errors. */
+export function ResultScreen({
+ status = "404",
+ title,
+ subTitle,
+ extra,
+ className,
+}: {
+ status?: React.ReactNode;
+ title: React.ReactNode;
+ subTitle?: React.ReactNode;
+ extra?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cn("flex flex-col items-center justify-center gap-3 px-6 py-24 text-center", className)}>
+ <p className="text-muted-foreground/60 text-6xl font-semibold tracking-tight">{status}</p>
+ <h2 className="text-lg font-semibold">{title}</h2>
+ {subTitle ? <p className="text-muted-foreground max-w-md text-sm">{subTitle}</p> : null}
+ {extra ? <div className="mt-2">{extra}</div> : null}
+ </div>
+ );
+}
+
+/** The 403 every admin-only page falls back to. */
+export function UnauthorizedResult() {
+ return (
+ <ResultScreen
+ status="403"
+ title={i18next.t("general:Unauthorized")}
+ subTitle={i18next.t(
+ "general:Sorry, you do not have permission to access this page or logged in status invalid.",
+ )}
+ extra={
+ <Button asChild>
+ <a href="/">{i18next.t("general:Back Home")}</a>
+ </Button>
+ }
+ />
+ );
+}
+
+/** Horizontal group with consistent spacing. */
+export function Space({
+ children,
+ className,
+ size = "default",
+ direction = "horizontal",
+ wrap = false,
+ align,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ size?: "small" | "default" | "large";
+ direction?: "horizontal" | "vertical";
+ wrap?: boolean;
+ align?: "start" | "end";
+}) {
+ const gap = {small: "gap-1.5", default: "gap-2", large: "gap-4"}[size] ?? "gap-2";
+ return (
+ <div
+ className={cn(
+ "flex",
+ direction === "vertical" ? "flex-col items-stretch" : "items-center",
+ wrap && "flex-wrap",
+ align === "start" && "items-start",
+ align === "end" && "items-end",
+ gap,
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/web/src/components/shared/number-input.tsx b/web/src/components/shared/number-input.tsx
new file mode 100644
index 0000000..a9c0dec
--- /dev/null
+++ b/web/src/components/shared/number-input.tsx
@@ -0,0 +1,85 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+import {Input} from "@/components/ui/input";
+
+export interface NumberInputProps
+ extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
+ value: number | undefined | null;
+ onChange: (value: number) => void;
+ /** Unit rendered against the right edge of the field, e.g. "seconds". */
+ addonAfter?: React.ReactNode;
+}
+
+/**
+ * Reports numbers rather than strings and clamps to `min`/`max` on blur, so a
+ * page never has to re-validate what it stores.
+ */
+const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(function NumberInput(
+ {className, value, onChange, addonAfter, min, max, ...props},
+ ref,
+) {
+ const clamp = (next: number) => {
+ let result = next;
+ if (min !== undefined && result < Number(min)) {
+ result = Number(min);
+ }
+ if (max !== undefined && result > Number(max)) {
+ result = Number(max);
+ }
+ return result;
+ };
+
+ const field = (
+ <Input
+ ref={ref}
+ type="number"
+ min={min}
+ max={max}
+ value={value ?? ""}
+ onChange={event => {
+ const parsed = parseInt(event.target.value, 10);
+ onChange(isNaN(parsed) ? 0 : parsed);
+ }}
+ onBlur={event => {
+ const parsed = parseInt(event.target.value, 10);
+ onChange(clamp(isNaN(parsed) ? 0 : parsed));
+ }}
+ className={cn(
+ "tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
+ addonAfter && "rounded-r-none",
+ className,
+ )}
+ {...props}
+ />
+ );
+
+ if (!addonAfter) {
+ return field;
+ }
+
+ return (
+ <div className="flex">
+ {field}
+ <span className="border-input bg-muted text-muted-foreground inline-flex h-9 items-center rounded-r-md border border-l-0 px-3 text-sm whitespace-nowrap">
+ {addonAfter}
+ </span>
+ </div>
+ );
+});
+
+export {NumberInput};
diff --git a/web/src/components/shared/page-header.tsx b/web/src/components/shared/page-header.tsx
new file mode 100644
index 0000000..7aee7ed
--- /dev/null
+++ b/web/src/components/shared/page-header.tsx
@@ -0,0 +1,84 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
+
+export function PageContainer({className, children}: {className?: string; children: React.ReactNode}) {
+ return <div className={cn("flex flex-col gap-4 p-4 md:p-6", className)}>{children}</div>;
+}
+
+export function PageHeader({
+ title,
+ description,
+ actions,
+ className,
+}: {
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ actions?: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cn("flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
+ <div className="min-w-0">
+ <h1 className="truncate text-xl font-semibold tracking-tight">{title}</h1>
+ {description ? <p className="text-muted-foreground mt-1 text-sm">{description}</p> : null}
+ </div>
+ {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
+ </div>
+ );
+}
+
+/**
+ * One group of fields on an edit page. The fields sit in a responsive grid so a
+ * long form reads as a few short sections rather than one tall column.
+ */
+export function Section({
+ title,
+ description,
+ columns = 3,
+ className,
+ children,
+}: {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ columns?: 1 | 2 | 3;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <Card className={cn("gap-4 py-5", className)}>
+ {title || description ? (
+ <CardHeader className="px-5">
+ {title ? <CardTitle className="text-[15px]">{title}</CardTitle> : null}
+ {description ? <CardDescription>{description}</CardDescription> : null}
+ </CardHeader>
+ ) : null}
+ <CardContent className="px-5">
+ <div
+ className={cn(
+ "grid gap-4",
+ columns === 2 && "md:grid-cols-2",
+ columns === 3 && "md:grid-cols-2 lg:grid-cols-3",
+ )}
+ >
+ {children}
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/components/shared/password-input.tsx b/web/src/components/shared/password-input.tsx
new file mode 100644
index 0000000..4d0b660
--- /dev/null
+++ b/web/src/components/shared/password-input.tsx
@@ -0,0 +1,42 @@
+// 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 * as React from "react";
+import {Eye, EyeOff} from "lucide-react";
+
+import {cn} from "@/lib/utils";
+import {Input} from "@/components/ui/input";
+
+/** Password field with a reveal toggle. */
+const PasswordInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
+ function PasswordInput({className, ...props}, ref) {
+ const [visible, setVisible] = React.useState(false);
+ return (
+ <div className="relative">
+ <Input ref={ref} type={visible ? "text" : "password"} className={cn("pr-9", className)} {...props} />
+ <button
+ type="button"
+ onClick={() => setVisible(prev => !prev)}
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 transition-colors"
+ aria-label={visible ? "Hide password" : "Show password"}
+ tabIndex={-1}
+ >
+ {visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
+ </button>
+ </div>
+ );
+ },
+);
+
+export {PasswordInput};
diff --git a/web/src/components/shared/simple-select.tsx b/web/src/components/shared/simple-select.tsx
new file mode 100644
index 0000000..fdc50c3
--- /dev/null
+++ b/web/src/components/shared/simple-select.tsx
@@ -0,0 +1,181 @@
+// 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 * as React from "react";
+import {Check, ChevronsUpDown, X} from "lucide-react";
+import i18next from "i18next";
+
+import {cn} from "@/lib/utils";
+import {Button} from "@/components/ui/button";
+import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "@/components/ui/command";
+import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
+
+export interface SelectOption {
+ label: React.ReactNode;
+ value: string;
+ disabled?: boolean;
+}
+
+// Radix refuses an item whose value is the empty string, but "" is exactly how
+// this app spells "no filter" (any status, any owner). The sentinel keeps that
+// vocabulary intact at the call sites and never leaks out of this module.
+const EMPTY_VALUE = "__all__";
+
+const toInternal = (value?: string) => (value === "" || value === undefined || value === null ? EMPTY_VALUE : value);
+const toExternal = (value: string) => (value === EMPTY_VALUE ? "" : value);
+
+function normalizeOptions(options: (SelectOption | string)[]): SelectOption[] {
+ return (options ?? []).map(option => (typeof option === "string" ? {label: option, value: option} : option));
+}
+
+/** A plain dropdown with an options array. */
+export function SimpleSelect({
+ value,
+ onChange,
+ options,
+ placeholder,
+ className,
+ contentClassName,
+ size = "default",
+ disabled = false,
+ id,
+ "aria-label": ariaLabel,
+}: {
+ value?: string;
+ onChange?: (value: string) => void;
+ options: (SelectOption | string)[];
+ placeholder?: string;
+ className?: string;
+ contentClassName?: string;
+ size?: "sm" | "default";
+ disabled?: boolean;
+ id?: string;
+ "aria-label"?: string;
+}) {
+ const items = normalizeOptions(options);
+ return (
+ <Select value={toInternal(value)} onValueChange={next => onChange?.(toExternal(next))} disabled={disabled}>
+ <SelectTrigger id={id} size={size} className={cn("w-full", className)} aria-label={ariaLabel}>
+ <SelectValue placeholder={placeholder ?? i18next.t("general:Select")} />
+ </SelectTrigger>
+ <SelectContent className={contentClassName}>
+ {items.map(option => (
+ <SelectItem key={option.value} value={toInternal(option.value)} disabled={option.disabled}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+/** Combobox for long lists — certificates, applications, base URLs. */
+export function SearchSelect({
+ value,
+ onChange,
+ options,
+ placeholder,
+ searchPlaceholder,
+ emptyText,
+ className,
+ disabled = false,
+ allowClear = false,
+ allowCustomValue = false,
+ id,
+}: {
+ value?: string;
+ onChange?: (value: string) => void;
+ options: (SelectOption | string)[];
+ placeholder?: string;
+ searchPlaceholder?: string;
+ emptyText?: React.ReactNode;
+ className?: string;
+ disabled?: boolean;
+ allowClear?: boolean;
+ /** Keeps whatever is typed even when it matches no option. */
+ allowCustomValue?: boolean;
+ id?: string;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [search, setSearch] = React.useState("");
+ const items = normalizeOptions(options);
+ const matched = items.find(option => option.value === (value ?? ""));
+ // A custom value has no option to read a label from, so it labels itself.
+ const selected = matched ?? (allowCustomValue && value ? {label: value, value} : undefined);
+
+ const pick = (next: string) => {
+ onChange?.(next);
+ setSearch("");
+ setOpen(false);
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ id={id}
+ type="button"
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ disabled={disabled}
+ className={cn("w-full justify-between font-normal", !selected && "text-muted-foreground", className)}
+ >
+ <span className="truncate">{selected ? selected.label : (placeholder ?? i18next.t("general:Select"))}</span>
+ <span className="flex items-center gap-1">
+ {allowClear && selected ? (
+ <X
+ className="hover:text-foreground size-3.5 opacity-50"
+ onClick={event => {
+ event.stopPropagation();
+ onChange?.("");
+ }}
+ />
+ ) : null}
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
+ </span>
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
+ <Command>
+ <CommandInput
+ value={search}
+ onValueChange={setSearch}
+ placeholder={searchPlaceholder ?? i18next.t("general:Search")}
+ />
+ <CommandList>
+ <CommandEmpty>
+ {allowCustomValue && search !== "" ? (
+ <button type="button" className="hover:text-foreground w-full px-2" onClick={() => pick(search)}>
+ {search}
+ </button>
+ ) : (
+ (emptyText ?? i18next.t("general:No data"))
+ )}
+ </CommandEmpty>
+ <CommandGroup>
+ {items.map(option => (
+ <CommandItem key={option.value} value={String(option.label)} onSelect={() => pick(option.value)}>
+ <Check className={cn("size-4", option.value === value ? "opacity-100" : "opacity-0")} />
+ {option.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+}
diff --git a/web/src/components/shared/stat-card.tsx b/web/src/components/shared/stat-card.tsx
new file mode 100644
index 0000000..4f5527d
--- /dev/null
+++ b/web/src/components/shared/stat-card.tsx
@@ -0,0 +1,83 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+import {Progress} from "@/components/ui/progress";
+
+/**
+ * A single number with its label. `tone` colours the value itself rather than
+ * the card, so a wall of these still reads as one surface.
+ */
+export function StatCard({
+ label,
+ value,
+ suffix,
+ icon: Icon,
+ tone = "default",
+ hint,
+ percent,
+ className,
+ onClick,
+}: {
+ label: React.ReactNode;
+ value: React.ReactNode;
+ suffix?: React.ReactNode;
+ icon?: React.ComponentType<{className?: string}>;
+ tone?: "default" | "success" | "warning" | "danger" | "info";
+ hint?: React.ReactNode;
+ percent?: number | null;
+ className?: string;
+ onClick?: () => void;
+}) {
+ const toneClass = {
+ default: "text-foreground",
+ success: "text-success",
+ warning: "text-warning",
+ danger: "text-destructive",
+ info: "text-info",
+ }[tone];
+
+ const Wrapper = onClick ? "button" : "div";
+
+ return (
+ <Wrapper
+ type={onClick ? "button" : undefined}
+ onClick={onClick}
+ className={cn(
+ "bg-card flex flex-col gap-2 rounded-xl border p-4 text-left shadow-sm",
+ onClick && "hover:border-ring/50 transition-colors",
+ className,
+ )}
+ >
+ <div className="flex items-center justify-between gap-2">
+ <span className="text-muted-foreground truncate text-xs font-medium">{label}</span>
+ {Icon ? <Icon className="text-muted-foreground size-4 shrink-0" /> : null}
+ </div>
+ <div className="flex items-baseline gap-1">
+ <span className={cn("text-2xl font-semibold tracking-tight tabular-nums", toneClass)}>{value}</span>
+ {suffix ? <span className="text-muted-foreground text-xs">{suffix}</span> : null}
+ </div>
+ {percent !== undefined && percent !== null ? (
+ <Progress
+ value={Math.min(100, Math.max(0, Number(percent) || 0))}
+ tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "success"}
+ className="h-1.5"
+ />
+ ) : null}
+ {hint ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
+ </Wrapper>
+ );
+}
diff --git a/web/src/components/shared/status-badge.tsx b/web/src/components/shared/status-badge.tsx
new file mode 100644
index 0000000..1df9ff1
--- /dev/null
+++ b/web/src/components/shared/status-badge.tsx
@@ -0,0 +1,102 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+import {Badge, type BadgeVariant} from "@/components/ui/badge";
+
+// Pages that came from the antd build picked Tag colours by name. Keeping a
+// translation table means those per-page colour maps port across unchanged
+// instead of each page inventing its own palette.
+const colorToVariant: Record<string, BadgeVariant> = {
+ green: "success",
+ success: "success",
+ lime: "success",
+ cyan: "info",
+ blue: "info",
+ geekblue: "info",
+ processing: "info",
+ gold: "warning",
+ orange: "warning",
+ warning: "warning",
+ yellow: "warning",
+ volcano: "danger",
+ red: "danger",
+ error: "danger",
+ magenta: "danger",
+ purple: "secondary",
+ default: "muted",
+};
+
+export function tagVariant(color?: string): BadgeVariant {
+ return (color && colorToVariant[color]) || "muted";
+}
+
+/** A Badge addressed by colour name, for one-to-one ports of Tag cells. */
+export function ColorTag({
+ color,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"span"> & {color?: string}) {
+ return (
+ <Badge variant={tagVariant(color)} className={className} {...props}>
+ {children}
+ </Badge>
+ );
+}
+
+// States that recur across agents, channels, sites and certificates. A page with
+// a genuinely local vocabulary still passes its own map via `variants`.
+const defaultStatusVariants: Record<string, BadgeVariant> = {
+ Active: "success",
+ Running: "success",
+ Ready: "success",
+ Healthy: "success",
+ Connected: "success",
+ Enabled: "success",
+ Valid: "success",
+ Pending: "warning",
+ Starting: "warning",
+ Expiring: "warning",
+ Unknown: "muted",
+ Disabled: "muted",
+ Stopped: "muted",
+ Failed: "danger",
+ Error: "danger",
+ Expired: "danger",
+ Disconnected: "danger",
+ "": "muted",
+};
+
+export function StatusBadge({
+ status,
+ variants,
+ className,
+ fallback = "Unknown",
+ ...props
+}: React.ComponentProps<"span"> & {
+ status?: React.ReactNode;
+ variants?: Record<string, BadgeVariant>;
+ fallback?: string;
+}) {
+ const label = status === undefined || status === null || status === "" ? fallback : String(status);
+ const variant = variants?.[label] ?? defaultStatusVariants[label] ?? "muted";
+ return (
+ <Badge variant={variant} className={cn("font-medium", className)} {...props}>
+ {label}
+ </Badge>
+ );
+}
diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx
index b7514e7..91e9d1a 100644
--- a/web/src/components/ui/alert-dialog.tsx
+++ b/web/src/components/ui/alert-dialog.tsx
@@ -1,95 +1,123 @@
+// 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 * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+import type {VariantProps} from "class-variance-authority";
import {cn} from "@/lib/utils";
import {buttonVariants} from "@/components/ui/button";
-const AlertDialog = AlertDialogPrimitive.Root;
-const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
-const AlertDialogPortal = AlertDialogPrimitive.Portal;
+function AlertDialog({...props}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
+}
+
+function AlertDialogTrigger({...props}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
+ return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
+}
+
+function AlertDialogPortal({...props}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
+ return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
+}
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
->(({className, ...props}, ref) => (
- <AlertDialogPrimitive.Overlay
- className={cn("fixed inset-0 z-50 bg-black/60", className)}
- {...props}
- ref={ref}
- />
-));
-AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
-
-const AlertDialogContent = React.forwardRef<
- React.ElementRef<typeof AlertDialogPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
->(({className, ...props}, ref) => (
- <AlertDialogPortal>
- <AlertDialogOverlay />
- <AlertDialogPrimitive.Content
+ React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
+>(function AlertDialogOverlay({className, ...props}, ref) {
+ return (
+ <AlertDialogPrimitive.Overlay
ref={ref}
+ data-slot="alert-dialog-overlay"
className={cn(
- "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-lg",
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
- </AlertDialogPortal>
-));
-AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+ );
+});
-const AlertDialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
- <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
-);
-AlertDialogHeader.displayName = "AlertDialogHeader";
+function AlertDialogContent({className, ...props}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
+ return (
+ <AlertDialogPortal>
+ <AlertDialogOverlay />
+ <AlertDialogPrimitive.Content
+ data-slot="alert-dialog-content"
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+ className,
+ )}
+ {...props}
+ />
+ </AlertDialogPortal>
+ );
+}
-const AlertDialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
- {...props}
- />
-);
-AlertDialogFooter.displayName = "AlertDialogFooter";
+function AlertDialogHeader({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-header"
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+ {...props}
+ />
+ );
+}
-const AlertDialogTitle = React.forwardRef<
- React.ElementRef<typeof AlertDialogPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
->(({className, ...props}, ref) => (
- <AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
-));
-AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+function AlertDialogFooter({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-footer"
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogTitle({className, ...props}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+ return (
+ <AlertDialogPrimitive.Title
+ data-slot="alert-dialog-title"
+ className={cn("text-lg font-semibold", className)}
+ {...props}
+ />
+ );
+}
-const AlertDialogDescription = React.forwardRef<
- React.ElementRef<typeof AlertDialogPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
->(({className, ...props}, ref) => (
- <AlertDialogPrimitive.Description
- ref={ref}
- className={cn("text-sm text-muted-foreground", className)}
- {...props}
- />
-));
-AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+ return (
+ <AlertDialogPrimitive.Description
+ data-slot="alert-dialog-description"
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+ );
+}
-const AlertDialogAction = React.forwardRef<
- React.ElementRef<typeof AlertDialogPrimitive.Action>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
->(({className, ...props}, ref) => (
- <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
-));
-AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+function AlertDialogAction({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & VariantProps<typeof buttonVariants>) {
+ return <AlertDialogPrimitive.Action className={cn(buttonVariants({variant}), className)} {...props} />;
+}
-const AlertDialogCancel = React.forwardRef<
- React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
- React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
->(({className, ...props}, ref) => (
- <AlertDialogPrimitive.Cancel
- ref={ref}
- className={cn(buttonVariants({variant: "outline"}), "mt-2 sm:mt-0", className)}
- {...props}
- />
-));
-AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+function AlertDialogCancel({className, ...props}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
+ return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({variant: "outline"}), className)} {...props} />;
+}
export {
AlertDialog,
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
index bc800da..3f3e513 100644
--- a/web/src/components/ui/alert.tsx
+++ b/web/src/components/ui/alert.tsx
@@ -1,46 +1,114 @@
+// 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 * as React from "react";
import {cva, type VariantProps} from "class-variance-authority";
+import {AlertCircleIcon, CheckCircle2Icon, InfoIcon, TriangleAlertIcon} from "lucide-react";
import {cn} from "@/lib/utils";
const alertVariants = cva(
- "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg~*]:pl-7",
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5",
{
variants: {
variant: {
- default: "bg-background text-foreground",
- info: "border-sky-500/40 bg-sky-500/10 [&>svg]:text-sky-600",
- success: "border-success/40 bg-success/10 [&>svg]:text-success",
- warning: "border-warning/40 bg-warning/10 [&>svg]:text-warning",
- destructive: "border-destructive/40 bg-destructive/10 [&>svg]:text-destructive",
+ default: "bg-card text-card-foreground",
+ destructive: "border-destructive/30 bg-destructive/8 text-destructive [&>svg]:text-destructive",
+ warning: "border-warning/35 bg-warning/10 text-warning [&>svg]:text-warning",
+ success: "border-success/30 bg-success/8 text-success [&>svg]:text-success",
+ info: "border-info/30 bg-info/8 text-info [&>svg]:text-info",
},
},
- defaultVariants: {
- variant: "default",
- },
+ defaultVariants: {variant: "default"},
},
);
-const Alert = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
->(({className, variant, ...props}, ref) => (
- <div ref={ref} role="alert" className={cn(alertVariants({variant}), className)} {...props} />
-));
-Alert.displayName = "Alert";
-
-const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
- ({className, ...props}, ref) => (
- <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
- ),
-);
-AlertTitle.displayName = "AlertTitle";
+type AlertVariant = NonNullable<VariantProps<typeof alertVariants>["variant"]>;
-const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
- <div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
- ),
-);
-AlertDescription.displayName = "AlertDescription";
+// data-variant is what lets a test assert "an error is showing" without
+// selecting on generated Tailwind classes.
+function Alert({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+ return (
+ <div
+ data-slot="alert"
+ data-variant={variant}
+ role="alert"
+ className={cn(alertVariants({variant}), className)}
+ {...props}
+ />
+ );
+}
+
+function AlertTitle({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-title"
+ className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
+ {...props}
+ />
+ );
+}
+
+function AlertDescription({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-description"
+ className={cn("col-start-2 grid justify-items-start gap-1 text-sm opacity-90 [&_p]:leading-relaxed", className)}
+ {...props}
+ />
+ );
+}
+
+const variantIcons: Record<AlertVariant, React.ComponentType<{className?: string}>> = {
+ destructive: AlertCircleIcon,
+ warning: TriangleAlertIcon,
+ success: CheckCircle2Icon,
+ info: InfoIcon,
+ default: InfoIcon,
+};
+
+// Nearly every page renders the same "the request failed, here is why" banner.
+// MessageAlert is that shape in one component: an icon picked from the variant,
+// a title, and an optional description or action.
+function MessageAlert({
+ variant = "destructive",
+ title,
+ description,
+ className,
+ showIcon = true,
+ action,
+ ...props
+}: React.ComponentProps<"div"> & {
+ variant?: AlertVariant;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ showIcon?: boolean;
+ action?: React.ReactNode;
+}) {
+ const Icon = variantIcons[variant] ?? InfoIcon;
+ return (
+ <Alert variant={variant} className={className} {...props}>
+ {showIcon ? <Icon /> : null}
+ {title ? <AlertTitle>{title}</AlertTitle> : null}
+ {description ? <AlertDescription>{description}</AlertDescription> : null}
+ {action ? <div className="col-start-2 mt-2">{action}</div> : null}
+ </Alert>
+ );
+}
-export {Alert, AlertDescription, AlertTitle};
+export {Alert, AlertTitle, AlertDescription, MessageAlert, alertVariants};
diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx
index e3c8185..1608228 100644
--- a/web/src/components/ui/avatar.tsx
+++ b/web/src/components/ui/avatar.tsx
@@ -1,41 +1,46 @@
+// 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 * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import {cn} from "@/lib/utils";
-const Avatar = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
->(({className, ...props}, ref) => (
- <AvatarPrimitive.Root
- ref={ref}
- className={cn("relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full", className)}
- {...props}
- />
-));
-Avatar.displayName = AvatarPrimitive.Root.displayName;
+function Avatar({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
+ {...props}
+ />
+ );
+}
-const AvatarImage = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Image>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
->(({className, ...props}, ref) => (
- <AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
-));
-AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+function AvatarImage({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
+ return (
+ <AvatarPrimitive.Image data-slot="avatar-image" className={cn("aspect-square size-full", className)} {...props} />
+ );
+}
-const AvatarFallback = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Fallback>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
->(({className, ...props}, ref) => (
- <AvatarPrimitive.Fallback
- ref={ref}
- className={cn(
- "flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium",
- className,
- )}
- {...props}
- />
-));
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+function AvatarFallback({className, ...props}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn("bg-muted flex size-full items-center justify-center rounded-full text-xs font-medium", className)}
+ {...props}
+ />
+ );
+}
-export {Avatar, AvatarFallback, AvatarImage};
+export {Avatar, AvatarImage, AvatarFallback};
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
index e434dbf..60bf062 100644
--- a/web/src/components/ui/badge.tsx
+++ b/web/src/components/ui/badge.tsx
@@ -1,35 +1,55 @@
+// 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 * as React from "react";
+import {Slot} from "@radix-ui/react-slot";
import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none",
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
- default: "border-transparent bg-primary/10 text-primary",
+ default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
- processing: "border-primary/20 bg-primary/10 text-primary",
- success: "border-success/20 bg-success/10 text-success",
- warning: "border-warning/20 bg-warning/10 text-warning",
- error: "border-destructive/20 bg-destructive/10 text-destructive",
- blue: "border-sky-500/20 bg-sky-500/10 text-sky-600 dark:text-sky-400",
+ // Semantic tones are tinted rather than solid, so a table row carrying
+ // several of them still reads as a row instead of a row of buttons.
+ success: "border-success/25 bg-success/12 text-success",
+ warning: "border-warning/30 bg-warning/15 text-warning",
+ info: "border-info/25 bg-info/12 text-info",
+ danger: "border-destructive/25 bg-destructive/12 text-destructive",
+ muted: "border-border bg-muted text-muted-foreground",
},
},
- defaultVariants: {
- variant: "default",
- },
+ defaultVariants: {variant: "default"},
},
);
-export interface BadgeProps
- extends React.HTMLAttributes<HTMLDivElement>,
- VariantProps<typeof badgeVariants> {}
+export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>["variant"]>;
-function Badge({className, variant, ...props}: BadgeProps) {
- return <div className={cn(badgeVariants({variant}), className)} {...props} />;
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {asChild?: boolean}) {
+ const Comp = asChild ? Slot : "span";
+ return <Comp data-slot="badge" className={cn(badgeVariants({variant}), className)} {...props} />;
}
export {Badge, badgeVariants};
diff --git a/web/src/components/ui/breadcrumb.tsx b/web/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..e75b82c
--- /dev/null
+++ b/web/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,103 @@
+// 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 * as React from "react";
+import {Slot} from "@radix-ui/react-slot";
+import {ChevronRight, MoreHorizontal} from "lucide-react";
+
+import {cn} from "@/lib/utils";
+
+function Breadcrumb({...props}: React.ComponentProps<"nav">) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
+}
+
+function BreadcrumbList({className, ...props}: React.ComponentProps<"ol">) {
+ return (
+ <ol
+ data-slot="breadcrumb-list"
+ className={cn(
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbItem({className, ...props}: React.ComponentProps<"li">) {
+ return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {asChild?: boolean}) {
+ const Comp = asChild ? Slot : "a";
+ return (
+ <Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
+ );
+}
+
+function BreadcrumbPage({className, ...props}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-page"
+ role="link"
+ aria-disabled="true"
+ aria-current="page"
+ className={cn("text-foreground font-normal", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbSeparator({children, className, ...props}: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("[&>svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ );
+}
+
+function BreadcrumbEllipsis({className, ...props}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
index 854ae6c..b25b5c5 100644
--- a/web/src/components/ui/button.tsx
+++ b/web/src/components/ui/button.tsx
@@ -1,3 +1,17 @@
+// 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 * as React from "react";
import {Slot} from "@radix-ui/react-slot";
import {cva, type VariantProps} from "class-variance-authority";
@@ -5,29 +19,29 @@ import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 shrink-0 outline-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
- outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20",
+ outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
+ secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
- "icon-sm": "h-7 w-7",
+ sm: "h-8 gap-1.5 rounded-md px-3",
+ xs: "h-7 gap-1 rounded-md px-2 text-xs",
+ lg: "h-10 rounded-md px-6",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-xs": "size-7",
},
},
- defaultVariants: {
- variant: "default",
- size: "default",
- },
+ defaultVariants: {variant: "default", size: "default"},
},
);
@@ -35,14 +49,40 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
+ /** Shows a spinner and blocks the click, so a caller never disables separately. */
+ loading?: boolean;
}
-const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
- ({className, variant, size, asChild = false, ...props}, ref) => {
- const Comp = asChild ? Slot : "button";
- return <Comp className={cn(buttonVariants({variant, size, className}))} ref={ref} {...props} />;
- },
-);
-Button.displayName = "Button";
+/**
+ * forwardRef is required, not optional: this app runs React 18, where a ref is
+ * not an ordinary prop, and every Radix `asChild` trigger (tooltip, dropdown,
+ * popover, dialog) hands its child a ref to anchor and focus.
+ */
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
+ {className, variant, size, asChild = false, loading = false, disabled, children, ...props},
+ ref,
+) {
+ const classes = cn(buttonVariants({variant, size, className}));
+
+ // asChild renders the caller's own element through Radix's Slot, which accepts
+ // exactly one child. The spinner is therefore only injected for a real
+ // <button>; adding it here would hand Slot two children and throw.
+ if (asChild) {
+ return (
+ <Slot ref={ref} data-slot="button" className={classes} {...props}>
+ {children}
+ </Slot>
+ );
+ }
+
+ return (
+ <button ref={ref} data-slot="button" className={classes} disabled={disabled || loading} {...props}>
+ {loading ? (
+ <span className="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
+ ) : null}
+ {children}
+ </button>
+ );
+});
export {Button, buttonVariants};
diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx
index cacde01..665d1f2 100644
--- a/web/src/components/ui/card.tsx
+++ b/web/src/components/ui/card.tsx
@@ -1,49 +1,68 @@
+// 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 * as React from "react";
import {cn} from "@/lib/utils";
-const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
+function Card({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card"
+ className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-header"
+ className={cn(
+ "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardTitle({className, ...props}: React.ComponentProps<"div">) {
+ return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
+}
+
+function CardDescription({className, ...props}: React.ComponentProps<"div">) {
+ return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
+}
+
+function CardAction({className, ...props}: React.ComponentProps<"div">) {
+ return (
<div
- ref={ref}
- className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
+ data-slot="card-action"
+ className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
- ),
-);
-Card.displayName = "Card";
-
-const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
- <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
- ),
-);
-CardHeader.displayName = "CardHeader";
-
-const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
- <div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
- ),
-);
-CardTitle.displayName = "CardTitle";
-
-const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
- <div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
- ),
-);
-CardDescription.displayName = "CardDescription";
-
-const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
-);
-CardContent.displayName = "CardContent";
-
-const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
- ({className, ...props}, ref) => (
- <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
- ),
-);
-CardFooter.displayName = "CardFooter";
-
-export {Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent};
+ );
+}
+
+function CardContent({className, ...props}: React.ComponentProps<"div">) {
+ return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
+}
+
+function CardFooter({className, ...props}: React.ComponentProps<"div">) {
+ return <div data-slot="card-footer" className={cn("flex items-center px-6", className)} {...props} />;
+}
+
+export {Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent};
diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx
deleted file mode 100644
index 1587d1a..0000000
--- a/web/src/components/ui/combobox.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import * as React from "react";
-import {Check, ChevronsUpDown} from "lucide-react";
-
-import {cn} from "@/lib/utils";
-import {Button} from "@/components/ui/button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command";
-import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
-
-export interface ComboboxOption {
- value: string;
- label?: string;
-}
-
-export interface ComboboxProps {
- value: string | undefined;
- onChange: (value: string) => void;
- options: ComboboxOption[];
- placeholder?: string;
- emptyText?: string;
- disabled?: boolean;
- /** Keeps whatever is typed even when it matches no option, like antd's AutoComplete. */
- allowCustomValue?: boolean;
- className?: string;
-}
-
-/**
- * Combobox is the searchable single-select that antd offered as
- * `Select showSearch` and `AutoComplete`.
- */
-export function Combobox({
- value,
- onChange,
- options,
- placeholder,
- emptyText,
- disabled,
- allowCustomValue = false,
- className,
-}: ComboboxProps) {
- const [open, setOpen] = React.useState(false);
- const [search, setSearch] = React.useState("");
- const selected = options.find(option => option.value === value);
-
- const pick = (next: string) => {
- onChange(next);
- setOpen(false);
- setSearch("");
- };
-
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={open}
- disabled={disabled}
- className={cn("w-full justify-between font-normal", !value && "text-muted-foreground", className)}
- >
- <span className="truncate">{selected?.label ?? value ?? placeholder ?? ""}</span>
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[--radix-popover-trigger-width] p-0">
- <Command>
- <CommandInput placeholder={placeholder} value={search} onValueChange={setSearch} />
- <CommandList>
- <CommandEmpty>
- {allowCustomValue && search !== "" ? (
- <button type="button" className="text-sm underline" onClick={() => pick(search)}>
- {search}
- </button>
- ) : (
- emptyText ?? "No results."
- )}
- </CommandEmpty>
- <CommandGroup>
- {options.map(option => (
- <CommandItem key={option.value} value={option.value} onSelect={() => pick(option.value)}>
- <Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
- {option.label ?? option.value}
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-}
diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx
index e364539..8a972bc 100644
--- a/web/src/components/ui/command.tsx
+++ b/web/src/components/ui/command.tsx
@@ -1,81 +1,106 @@
+// 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 * as React from "react";
import {Command as CommandPrimitive} from "cmdk";
-import {Search} from "lucide-react";
+import {SearchIcon} from "lucide-react";
import {cn} from "@/lib/utils";
-const Command = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive>
->(({className, ...props}, ref) => (
- <CommandPrimitive
- ref={ref}
- className={cn(
- "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
- className,
- )}
- {...props}
- />
-));
-Command.displayName = CommandPrimitive.displayName;
-
-const CommandInput = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Input>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
->(({className, ...props}, ref) => (
- <div className="flex items-center border-b px-3">
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
- <CommandPrimitive.Input
- ref={ref}
+function Command({className, ...props}: React.ComponentProps<typeof CommandPrimitive>) {
+ return (
+ <CommandPrimitive
+ data-slot="command"
className={cn(
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+ "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
- </div>
-));
-CommandInput.displayName = CommandPrimitive.Input.displayName;
+ );
+}
+
+function CommandInput({className, ...props}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+ return (
+ <div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
+ <SearchIcon className="size-4 shrink-0 opacity-50" />
+ <CommandPrimitive.Input
+ data-slot="command-input"
+ className={cn(
+ "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ </div>
+ );
+}
-const CommandList = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.List>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
->(({className, ...props}, ref) => (
- <CommandPrimitive.List
- ref={ref}
- className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
- {...props}
- />
-));
-CommandList.displayName = CommandPrimitive.List.displayName;
+function CommandList({className, ...props}: React.ComponentProps<typeof CommandPrimitive.List>) {
+ return (
+ <CommandPrimitive.List
+ data-slot="command-list"
+ className={cn("scrollbar-thin max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
+ {...props}
+ />
+ );
+}
-const CommandEmpty = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Empty>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
->((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
-CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+function CommandEmpty({...props}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+ return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
+}
-const CommandGroup = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Group>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
->(({className, ...props}, ref) => (
- <CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1", className)} {...props} />
-));
-CommandGroup.displayName = CommandPrimitive.Group.displayName;
+function CommandGroup({className, ...props}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+ return (
+ <CommandPrimitive.Group
+ data-slot="command-group"
+ className={cn(
+ "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
-const CommandItem = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
->(({className, ...props}, ref) => (
- <CommandPrimitive.Item
- ref={ref}
- className={cn(
- "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
- className,
- )}
- {...props}
- />
-));
-CommandItem.displayName = CommandPrimitive.Item.displayName;
+function CommandSeparator({className, ...props}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+ return (
+ <CommandPrimitive.Separator data-slot="command-separator" className={cn("bg-border -mx-1 h-px", className)} {...props} />
+ );
+}
+
+function CommandItem({className, ...props}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+ return (
+ <CommandPrimitive.Item
+ data-slot="command-item"
+ className={cn(
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CommandShortcut({className, ...props}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="command-shortcut"
+ className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
+ {...props}
+ />
+ );
+}
-export {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList};
+export {Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator};
diff --git a/web/src/components/ui/confirm-button.tsx b/web/src/components/ui/confirm-button.tsx
deleted file mode 100644
index 80a0c6d..0000000
--- a/web/src/components/ui/confirm-button.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-import i18next from "i18next";
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import {buttonVariants} from "@/components/ui/button";
-import {cn} from "@/lib/utils";
-
-export interface ConfirmButtonProps {
- title: React.ReactNode;
- description?: React.ReactNode;
- okText?: string;
- cancelText?: string;
- onConfirm: () => void;
- /** The trigger, e.g. a destructive Button. */
- children: React.ReactNode;
- destructive?: boolean;
-}
-
-/**
- * ConfirmButton is the shadcn stand-in for antd's Popconfirm: a trigger that
- * opens a modal asking to confirm before the action runs.
- */
-export function ConfirmButton({
- title,
- description,
- okText,
- cancelText,
- onConfirm,
- children,
- destructive = true,
-}: ConfirmButtonProps) {
- return (
- <AlertDialog>
- <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>{title}</AlertDialogTitle>
- {description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel>{cancelText ?? i18next.t("general:Cancel")}</AlertDialogCancel>
- <AlertDialogAction
- onClick={onConfirm}
- className={cn(destructive && buttonVariants({variant: "destructive"}))}
- >
- {okText ?? i18next.t("general:OK")}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- );
-}
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
index 72c6120..6ba775c 100644
--- a/web/src/components/ui/dialog.tsx
+++ b/web/src/components/ui/dialog.tsx
@@ -1,78 +1,123 @@
+// 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 * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
-import {X} from "lucide-react";
+import {XIcon} from "lucide-react";
import {cn} from "@/lib/utils";
-const Dialog = DialogPrimitive.Root;
-const DialogTrigger = DialogPrimitive.Trigger;
-const DialogPortal = DialogPrimitive.Portal;
-const DialogClose = DialogPrimitive.Close;
+function Dialog({...props}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({...props}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({...props}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({...props}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
->(({className, ...props}, ref) => (
- <DialogPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/60", className)} {...props} />
-));
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
-
-const DialogContent = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
->(({className, children, ...props}, ref) => (
- <DialogPortal>
- <DialogOverlay />
- <DialogPrimitive.Content
+ React.ComponentProps<typeof DialogPrimitive.Overlay>
+>(function DialogOverlay({className, ...props}, ref) {
+ return (
+ <DialogPrimitive.Overlay
ref={ref}
+ data-slot="dialog-overlay"
className={cn(
- "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-lg",
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
- >
- {children}
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
- <X className="h-4 w-4" />
- <span className="sr-only">Close</span>
- </DialogPrimitive.Close>
- </DialogPrimitive.Content>
- </DialogPortal>
-));
-DialogContent.displayName = DialogPrimitive.Content.displayName;
+ />
+ );
+});
-const DialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
- <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
-);
-DialogHeader.displayName = "DialogHeader";
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {showCloseButton?: boolean}) {
+ return (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ data-slot="dialog-content"
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ data-slot="dialog-close"
+ className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4"
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Content>
+ </DialogPortal>
+ );
+}
-const DialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
- {...props}
- />
-);
-DialogFooter.displayName = "DialogFooter";
+function DialogHeader({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />
+ );
+}
-const DialogTitle = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
->(({className, ...props}, ref) => (
- <DialogPrimitive.Title
- ref={ref}
- className={cn("text-lg font-semibold leading-none tracking-tight", className)}
- {...props}
- />
-));
-DialogTitle.displayName = DialogPrimitive.Title.displayName;
+function DialogFooter({className, ...props}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-footer"
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogTitle({className, ...props}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+ return (
+ <DialogPrimitive.Title
+ data-slot="dialog-title"
+ className={cn("text-lg leading-none font-semibold", className)}
+ {...props}
+ />
+ );
+}
-const DialogDescription = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
->(({className, ...props}, ref) => (
- <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
-));
-DialogDescription.displayName = DialogPrimitive.Description.displayName;
+function DialogDescription({className, ...props}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+ return (
+ <DialogPrimitive.Description
+ data-slot="dialog-description"
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+ );
+}
export {
Dialog,
diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx
index fb0f231..5ec826b 100644
--- a/web/src/components/ui/dropdown-menu.tsx
+++ b/web/src/components/ui/dropdown-menu.tsx
@@ -1,73 +1,224 @@
+// 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 * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import {CheckIcon, ChevronRightIcon, CircleIcon} from "lucide-react";
import {cn} from "@/lib/utils";
-const DropdownMenu = DropdownMenuPrimitive.Root;
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-
-const DropdownMenuContent = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
->(({className, sideOffset = 4, ...props}, ref) => (
- <DropdownMenuPrimitive.Portal>
- <DropdownMenuPrimitive.Content
- ref={ref}
- sideOffset={sideOffset}
+function DropdownMenu({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
+ return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
+}
+
+function DropdownMenuTrigger({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
+ return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+ return (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ data-slot="dropdown-menu-content"
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+ className,
+ )}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+ );
+}
+
+function DropdownMenuGroup({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
+ return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+ <DropdownMenuPrimitive.Item
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 [&_svg:not([class*=text-]) [...]
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
+ return (
+ <DropdownMenuPrimitive.CheckboxItem
+ data-slot="dropdown-menu-checkbox-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <CheckIcon className="size-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.CheckboxItem>
+ );
+}
+
+function DropdownMenuRadioGroup({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
+ return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
+ return (
+ <DropdownMenuPrimitive.RadioItem
+ data-slot="dropdown-menu-radio-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4",
+ className,
+ )}
+ {...props}
+ >
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <CircleIcon className="size-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {inset?: boolean}) {
+ return (
+ <DropdownMenuPrimitive.Label
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSeparator({className, ...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
+ return (
+ <DropdownMenuPrimitive.Separator
+ data-slot="dropdown-menu-separator"
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({className, ...props}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="dropdown-menu-shortcut"
+ className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
+ return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {inset?: boolean}) {
+ return (
+ <DropdownMenuPrimitive.SubTrigger
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto size-4" />
+ </DropdownMenuPrimitive.SubTrigger>
+ );
+}
+
+function DropdownMenuSubContent({className, ...props}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
+ return (
+ <DropdownMenuPrimitive.SubContent
+ data-slot="dropdown-menu-sub-content"
className={cn(
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
- </DropdownMenuPrimitive.Portal>
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-
-const DropdownMenuItem = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
->(({className, ...props}, ref) => (
- <DropdownMenuPrimitive.Item
- ref={ref}
- className={cn(
- "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
- className,
- )}
- {...props}
- />
-));
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
-
-const DropdownMenuLabel = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Label>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
->(({className, ...props}, ref) => (
- <DropdownMenuPrimitive.Label
- ref={ref}
- className={cn("px-2 py-1.5 text-sm font-semibold", className)}
- {...props}
- />
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
-
-const DropdownMenuSeparator = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
->(({className, ...props}, ref) => (
- <DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
-));
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+ );
+}
export {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
index 9ee2af1..d9d9e87 100644
--- a/web/src/components/ui/input.tsx
+++ b/web/src/components/ui/input.tsx
@@ -1,22 +1,38 @@
+// 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 * as React from "react";
import {cn} from "@/lib/utils";
-const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
- ({className, type, ...props}, ref) => {
+const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
+ function Input({className, type, ...props}, ref) {
return (
<input
+ ref={ref}
type={type}
+ data-slot="input"
className={cn(
- "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className,
)}
- ref={ref}
{...props}
/>
);
},
);
-Input.displayName = "Input";
export {Input};
diff --git a/web/src/components/ui/label.tsx b/web/src/components/ui/label.tsx
index 710ea84..42983bc 100644
--- a/web/src/components/ui/label.tsx
+++ b/web/src/components/ui/label.tsx
@@ -1,19 +1,33 @@
+// 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 * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
-import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "@/lib/utils";
-const labelVariants = cva(
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
-);
-
-const Label = React.forwardRef<
- React.ElementRef<typeof LabelPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
->(({className, ...props}, ref) => (
- <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
-));
-Label.displayName = LabelPrimitive.Root.displayName;
+function Label({className, ...props}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+ return (
+ <LabelPrimitive.Root
+ data-slot="label"
+ className={cn(
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
export {Label};
diff --git a/web/src/components/ui/number-input.tsx b/web/src/components/ui/number-input.tsx
deleted file mode 100644
index c82a155..0000000
--- a/web/src/components/ui/number-input.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import * as React from "react";
-
-import {cn} from "@/lib/utils";
-import {Input} from "@/components/ui/input";
-
-export interface NumberInputProps
- extends Omit<React.ComponentProps<"input">, "value" | "onChange" | "type"> {
- value: number | undefined | null;
- onChange: (value: number) => void;
- /** Suffix rendered inside the field, like antd's `addonAfter`. */
- addonAfter?: React.ReactNode;
-}
-
-/**
- * NumberInput is antd's InputNumber. It reports numbers rather than strings and
- * clamps to `min`/`max` so a page never has to re-validate what it stores.
- */
-const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
- ({className, value, onChange, addonAfter, min, max, ...props}, ref) => {
- const clamp = (next: number) => {
- let result = next;
- if (min !== undefined && result < Number(min)) {
- result = Number(min);
- }
- if (max !== undefined && result > Number(max)) {
- result = Number(max);
- }
- return result;
- };
-
- const field = (
- <Input
- ref={ref}
- type="number"
- min={min}
- max={max}
- value={value ?? ""}
- onChange={event => {
- const parsed = parseInt(event.target.value, 10);
- onChange(isNaN(parsed) ? 0 : parsed);
- }}
- onBlur={event => {
- const parsed = parseInt(event.target.value, 10);
- onChange(clamp(isNaN(parsed) ? 0 : parsed));
- }}
- className={cn(addonAfter ? "rounded-r-none" : "", className)}
- {...props}
- />
- );
-
- if (!addonAfter) {
- return field;
- }
-
- return (
- <div className="flex">
- {field}
- <span className="inline-flex h-9 items-center whitespace-nowrap rounded-r-md border border-l-0 border-input bg-muted px-3 text-sm text-muted-foreground">
- {addonAfter}
- </span>
- </div>
- );
- },
-);
-NumberInput.displayName = "NumberInput";
-
-export {NumberInput};
diff --git a/web/src/components/ui/password-input.tsx b/web/src/components/ui/password-input.tsx
deleted file mode 100644
index 103707e..0000000
--- a/web/src/components/ui/password-input.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as React from "react";
-import {Eye, EyeOff} from "lucide-react";
-
-import {cn} from "@/lib/utils";
-import {Input} from "@/components/ui/input";
-
-/** PasswordInput is antd's Input.Password: a text field with a reveal toggle. */
-const PasswordInput = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
- ({className, ...props}, ref) => {
- const [visible, setVisible] = React.useState(false);
-
- return (
- <div className="relative">
- <Input ref={ref} type={visible ? "text" : "password"} className={cn("pr-9", className)} {...props} />
- <button
- type="button"
- tabIndex={-1}
- onClick={() => setVisible(current => !current)}
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- aria-label={visible ? "Hide password" : "Show password"}
- >
- {visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
- </button>
- </div>
- );
- },
-);
-PasswordInput.displayName = "PasswordInput";
-
-export {PasswordInput};
diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx
index 55c6d04..5bf13fd 100644
--- a/web/src/components/ui/popover.tsx
+++ b/web/src/components/ui/popover.tsx
@@ -1,29 +1,54 @@
+// 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 * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {cn} from "@/lib/utils";
-const Popover = PopoverPrimitive.Root;
-const PopoverTrigger = PopoverPrimitive.Trigger;
-const PopoverAnchor = PopoverPrimitive.Anchor;
+function Popover({...props}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
+}
+
+function PopoverTrigger({...props}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
+}
+
+function PopoverAnchor({...props}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
+}
-const PopoverContent = React.forwardRef<
- React.ElementRef<typeof PopoverPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
->(({className, align = "start", sideOffset = 4, ...props}, ref) => (
- <PopoverPrimitive.Portal>
- <PopoverPrimitive.Content
- ref={ref}
- align={align}
- sideOffset={sideOffset}
- className={cn(
- "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
- className,
- )}
- {...props}
- />
- </PopoverPrimitive.Portal>
-));
-PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
+ return (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ data-slot="popover-content"
+ align={align}
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+ );
+}
-export {Popover, PopoverAnchor, PopoverContent, PopoverTrigger};
+export {Popover, PopoverTrigger, PopoverContent, PopoverAnchor};
diff --git a/web/src/components/ui/progress.tsx b/web/src/components/ui/progress.tsx
new file mode 100644
index 0000000..3bffbfb
--- /dev/null
+++ b/web/src/components/ui/progress.tsx
@@ -0,0 +1,57 @@
+// 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 * as React from "react";
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+
+import {cn} from "@/lib/utils";
+
+// `tone` exists because these bars are mostly utilisation gauges: a quota or a
+// rate limit goes amber then red as it fills, and a caller should be able to say
+// that without reaching for arbitrary classes.
+const toneClasses = {
+ default: "bg-primary",
+ success: "bg-success",
+ warning: "bg-warning",
+ danger: "bg-destructive",
+ info: "bg-info",
+};
+
+function Progress({
+ className,
+ value,
+ tone = "default",
+ indicatorClassName,
+ ...props
+}: React.ComponentProps<typeof ProgressPrimitive.Root> & {
+ tone?: keyof typeof toneClasses;
+ indicatorClassName?: string;
+}) {
+ return (
+ <ProgressPrimitive.Root
+ data-slot="progress"
+ className={cn("bg-muted relative h-2 w-full overflow-hidden rounded-full", className)}
+ value={value}
+ {...props}
+ >
+ <ProgressPrimitive.Indicator
+ data-slot="progress-indicator"
+ className={cn("h-full w-full flex-1 transition-all", toneClasses[tone] ?? toneClasses.default, indicatorClassName)}
+ style={{transform: `translateX(-${100 - (Number(value) || 0)}%)`}}
+ />
+ </ProgressPrimitive.Root>
+ );
+}
+
+export {Progress};
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
index d561889..b552710 100644
--- a/web/src/components/ui/select.tsx
+++ b/web/src/components/ui/select.tsx
@@ -1,131 +1,157 @@
+// 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 * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
-import {Check, ChevronDown, ChevronUp} from "lucide-react";
+import {CheckIcon, ChevronDownIcon, ChevronUpIcon} from "lucide-react";
import {cn} from "@/lib/utils";
-const Select = SelectPrimitive.Root;
-const SelectGroup = SelectPrimitive.Group;
-const SelectValue = SelectPrimitive.Value;
-
-const SelectTrigger = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Trigger>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
->(({className, children, ...props}, ref) => (
- <SelectPrimitive.Trigger
- ref={ref}
- className={cn(
- "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
- className,
- )}
- {...props}
- >
- {children}
- <SelectPrimitive.Icon asChild>
- <ChevronDown className="h-4 w-4 opacity-50" />
- </SelectPrimitive.Icon>
- </SelectPrimitive.Trigger>
-));
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+function Select({...props}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ return <SelectPrimitive.Root data-slot="select" {...props} />;
+}
-const SelectScrollUpButton = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
->(({className, ...props}, ref) => (
- <SelectPrimitive.ScrollUpButton
- ref={ref}
- className={cn("flex cursor-default items-center justify-center py-1", className)}
- {...props}
- >
- <ChevronUp className="h-4 w-4" />
- </SelectPrimitive.ScrollUpButton>
-));
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+function SelectGroup({...props}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />;
+}
-const SelectScrollDownButton = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
->(({className, ...props}, ref) => (
- <SelectPrimitive.ScrollDownButton
- ref={ref}
- className={cn("flex cursor-default items-center justify-center py-1", className)}
- {...props}
- >
- <ChevronDown className="h-4 w-4" />
- </SelectPrimitive.ScrollDownButton>
-));
-SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
+function SelectValue({...props}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />;
+}
-const SelectContent = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
->(({className, children, position = "popper", ...props}, ref) => (
- <SelectPrimitive.Portal>
- <SelectPrimitive.Content
- ref={ref}
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {size?: "sm" | "default"}) {
+ return (
+ <SelectPrimitive.Trigger
+ data-slot="select-trigger"
+ data-size={size}
className={cn(
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
+ "border-input data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]: [...]
className,
)}
- position={position}
{...props}
>
- <SelectScrollUpButton />
- <SelectPrimitive.Viewport
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDownIcon className="size-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ data-slot="select-content"
className={cn(
- "p-1",
- position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className,
)}
+ position={position}
+ {...props}
>
- {children}
- </SelectPrimitive.Viewport>
- <SelectScrollDownButton />
- </SelectPrimitive.Content>
- </SelectPrimitive.Portal>
-));
-SelectContent.displayName = SelectPrimitive.Content.displayName;
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+ );
+}
+
+function SelectLabel({className, ...props}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+ return (
+ <SelectPrimitive.Label
+ data-slot="select-label"
+ className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectItem({className, children, ...props}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+ return (
+ <SelectPrimitive.Item
+ data-slot="select-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 [&_svg:not([class*=text-])]:text-muted-foreground",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <CheckIcon className="size-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+ );
+}
-const SelectLabel = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Label>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
->(({className, ...props}, ref) => (
- <SelectPrimitive.Label
- ref={ref}
- className={cn("px-2 py-1.5 text-sm font-semibold", className)}
- {...props}
- />
-));
-SelectLabel.displayName = SelectPrimitive.Label.displayName;
+function SelectSeparator({className, ...props}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+ return (
+ <SelectPrimitive.Separator
+ data-slot="select-separator"
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+ {...props}
+ />
+ );
+}
-const SelectItem = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
->(({className, children, ...props}, ref) => (
- <SelectPrimitive.Item
- ref={ref}
- className={cn(
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className,
- )}
- {...props}
- >
- <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
- <SelectPrimitive.ItemIndicator>
- <Check className="h-4 w-4" />
- </SelectPrimitive.ItemIndicator>
- </span>
- <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
- </SelectPrimitive.Item>
-));
-SelectItem.displayName = SelectPrimitive.Item.displayName;
+function SelectScrollUpButton({className, ...props}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+ return (
+ <SelectPrimitive.ScrollUpButton
+ data-slot="select-scroll-up-button"
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
+ {...props}
+ >
+ <ChevronUpIcon className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+ );
+}
-const SelectSeparator = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
->(({className, ...props}, ref) => (
- <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
-));
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+function SelectScrollDownButton({className, ...props}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+ return (
+ <SelectPrimitive.ScrollDownButton
+ data-slot="select-scroll-down-button"
+ className={cn("flex cursor-default items-center justify-center py-1", className)}
+ {...props}
+ >
+ <ChevronDownIcon className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+ );
+}
export {
Select,
diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx
new file mode 100644
index 0000000..fedb799
--- /dev/null
+++ b/web/src/components/ui/separator.tsx
@@ -0,0 +1,40 @@
+// 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 * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import {cn} from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ data-slot="separator"
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {Separator};
diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..f59ebd1
--- /dev/null
+++ b/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,23 @@
+// 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 * as React from "react";
+
+import {cn} from "@/lib/utils";
+
+function Skeleton({className, ...props}: React.ComponentProps<"div">) {
+ return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />;
+}
+
+export {Skeleton};
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx
index 238dc4d..6ed18af 100644
--- a/web/src/components/ui/sonner.tsx
+++ b/web/src/components/ui/sonner.tsx
@@ -1,17 +1,41 @@
-import {Toaster as Sonner} from "sonner";
+// 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.
-export function Toaster() {
+import {Toaster as Sonner, type ToasterProps} from "sonner";
+
+// Sonner is themed through CSS variables rather than its own light/dark prop, so
+// it follows the `.dark` class on <html> exactly like the rest of the UI and
+// never disagrees with the page behind it.
+function Toaster({...props}: ToasterProps) {
return (
<Sonner
+ className="toaster group"
position="top-center"
+ richColors
+ closeButton
toastOptions={{
classNames: {
- toast: "group rounded-md border bg-background text-foreground shadow-lg",
- description: "text-muted-foreground",
- error: "border-destructive/40",
- success: "border-success/40",
+ toast:
+ "group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
+ description: "group-[.toast]:text-muted-foreground",
+ actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
+ cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
+ {...props}
/>
);
}
+
+export {Toaster};
diff --git a/web/src/components/ui/spinner.tsx b/web/src/components/ui/spinner.tsx
deleted file mode 100644
index 14969d2..0000000
--- a/web/src/components/ui/spinner.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import {Loader2} from "lucide-react";
-
-import {cn} from "@/lib/utils";
-
-export function Spinner({className}: {className?: string}) {
- return <Loader2 className={cn("h-4 w-4 animate-spin", className)} />;
-}
-
-export function PageSpinner({tip}: {tip?: string}) {
- return (
- <div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-muted-foreground">
- <Spinner className="h-8 w-8" />
- {tip ? <span className="text-sm">{tip}</span> : null}
- </div>
- );
-}
diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx
index 0f74d2b..a31198f 100644
--- a/web/src/components/ui/switch.tsx
+++ b/web/src/components/ui/switch.tsx
@@ -1,23 +1,46 @@
+// 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 * as React from "react";
-import * as SwitchPrimitives from "@radix-ui/react-switch";
+import * as SwitchPrimitive from "@radix-ui/react-switch";
import {cn} from "@/lib/utils";
+/** forwardRef for the same reason Button needs it: the agent pages hand a
+ * Switch straight to an `asChild` dialog trigger, which anchors on its ref. */
const Switch = React.forwardRef<
- React.ElementRef<typeof SwitchPrimitives.Root>,
- React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
->(({className, ...props}, ref) => (
- <SwitchPrimitives.Root
- className={cn(
- "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
- className,
- )}
- {...props}
- ref={ref}
- >
- <SwitchPrimitives.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
- </SwitchPrimitives.Root>
-));
-Switch.displayName = SwitchPrimitives.Root.displayName;
+ React.ElementRef<typeof SwitchPrimitive.Root>,
+ React.ComponentProps<typeof SwitchPrimitive.Root>
+>(function Switch({className, ...props}, ref) {
+ return (
+ <SwitchPrimitive.Root
+ ref={ref}
+ data-slot="switch"
+ className={cn(
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ className={cn(
+ "bg-background pointer-events-none block size-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
+ )}
+ />
+ </SwitchPrimitive.Root>
+ );
+});
export {Switch};
diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx
index 714975f..e8abcc0 100644
--- a/web/src/components/ui/table.tsx
+++ b/web/src/components/ui/table.tsx
@@ -1,67 +1,88 @@
+// 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 * as React from "react";
import {cn} from "@/lib/utils";
-const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
- ({className, ...props}, ref) => (
- <div className="relative w-full overflow-auto">
- <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
+function Table({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps<"table"> & {containerClassName?: string}) {
+ return (
+ <div data-slot="table-container" className={cn("relative w-full overflow-x-auto", containerClassName)}>
+ <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
- ),
-);
-Table.displayName = "Table";
+ );
+}
-const TableHeader = React.forwardRef<
- HTMLTableSectionElement,
- React.HTMLAttributes<HTMLTableSectionElement>
->(({className, ...props}, ref) => (
- <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
-));
-TableHeader.displayName = "TableHeader";
+function TableHeader({className, ...props}: React.ComponentProps<"thead">) {
+ return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
+}
-const TableBody = React.forwardRef<
- HTMLTableSectionElement,
- React.HTMLAttributes<HTMLTableSectionElement>
->(({className, ...props}, ref) => (
- <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
-));
-TableBody.displayName = "TableBody";
+function TableBody({className, ...props}: React.ComponentProps<"tbody">) {
+ return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
+}
-const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
- ({className, ...props}, ref) => (
+function TableFooter({className, ...props}: React.ComponentProps<"tfoot">) {
+ return (
+ <tfoot
+ data-slot="table-footer"
+ className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
+ {...props}
+ />
+ );
+}
+
+function TableRow({className, ...props}: React.ComponentProps<"tr">) {
+ return (
<tr
- ref={ref}
+ data-slot="table-row"
+ className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
+ {...props}
+ />
+ );
+}
+
+function TableHead({className, ...props}: React.ComponentProps<"th">) {
+ return (
+ <th
+ data-slot="table-head"
className={cn(
- "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+ "text-muted-foreground h-10 px-3 text-left align-middle text-xs font-semibold tracking-wide whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
- ),
-);
-TableRow.displayName = "TableRow";
+ );
+}
-const TableHead = React.forwardRef<
- HTMLTableCellElement,
- React.ThHTMLAttributes<HTMLTableCellElement>
->(({className, ...props}, ref) => (
- <th
- ref={ref}
- className={cn(
- "h-9 whitespace-nowrap px-2 text-left align-middle font-medium text-muted-foreground",
- className,
- )}
- {...props}
- />
-));
-TableHead.displayName = "TableHead";
+function TableCell({className, ...props}: React.ComponentProps<"td">) {
+ return (
+ <td
+ data-slot="table-cell"
+ className={cn("px-3 py-2.5 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+ );
+}
-const TableCell = React.forwardRef<
- HTMLTableCellElement,
- React.TdHTMLAttributes<HTMLTableCellElement>
->(({className, ...props}, ref) => (
- <td ref={ref} className={cn("p-2 align-middle", className)} {...props} />
-));
-TableCell.displayName = "TableCell";
+function TableCaption({className, ...props}: React.ComponentProps<"caption">) {
+ return (
+ <caption data-slot="table-caption" className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />
+ );
+}
-export {Table, TableHeader, TableBody, TableHead, TableRow, TableCell};
+export {Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption};
diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..67ea0de
--- /dev/null
+++ b/web/src/components/ui/tabs.tsx
@@ -0,0 +1,54 @@
+// 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 * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import {cn} from "@/lib/utils";
+
+function Tabs({className, ...props}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+ return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
+}
+
+function TabsList({className, ...props}: React.ComponentProps<typeof TabsPrimitive.List>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ className={cn(
+ "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TabsTrigger({className, ...props}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+ return (
+ <TabsPrimitive.Trigger
+ data-slot="tabs-trigger"
+ className={cn(
+ "data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 text-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [...]
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TabsContent({className, ...props}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+ return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
+}
+
+export {Tabs, TabsList, TabsTrigger, TabsContent};
diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
index 246a8e8..a684e56 100644
--- a/web/src/components/ui/textarea.tsx
+++ b/web/src/components/ui/textarea.tsx
@@ -1,21 +1,36 @@
+// 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 * as React from "react";
import {cn} from "@/lib/utils";
-const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
- ({className, ...props}, ref) => {
- return (
- <textarea
- className={cn(
- "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
- className,
- )}
- ref={ref}
- {...props}
- />
- );
- },
-);
-Textarea.displayName = "Textarea";
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.TextareaHTMLAttributes<HTMLTextAreaElement>
+>(function Textarea({className, ...props}, ref) {
+ return (
+ <textarea
+ ref={ref}
+ data-slot="textarea"
+ className={cn(
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
export {Textarea};
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
index b08bf5e..bf585d4 100644
--- a/web/src/components/ui/tooltip.tsx
+++ b/web/src/components/ui/tooltip.tsx
@@ -1,55 +1,87 @@
+// 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 * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {cn} from "@/lib/utils";
-const TooltipProvider = TooltipPrimitive.Provider;
-const TooltipRoot = TooltipPrimitive.Root;
-const TooltipTrigger = TooltipPrimitive.Trigger;
-
-const TooltipContent = React.forwardRef<
- React.ElementRef<typeof TooltipPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
->(({className, sideOffset = 4, ...props}, ref) => (
- <TooltipPrimitive.Portal>
- <TooltipPrimitive.Content
- ref={ref}
- sideOffset={sideOffset}
- className={cn(
- "z-50 max-w-lg overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md",
- className,
- )}
- {...props}
- />
- </TooltipPrimitive.Portal>
-));
-TooltipContent.displayName = TooltipPrimitive.Content.displayName;
-
-/**
- * Tooltip wraps the four Radix parts into the single-element form the pages use,
- * which keeps call sites as short as the antd component they replace.
- */
-function Tooltip({
+function TooltipProvider({
+ delayDuration = 200,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
+ return (
+ <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
+ );
+}
+
+function Tooltip({...props}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
+}
+
+function TooltipTrigger({...props}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 4,
+ children,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+ return (
+ <TooltipPrimitive.Portal>
+ <TooltipPrimitive.Content
+ data-slot="tooltip-content"
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
+ </TooltipPrimitive.Content>
+ </TooltipPrimitive.Portal>
+ );
+}
+
+// Most call sites just want "hover this, read that". SimpleTooltip keeps them
+// from repeating the four-part Radix composition every time.
+function SimpleTooltip({
title,
children,
- side,
- asChild = true,
-}: {
- title: React.ReactNode;
- children: React.ReactNode;
+ side = "top",
+ className,
+ ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Root> & {
+ title?: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
- asChild?: boolean;
+ className?: string;
}) {
- if (!title) {
+ if (title === undefined || title === null || title === "") {
return <>{children}</>;
}
-
return (
- <TooltipRoot>
- <TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
- <TooltipContent side={side}>{title}</TooltipContent>
- </TooltipRoot>
+ <Tooltip {...props}>
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
+ <TooltipContent side={side} className={className}>
+ {title}
+ </TooltipContent>
+ </Tooltip>
);
}
-export {Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger};
+export {Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip};
diff --git a/web/src/index.css b/web/src/index.css
index f611bc3..307fb46 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -1,89 +1,183 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+@import "tailwindcss";
+@import "tw-animate-css";
-@layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
- --card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 222.2 84% 4.9%;
- /* rgb(89,54,213), the brand purple the antd build set as @primary-color. */
- --primary: 255 60% 52%;
- --primary-foreground: 0 0% 100%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
- --destructive: 0 72.2% 50.6%;
- --destructive-foreground: 210 40% 98%;
- --success: 142.1 70.6% 35.3%;
- --success-foreground: 0 0% 100%;
- --warning: 32.1 94.6% 43.7%;
- --warning-foreground: 0 0% 100%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 255 60% 52%;
- /* Categorical series colors, assigned in slot order and never cycled.
- Validated for colorblind separation against both surfaces. */
- --chart-1: 212.8 67.7% 50.2%;
- --chart-2: 17 82.1% 56.3%;
- --chart-3: 158.5 73.3% 39.6%;
- --chart-4: 40.8 100% 46.5%;
- --chart-5: 337.4 70.3% 69.6%;
- --radius: 0.5rem;
- }
+@custom-variant dark (&:is(.dark *));
- .dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
- --primary: 255 70% 68%;
- --primary-foreground: 222.2 47.4% 11.2%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
- --destructive: 0 62.8% 50%;
- --destructive-foreground: 210 40% 98%;
- --success: 142.1 70.6% 45.3%;
- --success-foreground: 222.2 47.4% 11.2%;
- --warning: 32.1 94.6% 53.7%;
- --warning-foreground: 222.2 47.4% 11.2%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 255 70% 68%;
- --chart-1: 212.8 76.8% 56.1%;
- --chart-2: 17.1 70.2% 50%;
- --chart-3: 159.2 72.7% 35.9%;
- --chart-4: 39.7 100% 39.4%;
- --chart-5: 338.2 61.1% 57.6%;
- }
+/* shadcn's neutral base palette. --primary is a neutral near-black rather than
+ a hue, so the semantic colours below carry all the meaning a page needs. */
+:root {
+ --radius: 0.625rem;
+
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.627 0.194 149.214);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.705 0.187 47.604);
+ --warning-foreground: oklch(0.145 0 0);
+ --info: oklch(0.588 0.158 241.966);
+ --info-foreground: oklch(0.985 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.696 0.17 162.48);
+ --success-foreground: oklch(0.145 0 0);
+ --warning: oklch(0.769 0.188 70.08);
+ --warning-foreground: oklch(0.145 0 0);
+ --info: oklch(0.685 0.145 241.966);
+ --info-foreground: oklch(0.145 0 0);
+ --border: oklch(1 0 0 / 12%);
+ --input: oklch(1 0 0 / 18%);
+ --ring: oklch(0.556 0 0);
+
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.985 0 0);
+ --sidebar-primary-foreground: oklch(0.205 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 12%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-info: var(--info);
+ --color-info-foreground: var(--info-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+ --font-mono: "Cascadia Code", "Fira Mono", Consolas, ui-monospace, monospace;
}
@layer base {
* {
- @apply border-border;
+ @apply border-border outline-ring/50;
+ }
+ html, body, #root {
+ height: 100%;
}
-
body {
- @apply bg-background text-foreground;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "Roboto",
- "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ @apply bg-background text-foreground antialiased;
+ font-feature-settings: "rlig" 1, "calt" 1;
}
+}
- code, pre {
- font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
+@layer utilities {
+ /* Record payloads, rule expressions and log tails all scroll; a hairline
+ scrollbar keeps those panes from being dominated by platform chrome. */
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+ }
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: var(--border);
+ border-radius: 4px;
}
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+}
+
+@keyframes dot-bounce {
+ 0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
+ 40% { transform: scale(1); opacity: 1; }
}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index da01246..09468e8 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -17,6 +17,7 @@ import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import * as Setting from "@/Setting";
+import type {BadgeVariant} from "@/components/ui/badge";
import type {Agent, AgentSession} from "@/types";
/** The monitor keys the two Codex front ends under one agent id. */
@@ -234,13 +235,13 @@ export function activityOf(activity: Record<string, AgentActivity>, agent: Agent
}
/** The badge colour every record table paints an outcome with. */
-export function getOutcomeVariant(outcome: string | undefined) {
+export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
return (
{
- attempted: "processing",
+ attempted: "info",
denied: "warning",
- failure: "error",
+ failure: "danger",
success: "success",
} as const
- )[outcome as "attempted" | "denied" | "failure" | "success"];
+ )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index d3a6292..bf772cd 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -158,12 +158,14 @@
"Certs": "Certs",
"Client IP": "Client IP",
"Client ip": "Client ip",
+ "Copied": "Copied",
"Copied to clipboard": "Copied to clipboard",
"Copy": "Copy",
"Count": "Count",
"Create time": "Create time",
"Created time": "Created time",
"CreatedTime": "CreatedTime",
+ "Dark": "Dark",
"Dashboard": "Dashboard",
"Delete": "Delete",
"Deleted successfully": "Deleted successfully",
@@ -182,16 +184,17 @@
"Home": "Home",
"Host": "Host",
"ID": "ID",
- "IP Address": "IP Address",
"Input IP Addresses": "Input IP Addresses",
+ "IP Address": "IP Address",
+ "Light": "Light",
"Logged in successfully": "Logged in successfully",
"Login Error": "Login Error",
"Method": "Method",
"Name": "Name",
"Name cannot be empty": "Name cannot be empty",
"New Record": "New Record",
- "No Upgrade": "No Upgrade",
"No data": "No data",
+ "No Upgrade": "No Upgrade",
"Nodes": "Nodes",
"OK": "OK",
"Other": "Other",
@@ -205,6 +208,8 @@
"Restore": "Restore",
"Rules": "Rules",
"Save": "Save",
+ "Search": "Search",
+ "Select": "Select",
"Self": "Self",
"Signed out successfully": "Signed out successfully",
"Signing in...": "Signing in...",
@@ -214,10 +219,12 @@
"Sure to delete {name} ?": "Sure to delete {name} ?",
"Tag": "Tag",
"This is a read-only demo site!": "This is a read-only demo site!",
+ "Toggle navigation": "Toggle navigation",
"Toggle sidebar": "Toggle sidebar",
"Top 10 IP Addresses": "Top 10 IP Addresses",
"Top 10 User-Agents": "Top 10 User-Agents",
"Total Request Count": "Total Request Count",
+ "Unauthorized": "Unauthorized",
"Unique IP Count": "Unique IP Count",
"Up": "Up",
"Update time": "Update time",
@@ -267,6 +274,7 @@
"regex match": "regex match"
},
"site": {
+ "Alert": "Alert",
"Alert interval": "Alert interval",
"Alert providers": "Alert providers",
"Alert try times": "Alert try times",
@@ -288,6 +296,7 @@
"Public IP": "Public IP",
"Rules": "Rules",
"SSL cert": "SSL cert",
+ "Site": "Site",
"Status": "Status",
"The reverse proxy is not enabled": "The reverse proxy is not enabled",
"The sites below will not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin Gateway to enable it.": "The sites below will not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin Gateway to enable it."
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 267f646..e1ccd2a 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -158,12 +158,14 @@
"Certs": "证书",
"Client IP": "客户端 IP",
"Client ip": "客户端IP",
+ "Copied": "已复制",
"Copied to clipboard": "已复制到剪贴板",
"Copy": "复制",
"Count": "计数",
"Create time": "创建时间",
"Created time": "创建时间",
"CreatedTime": "创建时间",
+ "Dark": "深色",
"Dashboard": "数据看板",
"Delete": "删除",
"Deleted successfully": "删除成功",
@@ -182,16 +184,17 @@
"Home": "首页",
"Host": "主机",
"ID": "ID",
- "IP Address": "IP地址",
"Input IP Addresses": "请输入 IP 地址",
+ "IP Address": "IP地址",
+ "Light": "浅色",
"Logged in successfully": "登录成功",
"Login Error": "登录失败",
"Method": "方法",
"Name": "名称",
"Name cannot be empty": "名称不能为空",
"New Record": "新建记录",
- "No Upgrade": "不升级",
"No data": "暂无数据",
+ "No Upgrade": "不升级",
"Nodes": "节点",
"OK": "确定",
"Other": "其他",
@@ -205,6 +208,8 @@
"Restore": "复原",
"Rules": "规则",
"Save": "保存",
+ "Search": "搜索",
+ "Select": "请选择",
"Self": "自己",
"Signed out successfully": "已退出登录",
"Signing in...": "正在登录...",
@@ -214,10 +219,12 @@
"Sure to delete {name} ?": "确定删除 {name} ?",
"Tag": "标签",
"This is a read-only demo site!": "这是一个只读演示站点!",
+ "Toggle navigation": "收起/展开导航",
"Toggle sidebar": "折叠/展开侧边栏",
"Top 10 IP Addresses": "前10名IP地址",
"Top 10 User-Agents": "前10名 User-Agent",
"Total Request Count": "总请求数",
+ "Unauthorized": "无权限",
"Unique IP Count": "独立IP统计",
"Up": "上移",
"Update time": "更新时间",
@@ -267,6 +274,7 @@
"regex match": "正则表达式匹配"
},
"site": {
+ "Alert": "告警",
"Alert interval": "告警间隔",
"Alert providers": "告警提供商",
"Alert try times": "告警重试次数",
@@ -288,6 +296,7 @@
"Public IP": "公网IP",
"Rules": "规则",
"SSL cert": "SSL证书",
+ "Site": "站点",
"Status": "状态",
"The reverse proxy is not enabled": "反向代理未启用",
"The sites below will not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin Gateway to enable it.": "下面的站点不会被代理。请在 conf/app.conf 中设置 gatewayEnabled = true 并重启 Casbin Gateway 后生效。"
diff --git a/web/src/nav.ts b/web/src/nav.ts
new file mode 100644
index 0000000..2dae3db
--- /dev/null
+++ b/web/src/nav.ts
@@ -0,0 +1,102 @@
+// 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 {
+ Bot,
+ ChartColumn,
+ FileSearch,
+ Globe,
+ LayoutDashboard,
+ ListFilter,
+ MessageSquare,
+ Plug,
+ ScrollText,
+ Server,
+ Settings,
+ ShieldCheck,
+ type LucideIcon,
+} from "lucide-react";
+
+export interface NavLeaf {
+ key: string;
+ label: string;
+ path: string;
+ icon?: LucideIcon;
+ adminOnly?: boolean;
+}
+
+export interface NavGroup {
+ key: string;
+ label: string;
+ icon?: LucideIcon;
+ path?: string;
+ adminOnly?: boolean;
+ children?: NavLeaf[];
+}
+
+/**
+ * One description of the navigation, read by both the sidebar and the
+ * breadcrumb. Keeping them on the same tree is what stops the two from
+ * disagreeing about which section a page belongs to.
+ *
+ * `label` is an i18next key, resolved at render time so a language switch does
+ * not need the tree rebuilt. The reverse-proxy pages are a full feature set, but
+ * someone running Gateway to manage the agents on their own machine never needs
+ * them, so they live in one group instead of competing with the agent pages.
+ */
+export const navGroups: NavGroup[] = [
+ {key: "/", label: "general:Dashboard", icon: LayoutDashboard, path: "/"},
+ {key: "/agents", label: "agent:Agents", icon: Bot, path: "/agents", adminOnly: true},
+ {
+ key: "/agent-sessions",
+ label: "agent:Agent Sessions",
+ icon: MessageSquare,
+ path: "/agent-sessions",
+ adminOnly: true,
+ },
+ {key: "/agent-records", label: "agent:Agent Records", icon: FileSearch, path: "/agent-records", adminOnly: true},
+ {key: "/channels", label: "channel:Channels", icon: Plug, path: "/channels"},
+ {
+ key: "/advanced",
+ label: "general:Advanced",
+ icon: Settings,
+ children: [
+ {key: "/sites", label: "general:Sites", path: "/sites", icon: Globe},
+ {key: "/certs", label: "general:Certs", path: "/certs", icon: ShieldCheck},
+ {key: "/rules", label: "general:Rules", path: "/rules", icon: ListFilter},
+ {key: "/nodes", label: "general:Nodes", path: "/nodes", icon: Server},
+ {key: "/records", label: "general:Records", path: "/records", icon: ScrollText},
+ {key: "/dashboard", label: "general:Gateway Analytics", path: "/dashboard", icon: ChartColumn},
+ ],
+ },
+];
+
+/** All leaf entries, flattened, for lookups by first path segment. */
+export const navLeaves: NavLeaf[] = navGroups.flatMap(group =>
+ group.children ? group.children : [{...group, path: group.path ?? group.key} as NavLeaf],
+);
+
+export function findLeaf(segmentKey: string) {
+ return navLeaves.find(leaf => leaf.key === segmentKey);
+}
+
+export function findGroupOf(segmentKey: string) {
+ return navGroups.find(group => group.children?.some(child => child.key === segmentKey));
+}
+
+/** The key the sidebar treats as selected: the first path segment, or "/" at home. */
+export function selectedKeyOf(pathname: string) {
+ const firstSegment = pathname.split("/").filter(Boolean)[0];
+ return firstSegment === undefined ? "/" : `/${firstSegment}`;
+}
diff --git a/web/src/pages/AccountPage.tsx b/web/src/pages/AccountPage.tsx
index 6b50ac8..2d89274 100644
--- a/web/src/pages/AccountPage.tsx
+++ b/web/src/pages/AccountPage.tsx
@@ -17,20 +17,12 @@ import i18next from "i18next";
import * as AccountBackend from "@/backend/AccountBackend";
import * as Setting from "@/Setting";
-import {FormRow, PageHeader} from "@/components/FormRow";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
+import {PasswordInput} from "@/components/shared/password-input";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {Button} from "@/components/ui/button";
-import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
import {Input} from "@/components/ui/input";
-import {Label} from "@/components/ui/label";
-import {PasswordInput} from "@/components/ui/password-input";
import type {Account} from "@/types";
// Profile and password editing for the built-in login. Casdoor-backed accounts
@@ -79,82 +71,72 @@ export default function AccountPage({account}: {account: Account}) {
avatar.startsWith("http://") || avatar.startsWith("https://") || avatar.startsWith("data:image/");
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("account:My Account")}>
- <Button onClick={save}>{i18next.t("general:Save")}</Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("account:My Account")}
+ description={account.name}
+ actions={<Button onClick={save}>{i18next.t("general:Save")}</Button>}
+ />
- <Card className="mb-4">
- <CardHeader className="border-b py-3">
- <CardTitle className="text-sm">{i18next.t("account:Profile")}</CardTitle>
- </CardHeader>
- <CardContent className="divide-y py-0">
- <FormRow label={i18next.t("general:Name")}>
- <Input value={account.name} disabled />
- </FormRow>
- <FormRow label={i18next.t("general:Display name")}>
- <Input value={displayName} onChange={event => setDisplayName(event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("account:Avatar")}>
- <div className="flex items-center gap-3">
- <Avatar className="h-16 w-16">
- {isImageUrl ? <AvatarImage src={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>
- <Input
- value={avatar}
- placeholder={i18next.t("account:Avatar image URL, optional")}
- onChange={event => setAvatar(event.target.value)}
- />
- </div>
- </FormRow>
- </CardContent>
- </Card>
+ <Section title={i18next.t("account:Profile")} columns={2}>
+ <Field label={i18next.t("general:Name")} htmlFor="account-name">
+ <Input id="account-name" value={account.name} disabled />
+ </Field>
+ <Field label={i18next.t("general:Display name")} htmlFor="account-display-name">
+ <Input
+ id="account-display-name"
+ value={displayName}
+ onChange={event => setDisplayName(event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("account:Avatar")} htmlFor="account-avatar" className="md:col-span-2">
+ <div className="flex items-center gap-3">
+ <Avatar className="size-16">
+ {isImageUrl ? <AvatarImage src={avatar} alt={account.name} /> : null}
+ <AvatarFallback style={{backgroundColor: Setting.getAvatarColor(account.name), color: "#fff"}}>
+ {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <Input
+ id="account-avatar"
+ value={avatar}
+ placeholder={i18next.t("account:Avatar image URL, optional")}
+ onChange={event => setAvatar(event.target.value)}
+ />
+ </div>
+ </Field>
+ </Section>
- <Card>
- <CardHeader className="border-b py-3">
- <CardTitle className="text-sm">{i18next.t("general:Password")}</CardTitle>
- </CardHeader>
- <CardContent className="pt-4">
+ <Section title={i18next.t("general:Password")} columns={1}>
+ <div>
<Button variant="outline" onClick={() => setPasswordOpen(true)}>
{i18next.t("account:Modify password...")}
</Button>
- </CardContent>
- </Card>
+ </div>
+ </Section>
- <Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{i18next.t("account:Modify password")}</DialogTitle>
- </DialogHeader>
- <div className="space-y-4">
- <div className="space-y-2">
- <Label>{i18next.t("account:Old Password")}</Label>
- <PasswordInput
- value={currentPassword}
- onChange={event => setCurrentPassword(event.target.value)}
- />
- </div>
- <div className="space-y-2">
- <Label>{i18next.t("account:New Password")}</Label>
- <PasswordInput
- value={newPassword}
- onChange={event => setNewPassword(event.target.value)}
- />
- </div>
- </div>
- <DialogFooter>
- <Button variant="outline" onClick={() => setPasswordOpen(false)}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button onClick={setPassword}>{i18next.t("account:Set Password")}</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </div>
+ <FormDialog
+ open={passwordOpen}
+ onOpenChange={setPasswordOpen}
+ title={i18next.t("account:Modify password")}
+ submitText={i18next.t("account:Set Password")}
+ onSubmit={setPassword}
+ >
+ <Field label={i18next.t("account:Old Password")} htmlFor="account-old-password">
+ <PasswordInput
+ id="account-old-password"
+ value={currentPassword}
+ onChange={event => setCurrentPassword(event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("account:New Password")} htmlFor="account-new-password">
+ <PasswordInput
+ id="account-new-password"
+ value={newPassword}
+ onChange={event => setNewPassword(event.target.value)}
+ />
+ </Field>
+ </FormDialog>
+ </PageContainer>
);
}
diff --git a/web/src/pages/AgentDashboardPage.tsx b/web/src/pages/AgentDashboardPage.tsx
index e5af5f5..e427dbd 100644
--- a/web/src/pages/AgentDashboardPage.tsx
+++ b/web/src/pages/AgentDashboardPage.tsx
@@ -14,23 +14,23 @@
import * as React from "react";
import {Link} from "react-router-dom";
-import {Bot, ChevronRight, CircleX, Copy, RefreshCw} from "lucide-react";
-import copy from "copy-to-clipboard";
+import {Bot, ChevronRight, FileSearch, MessageSquare, RefreshCw, ShieldCheck} from "lucide-react";
import i18next from "i18next";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
-import {PageHeader} from "@/components/FormRow";
-import {UnauthorizedResult} from "@/components/Result";
-import {StatisticCard} from "@/components/charts/ChartCards";
-import {Alert, AlertDescription} from "@/components/ui/alert";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {StatCard} from "@/components/shared/stat-card";
+import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
-import {ConfirmButton} from "@/components/ui/confirm-button";
-import {Spinner} from "@/components/ui/spinner";
import {Switch} from "@/components/ui/switch";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import {activityOf, agentDetailPath, agentKey, useAgents, useAgentSessions} from "@/lib/agents";
import type {Account, Agent} from "@/types";
@@ -38,8 +38,8 @@ import type {Account, Agent} from "@/types";
function CardRow({label, children}: {label: string; children: React.ReactNode}) {
return (
<div className="flex items-baseline gap-2">
- <span className="shrink-0 text-xs text-muted-foreground">{label}</span>
- <span className="min-w-0 flex-1 text-right">{children}</span>
+ <span className="text-muted-foreground shrink-0 text-xs">{label}</span>
+ <span className="min-w-0 flex-1 truncate text-right">{children}</span>
</div>
);
}
@@ -70,67 +70,52 @@ function AgentCard({
);
return (
- <Card className="flex flex-col transition-colors hover:border-primary/50">
- <CardHeader className="flex flex-row items-start gap-3 space-y-0 p-4">
+ <Card className="hover:border-ring/50 flex flex-col gap-3 py-4 transition-colors">
+ <CardHeader className="flex flex-row items-start gap-3 px-4">
<AgentIcon
agent={agent.agentId || agent.name}
size={40}
- fallback={<Bot className="h-10 w-10 text-muted-foreground" />}
+ fallback={<Bot className="text-muted-foreground size-10" />}
/>
<div className="min-w-0 flex-1">
- <Link
- to={agentDetailPath(agent)}
- className="block truncate font-semibold hover:text-primary hover:underline"
- >
+ <Link to={agentDetailPath(agent)} className="hover:text-primary block truncate font-semibold hover:underline">
{agent.name}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
- <Badge variant="secondary">{agent.version || i18next.t("agent:Unknown")}</Badge>
- {agent.installMethod ? <Badge variant="secondary">{agent.installMethod}</Badge> : null}
+ <Badge variant="muted">{agent.version || i18next.t("agent:Unknown")}</Badge>
+ {agent.installMethod ? <Badge variant="muted">{agent.installMethod}</Badge> : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
- {busy ? <Spinner /> : null}
+ {busy ? <Loading type="small" className="py-0" /> : null}
{agent.supported ? (
// The dialog trigger clones its child to attach the click, so the
// switch has to be that child - a wrapper that is not a DOM element
// would drop the handler and the switch would do nothing.
- <ConfirmButton
+ <ConfirmDialog
title={`${action} ${agent.name}?`}
description={note || undefined}
- okText={action}
- destructive={agent.patched}
+ confirmText={action}
+ variant={agent.patched ? "destructive" : "default"}
onConfirm={onToggle}
>
{toggle}
- </ConfirmButton>
+ </ConfirmDialog>
) : (
- <Tooltip title={agent.detail || i18next.t("agent:Not supported")}>
+ <SimpleTooltip title={agent.detail || i18next.t("agent:Not supported")}>
<span>{toggle}</span>
- </Tooltip>
+ </SimpleTooltip>
)}
</div>
</CardHeader>
- <CardContent className="flex flex-1 flex-col gap-2 p-4 pt-0 text-sm">
+ <CardContent className="flex flex-1 flex-col gap-2 px-4 text-sm">
<CardRow label={i18next.t("general:Path")}>
- <span className="flex items-center justify-end gap-1">
- <Tooltip title={agent.path}>
- <code className="truncate text-xs">{agent.path}</code>
- </Tooltip>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 shrink-0"
- aria-label={i18next.t("agent:Copy path")}
- onClick={() => {
- copy(agent.path);
- Setting.showMessage("success", i18next.t("agent:Path copied to clipboard"));
- }}
- >
- <Copy className="h-3.5 w-3.5" />
- </Button>
- </span>
+ <SimpleTooltip title={agent.path}>
+ <span className="inline-flex max-w-full">
+ <CodeText copyable>{agent.path}</CodeText>
+ </span>
+ </SimpleTooltip>
</CardRow>
<CardRow label={i18next.t("general:Owner")}>
<span className="truncate text-xs">{agent.owner}</span>
@@ -139,8 +124,8 @@ function AgentCard({
<div className="mt-auto flex items-center justify-between border-t pt-3">
{/* The patcher's own wording is the most precise thing there is about
a given state, so it hangs off the status line as its tooltip. */}
- <Tooltip title={agent.detail}>
- <span className="min-w-0 truncate text-xs text-muted-foreground">
+ <SimpleTooltip title={agent.detail}>
+ <span className="text-muted-foreground min-w-0 truncate text-xs">
{!agent.supported
? i18next.t("agent:Not supported")
: !agent.patched
@@ -153,13 +138,13 @@ function AgentCard({
).toLocaleString()}`
: i18next.t("agent:Monitoring, no activity yet")}
</span>
- </Tooltip>
+ </SimpleTooltip>
<Link
to={agentDetailPath(agent)}
- className="inline-flex shrink-0 items-center text-xs text-primary hover:underline"
+ className="text-primary inline-flex shrink-0 items-center text-xs hover:underline"
>
{i18next.t("agent:Details")}
- <ChevronRight className="h-3.5 w-3.5" />
+ <ChevronRight className="size-3.5" />
</Link>
</div>
</CardContent>
@@ -177,51 +162,45 @@ export default function AgentDashboardPage({account}: {account: Account}) {
}
const patchedCount = agents.filter(agent => agent.patched).length;
- const sessionCount = Object.values(activity).reduce(
- (total, entry) => total + entry.sessionCount,
- 0,
- );
+ const sessionCount = Object.values(activity).reduce((total, entry) => total + entry.sessionCount, 0);
return (
- <div className="space-y-4 p-4 md:p-6">
- <PageHeader title={i18next.t("agent:Agents on this machine")}>
- <Button variant="outline" onClick={() => scan(true)} disabled={loading}>
- <RefreshCw className={loading ? "animate-spin" : undefined} />
- {i18next.t("agent:Scan")}
- </Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("agent:Agents on this machine")}
+ description={account.hostname}
+ actions={
+ <Button variant="outline" onClick={() => scan(true)} loading={loading}>
+ <RefreshCw />
+ {i18next.t("agent:Scan")}
+ </Button>
+ }
+ />
- {error && (
- <Alert variant="destructive">
- <CircleX />
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- )}
+ {error ? <MessageAlert title={error} /> : null}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
- <StatisticCard title={i18next.t("agent:Installed")} value={agents.length} />
- <StatisticCard title={i18next.t("agent:Monitored")} value={patchedCount} />
- <StatisticCard title={i18next.t("agent:Agent Sessions")} value={sessionCount} />
- <StatisticCard title={i18next.t("agent:Records")} value={recordCount} />
+ <StatCard label={i18next.t("agent:Installed")} value={agents.length} icon={Bot} />
+ <StatCard label={i18next.t("agent:Monitored")} value={patchedCount} icon={ShieldCheck} tone="success" />
+ <StatCard label={i18next.t("agent:Agent Sessions")} value={sessionCount} icon={MessageSquare} />
+ <StatCard label={i18next.t("agent:Records")} value={recordCount} icon={FileSearch} />
</div>
{!scanned ? (
- <div className="flex justify-center p-10">
- <Spinner />
- </div>
+ <Loading tip={i18next.t("agent:Scan")} />
) : agents.length === 0 ? (
- <Card>
- <CardContent className="flex flex-col items-center gap-3 p-10 text-center">
- <Bot className="h-12 w-12 text-muted-foreground" />
- <div className="font-medium">{i18next.t("agent:No supported agents found")}</div>
- <div className="max-w-md text-sm text-muted-foreground">
- {i18next.t("agent:Install an AI agent on this machine, then scan again")}
- </div>
- <Button variant="outline" onClick={() => scan(true)} disabled={loading}>
- <RefreshCw className={loading ? "animate-spin" : undefined} />
- {i18next.t("agent:Scan")}
- </Button>
- </CardContent>
+ <Card className="py-0">
+ <EmptyState
+ icon={Bot}
+ title={i18next.t("agent:No supported agents found")}
+ description={i18next.t("agent:Install an AI agent on this machine, then scan again")}
+ action={
+ <Button variant="outline" onClick={() => scan(true)} loading={loading}>
+ <RefreshCw />
+ {i18next.t("agent:Scan")}
+ </Button>
+ }
+ />
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@@ -236,6 +215,6 @@ export default function AgentDashboardPage({account}: {account: Account}) {
))}
</div>
)}
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx
index 4b850f1..870a424 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -22,14 +22,14 @@ import * as AgentBackend from "@/backend/AgentBackend";
import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {EnvSnippet} from "@/components/EnvSnippet";
-import {Result, UnauthorizedResult} from "@/components/Result";
+import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
-import {ConfirmButton} from "@/components/ui/confirm-button";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {
Select,
SelectContent,
@@ -37,8 +37,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import {Spinner} from "@/components/ui/spinner";
-import {Tooltip} from "@/components/ui/tooltip";
+import {AiDots} from "@/components/shared/loading";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import {
agentKey,
@@ -102,18 +102,18 @@ function MonitoringCard({
{note ? <p className="text-sm text-muted-foreground">{note}</p> : null}
{agent.supported ? (
- <ConfirmButton
+ <ConfirmDialog
title={`${action} ${agent.name}?`}
description={note || undefined}
- okText={action}
- destructive={agent.patched}
+ confirmText={action}
+ variant={agent.patched ? "destructive" : "default"}
onConfirm={onToggle}
>
<Button variant={agent.patched ? "outline" : "default"} disabled={busy}>
- {busy ? <Spinner /> : null}
+ {busy ? <AiDots size="small" /> : null}
{action}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
) : (
<Button variant="outline" disabled>
{i18next.t("agent:Patch")}
@@ -258,14 +258,14 @@ export default function AgentDetailPage({account}: {account: Account}) {
if (!scanned) {
return (
<div className="flex justify-center p-10">
- <Spinner />
+ <AiDots size="small" />
</div>
);
}
if (!agent) {
return (
- <Result
+ <ResultScreen
status="404"
title={i18next.t("agent:Agent installation not found")}
subTitle={error || path || agentId}
@@ -413,7 +413,7 @@ export default function AgentDetailPage({account}: {account: Account}) {
<InfoRow label={i18next.t("general:Path")}>
<span className="flex items-start gap-1">
<code className="min-w-0 flex-1 text-xs">{agent.path}</code>
- <Tooltip title={i18next.t("agent:Copy path")}>
+ <SimpleTooltip title={i18next.t("agent:Copy path")}>
<Button
variant="ghost"
size="icon"
@@ -426,7 +426,7 @@ export default function AgentDetailPage({account}: {account: Account}) {
>
<Copy className="h-3.5 w-3.5" />
</Button>
- </Tooltip>
+ </SimpleTooltip>
</span>
</InfoRow>
</CardContent>
@@ -483,7 +483,7 @@ export default function AgentDetailPage({account}: {account: Account}) {
{tab === "Agent Sessions" ? (
<DataTable
columns={sessionColumns}
- data={sessions}
+ dataSource={sessions}
rowKey={session => session.sessionKey}
pageSize={0}
emptyText={i18next.t("agent:Monitoring, no activity yet")}
@@ -491,7 +491,7 @@ export default function AgentDetailPage({account}: {account: Account}) {
) : (
<DataTable
columns={recordColumns}
- data={records}
+ dataSource={records}
rowKey={record => record.id}
pageSize={0}
emptyText={i18next.t("agent:Monitoring, no activity yet")}
diff --git a/web/src/pages/AgentRecordsPage.tsx b/web/src/pages/AgentRecordsPage.tsx
index 5e7f367..96d6b6a 100644
--- a/web/src/pages/AgentRecordsPage.tsx
+++ b/web/src/pages/AgentRecordsPage.tsx
@@ -14,33 +14,25 @@
import * as React from "react";
import {Link, useNavigate, useSearchParams} from "react-router-dom";
-import {Bot, CircleX, Info, RefreshCw} from "lucide-react";
+import {Bot, FileSearch, RefreshCw} from "lucide-react";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
-import {DataTable, type Column} from "@/components/DataTable";
-import {PageHeader} from "@/components/FormRow";
-import {UnauthorizedResult} from "@/components/Result";
-import {Alert, AlertDescription} from "@/components/ui/alert";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeBlock, CodeText, DescriptionList, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {getOutcomeVariant, monitorAgentId} from "@/lib/agents";
+import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import {Label} from "@/components/ui/label";
import {Switch} from "@/components/ui/switch";
import type {Account, AgentRecord} from "@/types";
-function monitorAgentId(agentId: string) {
- return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
-}
-
// The server keeps a bounded in-memory window; these are the slices of it the
// page can ask for. Without a control here the UI could only ever reach the
// first 200 of the records the backend was holding.
@@ -62,92 +54,40 @@ function formatPayload(object: unknown) {
}
}
-function getOutcomeVariant(outcome: string | undefined) {
- return (
- {
- attempted: "processing",
- denied: "warning",
- failure: "error",
- success: "success",
- } as const
- )[outcome as "attempted" | "denied" | "failure" | "success"];
-}
-
-function DetailField({label, children}: {label: string; children: React.ReactNode}) {
- return (
- <div className="grid grid-cols-[160px_minmax(0,1fr)] gap-2 border-b py-1.5 last:border-0">
- <div className="text-xs font-medium text-muted-foreground">{label}</div>
- <div className="min-w-0 break-words text-sm">{children}</div>
- </div>
- );
-}
-
function RecordDetail({record}: {record: AgentRecord}) {
- const mcpTarget =
- record.mcpServer && `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : ""}`;
+ const mcpTarget = record.mcpServer && `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : ""}`;
return (
- <div className="px-4 py-2">
- <DetailField label={i18next.t("general:ID")}>{record.id}</DetailField>
- <DetailField label={i18next.t("agent:Time")}>
- {new Date(record.createdTime).toLocaleString()}
- </DetailField>
- {record.agentPath && (
- <DetailField label={i18next.t("agent:Agent path")}>
- <code className="text-xs">{record.agentPath}</code>
- </DetailField>
- )}
- {record.user && <DetailField label={i18next.t("agent:User")}>{record.user}</DetailField>}
- {record.sessionKey && (
- <DetailField label={i18next.t("agent:Session")}>
- <code className="text-xs">{record.sessionKey}</code>
- </DetailField>
- )}
- {record.title && (
- <DetailField label={i18next.t("agent:Session title")}>{record.title}</DetailField>
- )}
- {record.promptId && (
- <DetailField label={i18next.t("agent:Prompt ID")}>
- <code className="text-xs">{record.promptId}</code>
- </DetailField>
- )}
- {record.toolUseId && (
- <DetailField label={i18next.t("agent:Tool use ID")}>
- <code className="text-xs">{record.toolUseId}</code>
- </DetailField>
- )}
- {record.toolName && (
- <DetailField label={i18next.t("agent:Tool")}>
- <code className="text-xs">{record.toolName}</code>
- </DetailField>
- )}
- {mcpTarget && (
- <DetailField label={i18next.t("agent:MCP target")}>
- <code className="text-xs">{mcpTarget}</code>
- </DetailField>
- )}
- {record.model && (
- <DetailField label={i18next.t("agent:Model")}>
- <code className="text-xs">{record.model}</code>
- </DetailField>
- )}
- {record.durationMs !== undefined && (
- <DetailField label={i18next.t("agent:Duration")}>
- {record.durationMs.toLocaleString()} ms
- </DetailField>
- )}
- {record.clientIp && (
- <DetailField label={i18next.t("agent:Reported from")}>
- <code className="text-xs">{record.clientIp}</code>
- </DetailField>
- )}
- {record.detail && <DetailField label={i18next.t("agent:Detail")}>{record.detail}</DetailField>}
+ <div className="flex flex-col gap-3 px-4 py-3">
+ <DescriptionList
+ columns={3}
+ items={[
+ {label: i18next.t("general:ID"), value: record.id},
+ {label: i18next.t("agent:Time"), value: new Date(record.createdTime).toLocaleString()},
+ record.agentPath && {label: i18next.t("agent:Agent path"), value: <CodeText>{record.agentPath}</CodeText>},
+ record.user && {label: i18next.t("agent:User"), value: record.user},
+ record.sessionKey && {label: i18next.t("agent:Session"), value: <CodeText>{record.sessionKey}</CodeText>},
+ record.title && {label: i18next.t("agent:Session title"), value: record.title},
+ record.promptId && {label: i18next.t("agent:Prompt ID"), value: <CodeText>{record.promptId}</CodeText>},
+ record.toolUseId && {label: i18next.t("agent:Tool use ID"), value: <CodeText>{record.toolUseId}</CodeText>},
+ record.toolName && {label: i18next.t("agent:Tool"), value: <CodeText>{record.toolName}</CodeText>},
+ mcpTarget && {label: i18next.t("agent:MCP target"), value: <CodeText>{mcpTarget}</CodeText>},
+ record.model && {label: i18next.t("agent:Model"), value: <CodeText>{record.model}</CodeText>},
+ record.durationMs !== undefined && {
+ label: i18next.t("agent:Duration"),
+ value: `${record.durationMs.toLocaleString()} ms`,
+ },
+ record.clientIp && {label: i18next.t("agent:Reported from"), value: <CodeText>{record.clientIp}</CodeText>},
+ record.detail && {label: i18next.t("agent:Detail"), value: record.detail},
+ ]}
+ />
{record.object ? (
- <DetailField label={i18next.t("agent:Payload")}>
- <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-2 font-mono text-xs">
+ <div className="grid gap-1">
+ <span className="text-muted-foreground text-xs">{i18next.t("agent:Payload")}</span>
+ <CodeBlock copyable maxHeight="20rem">
{formatPayload(record.object)}
- </pre>
- </DetailField>
+ </CodeBlock>
+ </div>
) : null}
</div>
);
@@ -262,8 +202,8 @@ export default function AgentRecordsPage({account}: {account: Account}) {
dataIndex: "agent",
width: "160px",
render: (value: string) => (
- <Badge variant="blue">
- <AgentIcon agent={value} fallback={<Bot className="h-4 w-4" />} size={16} />
+ <Badge variant="info">
+ <AgentIcon agent={value} fallback={<Bot className="size-3" />} size={12} />
{value}
</Badge>
),
@@ -274,11 +214,9 @@ export default function AgentRecordsPage({account}: {account: Account}) {
width: "200px",
render: (_value, record) => (
<div className="flex flex-wrap items-center gap-1">
- <Badge variant="secondary">{record.eventType}</Badge>
- {record.action && <code className="text-xs">{record.action}</code>}
- {record.outcome && (
- <Badge variant={getOutcomeVariant(record.outcome)}>{record.outcome}</Badge>
- )}
+ <Badge variant="muted">{record.eventType}</Badge>
+ {record.action && <CodeText>{record.action}</CodeText>}
+ {record.outcome && <Badge variant={getOutcomeVariant(record.outcome)}>{record.outcome}</Badge>}
</div>
),
},
@@ -291,9 +229,9 @@ export default function AgentRecordsPage({account}: {account: Account}) {
? `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : ""}`
: record.toolName;
return (
- <div className="flex flex-col">
- {target && <code className="truncate text-xs">{target}</code>}
- {record.model && <span className="truncate text-xs text-muted-foreground">{record.model}</span>}
+ <div className="flex min-w-0 flex-col gap-0.5">
+ {target && <CodeText>{target}</CodeText>}
+ {record.model && <span className="text-muted-foreground truncate text-xs">{record.model}</span>}
</div>
);
},
@@ -309,7 +247,7 @@ export default function AgentRecordsPage({account}: {account: Account}) {
{record.title && <span className="truncate font-medium">{record.title}</span>}
<Link
to={`/agent-records?agent=${encodeURIComponent(record.agent)}&session=${encodeURIComponent(value)}`}
- className="truncate text-xs text-primary hover:underline"
+ className="text-primary truncate text-xs hover:underline"
>
{value}
</Link>
@@ -327,87 +265,66 @@ export default function AgentRecordsPage({account}: {account: Account}) {
];
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("agent:Agent Records")}>
- <label className="flex items-center gap-2 text-sm">
- <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
- {i18next.t("agent:Auto refresh")}
- </label>
- <Button variant="outline" onClick={() => load(true)} disabled={loading}>
- <RefreshCw className={loading ? "animate-spin" : undefined} />
- {i18next.t("general:Refresh")}
- </Button>
- </PageHeader>
-
- <Alert variant="info" className="mb-4">
- <Info />
- <AlertDescription>
- {i18next.t("agent:Records are kept in memory only and are lost when Gateway restarts")}
- </AlertDescription>
- </Alert>
-
- <div className="mb-4 flex flex-wrap gap-2">
- <Select value={agent || ALL} onValueChange={value => setFilter("agent", value)}>
- <SelectTrigger className="w-[190px]">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ALL}>{i18next.t("agent:All agents")}</SelectItem>
- {agentOptions.map(value => (
- <SelectItem key={value} value={value}>
- {value}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
-
- <Select value={eventType || ALL} onValueChange={value => setFilter("eventType", value)}>
- <SelectTrigger className="w-[180px]">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ALL}>{i18next.t("agent:All event types")}</SelectItem>
- {["session", "prompt", "llm", "tool", "mcp", "permission", "subagent", "compact"].map(
- value => (
- <SelectItem key={value} value={value}>
- {value}
- </SelectItem>
- ),
- )}
- </SelectContent>
- </Select>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("agent:Agent Records")}
+ description={i18next.t("agent:Records are kept in memory only and are lost when Gateway restarts")}
+ actions={
+ <>
+ <Label className="text-sm font-normal">
+ <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
+ {i18next.t("agent:Auto refresh")}
+ </Label>
+ <Button variant="outline" onClick={() => load(true)} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ </>
+ }
+ />
- <Select value={outcome || ALL} onValueChange={value => setFilter("outcome", value)}>
- <SelectTrigger className="w-[170px]">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ALL}>{i18next.t("agent:All outcomes")}</SelectItem>
- {["attempted", "success", "failure", "denied"].map(value => (
- <SelectItem key={value} value={value}>
- {value}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ {error ? <MessageAlert title={error} /> : null}
- <Select
+ <div className="flex flex-wrap items-center gap-2">
+ <SimpleSelect
+ className="w-[190px]"
+ value={agent || ALL}
+ onChange={value => setFilter("agent", value)}
+ options={[
+ {label: i18next.t("agent:All agents"), value: ALL},
+ ...agentOptions.map(value => ({label: value, value})),
+ ]}
+ />
+ <SimpleSelect
+ className="w-[180px]"
+ value={eventType || ALL}
+ onChange={value => setFilter("eventType", value)}
+ options={[
+ {label: i18next.t("agent:All event types"), value: ALL},
+ ...["session", "prompt", "llm", "tool", "mcp", "permission", "subagent", "compact"].map(value => ({
+ label: value,
+ value,
+ })),
+ ]}
+ />
+ <SimpleSelect
+ className="w-[170px]"
+ value={outcome || ALL}
+ onChange={value => setFilter("outcome", value)}
+ options={[
+ {label: i18next.t("agent:All outcomes"), value: ALL},
+ ...["attempted", "success", "failure", "denied"].map(value => ({label: value, value})),
+ ]}
+ />
+ <SimpleSelect
+ className="w-[190px]"
value={String(limit)}
- onValueChange={value =>
- setFilter("limit", Number(value) === defaultLimit ? "" : value)
- }
- >
- <SelectTrigger className="w-[190px]">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {limitOptions.map(value => (
- <SelectItem key={value} value={String(value)}>
- {i18next.t("agent:Last {count} records").replace("{count}", String(value))}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ onChange={value => setFilter("limit", Number(value) === defaultLimit ? "" : value)}
+ options={limitOptions.map(value => ({
+ label: i18next.t("agent:Last {count} records").replace("{count}", String(value)),
+ value: String(value),
+ }))}
+ />
<form
className="flex gap-2"
@@ -428,22 +345,19 @@ export default function AgentRecordsPage({account}: {account: Account}) {
</form>
</div>
- {error && (
- <Alert variant="destructive" className="mb-4">
- <CircleX />
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- )}
-
<DataTable
+ title={i18next.t("agent:Agent Records")}
+ description={`${records.length} ${i18next.t("agent:Records")}`}
columns={columns}
- data={records}
- rowKey={record => record.id}
+ dataSource={records}
+ rowKey={record => String(record.id)}
loading={loading}
pageSize={20}
+ searchable
+ emptyIcon={FileSearch}
emptyText={i18next.t("agent:No agent records yet - patch an agent to start collecting them")}
- expandedRowRender={record => <RecordDetail record={record} />}
+ expandable={{expandedRowRender: record => <RecordDetail record={record} />}}
/>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/AgentSessionsPage.tsx b/web/src/pages/AgentSessionsPage.tsx
index 82ccc84..5edbe56 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -14,16 +14,16 @@
import * as React from "react";
import {Link} from "react-router-dom";
-import {Bot, CircleX, RefreshCw} from "lucide-react";
+import {Bot, MessageSquare, RefreshCw} from "lucide-react";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
-import {DataTable, type Column} from "@/components/DataTable";
-import {PageHeader} from "@/components/FormRow";
-import {UnauthorizedResult} from "@/components/Result";
-import {Alert, AlertDescription} from "@/components/ui/alert";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import type {Account, AgentSession} from "@/types";
@@ -75,11 +75,11 @@ export default function AgentSessionsPage({account}: {account: Account}) {
<div className="flex min-w-0 flex-col">
<Link
to={`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`}
- className="truncate text-primary hover:underline"
+ className="text-primary truncate font-medium hover:underline"
>
{session.title || session.sessionKey}
</Link>
- <span className="truncate text-xs text-muted-foreground">{session.sessionKey}</span>
+ <span className="text-muted-foreground truncate text-xs">{session.sessionKey}</span>
</div>
),
},
@@ -89,8 +89,8 @@ export default function AgentSessionsPage({account}: {account: Account}) {
dataIndex: "agent",
width: "180px",
render: (value: string) => (
- <Badge variant="blue">
- <AgentIcon agent={value} fallback={<Bot className="h-4 w-4" />} size={16} />
+ <Badge variant="info">
+ <AgentIcon agent={value} fallback={<Bot className="size-3" />} size={12} />
{value}
</Badge>
),
@@ -118,29 +118,29 @@ export default function AgentSessionsPage({account}: {account: Account}) {
];
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("agent:Agent Sessions")}>
- <Button variant="outline" onClick={load} disabled={loading}>
- <RefreshCw className={loading ? "animate-spin" : undefined} />
- {i18next.t("general:Refresh")}
- </Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader title={i18next.t("agent:Agent Sessions")} />
- {error && (
- <Alert variant="destructive" className="mb-4">
- <CircleX />
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- )}
+ {error ? <MessageAlert title={error} /> : null}
<DataTable
+ title={i18next.t("agent:Agent Sessions")}
+ description={`${sessions.length} ${i18next.t("agent:Agent Sessions")}`}
columns={columns}
- data={sessions}
+ dataSource={sessions}
rowKey={session => `${session.agent}:${session.sessionKey}`}
loading={loading}
pageSize={20}
+ searchable
+ emptyIcon={MessageSquare}
emptyText={i18next.t("agent:No agent sessions yet - patch an agent to start collecting them")}
+ toolbar={
+ <Button variant="outline" size="sm" onClick={load} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ }
/>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 13964fe..a2e378d 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -13,20 +13,19 @@
// limitations under the License.
import {Link} from "react-router-dom";
-import {Bot, CircleX, RefreshCw} from "lucide-react";
+import {Bot, RefreshCw} from "lucide-react";
import i18next from "i18next";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
-import {DataTable, type Column} from "@/components/DataTable";
-import {PageHeader} from "@/components/FormRow";
-import {UnauthorizedResult} from "@/components/Result";
-import {Alert, AlertDescription} from "@/components/ui/alert";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
-import {ConfirmButton} from "@/components/ui/confirm-button";
-import {Spinner} from "@/components/ui/spinner";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import {agentDetailPath, agentKey, monitorAgentId, useAgents} from "@/lib/agents";
import type {Account, Agent} from "@/types";
@@ -44,12 +43,9 @@ export default function AgentsPage({account}: {account: Account}) {
key: "name",
dataIndex: "name",
render: (value: string, record) => (
- <Link to={agentDetailPath(record)} className="flex items-center gap-2 hover:text-primary">
- <AgentIcon
- agent={record.agentId || value}
- fallback={<Bot className="h-[18px] w-[18px] text-muted-foreground" />}
- />
- <span className="hover:underline">{value}</span>
+ <Link to={agentDetailPath(record)} className="hover:text-primary flex items-center gap-2">
+ <AgentIcon agent={record.agentId || value} fallback={<Bot className="text-muted-foreground size-4" />} />
+ <span className="font-medium hover:underline">{value}</span>
</Link>
),
},
@@ -63,7 +59,7 @@ export default function AgentsPage({account}: {account: Account}) {
title: i18next.t("agent:Install Method"),
key: "installMethod",
dataIndex: "installMethod",
- render: (value: string) => <Badge variant="secondary">{value || "-"}</Badge>,
+ render: (value: string) => <Badge variant="muted">{value || "-"}</Badge>,
},
{
title: i18next.t("general:Owner"),
@@ -74,7 +70,8 @@ export default function AgentsPage({account}: {account: Account}) {
title: i18next.t("general:Path"),
key: "path",
dataIndex: "path",
- render: (value: string) => <code className="text-xs">{value}</code>,
+ ellipsis: true,
+ render: (value: string) => <CodeText copyable>{value}</CodeText>,
},
{
title: i18next.t("agent:Channel"),
@@ -94,16 +91,16 @@ export default function AgentsPage({account}: {account: Account}) {
key: "patched",
render: (_value, record) => {
const badge = !record.supported ? (
- <Badge variant="secondary">{i18next.t("agent:Not supported")}</Badge>
+ <Badge variant="muted">{i18next.t("agent:Not supported")}</Badge>
) : record.patched ? (
<Badge variant="success">{i18next.t("agent:Patched")}</Badge>
) : (
- <Badge variant="secondary">{i18next.t("agent:Not patched")}</Badge>
+ <Badge variant="muted">{i18next.t("agent:Not patched")}</Badge>
);
return (
- <Tooltip title={record.detail}>
+ <SimpleTooltip title={record.detail}>
<span>{badge}</span>
- </Tooltip>
+ </SimpleTooltip>
);
},
},
@@ -135,51 +132,50 @@ export default function AgentsPage({account}: {account: Account}) {
const action = i18next.t(`agent:${record.patched ? "Unpatch" : "Patch"}`);
const note = [record.notice, record.followup].filter(Boolean).join(" ");
return (
- <ConfirmButton
+ <ConfirmDialog
title={`${action} ${record.name}?`}
description={note || undefined}
- okText={action}
- destructive={record.patched}
+ confirmText={action}
+ variant={record.patched ? "destructive" : "default"}
onConfirm={() => togglePatch(record)}
>
<Button
size="sm"
variant={record.patched ? "outline" : "default"}
- disabled={busyKey === agentKey(record)}
+ loading={busyKey === agentKey(record)}
>
- {busyKey === agentKey(record) ? <Spinner /> : null}
{action}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
);
},
},
];
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("agent:Agents")}>
- <Button variant="outline" onClick={() => scan(true)} disabled={loading}>
- <RefreshCw className={loading ? "animate-spin" : undefined} />
- {i18next.t("agent:Scan")}
- </Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader title={i18next.t("agent:Agents")} description={account.hostname} />
- {error && (
- <Alert variant="destructive" className="mb-4">
- <CircleX />
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- )}
+ {error ? <MessageAlert title={error} /> : null}
<DataTable
+ title={i18next.t("agent:Agents")}
+ description={`${agents.length} ${i18next.t("agent:Agents")}`}
columns={columns}
- data={agents}
+ dataSource={agents}
rowKey={agentKey}
loading={loading}
pageSize={0}
+ searchable
+ emptyIcon={Bot}
emptyText={i18next.t("agent:No supported agents found")}
+ toolbar={
+ <Button variant="outline" size="sm" onClick={() => scan(true)} loading={loading}>
+ <RefreshCw />
+ {i18next.t("agent:Scan")}
+ </Button>
+ }
/>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx
index c895def..65788f8 100644
--- a/web/src/pages/AuthCallback.tsx
+++ b/web/src/pages/AuthCallback.tsx
@@ -16,8 +16,8 @@ import * as React from "react";
import i18next from "i18next";
import * as Setting from "@/Setting";
-import {Result} from "@/components/Result";
-import {PageSpinner} from "@/components/ui/spinner";
+import {Loading} from "@/components/shared/loading";
+import {ResultScreen} from "@/components/shared/misc";
/** Where Casdoor sends the browser back after a successful SSO login. */
export default function AuthCallback() {
@@ -35,8 +35,16 @@ export default function AuthCallback() {
}, []);
if (message === null) {
- return <PageSpinner tip={i18next.t("general:Signing in...")} />;
+ return (
+ <div className="bg-muted/30 flex min-h-screen items-center justify-center">
+ <Loading type="page" tip={i18next.t("general:Signing in...")} />
+ </div>
+ );
}
- return <Result status="error" title={i18next.t("general:Login Error")} subTitle={message} />;
+ return (
+ <div className="bg-muted/30 flex min-h-screen items-center justify-center">
+ <ResultScreen status="!" title={i18next.t("general:Login Error")} subTitle={message} />
+ </div>
+ );
}
diff --git a/web/src/pages/CertEditPage.tsx b/web/src/pages/CertEditPage.tsx
index 4c02512..a520907 100644
--- a/web/src/pages/CertEditPage.tsx
+++ b/web/src/pages/CertEditPage.tsx
@@ -20,18 +20,12 @@ import i18next from "i18next";
import * as CertBackend from "@/backend/CertBackend";
import * as Setting from "@/Setting";
-import {FormRow, PageHeader} from "@/components/FormRow";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {PageSpinner} from "@/components/ui/spinner";
import {Textarea} from "@/components/ui/textarea";
import type {Cert} from "@/types";
@@ -72,7 +66,7 @@ export default function CertEditPage() {
};
if (cert === null) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
const download = (text: string, filename: string) => {
@@ -80,143 +74,141 @@ export default function CertEditPage() {
};
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("cert:Edit Cert")}>
- <Button variant="outline" onClick={() => navigate("/certs")}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button onClick={save}>{i18next.t("general:Save")}</Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("cert:Edit Cert")}
+ description={`${cert.owner} / ${cert.name}`}
+ actions={
+ <>
+ <Button variant="outline" onClick={() => navigate("/certs")}>
+ {i18next.t("general:Cancel")}
+ </Button>
+ <Button onClick={save}>{i18next.t("general:Save")}</Button>
+ </>
+ }
+ />
- <Card>
- <CardContent className="divide-y py-0">
- <FormRow label={i18next.t("general:Name")}>
- <Input value={cert.name} onChange={event => updateField("name", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("cert:Type")}>
- <Select value={cert.type} onValueChange={value => updateField("type", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="SSL">SSL</SelectItem>
- </SelectContent>
- </Select>
- </FormRow>
- <FormRow label={i18next.t("cert:Crypto algorithm")}>
- <Select
- value={cert.cryptoAlgorithm}
- onValueChange={value => updateField("cryptoAlgorithm", value)}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="RSA">RSA</SelectItem>
- <SelectItem value="ECC">ECC</SelectItem>
- </SelectContent>
- </Select>
- </FormRow>
- <FormRow label={i18next.t("cert:Expire time")}>
- <Input value={Setting.getFormattedDate(cert.expireTime) ?? ""} disabled />
- </FormRow>
- <FormRow label={i18next.t("cert:Domain expire")}>
- <Input value={Setting.getFormattedDate(cert.domainExpireTime) ?? ""} disabled />
- </FormRow>
- <FormRow label={i18next.t("cert:Provider")}>
- <Select value={cert.provider} onValueChange={value => updateField("provider", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="GoDaddy">GoDaddy</SelectItem>
- <SelectItem value="Aliyun">Aliyun</SelectItem>
- </SelectContent>
- </Select>
- </FormRow>
- <FormRow label={i18next.t("cert:Account")}>
- <Input value={cert.account} onChange={event => updateField("account", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("cert:Access key")}>
- <Input
- value={cert.accessKey}
- onChange={event => updateField("accessKey", event.target.value)}
- />
- </FormRow>
- <FormRow label={i18next.t("cert:Access secret")}>
- <Input
- value={cert.accessSecret}
- onChange={event => updateField("accessSecret", event.target.value)}
- />
- </FormRow>
- </CardContent>
- </Card>
+ <Section title={i18next.t("cert:Cert")}>
+ <Field label={i18next.t("general:Name")} htmlFor="cert-name">
+ <Input id="cert-name" value={cert.name} onChange={event => updateField("name", event.target.value)} />
+ </Field>
+ <Field label={i18next.t("cert:Type")}>
+ <SimpleSelect
+ value={cert.type}
+ onChange={value => updateField("type", value)}
+ options={[{label: "SSL", value: "SSL"}]}
+ />
+ </Field>
+ <Field label={i18next.t("cert:Crypto algorithm")}>
+ <SimpleSelect
+ value={cert.cryptoAlgorithm}
+ onChange={value => updateField("cryptoAlgorithm", value)}
+ options={[
+ {label: "RSA", value: "RSA"},
+ {label: "ECC", value: "ECC"},
+ ]}
+ />
+ </Field>
+ <Field label={i18next.t("cert:Expire time")}>
+ <Input value={Setting.getFormattedDate(cert.expireTime) ?? ""} disabled />
+ </Field>
+ <Field label={i18next.t("cert:Domain expire")}>
+ <Input value={Setting.getFormattedDate(cert.domainExpireTime) ?? ""} disabled />
+ </Field>
+ <Field label={i18next.t("cert:Provider")}>
+ <SimpleSelect
+ value={cert.provider}
+ onChange={value => updateField("provider", value)}
+ options={[
+ {label: "GoDaddy", value: "GoDaddy"},
+ {label: "Aliyun", value: "Aliyun"},
+ ]}
+ />
+ </Field>
+ <Field label={i18next.t("cert:Account")} htmlFor="cert-account">
+ <Input
+ id="cert-account"
+ value={cert.account}
+ onChange={event => updateField("account", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("cert:Access key")} htmlFor="cert-access-key">
+ <Input
+ id="cert-access-key"
+ value={cert.accessKey}
+ onChange={event => updateField("accessKey", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("cert:Access secret")} htmlFor="cert-access-secret">
+ <Input
+ id="cert-access-secret"
+ value={cert.accessSecret}
+ onChange={event => updateField("accessSecret", event.target.value)}
+ />
+ </Field>
+ </Section>
- <div className="mt-4 grid gap-4 lg:grid-cols-2">
- <Card>
- <CardContent className="space-y-2 pt-4">
- <div className="flex flex-wrap items-center gap-2">
- <span className="text-sm font-medium">{i18next.t("cert:Certificate")}</span>
+ <div className="grid gap-4 lg:grid-cols-2">
+ <Section
+ title={i18next.t("cert:Certificate")}
+ columns={1}
+ description={
+ <span className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
copy(cert.certificate);
- Setting.showMessage(
- "success",
- i18next.t("cert:Certificate copied to clipboard successfully"),
- );
+ Setting.showMessage("success", i18next.t("cert:Certificate copied to clipboard successfully"));
}}
>
{i18next.t("cert:Copy certificate")}
</Button>
- <Button size="sm" onClick={() => download(cert.certificate, "token_jwt_key.pem")}>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => download(cert.certificate, "token_jwt_key.pem")}
+ >
{i18next.t("cert:Download certificate")}
</Button>
- </div>
- <Textarea
- className="h-[420px] font-mono text-xs"
- value={cert.certificate}
- onChange={event => updateField("certificate", event.target.value)}
- />
- </CardContent>
- </Card>
+ </span>
+ }
+ >
+ <Textarea
+ className="scrollbar-thin h-[420px] font-mono text-xs"
+ value={cert.certificate}
+ onChange={event => updateField("certificate", event.target.value)}
+ />
+ </Section>
- <Card>
- <CardContent className="space-y-2 pt-4">
- <div className="flex flex-wrap items-center gap-2">
- <span className="text-sm font-medium">{i18next.t("cert:Private key")}</span>
+ <Section
+ title={i18next.t("cert:Private key")}
+ columns={1}
+ description={
+ <span className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
copy(cert.privateKey);
- Setting.showMessage(
- "success",
- i18next.t("cert:Private key copied to clipboard successfully"),
- );
+ Setting.showMessage("success", i18next.t("cert:Private key copied to clipboard successfully"));
}}
>
{i18next.t("cert:Copy private key")}
</Button>
- <Button size="sm" onClick={() => download(cert.privateKey, "token_jwt_key.key")}>
+ <Button variant="outline" size="sm" onClick={() => download(cert.privateKey, "token_jwt_key.key")}>
{i18next.t("cert:Download private key")}
</Button>
- </div>
- <Textarea
- className="h-[420px] font-mono text-xs"
- value={cert.privateKey}
- onChange={event => updateField("privateKey", event.target.value)}
- />
- </CardContent>
- </Card>
- </div>
-
- <div className="mt-4">
- <Button size="lg" onClick={save}>
- {i18next.t("general:Save")}
- </Button>
+ </span>
+ }
+ >
+ <Textarea
+ className="scrollbar-thin h-[420px] font-mono text-xs"
+ value={cert.privateKey}
+ onChange={event => updateField("privateKey", event.target.value)}
+ />
+ </Section>
</div>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/CertListPage.tsx b/web/src/pages/CertListPage.tsx
index 6f07484..fd4a98a 100644
--- a/web/src/pages/CertListPage.tsx
+++ b/web/src/pages/CertListPage.tsx
@@ -15,14 +15,16 @@
import * as React from "react";
import {Link, useNavigate} from "react-router-dom";
import copy from "copy-to-clipboard";
+import {Plus, RefreshCw, ShieldCheck} from "lucide-react";
import i18next from "i18next";
import * as CertBackend from "@/backend/CertBackend";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
-import {Field, FormDialog} from "@/components/FormDialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
import {Button} from "@/components/ui/button";
-import {ConfirmButton} from "@/components/ui/confirm-button";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Input} from "@/components/ui/input";
import {
Select,
@@ -253,24 +255,34 @@ export default function CertListPage({account}: {account: Account}) {
<Button size="sm" onClick={() => navigate(`/certs/${record.owner}/${record.name}`)}>
{i18next.t("general:Edit")}
</Button>
- <ConfirmButton
+ <ConfirmDialog
title={i18next.t("general:Sure to delete {name} ?").replace("{name}", record.name)}
onConfirm={() => deleteCert(record)}
>
<Button size="sm" variant="destructive">
{i18next.t("general:Delete")}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
</div>
),
},
];
return (
- <div className="p-4 md:p-6">
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("general:Certs")}
+ actions={
+ <Button onClick={openAddDialog}>
+ <Plus />
+ {i18next.t("general:Add")}
+ </Button>
+ }
+ />
+
<DataTable
columns={columns}
- data={data}
+ dataSource={data}
rowKey={record => `${record.owner}/${record.name}`}
loading={loading}
serverPagination={{
@@ -280,9 +292,12 @@ export default function CertListPage({account}: {account: Account}) {
onChange: (nextPage, nextPageSize) => fetchCerts(nextPage, nextPageSize),
}}
title={i18next.t("general:Certs")}
+ description={`${total} ${i18next.t("general:Certs")}`}
+ emptyIcon={ShieldCheck}
toolbar={
- <Button size="sm" onClick={openAddDialog}>
- {i18next.t("general:Add")}
+ <Button variant="outline" size="sm" onClick={() => fetchCerts()} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
</Button>
}
/>
@@ -326,6 +341,6 @@ export default function CertListPage({account}: {account: Account}) {
</Select>
</Field>
</FormDialog>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/ChannelEditPage.tsx b/web/src/pages/ChannelEditPage.tsx
index c38b08d..423eef5 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ChannelEditPage.tsx
@@ -20,23 +20,16 @@ import i18next from "i18next";
import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
import {EnvSnippet} from "@/components/EnvSnippet";
-import {FormRow, PageHeader} from "@/components/FormRow";
-import {Result} from "@/components/Result";
-import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {CodeText, ResultScreen} from "@/components/shared/misc";
+import {NumberInput} from "@/components/shared/number-input";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
+import {PasswordInput} from "@/components/shared/password-input";
+import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
+import {MessageAlert} from "@/components/ui/alert";
import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
-import {Combobox} from "@/components/ui/combobox";
import {Input} from "@/components/ui/input";
-import {NumberInput} from "@/components/ui/number-input";
-import {PasswordInput} from "@/components/ui/password-input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {PageSpinner, Spinner} from "@/components/ui/spinner";
import {TagsInput} from "@/components/ui/tags-input";
import {channelProtocol, gatewayBaseUrl, localShell} from "@/lib/channels";
import type {Channel, ChannelTestResult} from "@/types";
@@ -180,17 +173,15 @@ export default function ChannelEditPage() {
};
if (channel === undefined) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
if (channel === null) {
return (
- <Result
+ <ResultScreen
status="404"
title={i18next.t("channel:Channel not found")}
- extra={
- <Button onClick={() => navigate("/channels")}>{i18next.t("channel:Channels")}</Button>
- }
+ extra={<Button onClick={() => navigate("/channels")}>{i18next.t("channel:Channels")}</Button>}
/>
);
}
@@ -198,130 +189,130 @@ export default function ChannelEditPage() {
const upstreamUrl = buildUpstreamUrl(channel.baseUrl, channel.type);
return (
- <div className="p-4 md:p-6">
- <PageHeader title={`${i18next.t("channel:Edit Channel")}: ${channel.displayName}`}>
- <Button variant="outline" onClick={test} disabled={testing}>
- {testing ? <Spinner /> : <Zap />}
- {i18next.t("channel:Test Connectivity")}
- </Button>
- <Button onClick={save}>
- <Save />
- {i18next.t("general:Save")}
- </Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("channel:Edit Channel")}
+ description={`${channel.owner} / ${channel.name}`}
+ actions={
+ <>
+ <Button variant="outline" onClick={test} loading={testing}>
+ <Zap />
+ {i18next.t("channel:Test Connectivity")}
+ </Button>
+ <Button onClick={save}>
+ <Save />
+ {i18next.t("general:Save")}
+ </Button>
+ </>
+ }
+ />
- <Card>
- <CardContent className="divide-y py-0">
- <FormRow label={i18next.t("general:Display name")}>
- <Input
- value={channel.displayName}
- onChange={event => setField("displayName", event.target.value)}
- />
- </FormRow>
- <FormRow label={i18next.t("channel:Type")}>
- <Select value={channel.type} onValueChange={value => setField("type", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="openai">OpenAI</SelectItem>
- <SelectItem value="anthropic">Anthropic</SelectItem>
- <SelectItem value="custom">Custom</SelectItem>
- </SelectContent>
- </Select>
- </FormRow>
- <FormRow
- label={i18next.t("channel:Base URL")}
- hint={
- upstreamUrl === "" ? undefined : (
- <>
- {i18next.t("channel:Base URL hint")}:{" "}
- <span className="font-mono">{upstreamUrl}</span>
- </>
- )
- }
- >
- <Combobox
- allowCustomValue
- value={channel.baseUrl}
- placeholder={channel.type === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1"}
- options={(BASE_URL_PRESETS[channel.type] ?? []).map(url => ({value: url}))}
- onChange={value => setField("baseUrl", value)}
- />
- </FormRow>
- <FormRow label={i18next.t("channel:API Key")} hint={i18next.t("channel:API Key hint")}>
- <PasswordInput
- placeholder="sk-..."
- value={channel.apiKey}
- onChange={event => setField("apiKey", event.target.value)}
- />
- </FormRow>
- <FormRow label={i18next.t("channel:Models")}>
- <TagsInput
- value={channel.models}
- placeholder={channel.type === "anthropic" ? "claude-opus-5, claude-sonnet-5" : "gpt-5, gpt-5-mini"}
- suggestions={MODEL_PRESETS[channel.type] ?? []}
- onChange={value => setField("models", value)}
- />
- </FormRow>
- <FormRow label={i18next.t("channel:Priority")} hint={i18next.t("channel:Priority hint")}>
- <NumberInput
- min={0}
- className="max-w-xs"
- value={channel.priority}
- onChange={value => setField("priority", value)}
- />
- </FormRow>
- <FormRow label={i18next.t("channel:Status")}>
- <Select value={channel.status} onValueChange={value => setField("status", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="enabled">
+ {result ? (
+ <MessageAlert
+ variant={result.success ? "success" : "destructive"}
+ title={
+ result.success ? i18next.t("channel:Connection Successful") : i18next.t("channel:Connection Failed")
+ }
+ description={result.statusCode ? `HTTP ${result.statusCode} - ${result.message}` : result.message}
+ />
+ ) : null}
+
+ <Section title={i18next.t("channel:Channel")}>
+ <Field label={i18next.t("general:Display name")} htmlFor="channel-display-name">
+ <Input
+ id="channel-display-name"
+ value={channel.displayName}
+ onChange={event => setField("displayName", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("channel:Type")}>
+ <SimpleSelect
+ value={channel.type}
+ onChange={value => setField("type", value)}
+ options={[
+ {label: "OpenAI", value: "openai"},
+ {label: "Anthropic", value: "anthropic"},
+ {label: "Custom", value: "custom"},
+ ]}
+ />
+ </Field>
+ <Field
+ label={i18next.t("channel:Base URL")}
+ hint={
+ upstreamUrl === "" ? undefined : (
+ <>
+ {i18next.t("channel:Base URL hint")}: <CodeText>{upstreamUrl}</CodeText>
+ </>
+ )
+ }
+ >
+ <SearchSelect
+ allowCustomValue
+ value={channel.baseUrl}
+ placeholder={channel.type === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1"}
+ options={(BASE_URL_PRESETS[channel.type] ?? []).map(url => ({label: url, value: url}))}
+ onChange={value => setField("baseUrl", value)}
+ />
+ </Field>
+ <Field
+ label={i18next.t("channel:API Key")}
+ htmlFor="channel-api-key"
+ hint={i18next.t("channel:API Key hint")}
+ >
+ <PasswordInput
+ id="channel-api-key"
+ placeholder="sk-..."
+ value={channel.apiKey}
+ onChange={event => setField("apiKey", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("channel:Models")}>
+ <TagsInput
+ value={channel.models}
+ placeholder={channel.type === "anthropic" ? "claude-opus-5, claude-sonnet-5" : "gpt-5, gpt-5-mini"}
+ suggestions={MODEL_PRESETS[channel.type] ?? []}
+ onChange={value => setField("models", value)}
+ />
+ </Field>
+ <Field label={i18next.t("channel:Priority")} hint={i18next.t("channel:Priority hint")}>
+ <NumberInput min={0} value={channel.priority} onChange={value => setField("priority", value)} />
+ </Field>
+ <Field label={i18next.t("channel:Status")}>
+ <SimpleSelect
+ value={channel.status}
+ onChange={value => setField("status", value)}
+ options={[
+ {
+ label: (
<span className="flex items-center gap-2">
- <CircleCheck className="h-4 w-4 text-success" />
+ <CircleCheck className="text-success size-4" />
{i18next.t("channel:Enabled")}
</span>
- </SelectItem>
- <SelectItem value="disabled">
+ ),
+ value: "enabled",
+ },
+ {
+ label: (
<span className="flex items-center gap-2">
- <CircleX className="h-4 w-4 text-destructive" />
+ <CircleX className="text-destructive size-4" />
{i18next.t("channel:Disabled")}
</span>
- </SelectItem>
- </SelectContent>
- </Select>
- </FormRow>
- </CardContent>
- </Card>
-
- <Card className="mt-4">
- <CardContent className="space-y-3 p-4">
- <div className="text-base font-medium">{i18next.t("channel:Usage")}</div>
- <p className="text-sm text-muted-foreground">{i18next.t("channel:Usage hint")}</p>
- <EnvSnippet
- protocol={channelProtocol(channel.type)}
- baseUrl={gatewayBaseUrl(channelProtocol(channel.type))}
- defaultShell={localShell()}
+ ),
+ value: "disabled",
+ },
+ ]}
/>
- <p className="text-sm text-muted-foreground">{i18next.t("channel:Model routing hint")}</p>
- </CardContent>
- </Card>
+ </Field>
+ </Section>
- {result ? (
- <Alert className="mt-4" variant={result.success ? "success" : "destructive"}>
- {result.success ? <CircleCheck /> : <CircleX />}
- <AlertTitle>
- {result.success
- ? i18next.t("channel:Connection Successful")
- : i18next.t("channel:Connection Failed")}
- </AlertTitle>
- <AlertDescription>
- {result.statusCode ? `HTTP ${result.statusCode} - ${result.message}` : result.message}
- </AlertDescription>
- </Alert>
- ) : null}
- </div>
+ <Section title={i18next.t("channel:Usage")} description={i18next.t("channel:Usage hint")} columns={1}>
+ <EnvSnippet
+ protocol={channelProtocol(channel.type)}
+ baseUrl={gatewayBaseUrl(channelProtocol(channel.type))}
+ defaultShell={localShell()}
+ />
+ <p className="text-muted-foreground text-sm">{i18next.t("channel:Model routing hint")}</p>
+ </Section>
+ </PageContainer>
);
}
diff --git a/web/src/pages/ChannelListPage.tsx b/web/src/pages/ChannelListPage.tsx
index 2529eeb..c5f929f 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -14,27 +14,23 @@
import * as React from "react";
import {Link, useNavigate} from "react-router-dom";
-import {CircleCheck, CircleX, Pencil, Plus, Trash2} from "lucide-react";
+import {CircleCheck, CircleX, Pencil, Plug, Plus, RefreshCw, Trash2} from "lucide-react";
import i18next from "i18next";
import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
-import {DataTable, type Column, type SortOrder} from "@/components/DataTable";
-import {Field, FormDialog} from "@/components/FormDialog";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column, type SortOrder} from "@/components/shared/data-table";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {CodeText} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {PasswordInput} from "@/components/shared/password-input";
+import {SimpleSelect} from "@/components/shared/simple-select";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
-import {ConfirmButton} from "@/components/ui/confirm-button";
import {Input} from "@/components/ui/input";
-import {PasswordInput} from "@/components/ui/password-input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import {TagsInput} from "@/components/ui/tags-input";
-import {Tooltip} from "@/components/ui/tooltip";
+import {SimpleTooltip} from "@/components/ui/tooltip";
import type {Account, Channel} from "@/types";
function newChannel(owner: string): Channel {
@@ -162,14 +158,14 @@ export default function ChannelListPage({account}: {account: Account}) {
// the current page.
sorter: true,
render: (text: string, record) => (
- <Tooltip title={text}>
+ <SimpleTooltip title={text}>
<Link
to={`/channels/${record.owner}/${record.name}`}
- className="block truncate text-primary hover:underline"
+ className="text-primary block truncate font-medium hover:underline"
>
{text}
</Link>
- </Tooltip>
+ </SimpleTooltip>
),
},
{
@@ -185,20 +181,21 @@ export default function ChannelListPage({account}: {account: Account}) {
key: "type",
dataIndex: "type",
width: "110px",
- render: (text: string) => (
- <Badge variant={text === "openai" ? "success" : "processing"}>{text}</Badge>
- ),
+ render: (text: string) => <Badge variant={text === "openai" ? "success" : "info"}>{text}</Badge>,
},
{
title: i18next.t("channel:Base URL"),
key: "baseUrl",
dataIndex: "baseUrl",
width: "220px",
+ ellipsis: true,
render: (text: string) =>
text ? (
- <Tooltip title={text}>
- <span className="block truncate font-mono text-xs">{text}</span>
- </Tooltip>
+ <SimpleTooltip title={text}>
+ <span className="inline-flex max-w-full">
+ <CodeText>{text}</CodeText>
+ </span>
+ </SimpleTooltip>
) : (
"-"
),
@@ -214,7 +211,7 @@ export default function ChannelListPage({account}: {account: Account}) {
) : (
<div className="flex flex-wrap gap-1">
{models.map(model => (
- <Badge key={model} variant="blue">
+ <Badge key={model} variant="muted">
{model}
</Badge>
))}
@@ -236,12 +233,12 @@ export default function ChannelListPage({account}: {account: Account}) {
render: (text: string) =>
text === "enabled" ? (
<Badge variant="success">
- <CircleCheck className="h-3 w-3" />
+ <CircleCheck />
{i18next.t("channel:Enabled")}
</Badge>
) : (
- <Badge variant="error">
- <CircleX className="h-3 w-3" />
+ <Badge variant="muted">
+ <CircleX />
{i18next.t("channel:Disabled")}
</Badge>
),
@@ -252,29 +249,40 @@ export default function ChannelListPage({account}: {account: Account}) {
width: "190px",
render: (_text, record) => (
<div className="flex gap-2">
- <Button size="sm" onClick={() => navigate(`/channels/${record.owner}/${record.name}`)}>
+ <Button size="sm" variant="outline" onClick={() => navigate(`/channels/${record.owner}/${record.name}`)}>
<Pencil />
{i18next.t("general:Edit")}
</Button>
- <ConfirmButton
+ <ConfirmDialog
title={i18next.t("general:Sure to delete {name} ?").replace("{name}", record.name)}
+ confirmText={i18next.t("general:Delete")}
onConfirm={() => deleteChannel(record)}
>
- <Button size="sm" variant="destructive">
+ <Button size="sm" variant="outline" className="text-destructive">
<Trash2 />
{i18next.t("general:Delete")}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
</div>
),
},
];
return (
- <div className="p-4 md:p-6">
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("channel:Channels")}
+ actions={
+ <Button onClick={openAddDialog}>
+ <Plus />
+ {i18next.t("channel:New Channel")}
+ </Button>
+ }
+ />
+
<DataTable
columns={columns}
- data={data}
+ dataSource={data}
// An admin sees channels across owners, where names may collide.
rowKey={record => `${record.owner}/${record.name}`}
loading={loading}
@@ -286,10 +294,12 @@ export default function ChannelListPage({account}: {account: Account}) {
onChange: (nextPage, nextPageSize) => fetchChannels(nextPage, nextPageSize),
}}
title={i18next.t("channel:Channels")}
+ description={`${total} ${i18next.t("channel:Channels")}`}
+ emptyIcon={Plug}
toolbar={
- <Button size="sm" onClick={openAddDialog}>
- <Plus />
- {i18next.t("channel:New Channel")}
+ <Button variant="outline" size="sm" onClick={() => fetchChannels()} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
</Button>
}
/>
@@ -319,16 +329,15 @@ export default function ChannelListPage({account}: {account: Account}) {
/>
</Field>
<Field label={i18next.t("channel:Type")}>
- <Select value={form.type} onValueChange={value => setFormField("type", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="openai">OpenAI</SelectItem>
- <SelectItem value="anthropic">Anthropic</SelectItem>
- <SelectItem value="custom">Custom</SelectItem>
- </SelectContent>
- </Select>
+ <SimpleSelect
+ value={form.type}
+ onChange={value => setFormField("type", value)}
+ options={[
+ {label: "OpenAI", value: "openai"},
+ {label: "Anthropic", value: "anthropic"},
+ {label: "Custom", value: "custom"},
+ ]}
+ />
</Field>
<Field label={i18next.t("channel:Base URL")} htmlFor="channel-base-url">
<Input
@@ -354,6 +363,6 @@ export default function ChannelListPage({account}: {account: Account}) {
/>
</Field>
</FormDialog>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 78c40b8..2404d44 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -18,7 +18,7 @@ import i18next from "i18next";
import * as MiscBackend from "@/backend/MiscBackend";
import * as Setting from "@/Setting";
import {cn} from "@/lib/utils";
-import {DataTable, type Column} from "@/components/DataTable";
+import {DataTable, type Column} from "@/components/shared/data-table";
import {BarChartCard, PieChartCard, StatisticCard} from "@/components/charts/ChartCards";
import type {MetricPoint} from "@/types";
@@ -163,7 +163,7 @@ export default function DashboardPage() {
},
countColumn,
]}
- data={ipAddresses}
+ dataSource={ipAddresses}
rowKey={(record, index) => `${record.data}-${index}`}
pageSize={0}
title={i18next.t("general:Top 10 IP Addresses")}
@@ -183,7 +183,7 @@ export default function DashboardPage() {
},
countColumn,
]}
- data={userAgents}
+ dataSource={userAgents}
rowKey={(record, index) => `${record.data}-${index}`}
pageSize={0}
title={i18next.t("general:Top 10 User-Agents")}
diff --git a/web/src/pages/NodeEditPage.tsx b/web/src/pages/NodeEditPage.tsx
index 34b25a0..3eda474 100644
--- a/web/src/pages/NodeEditPage.tsx
+++ b/web/src/pages/NodeEditPage.tsx
@@ -18,18 +18,12 @@ import i18next from "i18next";
import * as NodeBackend from "@/backend/NodeBackend";
import * as Setting from "@/Setting";
-import {FormRow, PageHeader} from "@/components/FormRow";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {PageSpinner} from "@/components/ui/spinner";
import type {Node} from "@/types";
const upgradeModes = ["At Any Time", "No Upgrade", "Half A Hour"];
@@ -71,57 +65,53 @@ export default function NodeEditPage() {
};
if (node === null) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("node:Edit Node")}>
- <Button variant="outline" onClick={() => navigate("/nodes")}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button onClick={save}>{i18next.t("general:Save")}</Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("node:Edit Node")}
+ description={`${node.owner} / ${node.name}`}
+ actions={
+ <>
+ <Button variant="outline" onClick={() => navigate("/nodes")}>
+ {i18next.t("general:Cancel")}
+ </Button>
+ <Button onClick={save}>{i18next.t("general:Save")}</Button>
+ </>
+ }
+ />
- <Card>
- <CardContent className="divide-y py-0">
- <FormRow label={i18next.t("general:Name")}>
- <Input value={node.name} onChange={event => updateField("name", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("general:Display name")}>
- <Input
- value={node.displayName}
- onChange={event => updateField("displayName", event.target.value)}
- />
- </FormRow>
- <FormRow label={i18next.t("general:Tag")}>
- <Input value={node.tag ?? ""} onChange={event => updateField("tag", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("general:Client IP")}>
- <Input
- value={node.clientIp}
- onChange={event => updateField("clientIp", event.target.value)}
- />
- </FormRow>
- <FormRow label={i18next.t("general:Upgrade mode")}>
- <Select
- value={node.upgradeMode}
- onValueChange={value => updateField("upgradeMode", value)}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {upgradeModes.map(mode => (
- <SelectItem key={mode} value={mode}>
- {i18next.t(`general:${mode}`)}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormRow>
- </CardContent>
- </Card>
- </div>
+ <Section>
+ <Field label={i18next.t("general:Name")} htmlFor="node-name">
+ <Input id="node-name" value={node.name} onChange={event => updateField("name", event.target.value)} />
+ </Field>
+ <Field label={i18next.t("general:Display name")} htmlFor="node-display-name">
+ <Input
+ id="node-display-name"
+ value={node.displayName}
+ onChange={event => updateField("displayName", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("general:Tag")} htmlFor="node-tag">
+ <Input id="node-tag" value={node.tag ?? ""} onChange={event => updateField("tag", event.target.value)} />
+ </Field>
+ <Field label={i18next.t("general:Client IP")} htmlFor="node-client-ip">
+ <Input
+ id="node-client-ip"
+ value={node.clientIp}
+ onChange={event => updateField("clientIp", event.target.value)}
+ />
+ </Field>
+ <Field label={i18next.t("general:Upgrade mode")}>
+ <SimpleSelect
+ value={node.upgradeMode}
+ onChange={value => updateField("upgradeMode", value)}
+ options={upgradeModes.map(mode => ({label: i18next.t(`general:${mode}`), value: mode}))}
+ />
+ </Field>
+ </Section>
+ </PageContainer>
);
}
diff --git a/web/src/pages/NodeListPage.tsx b/web/src/pages/NodeListPage.tsx
index 2579f6f..4b819c0 100644
--- a/web/src/pages/NodeListPage.tsx
+++ b/web/src/pages/NodeListPage.tsx
@@ -14,15 +14,17 @@
import * as React from "react";
import {Link, useNavigate} from "react-router-dom";
+import {Plus, RefreshCw, Server} from "lucide-react";
import i18next from "i18next";
import * as NodeBackend from "@/backend/NodeBackend";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
-import {Field, FormDialog} from "@/components/FormDialog";
-import {UnauthorizedResult} from "@/components/Result";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {UnauthorizedResult} from "@/components/shared/misc";
import {Button} from "@/components/ui/button";
-import {ConfirmButton} from "@/components/ui/confirm-button";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Input} from "@/components/ui/input";
import type {Account, Node} from "@/types";
@@ -178,31 +180,45 @@ export default function NodeListPage({account}: {account: Account}) {
<Button size="sm" onClick={() => navigate(`/nodes/${record.owner}/${record.name}`)}>
{i18next.t("general:Edit")}
</Button>
- <ConfirmButton
+ <ConfirmDialog
title={i18next.t("general:Sure to delete {name} ?").replace("{name}", record.name)}
onConfirm={() => deleteNode(record)}
>
<Button size="sm" variant="destructive">
{i18next.t("general:Delete")}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
</div>
),
},
];
return (
- <div className="p-4 md:p-6">
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("general:Nodes")}
+ actions={
+ <Button onClick={openAddDialog}>
+ <Plus />
+ {i18next.t("general:Add")}
+ </Button>
+ }
+ />
+
<DataTable
columns={columns}
- data={data}
+ dataSource={data}
rowKey={record => `${record.owner}/${record.name}`}
loading={loading}
pageSize={20}
+ searchable
title={i18next.t("general:Nodes")}
+ description={`${data.length} ${i18next.t("general:Nodes")}`}
+ emptyIcon={Server}
toolbar={
- <Button size="sm" onClick={openAddDialog}>
- {i18next.t("general:Add")}
+ <Button variant="outline" size="sm" onClick={() => fetchNodes()} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
</Button>
}
/>
@@ -239,6 +255,6 @@ export default function NodeListPage({account}: {account: Account}) {
/>
</Field>
</FormDialog>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/RecordEditPage.tsx b/web/src/pages/RecordEditPage.tsx
index 7456d31..0c6a619 100644
--- a/web/src/pages/RecordEditPage.tsx
+++ b/web/src/pages/RecordEditPage.tsx
@@ -18,11 +18,11 @@ import i18next from "i18next";
import * as RecordBackend from "@/backend/RecordBackend";
import * as Setting from "@/Setting";
-import {FormRow, PageHeader} from "@/components/FormRow";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
-import {PageSpinner} from "@/components/ui/spinner";
import type {Record as GatewayRecord} from "@/types";
export default function RecordEditPage() {
@@ -62,7 +62,7 @@ export default function RecordEditPage() {
};
if (record === null) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
const fields: {label: string; key: keyof GatewayRecord}[] = [
@@ -76,26 +76,31 @@ export default function RecordEditPage() {
];
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("general:Edit Record")}>
- <Button variant="outline" onClick={() => navigate("/records")}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button onClick={save}>{i18next.t("general:Save")}</Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("general:Edit Record")}
+ description={`${record.owner} / ${record.id}`}
+ actions={
+ <>
+ <Button variant="outline" onClick={() => navigate("/records")}>
+ {i18next.t("general:Cancel")}
+ </Button>
+ <Button onClick={save}>{i18next.t("general:Save")}</Button>
+ </>
+ }
+ />
- <Card>
- <CardContent className="divide-y py-0">
- {fields.map(field => (
- <FormRow key={String(field.key)} label={field.label}>
- <Input
- value={String(record[field.key] ?? "")}
- onChange={event => updateField(field.key, event.target.value as never)}
- />
- </FormRow>
- ))}
- </CardContent>
- </Card>
- </div>
+ <Section>
+ {fields.map(field => (
+ <Field key={String(field.key)} label={field.label} htmlFor={`record-${String(field.key)}`}>
+ <Input
+ id={`record-${String(field.key)}`}
+ value={String(record[field.key] ?? "")}
+ onChange={event => updateField(field.key, event.target.value as never)}
+ />
+ </Field>
+ ))}
+ </Section>
+ </PageContainer>
);
}
diff --git a/web/src/pages/RecordListPage.tsx b/web/src/pages/RecordListPage.tsx
index f19400c..0e1738e 100644
--- a/web/src/pages/RecordListPage.tsx
+++ b/web/src/pages/RecordListPage.tsx
@@ -14,14 +14,16 @@
import * as React from "react";
import {useNavigate} from "react-router-dom";
+import {Plus, RefreshCw, ScrollText} from "lucide-react";
import i18next from "i18next";
import * as RecordBackend from "@/backend/RecordBackend";
import * as Setting from "@/Setting";
-import {DataTable, type Column} from "@/components/DataTable";
-import {Field, FormDialog} from "@/components/FormDialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
import {Button} from "@/components/ui/button";
-import {ConfirmButton} from "@/components/ui/confirm-button";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Input} from "@/components/ui/input";
import {
Select,
@@ -189,21 +191,31 @@ export default function RecordListPage({account}: {account: Account}) {
<Button size="sm" onClick={() => navigate(`/records/${record.owner}/${record.id}`)}>
{i18next.t("general:Edit")}
</Button>
- <ConfirmButton title={i18next.t("general:Sure to delete {name} ?").replace("{name}", String(record.id))} onConfirm={() => deleteRecord(record)}>
+ <ConfirmDialog title={i18next.t("general:Sure to delete {name} ?").replace("{name}", String(record.id))} onConfirm={() => deleteRecord(record)}>
<Button size="sm" variant="destructive">
{i18next.t("general:Delete")}
</Button>
- </ConfirmButton>
+ </ConfirmDialog>
</div>
),
},
];
return (
- <div className="p-4 md:p-6">
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("general:Records")}
+ actions={
+ <Button onClick={openAddDialog}>
+ <Plus />
+ {i18next.t("general:Add")}
+ </Button>
+ }
+ />
+
<DataTable
columns={columns}
- data={data}
+ dataSource={data}
rowKey={record => String(record.id)}
loading={loading}
serverPagination={{
@@ -213,9 +225,12 @@ export default function RecordListPage({account}: {account: Account}) {
onChange: (nextPage, nextPageSize) => fetchRecords(nextPage, nextPageSize),
}}
title={i18next.t("general:Records")}
+ description={`${total} ${i18next.t("general:Records")}`}
+ emptyIcon={ScrollText}
toolbar={
- <Button size="sm" onClick={openAddDialog}>
- {i18next.t("general:Add")}
+ <Button variant="outline" size="sm" onClick={() => fetchRecords()} loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
</Button>
}
/>
@@ -263,6 +278,6 @@ export default function RecordListPage({account}: {account: Account}) {
/>
</Field>
</FormDialog>
- </div>
+ </PageContainer>
);
}
diff --git a/web/src/pages/RuleEditPage.tsx b/web/src/pages/RuleEditPage.tsx
index 2443364..1c4740f 100644
--- a/web/src/pages/RuleEditPage.tsx
+++ b/web/src/pages/RuleEditPage.tsx
@@ -18,24 +18,18 @@ import i18next from "i18next";
import * as RuleBackend from "@/backend/RuleBackend";
import * as Setting from "@/Setting";
-import {FormRow, PageHeader} from "@/components/FormRow";
import {CompoundRule} from "@/components/rules/CompoundRule";
import {IpRateRuleTable} from "@/components/rules/IpRateRuleTable";
import {IpRuleTable} from "@/components/rules/IpRuleTable";
import {UaRuleTable} from "@/components/rules/UaRuleTable";
import {WafRuleTable} from "@/components/rules/WafRuleTable";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {NumberInput} from "@/components/shared/number-input";
+import {PageContainer, PageHeader, Section} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
import {Input} from "@/components/ui/input";
-import {NumberInput} from "@/components/ui/number-input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {PageSpinner} from "@/components/ui/spinner";
import {Switch} from "@/components/ui/switch";
import {getRuleActions, getRuleTypes} from "@/lib/rules";
import type {Rule, RuleExpression} from "@/types";
@@ -91,7 +85,7 @@ export default function RuleEditPage() {
};
if (rule === null) {
- return <PageSpinner />;
+ return <Loading type="page" />;
}
const setExpressions = (expressions: RuleExpression[]) => updateField("expressions", expressions);
@@ -142,83 +136,58 @@ export default function RuleEditPage() {
const actions = getRuleActions();
return (
- <div className="p-4 md:p-6">
- <PageHeader title={i18next.t("rule:Edit Rule")}>
- <Button variant="outline" onClick={() => navigate("/rules")}>
- {i18next.t("general:Cancel")}
- </Button>
- <Button onClick={save}>{i18next.t("general:Save")}</Button>
- </PageHeader>
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("rule:Edit Rule")}
+ description={`${rule.owner} / ${rule.name}`}
+ actions={
+ <>
+ <Button variant="outline" onClick={() => navigate("/rules")}>
+ {i18next.t("general:Cancel")}
+ </Button>
+ <Button onClick={save}>{i18next.t("general:Save")}</Button>
+ </>
+ }
+ />
- <Card>
- <CardContent className="divide-y py-0">
- <FormRow label={i18next.t("general:Name")}>
- <Input value={rule.name} onChange={event => updateField("name", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("rule:Type")}>
- <Select value={rule.type} onValueChange={value => updateField("type", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {types.map(type => (
- <SelectItem key={type.value} value={type.value}>
- {type.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormRow>
- <FormRow label={i18next.t("rule:Expressions")}>
- {/* Keying on the type remounts the table when the type changes, so
- it seeds that type's default expressions. User-Agent and URL
- Path share one component and would otherwise stay put. */}
- <React.Fragment key={rule.type}>{renderExpressions()}</React.Fragment>
- </FormRow>
- {rule.type !== "WAF" && (
- <FormRow label={i18next.t("general:Action")}>
- <Select value={rule.action} onValueChange={value => updateField("action", value)}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {actions.map(action => (
- <SelectItem key={action.value} value={action.value}>
- {action.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormRow>
- )}
- {rule.type !== "WAF" && (rule.action === "Allow" || rule.action === "Block") && (
- <FormRow label={i18next.t("rule:Status code")}>
- <NumberInput
- min={100}
- max={599}
- className="max-w-xs"
- value={rule.statusCode}
- onChange={value => updateField("statusCode", value)}
- />
- </FormRow>
- )}
- <FormRow label={i18next.t("rule:Reason")}>
- <Input value={rule.reason ?? ""} onChange={event => updateField("reason", event.target.value)} />
- </FormRow>
- <FormRow label={i18next.t("rule:Verbose mode")}>
- <Switch
- checked={rule.isVerbose}
- onCheckedChange={checked => updateField("isVerbose", checked)}
+ <Section title={i18next.t("rule:Rule")}>
+ <Field label={i18next.t("general:Name")} htmlFor="rule-name">
+ <Input id="rule-name" value={rule.name} onChange={event => updateField("name", event.target.value)} />
+ </Field>
+ <Field label={i18next.t("rule:Type")}>
+ <SimpleSelect value={rule.type} onChange={value => updateField("type", value)} options={types} />
+ </Field>
+ {rule.type !== "WAF" && (
+ <Field label={i18next.t("general:Action")}>
+ <SimpleSelect value={rule.action} onChange={value => updateField("action", value)} options={actions} />
+ </Field>
+ )}
+ {rule.type !== "WAF" && (rule.action === "Allow" || rule.action === "Block") && (
+ <Field label={i18next.t("rule:Status code")}>
+ <NumberInput
+ min={100}
+ max={599}
+ value={rule.statusCode}
+ onChange={value => updateField("statusCode", value)}
/>
- </FormRow>
... 1879 lines suppressed ...
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org