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