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/18 15:00:22 UTC

(casbin-gateway) branch master updated: feat: rename web2 to web and drop the antd frontend

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 0df8674  feat: rename web2 to web and drop the antd frontend
0df8674 is described below

commit 0df867413fd6384b115415fe4ff8cceda86dd7fa
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Tue Aug 18 23:00:08 2026 +0800

    feat: rename web2 to web and drop the antd frontend
---
 .dockerignore                                      |     3 +-
 .github/workflows/build.yml                        |     4 +-
 .github/workflows/nightly.yml                      |    12 +-
 Dockerfile                                         |     8 +-
 README.md                                          |    20 +-
 embed.go                                           |    10 +-
 embedsupport/setup.go                              |     8 +-
 embedsupport/web.go                                |     2 +-
 i18n/generate.go                                   |     6 +-
 routers/filter.go                                  |     2 +-
 service/startup.go                                 |     2 +-
 web/.eslintignore                                  |     2 -
 web/.eslintrc                                      |   112 -
 {web2 => web}/.eslintrc.cjs                        |     2 +-
 web/.gitignore                                     |    26 +-
 web/README.md                                      |    93 +-
 {web2 => web}/components.json                      |     0
 web/craco.config.js                                |    31 -
 {web2 => web}/index.html                           |     0
 web/package.json                                   |    98 +-
 {web2 => web}/postcss.config.js                    |     0
 web/public/index.html                              |    43 -
 web/src/AccountPage.js                             |   185 -
 web/src/AgentRecordsPage.js                        |   345 -
 web/src/AgentSessionsPage.js                       |   134 -
 web/src/AgentsPage.js                              |   217 -
 web/src/App.css                                    |    40 -
 web/src/App.js                                     |   540 -
 web/src/App.less                                   |    79 -
 web/src/App.test.js                                |    24 -
 {web2 => web}/src/App.tsx                          |     0
 web/src/AuthCallback.js                            |    85 -
 web/src/BaseListPage.js                            |    72 -
 web/src/CertEditPage.js                            |   281 -
 web/src/CertListPage.js                            |   326 -
 web/src/ChannelEditPage.js                         |   342 -
 web/src/ChannelListPage.js                         |   263 -
 web/src/Conf.js                                    |    42 -
 {web2 => web}/src/Conf.ts                          |     0
 web/src/DashboardPage.js                           |   322 -
 web/src/HomePage.js                                |    34 -
 web/src/LanguageSelect.js                          |    79 -
 web/src/NodeEditPage.js                            |   165 -
 web/src/NodeListPage.js                            |   246 -
 web/src/NodeTable.js                               |   265 -
 web/src/RecordEditPage.js                          |   189 -
 web/src/RecordListPage.js                          |   217 -
 web/src/RuleEditPage.js                            |   295 -
 web/src/RuleListPage.js                            |   235 -
 web/src/SelectLanguageBox.js                       |    45 -
 web/src/Setting.js                                 |   327 -
 {web2 => web}/src/Setting.tsx                      |     0
 web/src/SigninPage.js                              |   129 -
 web/src/SiteEditPage.js                            |   506 -
 web/src/SiteListPage.js                            |   492 -
 web/src/backend/AccountBackend.js                  |    61 -
 {web2 => web}/src/backend/AccountBackend.ts        |     0
 web/src/backend/AgentBackend.js                    |    54 -
 {web2 => web}/src/backend/AgentBackend.ts          |     0
 web/src/backend/ApplicationBackend.js              |    22 -
 web/src/backend/CertBackend.js                     |    70 -
 {web2 => web}/src/backend/CertBackend.ts           |     0
 web/src/backend/ChannelBackend.js                  |    53 -
 {web2 => web}/src/backend/ChannelBackend.ts        |     0
 web/src/backend/DashboardBackend.js                |    29 -
 web/src/backend/FetchFilter.js                     |    58 -
 {web2 => web}/src/backend/MiscBackend.ts           |     0
 web/src/backend/NodeBackend.js                     |    63 -
 {web2 => web}/src/backend/NodeBackend.ts           |     0
 web/src/backend/ProviderBackend.js                 |    22 -
 web/src/backend/RecordBackend.js                   |    56 -
 {web2 => web}/src/backend/RecordBackend.ts         |     0
 web/src/backend/RuleBackend.js                     |    53 -
 {web2 => web}/src/backend/RuleBackend.ts           |     0
 web/src/backend/SiteBackend.js                     |    63 -
 {web2 => web}/src/backend/SiteBackend.ts           |     0
 web/src/backend/SystemBackend.js                   |    22 -
 {web2 => web}/src/backend/request.ts               |     0
 web/src/components/AgentIcon.js                    |    56 -
 {web2 => web}/src/components/AgentIcon.tsx         |     0
 web/src/components/BarChartCard.js                 |    61 -
 web/src/components/CompoundRule.js                 |   192 -
 {web2 => web}/src/components/DataTable.tsx         |     0
 {web2 => web}/src/components/FormRow.tsx           |     0
 web/src/components/IpRateRuleTable.js              |   114 -
 web/src/components/IpRuleTable.js                  |   197 -
 {web2 => web}/src/components/NodeTable.tsx         |     0
 web/src/components/PieChartCard.js                 |    67 -
 {web2 => web}/src/components/Result.tsx            |     0
 web/src/components/RuleTable.js                    |   141 -
 web/src/components/StatisticCard.js                |    60 -
 web/src/components/UaRuleTable.js                  |   193 -
 web/src/components/WafRuleTable.js                 |   164 -
 {web2 => web}/src/components/charts/ChartCards.tsx |     0
 {web2 => web}/src/components/layout/Footer.tsx     |     0
 .../src/components/layout/LanguageSelect.tsx       |     0
 {web2 => web}/src/components/layout/Sidebar.tsx    |     0
 .../src/components/rules/CompoundRule.tsx          |     0
 .../src/components/rules/IpRateRuleTable.tsx       |     0
 {web2 => web}/src/components/rules/IpRuleTable.tsx |     0
 {web2 => web}/src/components/rules/RowActions.tsx  |     0
 .../src/components/rules/SiteRuleTable.tsx         |     0
 {web2 => web}/src/components/rules/UaRuleTable.tsx |     0
 .../src/components/rules/WafRuleTable.tsx          |     0
 .../src/components/rules/useExpressionTable.ts     |     0
 {web2 => web}/src/components/ui/alert-dialog.tsx   |     0
 {web2 => web}/src/components/ui/alert.tsx          |     0
 {web2 => web}/src/components/ui/avatar.tsx         |     0
 {web2 => web}/src/components/ui/badge.tsx          |     0
 {web2 => web}/src/components/ui/button.tsx         |     0
 {web2 => web}/src/components/ui/card.tsx           |     0
 {web2 => web}/src/components/ui/chart.tsx          |     0
 {web2 => web}/src/components/ui/combobox.tsx       |     0
 {web2 => web}/src/components/ui/command.tsx        |     0
 {web2 => web}/src/components/ui/confirm-button.tsx |     0
 {web2 => web}/src/components/ui/dialog.tsx         |     0
 {web2 => web}/src/components/ui/dropdown-menu.tsx  |     0
 {web2 => web}/src/components/ui/input.tsx          |     0
 {web2 => web}/src/components/ui/label.tsx          |     0
 {web2 => web}/src/components/ui/number-input.tsx   |     0
 {web2 => web}/src/components/ui/password-input.tsx |     0
 {web2 => web}/src/components/ui/popover.tsx        |     0
 {web2 => web}/src/components/ui/select.tsx         |     0
 {web2 => web}/src/components/ui/sonner.tsx         |     0
 {web2 => web}/src/components/ui/spinner.tsx        |     0
 {web2 => web}/src/components/ui/switch.tsx         |     0
 {web2 => web}/src/components/ui/table.tsx          |     0
 {web2 => web}/src/components/ui/tags-input.tsx     |     0
 {web2 => web}/src/components/ui/textarea.tsx       |     0
 {web2 => web}/src/components/ui/tooltip.tsx        |     0
 web/src/font.css                                   |    77 -
 web/src/i18n.js                                    |    72 -
 {web2 => web}/src/i18n.ts                          |     0
 web/src/index.css                                  |   175 +-
 web/src/index.js                                   |    36 -
 {web2 => web}/src/lib/agents.ts                    |     0
 {web2 => web}/src/lib/utils.ts                     |     0
 web/src/locales/en/data.json                       |    40 +-
 web/src/locales/zh/data.json                       |    48 +-
 {web2 => web}/src/main.tsx                         |     0
 {web2 => web}/src/pages/AccountPage.tsx            |     0
 {web2 => web}/src/pages/AgentDashboardPage.tsx     |     0
 {web2 => web}/src/pages/AgentDetailPage.tsx        |     0
 {web2 => web}/src/pages/AgentRecordsPage.tsx       |     0
 {web2 => web}/src/pages/AgentSessionsPage.tsx      |     0
 {web2 => web}/src/pages/AgentsPage.tsx             |     0
 {web2 => web}/src/pages/AuthCallback.tsx           |     0
 {web2 => web}/src/pages/CertEditPage.tsx           |     0
 {web2 => web}/src/pages/CertListPage.tsx           |     0
 {web2 => web}/src/pages/ChannelEditPage.tsx        |     0
 {web2 => web}/src/pages/ChannelListPage.tsx        |     0
 {web2 => web}/src/pages/DashboardPage.tsx          |     0
 {web2 => web}/src/pages/NodeEditPage.tsx           |     0
 {web2 => web}/src/pages/NodeListPage.tsx           |     0
 {web2 => web}/src/pages/RecordEditPage.tsx         |     0
 {web2 => web}/src/pages/RecordListPage.tsx         |     0
 {web2 => web}/src/pages/RuleEditPage.tsx           |     0
 {web2 => web}/src/pages/RuleListPage.tsx           |     0
 {web2 => web}/src/pages/SigninPage.tsx             |     0
 {web2 => web}/src/pages/SiteEditPage.tsx           |     0
 {web2 => web}/src/pages/SiteListPage.tsx           |     0
 web/src/serviceWorker.js                           |   154 -
 {web2 => web}/src/types.ts                         |     0
 {web2 => web}/src/vite-env.d.ts                    |     0
 {web2 => web}/tailwind.config.js                   |     0
 {web2 => web}/tsconfig.app.json                    |     0
 {web2 => web}/tsconfig.app.tsbuildinfo             |     0
 {web2 => web}/tsconfig.json                        |     0
 {web2 => web}/tsconfig.node.json                   |     0
 {web2 => web}/tsconfig.node.tsbuildinfo            |     0
 {web2 => web}/vite.config.ts                       |     4 +-
 web/yarn.lock                                      | 11944 +++++--------------
 web2/.gitignore                                    |     5 -
 web2/README.md                                     |    54 -
 web2/package.json                                  |    60 -
 web2/public/favicon.ico                            |   Bin 3870 -> 0 bytes
 web2/public/logo192.png                            |   Bin 5347 -> 0 bytes
 web2/public/logo512.png                            |   Bin 9664 -> 0 bytes
 web2/public/manifest.json                          |    25 -
 web2/public/robots.txt                             |     3 -
 web2/src/index.css                                 |    89 -
 web2/src/locales/en/data.json                      |   257 -
 web2/src/locales/zh/data.json                      |   257 -
 web2/yarn.lock                                     |  3836 ------
 webui/webui.go                                     |    25 +-
 185 files changed, 2989 insertions(+), 23048 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index c184cd1..3631eaf 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,2 @@
 web/node_modules/
-web2/node_modules/
-web2/build/
+web/build/
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 76897f2..8518b46 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -34,9 +34,9 @@ jobs:
         with:
           node-version: 20
           cache: 'yarn'
-          cache-dependency-path: ./web2/yarn.lock
+          cache-dependency-path: ./web/yarn.lock
       - run: yarn install && yarn build
-        working-directory: ./web2
+        working-directory: ./web
 
   backend:
     name: Back-end
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 47892d2..ac22688 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -38,19 +38,19 @@ jobs:
         with:
           node-version: 20
           cache: 'yarn'
-          cache-dependency-path: ./web2/yarn.lock
+          cache-dependency-path: ./web/yarn.lock
 
-      # Everything under web2/build ends up inside every one of the three
+      # Everything under web/build ends up inside every one of the three
       # binaries below, which is why the Vite config leaves source maps out.
       - name: Build
         run: yarn install && yarn build
-        working-directory: ./web2
+        working-directory: ./web
 
       - name: Upload web build
         uses: actions/upload-artifact@v4
         with:
           name: web-build-${{ github.run_id }}
-          path: ./web2/build
+          path: ./web/build
           retention-days: 1
 
   nightly:
@@ -68,12 +68,12 @@ jobs:
           cache-dependency-path: ./go.mod
 
       # The binaries are built with -tags embed, which bakes conf/app.conf, the
-      # web UI and the IP database in, so web2/build has to be in place first.
+      # web UI and the IP database in, so web/build has to be in place first.
       - name: Download web build
         uses: actions/download-artifact@v4
         with:
           name: web-build-${{ github.run_id }}
-          path: ./web2/build
+          path: ./web/build
 
       - name: Build the platform archives
         run: |
diff --git a/Dockerfile b/Dockerfile
index e932c07..a6d64f1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
 FROM node:20 AS FRONT
-WORKDIR /web2
-COPY ./web2 .
+WORKDIR /web
+COPY ./web .
 RUN yarn install --frozen-lockfile --network-timeout 1000000 && yarn build
 
 FROM golang:1.20.12 AS BACK
@@ -12,8 +12,8 @@ FROM alpine:latest AS STANDARD
 LABEL MAINTAINER="https://caswaf.org/"
 
 COPY --from=BACK /go/src/casbin-gateway/ ./
-RUN mkdir -p web2/build && apk add --no-cache bash coreutils tzdata
-COPY --from=FRONT /web2/build /web2/build
+RUN mkdir -p web/build && apk add --no-cache bash coreutils tzdata
+COPY --from=FRONT /web/build /web/build
 # Holds the SQLite database at /data/casbin-gateway.db.
 VOLUME /data
 ENTRYPOINT ["./server"]
diff --git a/README.md b/README.md
index 0649815..83ed774 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ Casbin Gateway contains 2 parts:
 
 | Name     | Description                            | Language               | Source code                                              |
 |----------|----------------------------------------|------------------------|----------------------------------------------------------|
-| Frontend | Web frontend UI for Casbin Gateway     | TypeScript + React + shadcn/ui | https://github.com/apache/casbin-gateway/tree/master/web2 |
+| Frontend | Web frontend UI for Casbin Gateway     | TypeScript + React + shadcn/ui | https://github.com/apache/casbin-gateway/tree/master/web |
 | Backend  | RESTful API backend for Casbin Gateway | Golang + Beego + XORM  | https://github.com/apache/casbin-gateway                 |
 
 ## Installation
@@ -75,16 +75,14 @@ From nothing to a request flowing through the gateway, in four steps. No databas
 
 #### 1. Build the web UI
 
-The backend serves the compiled frontend from `web2/build`, so build it once before starting the backend:
+The backend serves the compiled frontend from `web/build`, so build it once before starting the backend:
 
 ```bash
-cd web2 && yarn install && yarn build
+cd web && yarn install && yarn build
 ```
 
 For frontend development, run `yarn dev` instead. That serves the UI on http://localhost:16002 with hot reload and proxies API calls to the backend on port 17000, so both have to be running.
 
-`web/` holds the older Ant Design frontend that `web2/` replaces. It still builds and the backend still serves `web/build` when `web2/build` is absent, so either one works while the migration finishes.
-
 #### 2. Run the backend
 
 ```bash
@@ -99,7 +97,7 @@ It prints a summary of what it is actually doing — ports, whether the reverse
 +----------------+-----------------------------------------------------------+
 | Management UI  | http://localhost:17000                                     |
 | Settings       | conf/app.conf                                              |
-| Web UI files   | web2/build                                                 |
+| Web UI files   | web/build                                                  |
 | Reverse proxy  | enabled                                                    |
 | Gateway HTTP   | :8080                                                      |
 | Gateway HTTPS  | :8443                                                      |
@@ -141,24 +139,24 @@ You should get your backend's response. A `site not found for host` reply means
 
 ### Single binary
 
-Normally Gateway reads three things from disk when it starts: `conf/app.conf`, the compiled web UI in `web2/build`, and the IP location database `ip/17monipdb.dat`. Building with the `embed` tag bakes all three into the executable, so it can be copied somewhere on its own and started from anywhere:
+Normally Gateway reads three things from disk when it starts: `conf/app.conf`, the compiled web UI in `web/build`, and the IP location database `ip/17monipdb.dat`. Building with the `embed` tag bakes all three into the executable, so it can be copied somewhere on its own and started from anywhere:
 
 ```bash
-cd web2 && yarn install && yarn build
+cd web && yarn install && yarn build
 ```
 
 ```bash
 go build -tags embed -o casbin-gateway .
 ```
 
-Build the frontend first: everything under `web2/build` goes into the binary, so `go build -tags embed` fails to compile while that directory is missing. The Vite config leaves source maps out, which keeps them out of the binary too, where they would cost several times what the code they map costs.
+Build the frontend first: everything under `web/build` goes into the binary, so `go build -tags embed` fails to compile while that directory is missing. The Vite config leaves source maps out, which keeps them out of the binary too, where they would cost several times what the code they map costs.
 
 Files on disk always win over the embedded copies, so a single binary can still be configured and developed against without rebuilding it:
 
 | Embedded asset | Overridden by |
 | --- | --- |
 | `conf/app.conf` | `conf/app.conf` in the working directory, or next to the executable |
-| `web2/build` | `web2/build/index.html` or `web/build/index.html` in the working directory, the first of those found then serving the whole UI |
+| `web/build` | `web/build/index.html` in the working directory, which then serves the whole UI |
 | `ip/17monipdb.dat` | `ip/17monipdb.dat` in the working directory |
 
 The startup summary reports which source each one came from:
@@ -227,7 +225,7 @@ dbName = casbin_gateway
 
 #### Run Casbin Gateway
 
-- Build the web UI once with `cd web2 && yarn install && yarn build`, then run the backend with `go run main.go`. See [Quick start](#quick-start) for the whole path, and the [documentation](https://caswaf.org) for everything else.
+- Build the web UI once with `cd web && yarn install && yarn build`, then run the backend with `go run main.go`. See [Quick start](#quick-start) for the whole path, and the [documentation](https://caswaf.org) for everything else.
 - Open browser: http://localhost:17000/ (the backend serves the compiled UI). During frontend development, `yarn dev` serves it on http://localhost:16002/ instead and proxies API calls to port 17000.
 - Sign in as `admin` with the password `123`, then change it from the "My Account" page.
 
diff --git a/embed.go b/embed.go
index 69a21b7..624d3b3 100644
--- a/embed.go
+++ b/embed.go
@@ -19,12 +19,12 @@
 // the binary runs on its own with no files next to it. Files on disk still win
 // over the embedded copies at runtime — see the embedsupport package.
 //
-// web2/build must exist before building with this tag, or go:embed fails to
+// web/build must exist before building with this tag, or go:embed fails to
 // compile: build the frontend first with
 //
-//	cd web2 && yarn install && yarn build
+//	cd web && yarn install && yarn build
 //
-// Everything under web2/build ends up inside the binary, which is why the Vite
+// Everything under web/build ends up inside the binary, which is why the Vite
 // config turns source maps off: they are several times the size of the code
 // they map.
 
@@ -46,14 +46,14 @@ import (
 //go:embed conf/app.conf
 var embeddedAppConf string
 
-//go:embed web2/build
+//go:embed web/build
 var embeddedWeb embed.FS
 
 //go:embed ip/17monipdb.dat
 var embeddedIpDb []byte
 
 func init() {
-	webFS, err := fs.Sub(embeddedWeb, "web2/build")
+	webFS, err := fs.Sub(embeddedWeb, "web/build")
 	if err != nil {
 		panic(err)
 	}
diff --git a/embedsupport/setup.go b/embedsupport/setup.go
index ebcb025..3760ca1 100644
--- a/embedsupport/setup.go
+++ b/embedsupport/setup.go
@@ -14,13 +14,13 @@
 
 // Package embedsupport wires up the assets that are baked into the binary when
 // it is built with -tags embed: conf/app.conf, the compiled web UI in
-// web2/build, and the IP location database. Those are every file Gateway needs
+// web/build, and the IP location database. Those are every file Gateway needs
 // to find on disk at startup, so embedding them turns it into a single
 // executable that runs with nothing next to it.
 //
 // The embedded copies are a fallback, never an override: whenever the matching
 // file exists on disk it wins, so an operator can drop a conf/app.conf beside
-// the binary, or keep developing against a live web2/build, without rebuilding.
+// the binary, or keep developing against a live web/build, without rebuilding.
 //
 // A build without -tags embed never calls Setup, so both accessors report
 // "nothing embedded" and every caller keeps its original on-disk behaviour.
@@ -35,7 +35,7 @@ var (
 
 // Setup must be called before any config value is read or any request is
 // served. appConf is the contents of the embedded conf/app.conf, web is the
-// embedded web2/build tree, and ipDb is the embedded IP location database; each
+// embedded web/build tree, and ipDb is the embedded IP location database; each
 // of them may be empty or nil.
 func Setup(appConf string, web fs.FS, ipDatabase []byte) {
 	webFS = web
@@ -43,7 +43,7 @@ func Setup(appConf string, web fs.FS, ipDatabase []byte) {
 	setupConf(appConf)
 }
 
-// WebFS returns the embedded web2/build filesystem, or nil when the binary was
+// WebFS returns the embedded web/build filesystem, or nil when the binary was
 // built without embedded web assets.
 func WebFS() fs.FS { return webFS }
 
diff --git a/embedsupport/web.go b/embedsupport/web.go
index 39332d5..bcc353e 100644
--- a/embedsupport/web.go
+++ b/embedsupport/web.go
@@ -24,7 +24,7 @@ import (
 	"time"
 )
 
-// ServeWeb answers a request for a frontend asset from the embedded web2/build
+// ServeWeb answers a request for a frontend asset from the embedded web/build
 // tree. urlPath is the request path, e.g. "/" or "/static/js/main.abc123.js".
 // Anything the tree does not contain falls back to index.html, because the web
 // UI is a single-page app whose routes ("/sites", "/rules", ...) exist only in
diff --git a/i18n/generate.go b/i18n/generate.go
index 2e0a424..e5eb347 100644
--- a/i18n/generate.go
+++ b/i18n/generate.go
@@ -46,7 +46,7 @@ func getAllI18nStrings(fileContent string) []string {
 	return res
 }
 
-func getAllJsFilePaths() []string {
+func getAllSourceFilePaths() []string {
 	path := "../web/src"
 
 	res := []string{}
@@ -56,7 +56,7 @@ func getAllJsFilePaths() []string {
 				return err
 			}
 
-			if !strings.HasSuffix(info.Name(), ".js") {
+			if !strings.HasSuffix(info.Name(), ".ts") && !strings.HasSuffix(info.Name(), ".tsx") {
 				return nil
 			}
 
@@ -73,7 +73,7 @@ func getAllJsFilePaths() []string {
 
 func parseToData() *I18nData {
 	allWords := []string{}
-	paths := getAllJsFilePaths()
+	paths := getAllSourceFilePaths()
 	for _, path := range paths {
 		fileContent := util.ReadStringFromPath(path)
 		words := getAllI18nStrings(fileContent)
diff --git a/routers/filter.go b/routers/filter.go
index 3626303..d2df1b5 100644
--- a/routers/filter.go
+++ b/routers/filter.go
@@ -40,7 +40,7 @@ func TransparentStatic(ctx *context.Context) {
 
 		// Nothing is built and nothing is embedded. Serving from the preferred
 		// directory anyway turns this into a plain 404 that names it.
-		webBuildDir = webui.BuildDirs[0]
+		webBuildDir = webui.BuildDir
 	}
 
 	indexPath := webBuildDir + "/index.html"
diff --git a/service/startup.go b/service/startup.go
index e4086c8..e02b14e 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -66,7 +66,7 @@ func describeWebBuild() string {
 		return "embedded in the binary"
 	}
 
-	return fmt.Sprintf("%s is missing, run \"yarn install && yarn build\" in web2/", webui.BuildDirs[0])
+	return fmt.Sprintf("%s is missing, run \"yarn install && yarn build\" in web/", webui.BuildDir)
 }
 
 func describeGateway() string {
diff --git a/web/.eslintignore b/web/.eslintignore
deleted file mode 100644
index dd87e2d..0000000
--- a/web/.eslintignore
+++ /dev/null
@@ -1,2 +0,0 @@
-node_modules
-build
diff --git a/web/.eslintrc b/web/.eslintrc
deleted file mode 100644
index ece8846..0000000
--- a/web/.eslintrc
+++ /dev/null
@@ -1,112 +0,0 @@
-{
-  "env": {
-    "browser": true,
-    "es6": true,
-    "node": true
-  },
-  "parser": "@babel/eslint-parser",
-  "parserOptions": {
-    "ecmaVersion": 12,
-    "sourceType": "module",
-    "ecmaFeatures": {
-      "jsx": true
-    },
-    "requireConfigFile": false,
-    "babelOptions": {
-      "babelrc": false,
-      "configFile": false,
-      "presets": ["@babel/preset-react"]
-    }
-  },
-  "settings": {
-    "react": {
-      "version": "detect"
-    }
-  },
-  "plugins": ["unused-imports"],
-  "extends": ["eslint:recommended", "plugin:react/recommended"],
-  "rules": {
-    "semi": ["error", "always"],
-    "indent": ["error", 2],
-    // follow antd's style guide
-    "quotes": ["error", "double"],
-    "jsx-quotes": ["error", "prefer-double"],
-    "space-in-parens": ["error", "never"],
-    "object-curly-spacing": ["error", "never"],
-    "array-bracket-spacing": ["error", "never"],
-    "comma-spacing": ["error", { "before": false, "after": true }],
-    "react/jsx-curly-spacing": [
-      "error",
-      { "when": "never", "allowMultiline": true, "children": true }
-    ],
-    "arrow-spacing": ["error", { "before": true, "after": true }],
-    "space-before-blocks": ["error", "always"],
-    "spaced-comment": ["error", "always"],
-    "react/jsx-tag-spacing": ["error", { "beforeSelfClosing": "always" }],
-    "block-spacing": ["error", "never"],
-    "space-before-function-paren": ["error", "never"],
-    "no-trailing-spaces": ["error", { "ignoreComments": true }],
-    "eol-last": ["error", "always"],
-    "no-var": ["error"],
-    "prefer-const": [
-      "error",
-      {
-        "destructuring": "all"
-      }
-    ],
-    "curly": ["error", "all"],
-    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
-    "no-mixed-spaces-and-tabs": "error",
-    "sort-imports": [
-      "error",
-      {
-        "ignoreDeclarationSort": true
-      }
-    ],
-    "no-multiple-empty-lines": [
-      "error",
-      { "max": 1, "maxBOF": 0, "maxEOF": 0 }
-    ],
-    "space-unary-ops": ["error", { "words": true, "nonwords": false }],
-    "space-infix-ops": "error",
-    "key-spacing": ["error", { "beforeColon": false, "afterColon": true }],
-    "comma-style": ["error", "last"],
-    "comma-dangle": [
-      "error",
-      {
-        "arrays": "always-multiline",
-        "objects": "always-multiline",
-        "imports": "never",
-        "exports": "never",
-        "functions": "never"
-      }
-    ],
-    "no-multi-spaces": ["error", { "ignoreEOLComments": true }],
-    "react/no-unknown-property": [
-      "error",
-      {
-        "ignore": ["css"]
-      }
-    ],
-    "unused-imports/no-unused-imports": "error",
-    "unused-imports/no-unused-vars": [
-      "error",
-      {
-        "vars": "all",
-        "varsIgnorePattern": "^_",
-        "args": "none",
-        "argsIgnorePattern": "^_"
-      }
-    ],
-    "no-unused-vars": "off",
-    "react/no-deprecated": "error",
-    "react/jsx-key": "error",
-    "no-console": "error",
-    "eqeqeq": "error",
-    "keyword-spacing": "error",
-    "react/prop-types": "off",
-    "react/display-name": "off",
-    "react/react-in-jsx-scope": "off",
-    "no-case-declarations": "off"
-  }
-}
diff --git a/web2/.eslintrc.cjs b/web/.eslintrc.cjs
similarity index 91%
rename from web2/.eslintrc.cjs
rename to web/.eslintrc.cjs
index 4ad2f7b..25fd64c 100644
--- a/web2/.eslintrc.cjs
+++ b/web/.eslintrc.cjs
@@ -14,7 +14,7 @@ module.exports = {
   ],
   ignorePatterns: ["build", "dist", "node_modules", "*.cjs"],
   rules: {
-    // Matches the conventions the antd frontend in web/ is linted with.
+    // The house style the rest of the Casbin projects' frontends are linted with.
     quotes: ["error", "double"],
     semi: ["error", "always"],
     indent: ["error", 2, {SwitchCase: 0}],
diff --git a/web/.gitignore b/web/.gitignore
index 4d29575..9657599 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -1,23 +1,5 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.js
-
-# testing
-/coverage
-
-# production
-/build
-
-# misc
-.DS_Store
+node_modules
+build
+dist
 .env.local
-.env.development.local
-.env.test.local
-.env.production.local
-
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
+*.log
diff --git a/web/README.md b/web/README.md
index 02aac3f..d3d7efc 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,70 +1,53 @@
-# Getting Started with Create React App
+# Casbin Gateway web UI
 
-This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
+The management UI, built with [Vite](https://vite.dev), React, TypeScript, Tailwind CSS and
+[shadcn/ui](https://ui.shadcn.com). It talks to the Go backend over the `/api` endpoints.
 
-## Available Scripts
+## Running it
 
-In the project directory, you can run:
+```bash
+cd web && yarn install && yarn dev
+```
 
-### `yarn start`
+That serves the UI on http://localhost:16002 with hot reload, and proxies `/api` and `/v1` to the
+backend on port 17000, so the backend has to be running too. Point the proxy somewhere else with
+`VITE_BACKEND_URL=http://host:port yarn dev`.
 
-Runs the app in the development mode.\
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+Because the dev server proxies rather than calling an absolute URL, the browser only ever sees one
+origin: the beego session cookie is stored and replayed with no CORS or SameSite special cases, in
+development exactly as in production.
 
-The page will reload if you make edits.\
-You will also see any lint errors in the console.
+## Building it
 
-### `yarn test`
+```bash
+cd web && yarn build
+```
 
-Launches the test runner in the interactive watch mode.\
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
+The output lands in `web/build`, which is where the backend looks for the compiled UI — see
+`webui.BuildDir`.
 
-### `yarn build`
+`yarn lint` runs ESLint and `yarn fix` applies what it can fix. `yarn build` type-checks first, so a
+type error fails the build.
 
-Builds the app for production to the `build` folder.\
-It correctly bundles React in production mode and optimizes the build for the best performance.
+## Layout
 
-The build is minified and the filenames include the hashes.\
-Your app is ready to be deployed!
+| Path                 | What lives there                                                            |
+| -------------------- | --------------------------------------------------------------------------- |
+| `src/backend/`       | One typed module per REST resource; `request.ts` is the single `fetch` call   |
+| `src/pages/`         | One component per route, mounted in `src/App.tsx`                            |
+| `src/components/ui/` | shadcn/ui primitives, generated with the CLI settings in `components.json`   |
+| `src/components/`    | Shared pieces: `DataTable`, `FormRow`, the rule and node sub-tables, charts   |
+| `src/locales/`       | English and Chinese strings                                                  |
+| `src/types.ts`       | The shapes the Go API returns, including its `{status, msg, data, data2}` envelope |
 
-See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
+Translations are addressed as `i18next.t("general:Name")`, where the part before the colon is a
+top-level key of `locales/*/data.json`.
 
-### `yarn eject`
+## Adding a shadcn component
 
-**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
+```bash
+npx shadcn@latest add <component>
+```
 
-If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
-
-Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
-
-You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
-
-## Learn More
-
-You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
-
-To learn React, check out the [React documentation](https://reactjs.org/).
-
-### Code Splitting
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
-
-### Analyzing the Bundle Size
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
-
-### Making a Progressive Web App
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
-
-### Advanced Configuration
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
-
-### Deployment
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
-
-### `yarn build` fails to minify
-
-This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
+`components.json` already points the CLI at `src/components/ui`, the `@/` alias and the "new-york"
+style, so a generated component needs no further edits.
diff --git a/web2/components.json b/web/components.json
similarity index 100%
rename from web2/components.json
rename to web/components.json
diff --git a/web/craco.config.js b/web/craco.config.js
deleted file mode 100644
index 2d1256a..0000000
--- a/web/craco.config.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright 2023 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.
-
-const CracoLessPlugin = require('craco-less');
-
-module.exports = {
-  plugins: [
-    {
-      plugin: CracoLessPlugin,
-      options: {
-        lessLoaderOptions: {
-          lessOptions: {
-            modifyVars: {"@primary-color": "rgb(89,54,213)", "@border-radius-base": "5px"},
-            javascriptEnabled: true,
-          },
-        },
-      },
-    },
-  ],
-};
diff --git a/web2/index.html b/web/index.html
similarity index 100%
rename from web2/index.html
rename to web/index.html
diff --git a/web/package.json b/web/package.json
index 2511886..4a69047 100644
--- a/web/package.json
+++ b/web/package.json
@@ -2,61 +2,59 @@
   "name": "web",
   "version": "0.1.0",
   "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "start": "vite",
+    "build": "tsc -b && vite build",
+    "preview": "vite preview",
+    "lint": "eslint src --ext .ts,.tsx",
+    "fix": "eslint src --ext .ts,.tsx --fix"
+  },
   "dependencies": {
-    "@ant-design/icons": "4.6.2",
-    "@craco/craco": "6.4.5",
-    "antd": "4.24.12",
+    "@radix-ui/react-alert-dialog": "^1.1.6",
+    "@radix-ui/react-avatar": "^1.1.3",
+    "@radix-ui/react-dialog": "^1.1.6",
+    "@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-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-tooltip": "^1.1.8",
     "casdoor-js-sdk": "^0.2.7",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "cmdk": "^1.1.1",
     "copy-to-clipboard": "^3.3.3",
-    "craco-less": "2.0.0",
-    "echarts": "^5.5.0",
-    "echarts-for-react": "^3.0.2",
-    "eslint-plugin-unused-imports": "^2.0.0",
     "file-saver": "^2.0.5",
-    "i18next": "^19.8.9",
-    "moment": "^2.29.1",
-    "react": "17.0.2",
-    "react-device-detect": "1.17.0",
-    "react-dom": "17.0.2",
-    "react-i18next": "^11.8.7",
-    "react-router-dom": "5.1.2",
-    "react-scripts": "5.0.1"
+    "i18next": "^23.16.8",
+    "lucide-react": "^0.475.0",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-i18next": "^15.4.0",
+    "react-router-dom": "^6.30.0",
+    "recharts": "^2.15.0",
+    "sonner": "^2.0.1",
+    "tailwind-merge": "^2.6.0",
+    "tailwindcss-animate": "^1.0.7"
   },
   "devDependencies": {
-    "@babel/core": "^7.18.13",
-    "@babel/eslint-parser": "^7.18.9",
-    "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
-    "@babel/preset-react": "^7.18.6",
-    "eslint": "8.22.0",
-    "eslint-plugin-react": "^7.31.1"
-  },
-  "scripts": {
-    "start": "set PORT=16001 && craco start",
-    "build": "craco build",
-    "test": "craco test",
-    "eject": "craco eject",
-    "analyze": "source-map-explorer 'build/static/js/*.js'",
-    "preinstall": "node -e \"if (process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('Use yarn for installing: https://yarnpkg.com/en/docs/install')\"",
-    "fix": "eslint --fix src/**/*.{js,jsx,ts,tsx}",
-    "lint:css": "stylelint src/**/*.{css,less} --fix"
-  },
-  "eslintConfig": {
-    "extends": [
-      "react-app"
-    ]
-  },
-  "browserslist": {
-    "production": [
-      ">0.2%",
-      "not dead",
-      "not op_mini all",
-      "ie 9, ie 10, ie 11"
-    ],
-    "development": [
-      "last 1 chrome version",
-      "last 1 firefox version",
-      "last 1 safari version",
-      "ie 9, ie 10, ie 11"
-    ]
+    "@types/file-saver": "^2.0.7",
+    "@types/node": "^22.13.5",
+    "@types/react": "^18.3.18",
+    "@types/react-dom": "^18.3.5",
+    "@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",
+    "typescript": "^5.7.3",
+    "vite": "^6.2.0"
   }
 }
diff --git a/web2/postcss.config.js b/web/postcss.config.js
similarity index 100%
rename from web2/postcss.config.js
rename to web/postcss.config.js
diff --git a/web/public/index.html b/web/public/index.html
deleted file mode 100644
index 7b931d3..0000000
--- a/web/public/index.html
+++ /dev/null
@@ -1,43 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <link rel="icon" href="https://cdn.casbin.com/static/favicon.ico" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#000000" />
-    <meta
-      name="description"
-      content="Web site created using create-react-app"
-    />
-    <link rel="apple-touch-icon" href="https://cdn.casbin.com/static/favicon.ico" />
-    <!--
-      manifest.json provides metadata used when your web app is installed on a
-      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-    -->
-    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
-    <!--
-      Notice the use of %PUBLIC_URL% in the tags above.
-      It will be replaced with the URL of the `public` folder during the build.
-      Only files inside the `public` folder can be referenced from the HTML.
-
-      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
-      work correctly both with client-side routing and a non-root public URL.
-      Learn how to configure a non-root public URL by running `npm run build`.
-    -->
-    <title>Casbin Gateway</title>
-  </head>
-  <body>
-    <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
-    <!--
-      This HTML file is a template.
-      If you open it directly in the browser, you will see an empty page.
-
-      You can add webfonts, meta tags, or analytics to this file.
-      The build step will place the bundled scripts into the <body> tag.
-
-      To begin the development, run `npm start` or `yarn start`.
-      To create a production bundle, use `npm run build` or `yarn build`.
-    -->
-  </body>
-</html>
diff --git a/web/src/AccountPage.js b/web/src/AccountPage.js
deleted file mode 100644
index 21daa03..0000000
--- a/web/src/AccountPage.js
+++ /dev/null
@@ -1,185 +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 React from "react";
-import {Avatar, Button, Card, Col, Form, Input, Modal, Row} from "antd";
-import i18next from "i18next";
-import * as AccountBackend from "./backend/AccountBackend";
-import * as Setting from "./Setting";
-
-// Profile and password editing for the built-in login. Casdoor-backed accounts
-// are managed in Casdoor itself, so this page is only reachable without Casdoor.
-class AccountPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.formRef = React.createRef();
-    this.state = {
-      avatar: props.account?.avatar ?? "",
-      passwordModalVisible: false,
-      currentPassword: "",
-      newPassword: "",
-    };
-  }
-
-  onFinish(values) {
-    AccountBackend.updateAccount(values)
-      .then((res) => {
-        if (res.status === "ok") {
-          Setting.showMessage("success", "Successfully saved");
-          window.location.reload();
-        } else {
-          Setting.showMessage("error", res.msg);
-        }
-      })
-      .catch((error) => Setting.showMessage("error", error.message));
-  }
-
-  closePasswordModal() {
-    this.setState({
-      passwordModalVisible: false,
-      currentPassword: "",
-      newPassword: "",
-    });
-  }
-
-  setPassword() {
-    const values = this.formRef.current.getFieldsValue();
-    AccountBackend.updateAccount({...values, currentPassword: this.state.currentPassword, newPassword: this.state.newPassword})
-      .then((res) => {
-        if (res.status === "ok") {
-          Setting.showMessage("success", "Successfully saved");
-          this.closePasswordModal();
-        } else {
-          Setting.showMessage("error", res.msg);
-        }
-      })
-      .catch((error) => Setting.showMessage("error", error.message));
-  }
-
-  renderAvatar() {
-    const account = this.props.account;
-    const avatarUrl = this.state.avatar || "";
-
-    if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://") && !avatarUrl.startsWith("data:image/")) {
-      return (
-        <Avatar style={{backgroundColor: Setting.getAvatarColor(account.name)}} size={64}>
-          {Setting.getShortName(account.name)}
-        </Avatar>
-      );
-    }
-
-    return (
-      <Avatar src={avatarUrl} size={64}>
-        {Setting.getShortName(account.name)}
-      </Avatar>
-    );
-  }
-
-  renderField(label, control, span = 12) {
-    return (
-      <Col style={{marginTop: "12px"}} span={Setting.isMobile() ? 24 : span}>
-        <div style={{marginBottom: "6px", fontWeight: 500}}>{label}</div>
-        {control}
-      </Col>
-    );
-  }
-
-  render() {
-    const account = this.props.account;
-
-    return (
-      <div style={{padding: "16px 20px 32px"}}>
-        <div style={{marginBottom: "16px", display: "flex", justifyContent: "space-between", alignItems: "center"}}>
-          <span style={{fontSize: "22px", fontWeight: 600}}>{i18next.t("account:My Account")}</span>
-          <Button type="primary" onClick={() => this.formRef.current.submit()}>{i18next.t("general:Save")}</Button>
-        </div>
-
-        <Form
-          ref={this.formRef}
-          initialValues={{
-            username: account.name,
-            displayName: account.displayName,
-            avatar: account.avatar,
-          }}
-          onFinish={(values) => this.onFinish(values)}
-          onValuesChange={(_, values) => this.setState({avatar: values.avatar ?? ""})}
-        >
-          <Card size="small" title={i18next.t("account:Profile")} style={{marginBottom: "16px"}}>
-            <Row gutter={[16, 8]}>
-              {this.renderField(
-                i18next.t("general:Name"),
-                <Form.Item name="username" style={{margin: 0}}>
-                  <Input disabled />
-                </Form.Item>
-              )}
-              {this.renderField(
-                i18next.t("general:Display name"),
-                <Form.Item name="displayName" style={{margin: 0}}>
-                  <Input />
-                </Form.Item>
-              )}
-              {this.renderField(
-                i18next.t("account:Avatar"),
-                <Row gutter={10} align="middle">
-                  <Col flex="80px">
-                    {this.renderAvatar()}
-                  </Col>
-                  <Col flex="auto">
-                    <Form.Item name="avatar" style={{margin: 0}}>
-                      <Input placeholder={i18next.t("account:Avatar image URL, optional")} />
-                    </Form.Item>
-                  </Col>
-                </Row>,
-                24
-              )}
-            </Row>
-          </Card>
-
-          <Card size="small" title={i18next.t("general:Password")}>
-            <Button onClick={() => this.setState({passwordModalVisible: true})}>
-              {i18next.t("account:Modify password...")}
-            </Button>
-          </Card>
-        </Form>
-
-        <Modal
-          maskClosable={false}
-          title={i18next.t("account:Modify password")}
-          open={this.state.passwordModalVisible}
-          okText={i18next.t("account:Set Password")}
-          cancelText={i18next.t("general:Cancel")}
-          onCancel={() => this.closePasswordModal()}
-          onOk={() => this.setPassword()}
-          width={520}
-        >
-          <div style={{padding: "16px 0 8px"}}>
-            <div style={{marginBottom: "6px", fontWeight: 500}}>{i18next.t("account:Old Password")}</div>
-            <Input.Password
-              style={{marginBottom: "20px"}}
-              value={this.state.currentPassword}
-              onChange={e => this.setState({currentPassword: e.target.value})}
-            />
-            <div style={{marginBottom: "6px", fontWeight: 500}}>{i18next.t("account:New Password")}</div>
-            <Input.Password
-              value={this.state.newPassword}
-              onChange={e => this.setState({newPassword: e.target.value})}
-            />
-          </div>
-        </Modal>
-      </div>
-    );
-  }
-}
-
-export default AccountPage;
diff --git a/web/src/AgentRecordsPage.js b/web/src/AgentRecordsPage.js
deleted file mode 100644
index 165d89f..0000000
--- a/web/src/AgentRecordsPage.js
+++ /dev/null
@@ -1,345 +0,0 @@
-// Copyright 2025 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 React, {useCallback, useEffect, useState} from "react";
-import {Alert, Button, Descriptions, Input, Result, Select, Space, Switch, Table, Tag, Typography} from "antd";
-import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import {Link, useHistory, useLocation} from "react-router-dom";
-import * as AgentBackend from "./backend/AgentBackend";
-import * as Setting from "./Setting";
-import AgentIcon from "./components/AgentIcon";
-
-const {Text, Title} = Typography;
-
-function monitorAgentId(agentId) {
-  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.
-const limitOptions = [200, 500, 1000, 5000];
-const defaultLimit = 200;
-
-// A translucent overlay rather than a fixed grey, so the payload block stays
-// readable whichever theme the surrounding app is using.
-const payloadStyle = {
-  background: "rgba(127, 127, 127, 0.12)",
-  color: "inherit",
-  borderRadius: 4,
-  fontFamily: "monospace",
-  fontSize: 12,
-  margin: 0,
-  maxHeight: 300,
-  overflow: "auto",
-  padding: 8,
-  whiteSpace: "pre-wrap",
-  wordBreak: "break-all",
-};
-
-function formatPayload(object) {
-  if (typeof object !== "string") {
-    return JSON.stringify(object, null, 2);
-  }
-
-  try {
-    return JSON.stringify(JSON.parse(object), null, 2);
-  } catch {
-    return object;
-  }
-}
-
-function getOutcomeColor(outcome) {
-  return {
-    attempted: "processing",
-    denied: "warning",
-    failure: "error",
-    success: "success",
-  }[outcome];
-}
-
-function RecordDetail({record}) {
-  const mcpTarget = record.mcpServer && `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : ""}`;
-
-  return (
-    <Descriptions bordered column={1} size="small" style={{margin: 8}}>
-      <Descriptions.Item label={i18next.t("general:ID")}>{record.id}</Descriptions.Item>
-      <Descriptions.Item label={i18next.t("agent:Time")}>{new Date(record.createdTime).toLocaleString()}</Descriptions.Item>
-      {record.agentPath && <Descriptions.Item label={i18next.t("agent:Agent path")}><Text code>{record.agentPath}</Text></Descriptions.Item>}
-      {record.user && <Descriptions.Item label={i18next.t("agent:User")}>{record.user}</Descriptions.Item>}
-      {record.sessionKey && <Descriptions.Item label={i18next.t("agent:Session")}><Text code>{record.sessionKey}</Text></Descriptions.Item>}
-      {record.title && <Descriptions.Item label={i18next.t("agent:Session title")}>{record.title}</Descriptions.Item>}
-      {record.promptId && <Descriptions.Item label={i18next.t("agent:Prompt ID")}><Text code>{record.promptId}</Text></Descriptions.Item>}
-      {record.toolUseId && <Descriptions.Item label={i18next.t("agent:Tool use ID")}><Text code>{record.toolUseId}</Text></Descriptions.Item>}
-      {record.toolName && <Descriptions.Item label={i18next.t("agent:Tool")}><Text code>{record.toolName}</Text></Descriptions.Item>}
-      {mcpTarget && <Descriptions.Item label={i18next.t("agent:MCP target")}><Text code>{mcpTarget}</Text></Descriptions.Item>}
-      {record.model && <Descriptions.Item label={i18next.t("agent:Model")}><Text code>{record.model}</Text></Descriptions.Item>}
-      {record.durationMs !== undefined && <Descriptions.Item label={i18next.t("agent:Duration")}>{record.durationMs.toLocaleString()} ms</Descriptions.Item>}
-      {record.clientIp && <Descriptions.Item label={i18next.t("agent:Reported from")}><Text code>{record.clientIp}</Text></Descriptions.Item>}
-      {record.detail && <Descriptions.Item label={i18next.t("agent:Detail")}>{record.detail}</Descriptions.Item>}
-      {record.object && (
-        <Descriptions.Item label={i18next.t("agent:Payload")}>
-          <pre style={payloadStyle}>{formatPayload(record.object)}</pre>
-        </Descriptions.Item>
-      )}
-    </Descriptions>
-  );
-}
-
-export default function AgentRecordsPage({account}) {
-  const history = useHistory();
-  const location = useLocation();
-  const search = new URLSearchParams(location.search);
-  const agent = search.get("agent") || "";
-  const eventType = search.get("eventType") || "";
-  const outcome = search.get("outcome") || "";
-  const session = search.get("session") || "";
-  const limit = Number(search.get("limit")) || defaultLimit;
-  const isAdmin = Setting.isAdminUser(account);
-  const [agents, setAgents] = useState([]);
-  const [records, setRecords] = useState([]);
-  const [error, setError] = useState("");
-  const [loading, setLoading] = useState(false);
-  const [autoRefresh, setAutoRefresh] = useState(true);
-  const [sessionDraft, setSessionDraft] = useState(session);
-
-  const load = useCallback((foreground = true) => {
-    if (!isAdmin) {
-      return;
-    }
-
-    // Background refreshes must not raise the table's loading state: the poll
-    // below runs every few seconds and would otherwise cover the rows the
-    // operator is reading, and spin the refresh button permanently.
-    if (foreground) {
-      setLoading(true);
-    }
-    AgentBackend.getAgentRecords(agent, eventType, outcome, session, limit).then(res => {
-      if (res.status === "ok") {
-        setRecords(res.data || []);
-        setError("");
-      } else {
-        setError(res.msg || i18next.t("agent:Failed to get agent records"));
-      }
-    }).catch(err => {
-      setError(err.message || String(err));
-    }).then(() => {
-      if (foreground) {
-        setLoading(false);
-      }
-    });
-  }, [agent, eventType, isAdmin, limit, outcome, session]);
-
-  useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-
-    AgentBackend.getAgents().then(res => {
-      if (res.status === "ok") {
-        setAgents(res.data || []);
-      }
-    });
-  }, [isAdmin]);
-
-  useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-
-    load(true);
-    if (!autoRefresh) {
-      return undefined;
-    }
-    const interval = setInterval(() => load(false), 3000);
-    return () => clearInterval(interval);
-  }, [autoRefresh, isAdmin, load]);
-
-  useEffect(() => {
-    setSessionDraft(session);
-  }, [session]);
-
-  if (!isAdmin) {
-    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={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>}
-      />
-    );
-  }
-
-  const setFilter = (key, value) => {
-    const params = new URLSearchParams(location.search);
-    if (value) {
-      params.set(key, value);
-    } else {
-      params.delete(key);
-    }
-    const query = params.toString();
-    history.push(query ? `/agent-records?${query}` : "/agent-records");
-  };
-
-  const agentOptions = [
-    {label: i18next.t("agent:All agents"), value: ""},
-    ...Array.from(new Set(agents.map(item => monitorAgentId(item.agentId)))).filter(Boolean).map(value => ({label: value, value: value})),
-  ];
-
-  const columns = [
-    {
-      title: i18next.t("agent:Time"),
-      dataIndex: "createdTime",
-      key: "createdTime",
-      width: 180,
-      render: value => new Date(value).toLocaleString(),
-    },
-    {
-      title: i18next.t("agent:Agent"),
-      dataIndex: "agent",
-      key: "agent",
-      width: 150,
-      render: value => (
-        <Tag color="blue" style={{alignItems: "center", display: "inline-flex", gap: 6}}>
-          <AgentIcon agent={value} fallback={<RobotOutlined />} size={16} />
-          {value}
-        </Tag>
-      ),
-    },
-    {
-      title: i18next.t("agent:Event"),
-      key: "event",
-      width: 180,
-      render: (_, record) => (
-        <Space size={4} wrap>
-          <Tag>{record.eventType}</Tag>
-          {record.action && <Text code>{record.action}</Text>}
-          {record.outcome && <Tag color={getOutcomeColor(record.outcome)}>{record.outcome}</Tag>}
-        </Space>
-      ),
-    },
-    {
-      title: i18next.t("agent:Target / Model"),
-      key: "target",
-      width: 190,
-      render: (_, record) => {
-        const target = record.mcpServer
-          ? `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : ""}`
-          : record.toolName;
-        return (
-          <Space direction="vertical" size={0}>
-            {target && <Text code>{target}</Text>}
-            {record.model && <Text type="secondary">{record.model}</Text>}
-          </Space>
-        );
-      },
-    },
-    {
-      title: i18next.t("agent:Session"),
-      dataIndex: "sessionKey",
-      key: "sessionKey",
-      width: 220,
-      render: (value, record) => value && (
-        <Space direction="vertical" size={0} style={{display: "flex", minWidth: 0}}>
-          {record.title && <Text strong ellipsis>{record.title}</Text>}
-          <Link to={`/agent-records?agent=${encodeURIComponent(record.agent)}&session=${encodeURIComponent(value)}`} style={{overflow: "hidden", textOverflow: "ellipsis"}}>{value}</Link>
-        </Space>
-      ),
-    },
-    {
-      title: i18next.t("agent:Duration"),
-      dataIndex: "durationMs",
-      key: "durationMs",
-      width: 110,
-      render: value => value === undefined ? null : `${value.toLocaleString()} ms`,
-    },
-  ];
-
-  return (
-    <div style={{padding: "24px"}}>
-      <Space style={{display: "flex", justifyContent: "space-between", marginBottom: 16}}>
-        <Title level={3} style={{margin: 0}}>{i18next.t("agent:Agent Records")}</Title>
-        <Space>
-          <Switch
-            checked={autoRefresh}
-            checkedChildren={i18next.t("agent:Auto refresh")}
-            unCheckedChildren={i18next.t("agent:Auto refresh")}
-            onChange={setAutoRefresh}
-          />
-          <Button icon={<ReloadOutlined />} loading={loading} onClick={() => load(true)}>{i18next.t("general:Refresh")}</Button>
-        </Space>
-      </Space>
-      <Alert
-        type="info"
-        showIcon
-        message={i18next.t("agent:Records are kept in memory only and are lost when Gateway restarts")}
-        style={{marginBottom: 16}}
-      />
-      <Space wrap style={{marginBottom: 16}}>
-        <Select
-          value={agent}
-          options={agentOptions}
-          onChange={value => setFilter("agent", value)}
-          style={{width: 190}}
-        />
-        <Select
-          value={eventType}
-          options={[
-            {label: i18next.t("agent:All event types"), value: ""},
-            ...["session", "prompt", "llm", "tool", "mcp", "permission", "subagent", "compact"].map(value => ({label: value, value: value})),
-          ]}
-          onChange={value => setFilter("eventType", value)}
-          style={{width: 170}}
-        />
-        <Select
-          value={outcome}
-          options={[
-            {label: i18next.t("agent:All outcomes"), value: ""},
-            ...["attempted", "success", "failure", "denied"].map(value => ({label: value, value: value})),
-          ]}
-          onChange={value => setFilter("outcome", value)}
-          style={{width: 160}}
-        />
-        <Select
-          value={limit}
-          options={limitOptions.map(value => ({label: i18next.t("agent:Last {count} records").replace("{count}", value), value: value}))}
-          onChange={value => setFilter("limit", value === defaultLimit ? "" : String(value))}
-          style={{width: 170}}
-        />
-        <Input.Search
-          allowClear
-          enterButton={i18next.t("agent:Filter")}
-          onChange={event => setSessionDraft(event.target.value)}
-          onSearch={value => setFilter("session", value)}
-          placeholder={i18next.t("agent:Session")}
-          style={{width: 290}}
-          value={sessionDraft}
-        />
-      </Space>
-      {error && <Alert type="error" message={error} style={{marginBottom: 16}} />}
-      <Table
-        rowKey="id"
-        dataSource={records}
-        columns={columns}
-        loading={loading}
-        pagination={{pageSize: 20}}
-        size="small"
-        scroll={{x: 1030}}
-        locale={{emptyText: i18next.t("agent:No agent records yet - patch an agent to start collecting them")}}
-        expandable={{expandedRowRender: record => <RecordDetail record={record} />}}
-      />
-    </div>
-  );
-}
diff --git a/web/src/AgentSessionsPage.js b/web/src/AgentSessionsPage.js
deleted file mode 100644
index 4f6d3ca..0000000
--- a/web/src/AgentSessionsPage.js
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright 2025 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 React, {useCallback, useEffect, useState} from "react";
-import {Alert, Button, Result, Space, Table, Tag, Typography} from "antd";
-import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import {Link} from "react-router-dom";
-import * as AgentBackend from "./backend/AgentBackend";
-import * as Setting from "./Setting";
-import AgentIcon from "./components/AgentIcon";
-
-const {Text, Title} = Typography;
-
-export default function AgentSessionsPage({account}) {
-  const [sessions, setSessions] = useState([]);
-  const [error, setError] = useState("");
-  const [loading, setLoading] = useState(false);
-  const isAdmin = Setting.isAdminUser(account);
-
-  const load = useCallback(() => {
-    if (!isAdmin) {
-      return;
-    }
-
-    setLoading(true);
-    AgentBackend.getAgentSessions().then(res => {
-      if (res.status === "ok") {
-        setSessions(res.data || []);
-        setError("");
-      } else {
-        setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
-      }
-    }).catch(err => {
-      setError(err.message || String(err));
-    }).then(() => {
-      setLoading(false);
-    });
-  }, [isAdmin]);
-
-  useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-
-    load();
-    const interval = setInterval(load, 3000);
-    return () => clearInterval(interval);
-  }, [isAdmin, load]);
-
-  if (!isAdmin) {
-    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={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>}
-      />
-    );
-  }
-
-  const columns = [
-    {
-      title: i18next.t("agent:Session"),
-      key: "session",
-      render: (_, session) => (
-        <Space direction="vertical" size={0}>
-          <Link to={`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`}>
-            {session.title || session.sessionKey}
-          </Link>
-          <Text type="secondary" style={{fontSize: 12}} ellipsis>{session.sessionKey}</Text>
-        </Space>
-      ),
-    },
-    {
-      title: i18next.t("agent:Agent"),
-      dataIndex: "agent",
-      key: "agent",
-      render: value => (
-        <Tag color="blue" style={{alignItems: "center", display: "inline-flex", gap: 6}}>
-          <AgentIcon agent={value} fallback={<RobotOutlined />} size={16} />
-          {value}
-        </Tag>
-      ),
-    },
-    {
-      title: i18next.t("agent:Records"),
-      dataIndex: "recordCount",
-      key: "recordCount",
-    },
-    {
-      title: i18next.t("agent:First activity"),
-      dataIndex: "firstTime",
-      key: "firstTime",
-      render: value => new Date(value).toLocaleString(),
-    },
-    {
-      title: i18next.t("agent:Last activity"),
-      dataIndex: "lastTime",
-      key: "lastTime",
-      render: value => new Date(value).toLocaleString(),
-    },
-  ];
-
-  return (
-    <div style={{padding: "24px"}}>
-      <Space style={{display: "flex", justifyContent: "space-between", marginBottom: 16}}>
-        <Title level={3} style={{margin: 0}}>{i18next.t("agent:Agent Sessions")}</Title>
-        <Button icon={<ReloadOutlined />} loading={loading} onClick={load}>{i18next.t("general:Refresh")}</Button>
-      </Space>
-      {error && <Alert type="error" message={error} style={{marginBottom: 16}} />}
-      <Table
-        rowKey={session => `${session.agent}:${session.sessionKey}`}
-        dataSource={sessions}
-        columns={columns}
-        loading={loading}
-        pagination={{pageSize: 20}}
-        size="small"
-        locale={{emptyText: i18next.t("agent:No agent sessions yet - patch an agent to start collecting them")}}
-      />
-    </div>
-  );
-}
diff --git a/web/src/AgentsPage.js b/web/src/AgentsPage.js
deleted file mode 100644
index 41bee33..0000000
--- a/web/src/AgentsPage.js
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright 2025 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 React, {useCallback, useEffect, useState} from "react";
-import {Alert, Button, Popconfirm, Result, Space, Table, Tag, Tooltip, Typography} from "antd";
-import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import {Link} from "react-router-dom";
-import * as AgentBackend from "./backend/AgentBackend";
-import * as Setting from "./Setting";
-import AgentIcon from "./components/AgentIcon";
-
-const {Text, Title} = Typography;
-
-const rowKey = record => `${record.owner}:${record.path}`;
-
-function monitorAgentId(agentId) {
-  return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
-}
-
-export default function AgentsPage({account}) {
-  const [agents, setAgents] = useState([]);
-  const [loading, setLoading] = useState(false);
-  const [error, setError] = useState("");
-  const [busyKey, setBusyKey] = useState("");
-  const isAdmin = Setting.isAdminUser(account);
-
-  const scan = useCallback((forceRefresh = false) => {
-    if (!isAdmin) {
-      return;
-    }
-
-    setLoading(true);
-    setError("");
-    AgentBackend.getAgents(forceRefresh).then(res => {
-      if (res.status === "ok") {
-        setAgents(res.data || []);
-      } else {
-        setError(res.msg || i18next.t("agent:Failed to scan agents"));
-      }
-    }).catch(err => {
-      setError(err.message || String(err));
-    }).then(() => {
-      setLoading(false);
-    });
-  }, [isAdmin]);
-
-  useEffect(() => {
-    scan();
-  }, [scan]);
-
-  const togglePatch = record => {
-    const target = {
-      agentId: record.agentId,
-      path: record.path,
-      owner: record.owner,
-    };
-    const patched = record.patched;
-
-    setBusyKey(rowKey(record));
-    (patched ? AgentBackend.unpatchAgent(target) : AgentBackend.patchAgent(target)).then(res => {
-      if (res.status === "ok") {
-        const done = patched ? i18next.t("agent:Monitoring disabled") : i18next.t("agent:Monitoring enabled");
-        const followup = res.data?.followup || record.followup;
-        Setting.showMessage("success", followup
-          ? `${done}: ${record.name}. ${followup}`
-          : `${done}: ${record.name}`);
-        scan();
-      } else {
-        Setting.showMessage("error", res.msg || i18next.t("agent:Failed to update agent patch"));
-      }
-    }).catch(err => {
-      Setting.showMessage("error", err.message || String(err));
-    }).then(() => {
-      setBusyKey("");
-    });
-  };
-
-  if (!isAdmin) {
-    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={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>}
-      />
-    );
-  }
-
-  const renderPatchStatus = (_, record) => {
-    if (!record.supported) {
-      const tag = <Tag>{i18next.t("agent:Not supported")}</Tag>;
-      return record.detail ? <Tooltip title={record.detail}>{tag}</Tooltip> : tag;
-    }
-
-    const tag = record.patched
-      ? <Tag color="green">{i18next.t("agent:Patched")}</Tag>
-      : <Tag>{i18next.t("agent:Not patched")}</Tag>;
-    return record.detail ? <Tooltip title={record.detail}>{tag}</Tooltip> : tag;
-  };
-
-  const renderAction = (_, record) => {
-    if (!record.supported) {
-      return <Button size="small" disabled>{i18next.t("agent:Patch")}</Button>;
-    }
-
-    const patched = record.patched;
-    const actionKey = patched ? "Unpatch" : "Patch";
-    const action = i18next.t(`agent:${actionKey}`);
-    const note = [record.notice, record.followup].filter(Boolean).join(" ");
-    return (
-      <Popconfirm
-        title={note ? `${action} ${record.name}? ${note}` : `${action} ${record.name}?`}
-        okText={action}
-        cancelText={i18next.t("general:Cancel")}
-        onConfirm={() => togglePatch(record)}
-      >
-        <Button size="small" type={patched ? "default" : "primary"} loading={busyKey === rowKey(record)}>
-          {action}
-        </Button>
-      </Popconfirm>
-    );
-  };
-
-  const columns = [
-    {
-      title: i18next.t("agent:Agent"),
-      dataIndex: "name",
-      key: "name",
-      render: (value, record) => (
-        <Space size={8}>
-          <AgentIcon
-            agent={record.agentId || value}
-            fallback={<RobotOutlined style={{fontSize: 18, color: "#8c8c8c"}} />}
-          />
-          {value}
-        </Space>
-      ),
-    },
-    {
-      title: i18next.t("agent:Version"),
-      dataIndex: "version",
-      key: "version",
-      render: value => value || i18next.t("agent:Unknown"),
-    },
-    {
-      title: i18next.t("agent:Install Method"),
-      dataIndex: "installMethod",
-      key: "installMethod",
-      render: value => <Tag>{value || "-"}</Tag>,
-    },
-    {
-      title: i18next.t("general:Owner"),
-      dataIndex: "owner",
-      key: "owner",
-    },
-    {
-      title: i18next.t("general:Path"),
-      dataIndex: "path",
-      key: "path",
-      render: value => <Text code>{value}</Text>,
-    },
-    {
-      title: i18next.t("agent:Patch Status"),
-      key: "patched",
-      render: renderPatchStatus,
-    },
-    {
-      title: i18next.t("agent:Records"),
-      key: "records",
-      render: (_, record) => record.patched && (
-        <Link to={`/agent-records?agent=${encodeURIComponent(monitorAgentId(record.agentId))}`}>
-          {i18next.t("agent:View Records")}
-        </Link>
-      ),
-    },
-    {
-      title: i18next.t("general:Action"),
-      key: "action",
-      render: renderAction,
-    },
-  ];
-
-  return (
-    <div style={{padding: "24px"}}>
-      <Space style={{display: "flex", justifyContent: "space-between", marginBottom: 16}}>
-        <Title level={3} style={{margin: 0}}>{i18next.t("agent:Agents")}</Title>
-        <Button icon={<ReloadOutlined />} loading={loading} onClick={() => scan(true)}>
-          {i18next.t("agent:Scan")}
-        </Button>
-      </Space>
-      {error && <Alert type="error" message={error} style={{marginBottom: 16}} />}
-      <Table
-        rowKey={rowKey}
-        dataSource={agents}
-        columns={columns}
-        loading={loading}
-        pagination={false}
-        locale={{emptyText: i18next.t("agent:No supported agents found")}}
-        size="small"
-        bordered
-        scroll={{x: true}}
-      />
-    </div>
-  );
-}
diff --git a/web/src/App.css b/web/src/App.css
deleted file mode 100644
index 016ba35..0000000
--- a/web/src/App.css
+++ /dev/null
@@ -1,40 +0,0 @@
-@import '~antd/dist/antd.css';
-
-.App {
-  text-align: center;
-}
-
-.App-logo {
-  height: 40vmin;
-  pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
-  .App-logo {
-    animation: App-logo-spin infinite 20s linear;
-  }
-}
-
-.App-header {
-  background-color: #282c34;
-  min-height: 100vh;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  font-size: calc(10px + 2vmin);
-  color: white;
-}
-
-.App-link {
-  color: #61dafb;
-}
-
-@keyframes App-logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}
diff --git a/web/src/App.js b/web/src/App.js
deleted file mode 100644
index e97bc87..0000000
--- a/web/src/App.js
+++ /dev/null
@@ -1,540 +0,0 @@
-// Copyright 2023 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 React, {Component} from "react";
-import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
-import {Avatar, BackTop, Drawer, Dropdown, Layout, Menu, Tooltip} from "antd";
-import {DeploymentUnitOutlined, DownOutlined, FileSearchOutlined, GithubOutlined, LogoutOutlined, MessageOutlined, RobotOutlined, SettingOutlined, ShareAltOutlined} from "@ant-design/icons";
-import "./App.less";
-import * as Setting from "./Setting";
-import * as AccountBackend from "./backend/AccountBackend";
-import AuthCallback from "./AuthCallback";
-import * as Conf from "./Conf";
-import HomePage from "./HomePage";
-import NodeListPage from "./NodeListPage";
-import NodeEditPage from "./NodeEditPage";
-import SiteListPage from "./SiteListPage";
-import SiteEditPage from "./SiteEditPage";
-import CertListPage from "./CertListPage";
-import CertEditPage from "./CertEditPage";
-import RuleListPage from "./RuleListPage";
-import RuleEditPage from "./RuleEditPage";
-import ChannelListPage from "./ChannelListPage";
-import ChannelEditPage from "./ChannelEditPage";
-import SigninPage from "./SigninPage";
-import AccountPage from "./AccountPage";
-import RecordListPage from "./RecordListPage";
-import RecordEditPage from "./RecordEditPage";
-import i18next from "i18next";
-import DashboardPage from "./DashboardPage";
-import AgentsPage from "./AgentsPage";
-import AgentRecordsPage from "./AgentRecordsPage";
-import AgentSessionsPage from "./AgentSessionsPage";
-import LanguageSelect from "./LanguageSelect";
-import {withTranslation} from "react-i18next";
-// import SelectLanguageBox from "./SelectLanguageBox";
-
-const {Header, Footer} = Layout;
-
-class App extends Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      selectedMenuKey: 0,
-      account: undefined,
-      uri: null,
-      isAiAssistantOpen: false,
-    };
-
-    Setting.initServerUrl();
-    Setting.initCasdoorSdk(Conf.AuthConfig);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.updateMenuKey();
-    this.getSigninOptions();
-  }
-
-  // The auth config lives in app.conf, so ask the backend for it before doing
-  // anything that depends on whether Casdoor is configured at all.
-  getSigninOptions() {
-    AccountBackend.getSigninOptions()
-      .then((res) => {
-        if (res.status === "ok") {
-          Conf.setAuthConfig(res.data.authConfig);
-          Setting.initCasdoorSdk(Conf.AuthConfig);
-        }
-        this.getAccount();
-      })
-      .catch(() => {
-        this.getAccount();
-      });
-  }
-
-  componentDidUpdate() {
-    // eslint-disable-next-line no-restricted-globals
-    const uri = location.pathname;
-    if (this.state.uri !== uri) {
-      this.updateMenuKey();
-    }
-  }
-
-  updateMenuKey() {
-    // eslint-disable-next-line no-restricted-globals
-    const uri = location.pathname;
-    this.setState({
-      uri: uri,
-    });
-    if (uri === "/") {
-      this.setState({selectedMenuKey: "/"});
-    } else if (uri.includes("/dashboard")) {
-      this.setState({selectedMenuKey: "/dashboard"});
-    } else if (uri.includes("/agent-records")) {
-      this.setState({selectedMenuKey: "/agent-records"});
-    } else if (uri.includes("/agent-sessions")) {
-      this.setState({selectedMenuKey: "/agent-sessions"});
-    } else if (uri.includes("/agents")) {
-      this.setState({selectedMenuKey: "/agents"});
-    } else if (uri.includes("/nodes")) {
-      this.setState({selectedMenuKey: "/nodes"});
-    } else if (uri.includes("/sites")) {
-      this.setState({selectedMenuKey: "/sites"});
-    } else if (uri.includes("/certs")) {
-      this.setState({selectedMenuKey: "/certs"});
-    } else if (uri.includes("/records")) {
-      this.setState({selectedMenuKey: "/records"});
-    } else if (uri.includes("/rules")) {
-      this.setState({selectedMenuKey: "/rules"});
-    } else if (uri.includes("/channels")) {
-      this.setState({selectedMenuKey: "/channels"});
-    } else {
-      this.setState({selectedMenuKey: "null"});
-    }
-  }
-
-  onUpdateAccount(account) {
-    this.setState({
-      account: account,
-    });
-  }
-
-  setLanguage(account) {
-    // let language = account?.language;
-    const language = localStorage.getItem("language");
-    if (language !== "" && language !== i18next.language) {
-      Setting.setLanguage(language);
-    }
-  }
-
-  openAiAssistant = () => {
-    this.setState({
-      isAiAssistantOpen: true,
-    });
-  };
-
-  getAccount() {
-    AccountBackend.getAccount()
-      .then((res) => {
-        const account = res.data;
-        if (account !== null) {
-          this.setLanguage(account);
-          account.hostname = res.data2;
-        }
-
-        this.setState({
-          account: account,
-        });
-      });
-  }
-
-  signout() {
-    AccountBackend.signout()
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            account: null,
-          });
-
-          Setting.showMessage("success", "Successfully signed out, redirected to homepage");
-          Setting.goToLink("/");
-          // this.props.history.push("/");
-        } else {
-          Setting.showMessage("error", `Signout failed: ${res.msg}`);
-        }
-      });
-  }
-
-  handleRightDropdownClick(e) {
-    if (e.key === "/account") {
-      const profileUrl = Setting.getMyProfileUrl(this.state.account);
-      if (profileUrl === "") {
-        // Built-in login: the profile is edited in Gateway itself.
-        this.props.history.push("/account");
-      } else {
-        Setting.openLink(profileUrl);
-      }
-    } else if (e.key === "/logout") {
-      this.signout();
-    }
-  }
-
-  renderAvatar() {
-    if (this.state.account.avatar === "") {
-      return (
-        <Avatar style={{backgroundColor: Setting.getAvatarColor(this.state.account.name), verticalAlign: "middle"}} size="large">
-          {Setting.getShortName(this.state.account.name)}
-        </Avatar>
-      );
-    } else {
-      return (
-        <Avatar src={this.state.account.avatar} style={{verticalAlign: "middle"}} size="large">
-          {Setting.getShortName(this.state.account.name)}
-        </Avatar>
-      );
-    }
-  }
-
-  renderRightDropdown() {
-    const menu = (
-      <Menu onClick={this.handleRightDropdownClick.bind(this)}>
-        <Menu.Item key="/account" icon={<SettingOutlined />}>
-          {i18next.t("account:My Account")}
-        </Menu.Item>
-        <Menu.Item key="/logout" icon={<LogoutOutlined />}>
-          {i18next.t("account:Sign Out")}
-        </Menu.Item>
-      </Menu>
-    );
-
-    return (
-      <Dropdown key="/rightDropDown" overlay={menu} className="rightDropDown">
-        <div className="ant-dropdown-link" style={{float: "right", cursor: "pointer"}}>
-          &nbsp;
-          &nbsp;
-          {
-            this.renderAvatar()
-          }
-          &nbsp;
-          &nbsp;
-          {Setting.isMobile() ? null : Setting.getShortName(this.state.account.displayName)} &nbsp; <DownOutlined />
-          &nbsp;
-          &nbsp;
-          &nbsp;
-        </div>
-      </Dropdown>
-    );
-  }
-
-  renderAccount() {
-    const res = [];
-
-    if (this.state.account === undefined) {
-      return null;
-    } else if (this.state.account === null) {
-      // Signing up is a Casdoor feature; the built-in login has no self-service.
-      if (Setting.getSignupUrl() !== "") {
-        res.push(
-          <Menu.Item key="/signup" style={{float: "right", marginRight: "20px"}}>
-            <a href={Setting.getSignupUrl()}>
-              {i18next.t("account:Sign Up")}
-            </a>
-          </Menu.Item>
-        );
-      }
-      res.push(
-        <Menu.Item key="/signin" style={{float: "right"}}>
-          <a href={Setting.getSigninUrl() || "/signin"}>
-            {i18next.t("account:Sign In")}
-          </a>
-        </Menu.Item>
-      );
-      res.push(
-        <Menu.Item key="/" style={{float: "right"}}>
-          <a href="/">
-            {i18next.t("general:Home")}
-          </a>
-        </Menu.Item>
-      );
-      return (
-        <Menu>
-          {
-            res
-          }
-        </Menu>
-      );
-    } else {
-      res.push(
-        <div style={{float: "right", display: "flex", alignItems: "center"}}>
-          <Tooltip title="Click to open AI assitant" style={{marginRight: "50px", marginTop: "10px"}} >
-            <div className="select-box" onClick={this.openAiAssistant}>
-              <DeploymentUnitOutlined style={{fontSize: "24px"}} />
-            </div>
-          </Tooltip>
-          <LanguageSelect />
-          {this.renderRightDropdown()}
-        </div>
-      );
-      return (
-        <div style={{margin: "0px", padding: "0px"}}>
-          {
-            res
-          }
-        </div>
-      );
-    }
-  }
-
-  renderMenu() {
-    const res = [];
-
-    if (this.state.account === null || this.state.account === undefined) {
-      return [];
-    }
-
-    res.push(
-      <Menu.Item key="/">
-        <a href="/">
-          {i18next.t("general:Home")}
-        </a>
-        {/* <Link to="/">*/}
-        {/*  Home*/}
-        {/* </Link>*/}
-      </Menu.Item>
-    );
-
-    res.push(
-      <Menu.Item key="/dashboard">
-        <Link to="/dashboard">
-          {i18next.t("general:Dashboard")}
-        </Link>
-      </Menu.Item>
-    );
-    if (Setting.isAdminUser(this.state.account)) {
-      res.push(
-        <Menu.Item key="/agents" icon={<RobotOutlined />}>
-          <Link to="/agents">
-            {i18next.t("agent:Agents")}
-          </Link>
-        </Menu.Item>
-      );
-      res.push(
-        <Menu.Item key="/agent-records" icon={<FileSearchOutlined />}>
-          <Link to="/agent-records">
-            {i18next.t("agent:Agent Records")}
-          </Link>
-        </Menu.Item>
-      );
-      res.push(
-        <Menu.Item key="/agent-sessions" icon={<MessageOutlined />}>
-          <Link to="/agent-sessions">
-            {i18next.t("agent:Agent Sessions")}
-          </Link>
-        </Menu.Item>
-      );
-    }
-    res.push(
-      <Menu.Item key="/nodes">
-        <Link to="/nodes">
-          {i18next.t("general:Nodes")}
-        </Link>
-      </Menu.Item>
-    );
-    res.push(
-      <Menu.Item key="/sites">
-        <Link to="/sites">
-          {i18next.t("general:Sites")}
-        </Link>
-      </Menu.Item>
-    );
-    res.push(
-      <Menu.Item key="/certs">
-        <Link to="/certs">
-          {i18next.t("general:Certs")}
-        </Link>
-      </Menu.Item>
-    );
-
-    res.push(
-      <Menu.Item key="/records">
-        <Link to="/records">
-          {i18next.t("general:Records")}
-        </Link>
-      </Menu.Item>
-    );
-
-    res.push(
-      <Menu.Item key="/rules">
-        <Link to="/rules">
-          {i18next.t("general:Rules")}
-        </Link>
-      </Menu.Item>
-    );
-    // Mount Channel menu for LLM gateway milestone 1.1.
-    res.push(
-      <Menu.Item key="/channels">
-        <Link to="/channels">
-          {i18next.t("channel:Channels")}
-        </Link>
-      </Menu.Item>
-    );
-    return res;
-  }
-
-  renderHomeIfSignedIn(component) {
-    if (this.state.account !== null && this.state.account !== undefined) {
-      return <Redirect to="/" />;
-    } else {
-      return component;
-    }
-  }
-
-  renderSigninIfNotSignedIn(component) {
-    if (this.state.account === null) {
-      sessionStorage.setItem("from", window.location.pathname);
-      return <Redirect to="/signin" />;
-    } else if (this.state.account === undefined) {
-      return null;
-    } else {
-      return component;
-    }
-  }
-
-  renderContent() {
-    return (
-      <div>
-        <Header style={{padding: "0", marginBottom: "3px", backgroundColor: "white", width: "100%"}} mode={"horizontal"}>
-          {
-            Setting.isMobile() ? null : (
-              <Link to={"/"}>
-                <div className="logo" />
-              </Link>
-            )
-          }
-          <Menu
-            // theme="dark"
-            mode={"horizontal"}
-            selectedKeys={[`${this.state.selectedMenuKey}`]}
-            style={{lineHeight: "64px", position: "absolute", left: 138, right: "300px"}}
-          >
-            {
-              this.renderMenu()
-            }
-            {/* <SelectLanguageBox /> */}
-          </Menu>
-          {
-            this.renderAccount()
-          }
-        </Header>
-        <Switch>
-          <Route exact path="/callback" component={AuthCallback} />
-          <Route exact path="/home" render={(props) => <HomePage account={this.state.account} {...props} />} />
-          <Route exact path="/" render={(props) => <Redirect to="/sites" />} />
-          <Route exact path="/signin" render={(props) => this.renderHomeIfSignedIn(<SigninPage {...props} />)} />
-          <Route exact path="/account" render={(props) => this.renderSigninIfNotSignedIn(<AccountPage account={this.state.account} {...props} />)} />
-          <Route exact path="/agents" render={(props) => this.renderSigninIfNotSignedIn(<AgentsPage account={this.state.account} {...props} />)} />
-          <Route exact path="/agent-records" render={(props) => this.renderSigninIfNotSignedIn(<AgentRecordsPage account={this.state.account} {...props} />)} />
-          <Route exact path="/agent-sessions" render={(props) => this.renderSigninIfNotSignedIn(<AgentSessionsPage account={this.state.account} {...props} />)} />
-          <Route exact path="/nodes" render={(props) => this.renderSigninIfNotSignedIn(<NodeListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/nodes/:owner/:nodeName" render={(props) => this.renderSigninIfNotSignedIn(<NodeEditPage account={this.state.account} {...props} />)} />
-          <Route exact path="/sites" render={(props) => this.renderSigninIfNotSignedIn(<SiteListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/sites/:owner/:siteName" render={(props) => this.renderSigninIfNotSignedIn(<SiteEditPage account={this.state.account} {...props} />)} />
-          <Route exact path="/certs" render={(props) => this.renderSigninIfNotSignedIn(<CertListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/certs/:owner/:certName" render={(props) => this.renderSigninIfNotSignedIn(<CertEditPage account={this.state.account} {...props} />)} />
-
-          <Route exact path="/records" render={(props) => this.renderSigninIfNotSignedIn(<RecordListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/records/:owner/:id" render={(props) => this.renderSigninIfNotSignedIn(<RecordEditPage account={this.state.account} {...props} />)} />
-          <Route exact path="/rules" render={(props) => this.renderSigninIfNotSignedIn(<RuleListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/rules/:owner/:ruleName" render={(props) => this.renderSigninIfNotSignedIn(<RuleEditPage account={this.state.account} {...props} />)} />
-          {/* Channel routes for LLM gateway milestone 1.1. */}
-          <Route exact path="/channels" render={(props) => this.renderSigninIfNotSignedIn(<ChannelListPage account={this.state.account} {...props} />)} />
-          <Route exact path="/channels/:owner/:channelName" render={(props) => this.renderSigninIfNotSignedIn(<ChannelEditPage account={this.state.account} {...props} />)} />
-          <Route exact path="/dashboard" render={(props) => this.renderSigninIfNotSignedIn(<DashboardPage account={this.state.account} {...props} />)} />
-        </Switch>
-      </div>
-    );
-  }
-
-  renderFooter() {
-    // How to keep your footer where it belongs ?
-    // https://www.freecodecamp.org/news/how-to-keep-your-footer-where-it-belongs-59c6aa05c59c/
-
-    return (
-      <Footer id="footer" style={
-        {
-          borderTop: "1px solid #e8e8e8",
-          backgroundColor: "white",
-          textAlign: "center",
-        }
-      }>
-        Powered by <a target="_blank" href="https://github.com/apache/casbin-gateway" rel="noreferrer"><img style={{paddingBottom: "3px"}} height={"20px"} alt={"Casdoor"} src={`${Setting.StaticBaseUrl}/img/casbin_logo_1024x256.png`} /></a>
-      </Footer>
-    );
-  }
-
-  renderAiAssistant() {
-    return (
-      <Drawer
-        title={
-          <React.Fragment>
-            <Tooltip title="Want to deploy your own AI assistant? Click to learn more!">
-              <a target="_blank" rel="noreferrer" href={"https://casdoor.com"}>
-                <img style={{width: "20px", marginRight: "10px", marginBottom: "2px"}} alt="help" src="https://casbin.org/img/casbin.svg" />
-                AI Assistant
-              </a>
-            </Tooltip>
-            <a className="custom-link" style={{float: "right", marginRight: "35px", marginTop: "2px"}} target="_blank" rel="noreferrer" href={"https://ai.casbin.com"}>
-              <ShareAltOutlined className="custom-link" style={{fontSize: "20px", color: "rgb(140,140,140)"}} />
-            </a>
-            <a className="custom-link" style={{float: "right", marginRight: "30px", marginTop: "2px"}} target="_blank" rel="noreferrer" href={"https://github.com/casibase/casibase"}>
-              <GithubOutlined className="custom-link" style={{fontSize: "20px", color: "rgb(140,140,140)"}} />
-            </a>
-          </React.Fragment>
-        }
-        placement="right"
-        width={500}
-        mask={false}
-        onClose={() => {
-          this.setState({
-            isAiAssistantOpen: false,
-          });
-        }}
-        visible={this.state.isAiAssistantOpen}
-      >
-        <iframe id="iframeHelper" title={"iframeHelper"} src={"https://ai.casbin.com/?isRaw=1"} width="100%" height="100%" scrolling="no" frameBorder="no" />
-      </Drawer>
-    );
-  }
-
-  render() {
-    return (
-      <div id="parent-area">
-        <BackTop />
-        <div id="content-wrap">
-          {
-            this.renderContent()
-          }
-        </div>
-        {
-          this.renderFooter()
-        }
-        {
-          this.renderAiAssistant()
-        }
-      </div>
-    );
-  }
-}
-
-export default withRouter(withTranslation()(App));
diff --git a/web/src/App.less b/web/src/App.less
deleted file mode 100644
index b994f0c..0000000
--- a/web/src/App.less
+++ /dev/null
@@ -1,79 +0,0 @@
-@import '~antd/dist/antd.less';
-
-.App {
-  text-align: center;
-}
-
-.App-logo {
-  height: 40vmin;
-}
-
-.App-header {
-  background-color: #282c34;
-  min-height: 100vh;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  font-size: calc(10px + 2vmin);
-  color: white;
-}
-
-.App-link {
-  color: #09d3ac;
-}
-
-#parent-area {
-  position: relative;
-  min-height: 100vh;
-}
-
-#content-wrap {
-  padding-bottom: 70px; /* Footer height */
-}
-
-#footer {
-  position: absolute;
-  bottom: 0;
-  width: 100%;
-  height: 70px; /* Footer height */
-}
-
-.ant-table-body {
-  overflow-y: hidden !important
-}
-
-.language_box {
-  background: url("https://cdn.casbin.org/img/muti_language.svg");
-  background-size: 25px, 25px;
-  background-position: center;
-  background-repeat: no-repeat;
-  width: 45px;
-  height: 65px;
-  float: right;
-  cursor: pointer;
-}
-
-.language_box:hover {
-  background-color: #f5f5f5;
-}
-
-
-.rightDropDown:hover {
-  background-color: #f5f5f5;
-}
-
-.select-box {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: 5px;
-  width: 45px;
-  height: 64px;
-  float: right;
-  cursor: pointer;
-
-  &:hover {
-    background-color: #f5f5f5a5 !important;
-  }
-}
\ No newline at end of file
diff --git a/web/src/App.test.js b/web/src/App.test.js
deleted file mode 100644
index 43bf795..0000000
--- a/web/src/App.test.js
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright 2023 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 React from "react";
-import ReactDOM from "react-dom";
-import App from "./App";
-import {test} from "@craco/craco/lib/cra";
-
-test("renders without crashing", () => {
-  const div = document.createElement("div");
-  ReactDOM.render(<App />, div);
-  ReactDOM.unmountComponentAtNode(div);
-});
diff --git a/web2/src/App.tsx b/web/src/App.tsx
similarity index 100%
rename from web2/src/App.tsx
rename to web/src/App.tsx
diff --git a/web/src/AuthCallback.js b/web/src/AuthCallback.js
deleted file mode 100644
index 5811cd5..0000000
--- a/web/src/AuthCallback.js
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Result, Spin} from "antd";
-import {withRouter} from "react-router-dom";
-import * as Setting from "./Setting";
-
-class AuthCallback extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      msg: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.login();
-  }
-
-  getFromLink() {
-    const from = sessionStorage.getItem("from");
-    if (from === null) {
-      return "/";
-    }
-    return from;
-  }
-
-  login() {
-    Setting.signin().then((res) => {
-      if (res.status === "ok") {
-        Setting.showMessage("success", "Logged in successfully");
-
-        const link = this.getFromLink();
-        Setting.goToLink(link);
-      } else {
-        this.setState({
-          msg: res.msg,
-        });
-      }
-    });
-  }
-
-  render() {
-    return (
-      <div style={{textAlign: "center"}}>
-        {this.state.msg === null ? (
-          <Spin
-            size="large"
-            tip="Signing in..."
-            style={{paddingTop: "10%"}}
-          />
-        ) : (
-          <div style={{display: "inline"}}>
-            <Result
-              status="error"
-              title="Login Error"
-              subTitle={this.state.msg}
-              extra={[
-                <Button type="primary" key="details">
-                  Details
-                </Button>,
-                <Button key="help">Help</Button>,
-              ]}
-            />
-          </div>
-        )}
-      </div>
-    );
-  }
-}
-
-export default withRouter(AuthCallback);
diff --git a/web/src/BaseListPage.js b/web/src/BaseListPage.js
deleted file mode 100644
index bcef012..0000000
--- a/web/src/BaseListPage.js
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright 2024 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 React from "react";
-import {Button, Result} from "antd";
-import i18next from "i18next";
-
-class BaseListPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      data: [],
-      pagination: {
-        current: 1,
-        pageSize: 1000,
-      },
-      loading: false,
-      isAuthorized: true,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    const {pagination} = this.state;
-    this.fetch({pagination});
-  }
-
-  handleTableChange = (pagination, filters, sorter) => {
-    this.fetch({
-      sortField: sorter.field,
-      sortOrder: sorter.order,
-      pagination,
-      ...filters,
-      searchText: this.state.searchText,
-      searchedColumn: this.state.searchedColumn,
-    });
-  };
-
-  render() {
-    if (!this.state.isAuthorized) {
-      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={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>}
-        />
-      );
-    }
-
-    return (
-      <div>
-        {
-          this.renderTable(this.state.data)
-        }
-      </div>
-    );
-  }
-}
-
-export default BaseListPage;
diff --git a/web/src/CertEditPage.js b/web/src/CertEditPage.js
deleted file mode 100644
index 688a759..0000000
--- a/web/src/CertEditPage.js
+++ /dev/null
@@ -1,281 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Card, Col, Input, Row, Select} from "antd";
-import * as CertBackend from "./backend/CertBackend";
-import * as Setting from "./Setting";
-import i18next from "i18next";
-import copy from "copy-to-clipboard";
-import FileSaver from "file-saver";
-
-const {Option} = Select;
-const {TextArea} = Input;
-
-class CertEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      owner: props.match.params.owner,
-      certName: props.match.params.certName,
-      cert: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getCert();
-  }
-
-  getCert() {
-    CertBackend.getCert(this.state.owner, this.state.certName)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            cert: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get cert: ${res.msg}`);
-        }
-      });
-  }
-
-  parseCertField(key, value) {
-    if (["score"].includes(key)) {
-      value = Setting.myParseInt(value);
-    }
-    return value;
-  }
-
-  updateCertField(key, value) {
-    value = this.parseCertField(key, value);
-
-    const cert = this.state.cert;
-    cert[key] = value;
-    this.setState({
-      cert: cert,
-    });
-  }
-
-  renderCert() {
-    const editorWidth = Setting.isMobile() ? 22 : 9;
-    return (
-      <Card size="small" title={
-        <div>
-          {i18next.t("cert:Edit Cert")}&nbsp;&nbsp;&nbsp;&nbsp;
-          <Button type="primary" onClick={this.submitCertEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-        </div>
-      } style={{marginLeft: "5px"}} type="inner">
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Name")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.cert.name} onChange={e => {
-              this.updateCertField("name", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Type")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.cert.type} onChange={(value => {this.updateCertField("type", value);})}>
-              {
-                [
-                  {id: "SSL", name: "SSL"},
-                ].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Crypto algorithm")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.cert.cryptoAlgorithm} onChange={(value => {this.updateCertField("cryptoAlgorithm", value);})}>
-              {
-                [
-                  {id: "RSA", name: "RSA"},
-                  {id: "ECC", name: "ECC"},
-                ].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Expire time")}:
-          </Col>
-          <Col span={22} >
-            <Input disabled={true} value={Setting.getFormattedDate(this.state.cert.expireTime)} onChange={e => {
-              this.updateCertField("expireTime", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Domain expire")}:
-          </Col>
-          <Col span={22} >
-            <Input disabled={true} value={Setting.getFormattedDate(this.state.cert.domainExpireTime)} onChange={e => {
-              this.updateCertField("domainExpireTime", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Provider")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.cert.provider} onChange={(value => {this.updateCertField("provider", value);})}>
-              {
-                [
-                  {id: "GoDaddy", name: "GoDaddy"},
-                  {id: "Aliyun", name: "Aliyun"},
-                ].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Account")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.cert.account} onChange={e => {
-              this.updateCertField("account", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Access key")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.cert.accessKey} onChange={e => {
-              this.updateCertField("accessKey", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("cert:Access secret")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.cert.accessSecret} onChange={e => {
-              this.updateCertField("accessSecret", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
-            {i18next.t("cert:Certificate")}:
-          </Col>
-          <Col span={editorWidth} >
-            <Button style={{marginRight: "10px", marginBottom: "10px"}} onClick={() => {
-              copy(this.state.cert.certificate);
-              Setting.showMessage("success", i18next.t("cert:Certificate copied to clipboard successfully"));
-            }}
-            >
-              {i18next.t("cert:Copy certificate")}
-            </Button>
-            <Button type="primary" onClick={() => {
-              const blob = new Blob([this.state.cert.certificate], {type: "text/plain;charset=utf-8"});
-              FileSaver.saveAs(blob, "token_jwt_key.pem");
-            }}
-            >
-              {i18next.t("cert:Download certificate")}
-            </Button>
-            <TextArea autoSize={{minRows: 30, maxRows: 30}} value={this.state.cert.certificate} onChange={e => {
-              this.updateCertField("certificate", e.target.value);
-            }} />
-          </Col>
-          <Col span={1} />
-          <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
-            {i18next.t("cert:Private key")}:
-          </Col>
-          <Col span={editorWidth} >
-            <Button style={{marginRight: "10px", marginBottom: "10px"}} onClick={() => {
-              copy(this.state.cert.privateKey);
-              Setting.showMessage("success", i18next.t("cert:Private key copied to clipboard successfully"));
-            }}
-            >
-              {i18next.t("cert:Copy private key")}
-            </Button>
-            <Button type="primary" onClick={() => {
-              const blob = new Blob([this.state.cert.privateKey], {type: "text/plain;charset=utf-8"});
-              FileSaver.saveAs(blob, "token_jwt_key.key");
-            }}
-            >
-              {i18next.t("cert:Download private key")}
-            </Button>
-            <TextArea autoSize={{minRows: 30, maxRows: 30}} value={this.state.cert.privateKey} onChange={e => {
-              this.updateCertField("privateKey", e.target.value);
-            }} />
-          </Col>
-        </Row>
-      </Card>
-    );
-  }
-
-  submitCertEdit() {
-    const cert = Setting.deepCopy(this.state.cert);
-    CertBackend.updateCert(this.state.cert.owner, this.state.certName, cert)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to save: ${res.msg}`);
-          this.updateCertField("name", this.state.certName);
-        } else {
-          Setting.showMessage("success", "Successfully saved");
-          this.setState({
-            certName: this.state.cert.name,
-          });
-          this.props.history.push(`/certs/${this.state.cert.owner}/${this.state.cert.name}`);
-        }
-      })
-      .catch(error => {
-        Setting.showMessage("error", `failed to save: ${error}`);
-      });
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{width: "100%"}}>
-          <Col span={1}>
-          </Col>
-          <Col span={22}>
-            {
-              this.state.cert !== null ? this.renderCert() : null
-            }
-          </Col>
-          <Col span={1}>
-          </Col>
-        </Row>
-        <Row style={{margin: 10}}>
-          <Col span={2}>
-          </Col>
-          <Col span={18}>
-            <Button type="primary" size="large" onClick={this.submitCertEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default CertEditPage;
diff --git a/web/src/CertListPage.js b/web/src/CertListPage.js
deleted file mode 100644
index be80d2c..0000000
--- a/web/src/CertListPage.js
+++ /dev/null
@@ -1,326 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Link} from "react-router-dom";
-import {Button, Popconfirm, Table} from "antd";
-import moment from "moment";
-import * as Setting from "./Setting";
-import * as CertBackend from "./backend/CertBackend";
-import i18next from "i18next";
-import copy from "copy-to-clipboard";
-import BaseListPage from "./BaseListPage";
-
-class CertListPage extends BaseListPage {
-  constructor(props) {
-    super(props);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.setState({
-      pagination: {
-        ...this.state.pagination,
-        current: 1,
-        pageSize: 1000,
-      },
-    });
-    this.fetch({pagination: this.state.pagination});
-  }
-
-  newCert() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `cert_${randomName}`,
-      createdTime: moment().format(),
-      displayName: `New Cert - ${randomName}`,
-      type: "SSL",
-      cryptoAlgorithm: "RSA",
-      expireTime: "",
-      certificate: "",
-      privateKey: "",
-    };
-  }
-
-  addCert() {
-    const newCert = this.newCert();
-    CertBackend.addCert(newCert)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to add: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Cert added successfully");
-          this.setState({
-            data: Setting.prependRow(this.state.data, newCert),
-          });
-          this.fetch();
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Cert failed to add: ${error}`);
-      });
-  }
-
-  deleteCert(i) {
-    CertBackend.deleteCert(this.state.data[i])
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to delete: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Cert deleted successfully");
-          this.fetch({
-            pagination: {
-              ...this.state.pagination,
-              current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-            },
-          });
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Cert failed to delete: ${error}`);
-      });
-  }
-
-  refreshCert(i) {
-    const cert = this.state.data[i];
-    CertBackend.refreshDomainExpire(cert.owner, cert.name)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to refresh domain expire: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Domain expire refresh successfully");
-          this.fetch();
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Domain expire failed to refresh: ${error}`);
-      });
-  }
-
-  renderTable(data) {
-    const columns = [
-      {
-        title: i18next.t("general:Owner"),
-        dataIndex: "owner",
-        key: "owner",
-        width: "120px",
-        sorter: (a, b) => a.owner.localeCompare(b.owner),
-      },
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "120px",
-        sorter: (a, b) => a.name.localeCompare(b.name),
-        render: (text, record, index) => {
-          return (
-            <Link to={`/certs/${record.owner}/${record.name}`}>
-              {text}
-            </Link>
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Create time"),
-        dataIndex: "createdTime",
-        key: "createdTime",
-        width: "150px",
-        sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
-        render: (text, record, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      // {
-      //   title: i18next.t("general:Display name"),
-      //   dataIndex: "displayName",
-      //   key: "displayName",
-      //   // width: "200px",
-      //   sorter: (a, b) => a.displayName.localeCompare(b.displayName),
-      // },
-      // {
-      //   title: i18next.t("cert:Type"),
-      //   dataIndex: "type",
-      //   key: "type",
-      //   width: "180px",
-      //   sorter: (a, b) => a.type.localeCompare(b.type),
-      // },
-      // {
-      //   title: i18next.t("cert:Crypto algorithm"),
-      //   dataIndex: "cryptoAlgorithm",
-      //   key: "cryptoAlgorithm",
-      //   width: "180px",
-      //   sorter: (a, b) => a.cryptoAlgorithm.localeCompare(b.cryptoAlgorithm),
-      // },
-      {
-        title: i18next.t("cert:Expire time"),
-        dataIndex: "expireTime",
-        key: "expireTime",
-        width: "150px",
-        sorter: (a, b) => a.expireTime.localeCompare(b.expireTime),
-        render: (text, record, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("cert:Domain expire"),
-        dataIndex: "domainExpireTime",
-        key: "domainExpireTime",
-        width: "150px",
-        sorter: (a, b) => a.domainExpireTime.localeCompare(b.domainExpireTime),
-        render: (text, record, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("cert:Provider"),
-        dataIndex: "provider",
-        key: "provider",
-        width: "110px",
-        sorter: (a, b) => a.provider.localeCompare(b.provider),
-      },
-      {
-        title: i18next.t("cert:Account"),
-        dataIndex: "account",
-        key: "account",
-        width: "120px",
-        sorter: (a, b) => a.account.localeCompare(b.account),
-      },
-      // {
-      //   title: i18next.t("cert:Access key"),
-      //   dataIndex: "accessKey",
-      //   key: "accessKey",
-      //   width: "180px",
-      //   sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
-      // },
-      // {
-      //   title: i18next.t("cert:Access secret"),
-      //   dataIndex: "accessSecret",
-      //   key: "accessSecret",
-      //   width: "180px",
-      //   sorter: (a, b) => a.accessSecret.localeCompare(b.accessSecret),
-      // },
-      {
-        title: i18next.t("cert:Certificate"),
-        dataIndex: "certificate",
-        key: "certificate",
-        width: "180px",
-        sorter: (a, b) => a.certificate.localeCompare(b.certificate),
-        render: (text, record, index) => {
-          return (
-            <Button onClick={() => {
-              copy(text);
-              Setting.showMessage("success", i18next.t("cert:Certificate copied to clipboard successfully"));
-            }}
-            >
-              {Setting.getShortText(text.replace(/^-----BEGIN CERTIFICATE-----/, ""), 15)}
-            </Button>
-          );
-        },
-      },
-      {
-        title: i18next.t("cert:Private key"),
-        dataIndex: "privateKey",
-        key: "privateKey",
-        width: "180px",
-        sorter: (a, b) => a.privateKey.localeCompare(b.privateKey),
-        render: (text, record, index) => {
-          return (
-            <Button onClick={() => {
-              copy(text);
-              Setting.showMessage("success", i18next.t("cert:Private key copied to clipboard successfully"));
-            }}
-            >
-              {Setting.getShortText(text.replace(/^-----BEGIN RSA PRIVATE KEY-----/, ""), 15)}
-            </Button>
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "action",
-        key: "action",
-        width: "260px",
-        render: (text, record, index) => {
-          return (
-            <div style={{display: "flex", alignItems: "center", flexWrap: "nowrap"}}>
-              <Button style={{margin: "10px 10px 10px 0"}} type="default" onClick={() => this.refreshCert(index)}>{i18next.t("general:Refresh")}
-              </Button>
-              <Button style={{margin: "10px 10px 10px 0"}} type="primary" onClick={() => this.props.history.push(`/certs/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
-              <Popconfirm
-                title={`Sure to delete cert: ${record.name} ?`}
-                onConfirm={() => this.deleteCert(index)}
-                okText="OK"
-                cancelText="Cancel"
-              >
-                <Button style={{margin: "10px 10px 10px 0"}} type="danger">{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <div>
-        <Table
-          columns={columns} dataSource={data} rowKey="name" size="middle" bordered pagination={this.state.pagination}
-          title={() => (
-            <div>
-              {i18next.t("general:Certs")}&nbsp;&nbsp;&nbsp;&nbsp;
-              <Button type="primary" size="small" onClick={this.addCert.bind(this)}>{i18next.t("general:Add")}</Button>
-            </div>
-          )}
-          loading={this.state.loading}
-          onChange={this.handleTableChange}
-        />
-      </div>
-    );
-  }
-
-  fetch = (params = {}) => {
-    const field = params.searchedColumn, value = params.searchText;
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (!params.pagination) {
-      params.pagination = {current: 1, pageSize: 100};
-    }
-    this.setState({loading: true});
-    CertBackend.getCerts(this.props.account.name, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
-      .then((res) => {
-        this.setState({
-          loading: false,
-        });
-        if (res.status === "ok") {
-          this.setState({
-            data: res.data,
-            pagination: {
-              ...params.pagination,
-              total: res.data2,
-            },
-            searchText: params.searchText,
-            searchedColumn: params.searchedColumn,
-            sortField: sortField,
-            sortOrder: sortOrder,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get certs: ${res.msg}`);
-        }
-      });
-  };
-
-}
-
-export default CertListPage;
diff --git a/web/src/ChannelEditPage.js b/web/src/ChannelEditPage.js
deleted file mode 100644
index ae22eec..0000000
--- a/web/src/ChannelEditPage.js
+++ /dev/null
@@ -1,342 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Alert, AutoComplete, Button, Card, Col, Input, InputNumber, Result, Row, Select, Space, Spin} from "antd";
-import {ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, SaveOutlined, ThunderboltOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import * as ChannelBackend from "./backend/ChannelBackend";
-import * as Setting from "./Setting";
-
-const {Option} = Select;
-
-// Hard-coded presets for milestone 1.1 (per channel type).
-const BASE_URL_PRESETS = {
-  openai: ["https://api.openai.com/v1"],
-  custom: ["https://oneapi.example.com", "https://api.deepseek.com/v1", "https://api.moonshot.cn/v1"],
-};
-
-// Mirrors object.BuildOpenAiUrl on the server.
-function buildOpenAiUrl(baseUrl, endpoint) {
-  let url;
-  try {
-    url = new URL(baseUrl);
-  } catch {
-    return "";
-  }
-  if (url.protocol !== "http:" && url.protocol !== "https:") {
-    return "";
-  }
-
-  let path = url.pathname.replace(/\/+$/, "");
-  if (path.endsWith(endpoint)) {
-    path = path.slice(0, path.length - endpoint.length);
-  }
-  if (!path.endsWith("/v1")) {
-    path += "/v1";
-  }
-
-  url.pathname = path + endpoint;
-  return url.toString();
-}
-
-const MODEL_PRESETS = {
-  openai: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
-  custom: ["deepseek-chat", "deepseek-reasoner", "moonshot-v1-8k", "qwen-max"],
-};
-
-class ChannelEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      // undefined while loading, null when the channel could not be loaded.
-      channel: undefined,
-      testing: false,
-      result: null,
-    };
-  }
-
-  componentDidMount() {
-    this.load();
-  }
-
-  load() {
-    const {owner, channelName} = this.props.match.params;
-    ChannelBackend.getChannel(owner, channelName).then(res => {
-      if (res.status === "ok") {
-        this.setState({channel: res.data});
-      } else {
-        this.setState({channel: null});
-        Setting.showMessage("error", `${i18next.t("channel:Failed to get channel")}: ${res.msg}`);
-      }
-    }).catch(error => {
-      this.setState({channel: null});
-      Setting.showMessage("error", `${i18next.t("channel:Failed to get channel")}: ${error}`);
-    });
-  }
-
-  setChannelField(key, value) {
-    this.setState({
-      channel: {
-        ...this.state.channel,
-        [key]: value,
-      },
-    });
-  }
-
-  save() {
-    const {owner, channelName} = this.props.match.params;
-    ChannelBackend.updateChannel(owner, channelName, this.state.channel).then(res => {
-      if (res.status === "error") {
-        Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: ${res.msg}`);
-        return;
-      }
-
-      Setting.showMessage("success", i18next.t("channel:Channel saved"));
-    }).catch(error => {
-      Setting.showMessage("error", `${i18next.t("channel:Failed to save")}: ${error}`);
-    });
-  }
-
-  test() {
-    const {owner, channelName} = this.props.match.params;
-    this.setState({testing: true, result: null});
-
-    ChannelBackend.testChannel(owner, channelName).then(res => {
-      if (res.status === "error") {
-        this.setState({testing: false});
-        Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: ${res.msg}`);
-        return;
-      }
-
-      this.setState({testing: false, result: res.data});
-    }).catch(error => {
-      this.setState({testing: false});
-      Setting.showMessage("error", `${i18next.t("channel:Failed to test")}: ${error}`);
-    });
-  }
-
-  renderUpstreamUrl(channel) {
-    const upstreamUrl = buildOpenAiUrl(channel.baseUrl, "/chat/completions");
-    if (upstreamUrl === "") {
-      return null;
-    }
-
-    return (
-      <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
-        {i18next.t("channel:Base URL hint")}: <span style={{fontFamily: "monospace"}}>{upstreamUrl}</span>
-      </div>
-    );
-  }
-
-  render() {
-    const channel = this.state.channel;
-    if (channel === undefined) {
-      return (
-        <div style={{textAlign: "center", marginTop: "100px"}}>
-          <Spin size="large" />
-        </div>
-      );
-    }
-    if (channel === null) {
-      return (
-        <Result
-          status="404"
-          title={i18next.t("channel:Channel not found")}
-          extra={<Button type="primary" onClick={() => this.props.history.push("/channels")}>{i18next.t("channel:Channels")}</Button>}
-        />
-      );
-    }
-
-    return (
-      <Card
-        title={
-          <span>
-            <ApiOutlined style={{marginRight: "8px", color: "#1890ff"}} />
-            {i18next.t("channel:Edit Channel")}: {channel.displayName}
-          </span>
-        }
-        extra={
-          <Space>
-            <Button
-              type="primary"
-              icon={<SaveOutlined />}
-              onClick={this.save.bind(this)}
-            >
-              {i18next.t("general:Save")}
-            </Button>
-            <Button
-              icon={<ThunderboltOutlined />}
-              loading={this.state.testing}
-              onClick={this.test.bind(this)}
-            >
-              {i18next.t("channel:Test Connectivity")}
-            </Button>
-          </Space>
-        }
-      >
-        <Row style={{marginTop: "10px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Display name")}:
-          </Col>
-          <Col span={22}>
-            <Input
-              value={channel.displayName}
-              onChange={e => {
-                this.setChannelField("displayName", e.target.value);
-              }}
-            />
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:Type")}:
-          </Col>
-          <Col span={22}>
-            <Select
-              style={{width: "100%"}}
-              value={channel.type}
-              onChange={value => {
-                this.setChannelField("type", value);
-              }}
-            >
-              <Option value="openai">OpenAI</Option>
-              <Option value="custom">Custom</Option>
-            </Select>
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:Base URL")}:
-          </Col>
-          <Col span={22}>
-            <AutoComplete
-              style={{width: "100%"}}
-              value={channel.baseUrl}
-              options={(BASE_URL_PRESETS[channel.type] || []).map(url => ({value: url, label: url}))}
-              onChange={value => {
-                this.setChannelField("baseUrl", value);
-              }}
-              placeholder="https://api.openai.com/v1"
-            />
-            {this.renderUpstreamUrl(channel)}
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:API Key")}:
-          </Col>
-          <Col span={22}>
-            <Input.Password
-              placeholder="sk-..."
-              value={channel.apiKey}
-              onChange={e => {
-                this.setChannelField("apiKey", e.target.value);
-              }}
-            />
-            <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
-              {i18next.t("channel:API Key hint")}
-            </div>
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:Models")}:
-          </Col>
-          <Col span={22}>
-            <Select
-              mode="tags"
-              style={{width: "100%"}}
-              placeholder="gpt-5, gpt-5-mini"
-              value={channel.models}
-              onChange={value => {
-                this.setChannelField("models", value);
-              }}
-            >
-              {
-                (MODEL_PRESETS[channel.type] || []).map(model => (
-                  <Option key={model} value={model}>{model}</Option>
-                ))
-              }
-            </Select>
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:Priority")}:
-          </Col>
-          <Col span={22}>
-            <InputNumber
-              min={0}
-              value={channel.priority}
-              onChange={value => {
-                this.setChannelField("priority", value);
-              }}
-            />
-            <div style={{color: "#888", fontSize: "12px", marginTop: "4px"}}>
-              {i18next.t("channel:Priority hint")}
-            </div>
-          </Col>
-        </Row>
-
-        <Row style={{marginTop: "20px"}}>
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("channel:Status")}:
-          </Col>
-          <Col span={22}>
-            <Select
-              style={{width: "100%"}}
-              value={channel.status}
-              onChange={value => {
-                this.setChannelField("status", value);
-              }}
-            >
-              <Option value="enabled">
-                <CheckCircleOutlined style={{color: "#52c41a", marginRight: "8px"}} />
-                {i18next.t("channel:Enabled")}
-              </Option>
-              <Option value="disabled">
-                <CloseCircleOutlined style={{color: "#ff4d4f", marginRight: "8px"}} />
-                {i18next.t("channel:Disabled")}
-              </Option>
-            </Select>
-          </Col>
-        </Row>
-
-        {
-          this.state.result ? (
-            <Row style={{marginTop: "20px"}}>
-              <Col span={2} />
-              <Col span={22}>
-                <Alert
-                  message={this.state.result.success ? i18next.t("channel:Connection Successful") : i18next.t("channel:Connection Failed")}
-                  description={this.state.result.statusCode ? `HTTP ${this.state.result.statusCode} - ${this.state.result.message}` : this.state.result.message}
-                  type={this.state.result.success ? "success" : "error"}
-                  showIcon
-                />
-              </Col>
-            </Row>
-          ) : null
-        }
-      </Card>
-    );
-  }
-}
-
-export default ChannelEditPage;
diff --git a/web/src/ChannelListPage.js b/web/src/ChannelListPage.js
deleted file mode 100644
index 53760c7..0000000
--- a/web/src/ChannelListPage.js
+++ /dev/null
@@ -1,263 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Link} from "react-router-dom";
-import {Button, Popconfirm, Space, Table, Tag, Tooltip} from "antd";
-import {CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import BaseListPage from "./BaseListPage";
-import * as ChannelBackend from "./backend/ChannelBackend";
-import * as Setting from "./Setting";
-
-class ChannelListPage extends BaseListPage {
-  UNSAFE_componentWillMount() {
-    const pagination = {...this.state.pagination, current: 1, pageSize: 10};
-    this.setState({pagination: pagination});
-    this.fetch({pagination: pagination});
-  }
-
-  fetch = (params = {}) => {
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (!params.pagination) {
-      params.pagination = {current: 1, pageSize: 10};
-    }
-    this.setState({loading: true});
-
-    ChannelBackend.getChannels(this.props.account.name, params.pagination.current, params.pagination.pageSize, sortField, sortOrder).then(res => {
-      this.setState({loading: false});
-      if (res.status === "ok") {
-        this.setState({
-          data: res.data,
-          pagination: {
-            ...params.pagination,
-            total: res.data2,
-          },
-        });
-      } else {
-        Setting.showMessage("error", `${i18next.t("channel:Failed to get channels")}: ${res.msg}`);
-      }
-    }).catch(error => {
-      this.setState({loading: false});
-      Setting.showMessage("error", `${i18next.t("channel:Failed to get channels")}: ${error}`);
-    });
-  };
-
-  deleteChannel = (channel) => {
-    ChannelBackend.deleteChannel(channel).then(res => {
-      if (res.status === "error") {
-        Setting.showMessage("error", `${i18next.t("channel:Failed to delete")}: ${res.msg}`);
-        return;
-      }
-
-      Setting.showMessage("success", i18next.t("channel:Channel deleted successfully"));
-      this.fetch({
-        pagination: {
-          ...this.state.pagination,
-          current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-        },
-      });
-    }).catch(error => {
-      Setting.showMessage("error", `${i18next.t("channel:Failed to delete")}: ${error}`);
-    });
-  };
-
-  newChannel() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `channel_${randomName}`,
-      displayName: `New Channel - ${randomName}`,
-      type: "openai",
-      status: "enabled",
-      models: [],
-      priority: 0,
-      baseUrl: "",
-      apiKey: "",
-    };
-  }
-
-  addChannel() {
-    const newChannel = this.newChannel();
-    ChannelBackend.addChannel(newChannel).then(res => {
-      if (res.status === "error") {
-        Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: ${res.msg}`);
-      } else {
-        Setting.showMessage("success", i18next.t("channel:Channel added successfully"));
-        this.fetch({pagination: this.state.pagination});
-      }
-    }).catch(error => {
-      Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: ${error}`);
-    });
-  }
-
-  renderTypeTag(type) {
-    const colorMap = {
-      openai: "#10a37f",
-      custom: "#8b5cf6",
-    };
-    return (
-      <Tag color={colorMap[type] || "default"}>{type}</Tag>
-    );
-  }
-
-  renderStatusTag(status) {
-    return status === "enabled" ? (
-      <Tag icon={<CheckCircleOutlined />} color="success">{i18next.t("channel:Enabled")}</Tag>
-    ) : (
-      <Tag icon={<CloseCircleOutlined />} color="error">{i18next.t("channel:Disabled")}</Tag>
-    );
-  }
-
-  renderTable(data) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "140px",
-        ellipsis: true,
-        // The server sorts and paginates, so sorting locally would only reorder
-        // the current page.
-        sorter: true,
-        render: (text, record) => {
-          return (
-            <Tooltip title={text}>
-              <Link to={`/channels/${record.owner}/${record.name}`}>
-                {text}
-              </Link>
-            </Tooltip>
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Display name"),
-        dataIndex: "displayName",
-        key: "displayName",
-        width: "180px",
-        ellipsis: true,
-        sorter: true,
-        render: (text) => {
-          if (!text) {return "-";}
-          return (
-            <Tooltip title={text}>
-              <span>{text}</span>
-            </Tooltip>
-          );
-        },
-      },
-      {
-        title: i18next.t("channel:Type"),
-        dataIndex: "type",
-        key: "type",
-        width: "90px",
-        render: (text) => this.renderTypeTag(text),
-      },
-      {
-        title: i18next.t("channel:Base URL"),
-        dataIndex: "baseUrl",
-        key: "baseUrl",
-        width: "150px",
-        ellipsis: true,
-        render: (text) => {
-          if (!text) {return "-";}
-          return (
-            <Tooltip title={text}>
-              <span style={{fontFamily: "monospace", fontSize: "12px"}}>{text}</span>
-            </Tooltip>
-          );
-        },
-      },
-      {
-        title: i18next.t("channel:Models"),
-        dataIndex: "models",
-        key: "models",
-        width: "180px",
-        render: (models) => {
-          if (!models || models.length === 0) {return "-";}
-          return models.map(model => {
-            return <Tag key={model} color="blue">{model}</Tag>;
-          });
-        },
-      },
-      {
-        title: i18next.t("channel:Priority"),
-        dataIndex: "priority",
-        key: "priority",
-        width: "80px",
-        sorter: true,
-      },
-      {
-        title: i18next.t("channel:Status"),
-        dataIndex: "status",
-        key: "status",
-        width: "110px",
-        render: (text) => this.renderStatusTag(text),
-      },
-      {
-        title: i18next.t("general:Action"),
-        key: "action",
-        width: "180px",
-        render: (text, record) => {
-          return (
-            <Space>
-              <Button
-                type="primary"
-                size="small"
-                icon={<EditOutlined />}
-                onClick={() => this.props.history.push(`/channels/${record.owner}/${record.name}`)}
-              >
-                {i18next.t("general:Edit")}
-              </Button>
-              <Popconfirm
-                title={`${i18next.t("general:Delete")} channel "${record.name}"?`}
-                onConfirm={() => this.deleteChannel(record)}
-                okText={i18next.t("general:OK")}
-                cancelText={i18next.t("general:Cancel")}
-              >
-                <Button size="small" danger icon={<DeleteOutlined />}>{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </Space>
-          );
-        },
-      },
-    ];
-
-    return (
-      <div>
-        <Table
-          className="channel-table"
-          columns={columns}
-          dataSource={data}
-          // An admin sees channels across owners, where names may collide.
-          rowKey={(record) => `${record.owner}/${record.name}`}
-          bordered
-          pagination={this.state.pagination}
-          title={() => (
-            <div>
-              {i18next.t("channel:Channels")}&nbsp;&nbsp;&nbsp;&nbsp;
-              <Button type="primary" size="small" icon={<PlusOutlined />} onClick={this.addChannel.bind(this)}>
-                {i18next.t("channel:New Channel")}
-              </Button>
-            </div>
-          )}
-          loading={this.state.loading}
-          onChange={this.handleTableChange}
-        />
-      </div>
-    );
-  }
-}
-
-export default ChannelListPage;
diff --git a/web/src/Conf.js b/web/src/Conf.js
deleted file mode 100644
index 70aaf84..0000000
--- a/web/src/Conf.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright 2023 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.
-
-// Casdoor is optional. These values stay empty unless the backend reports a
-// configured "casdoorEndpoint" in app.conf, in which case setAuthConfig() fills
-// them in at startup and the app signs in through Casdoor instead of the
-// built-in username/password form.
-export const AuthConfig = {
-  serverUrl: "",
-  clientId: "",
-  appName: "",
-  organizationName: "",
-  redirectPath: "/callback",
-};
-
-export function setAuthConfig(config) {
-  if (config === null || config === undefined) {
-    return;
-  }
-
-  Object.keys(AuthConfig).forEach((key) => {
-    if (config[key] !== undefined && config[key] !== null) {
-      AuthConfig[key] = config[key];
-    }
-  });
-}
-
-export const IsDemoMode = false;
-
-export const ForceLanguage = "";
-export const DefaultLanguage = "en";
diff --git a/web2/src/Conf.ts b/web/src/Conf.ts
similarity index 100%
rename from web2/src/Conf.ts
rename to web/src/Conf.ts
diff --git a/web/src/DashboardPage.js b/web/src/DashboardPage.js
deleted file mode 100644
index 07fd861..0000000
--- a/web/src/DashboardPage.js
+++ /dev/null
@@ -1,322 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Card, Col, Radio, Row, Table} from "antd";
-import * as Setting from "./Setting";
-import BarChartCard from "./components/BarChartCard";
-import StatisticCard from "./components/StatisticCard";
-import * as DashboardBackend from "./backend/DashboardBackend";
-import i18next from "i18next";
-import PieChartCard from "./components/PieChartCard";
-
-class DashboardDetailPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      site: null,
-      userAgents: [{}],
-      sites: [{}],
-      uniqueIPCount: 0,
-      totleRequestCount: 0,
-      ipAddresses: [{}],
-      requestCountOverTime: [{}],
-      rangeType: "All",
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getAllData(this.state.rangeType);
-  }
-
-  async getMetric(type, rangeType, top) {
-    rangeType = rangeType === "All" ? "month" : rangeType.toLowerCase();
-    const count = this.getRangeValue(rangeType);
-    if (type === "UserAgent" || type === "IPAddress") {
-      top = 10;
-    }
-    return DashboardBackend.getMetric(type, rangeType, count, top).then((res) => {
-      if (res.status === "ok") {
-        return res;
-      } else {
-        Setting.showMessage("error", res.msg);
-      }
-    });
-  }
-
-  async getMetricOverTime(rangeType) {
-    rangeType = rangeType === "All" ? "week" : rangeType.toLowerCase();
-    const count = this.getRangeValue(rangeType);
-    const timeType = this.getGranularity(rangeType);
-    return DashboardBackend.getMetricOverTime(rangeType, count, timeType).then((res) => {
-      if (res.status === "ok") {
-        return res;
-      } else {
-        Setting.showMessage("error", res.msg);
-      }
-    });
-  }
-
-  getAllData(rangeType) {
-    this.getUserAgents(rangeType);
-    this.getIPAddresses(rangeType);
-    this.getSites(rangeType);
-    this.getRequestCount(rangeType);
-  }
-
-  getRangeValue(rangeType) {
-    switch (rangeType) {
-    case "hour":
-      return 72;
-    case "day":
-      return 7;
-    case "week":
-      return 12;
-    case "month":
-      return 12;
-    default:
-      return 7;
-    }
-  }
-
-  getGranularity(rangeType) {
-    switch (rangeType) {
-    case "hour":
-      return "hour";
-    case "day":
-      return "hour";
-    case "week":
-      return "day";
-    case "month":
-      return "month";
-    default:
-      return "day";
-    }
-  }
-
-  getUserAgents(rangeType) {
-    this.getMetric("userAgent", rangeType, 10).then(res => {
-      this.setState({
-        userAgents: res.data,
-      });
-    });
-  }
-
-  getIPAddresses(rangeType) {
-    this.getMetric("ip", rangeType).then((res) => {
-      if (!res.data) {
-        return;
-      }
-
-      this.setState({
-        ipAddresses: res.data.slice(0, 10),
-        uniqueIPCount: res.data.length,
-      });
-    });
-  }
-
-  getRequestCount(rangeType) {
-    this.getMetricOverTime(rangeType).then((res) => {
-      this.setState({
-        requestCountOverTime: res.data,
-        totleRequestCount: res.data2,
-      });
-    });
-  }
-
-  getSites(rangeType) {
-    this.getMetric("site", rangeType).then((res) => {
-      this.setState({
-        sites: res.data,
-      });
-    });
-  }
-
-  renderUserAgentsTable() {
-    const columns = [
-      {
-        title: i18next.t("general:User-Agent"),
-        dataIndex: "data",
-        key: "data",
-        width: "440px",
-      },
-      {
-        title: i18next.t("general:Count"),
-        dataIndex: "count",
-        key: "count",
-        width: "40px",
-        sorter: (a, b) => a.count - b.count,
-      },
-    ];
-
-    return (
-      <Card title={i18next.t("general:Top 10 User-Agents")} className="dashboard-card">
-        <Table
-          columns={columns}
-          dataSource={this.state.userAgents}
-          rowKey="userAgent"
-          size="small"
-          pagination={{hideOnSinglePage: true}}
-        />
-      </Card>
-    );
-
-  }
-
-  renderIPAddressTable() {
-    const columns = [
-      {
-        title: i18next.t("general:IP Address"),
-        dataIndex: "data",
-        key: "data",
-        width: "140px",
-      },
-      {
-        title: i18next.t("general:Count"),
-        dataIndex: "count",
-        key: "count",
-        width: "20px",
-        sorter: (a, b) => a.count - b.count,
-      },
-    ];
-
-    return (
-      <Card title={i18next.t("general:Top 10 IP Addresses")} className="dashboard-card">
-        <Table
-          columns={columns}
-          dataSource={this.state.ipAddresses}
-          rowKey="ipAddress"
-          size="small"
-          pagination={{hideOnSinglePage: true}}
-        />
-      </Card>
-    );
-
-  }
-
-  renderSitesPieChart() {
-    return this.renderPieChart("Sites", this.state.sites);
-  }
-
-  renderPieChart(title, data) {
-    if (data === null) {
-      data = [];
-    }
-
-    const d = data.map((item) => {
-      return {value: item.count, name: item.data};
-    });
-    return (
-      <div className="dashboard-card" >
-        <PieChartCard title={title} data={d} />
-      </div>
-    );
-  }
-
-  renderTotalRequestCountStatistic() {
-    return this.renderStatistic(i18next.t("general:Total Request Count"), this.state.totleRequestCount);
-  }
-
-  renderUniqueIPCountStatistic() {
-    return this.renderStatistic(i18next.t("general:Unique IP Count"), this.state.uniqueIPCount);
-  }
-
-  renderStatistic(title, value) {
-    return (
-      <div className="dashboard-card" >
-        <StatisticCard title={title} value={value} className="dashboard-card" />
-      </div>
-    );
-  }
-
-  renderBarChart(title, data) {
-    if (data === null) {
-      data = [];
-    }
-
-    return (
-      <div className="dashboard-card" >
-        <BarChartCard title={title} data={data} className="dashboard-card" />
-      </div>
-    );
-  }
-
-  renderRadio() {
-    return (
-      <div style={{margin: "10px", float: "right"}}>
-        <Radio.Group style={{marginBottom: "10px"}} buttonStyle="solid" value={this.state.rangeType} onChange={e => {
-          const rangeType = e.target.value;
-          this.getAllData(rangeType);
-          this.setState({
-            rangeType: rangeType,
-          });
-        }}>
-          <Radio.Button value={"All"}>{i18next.t("usage:All")}</Radio.Button>
-          <Radio.Button value={"Hour"}>{i18next.t("usage:Hour")}</Radio.Button>
-          <Radio.Button value={"Day"}>{i18next.t("usage:Day")}</Radio.Button>
-          <Radio.Button value={"Week"}>{i18next.t("usage:Week")}</Radio.Button>
-          <Radio.Button value={"Month"}>{i18next.t("usage:Month")}</Radio.Button>
-        </Radio.Group>
-      </div>
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        {this.renderRadio()}
-        <Row style={{width: "100%"}}>
-          <Col span={4}>
-            {
-              this.renderTotalRequestCountStatistic()
-            }
-          </Col>
-          <Col span={20}>
-            {
-              this.renderBarChart("Request Count Over Time", this.state.requestCountOverTime)
-            }
-          </Col>
-        </Row>
-        <Row style={{width: "100%"}}>
-          <Col span={20}>
-            {this.renderSitesPieChart()}
-          </Col>
-          {/* <Col span={10}>
-            {this.renderHTTPVersionPieChart()}
-          </Col> */}
-          <Col span={4}>
-            {
-              this.renderUniqueIPCountStatistic()
-            }
-          </Col>
-        </Row>
-        <Row>
-          <Col span={8}>
-            {
-              this.renderIPAddressTable()
-            }
-          </Col>
-          <Col span={16}>
-            {
-              this.renderUserAgentsTable()
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default DashboardDetailPage;
diff --git a/web/src/HomePage.js b/web/src/HomePage.js
deleted file mode 100644
index bfcb0fa..0000000
--- a/web/src/HomePage.js
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2023 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 React from "react";
-
-class HomePage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-    };
-  }
-
-  render() {
-    return (
-      <div>
-        hello
-      </div>
-    );
-  }
-}
-
-export default HomePage;
diff --git a/web/src/LanguageSelect.js b/web/src/LanguageSelect.js
deleted file mode 100644
index 010d0f1..0000000
--- a/web/src/LanguageSelect.js
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright 2024 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 React from "react";
-import * as Setting from "./Setting";
-import {Dropdown, Menu} from "antd";
-import {GlobalOutlined} from "@ant-design/icons";
-
-function flagIcon(country, alt) {
-  return <img src={`${Setting.StaticBaseUrl}/flag-icons/${country}.svg`} alt={alt} style={{marginRight: 8, width: 24}} />;
-}
-
-class LanguageSelect extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      languages: Setting.Countries.map(item => item.key),
-    };
-
-    // Preload flag icons
-    Setting.Countries.forEach((country) => {
-      new Image().src = `${Setting.StaticBaseUrl}/flag-icons/${country.country}.svg`;
-    });
-  }
-
-  items = Setting.Countries.map((country) => ({
-    key: country.key,
-    label: (
-      <span>
-        {flagIcon(country.country, country.alt)}
-        {country.label}
-      </span>
-    ),
-  }));
-
-  getLanguages(languages) {
-    const select = [];
-    for (const language of languages) {
-      this.items.forEach((item) => item.key === language ? select.push(item) : null);
-    }
-    return select;
-  }
-
-  render() {
-    const languageItems = this.getLanguages(this.state.languages);
-
-    const menu = (
-      <Menu onClick={(e) => Setting.setLanguage(e.key)}>
-        {languageItems.map(item => (
-          <Menu.Item key={item.key}>
-            {item.label}
-          </Menu.Item>
-        ))}
-      </Menu>
-    );
-
-    return (
-      <Dropdown overlay={menu}>
-        <div className="select-box" style={{display: languageItems.length === 0 ? "none" : null, ...this.props.style}}>
-          <GlobalOutlined style={{fontSize: "24px"}} />
-        </div>
-      </Dropdown>
-    );
-  }
-}
-
-export default LanguageSelect;
diff --git a/web/src/NodeEditPage.js b/web/src/NodeEditPage.js
deleted file mode 100644
index b1e0c90..0000000
--- a/web/src/NodeEditPage.js
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright 2025 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 React from "react";
-import {Button, Card, Col, Input, Row, Select} from "antd";
-import * as NodeBackend from "./backend/NodeBackend";
-import * as Setting from "./Setting";
-import i18next from "i18next";
-
-const {Option} = Select;
-
-class NodeEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      owner: props.match.params.owner,
-      nodeName: props.match.params.nodeName,
-      node: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getNode();
-  }
-
-  getNode() {
-    NodeBackend.getNode(this.state.owner, this.state.nodeName)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            node: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get node: ${res.msg}`);
-        }
-      });
-  }
-
-  parseNodeField(key, value) {
-    return value;
-  }
-
-  updateNodeField(key, value) {
-    value = this.parseNodeField(key, value);
-
-    const node = this.state.node;
-    node[key] = value;
-    this.setState({
-      node: node,
-    });
-  }
-
-  renderNode() {
-    return (
-      <Card size="small" title={
-        <div>
-          {i18next.t("node:Edit Node")}&nbsp;&nbsp;&nbsp;&nbsp;
-          <Button type="primary" onClick={this.submitNodeEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-        </div>
-      } style={{marginLeft: "5px"}} type="inner">
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Name")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.node.name} onChange={e => {
-              this.updateNodeField("name", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Display name")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.node.displayName} onChange={e => {
-              this.updateNodeField("displayName", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Tag")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.node.tag} onChange={e => {
-              this.updateNodeField("tag", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Client IP")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.node.clientIp} onChange={e => {
-              this.updateNodeField("clientIp", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Upgrade mode")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.node.upgradeMode} onChange={(value => {
-              this.updateNodeField("upgradeMode", value);
-            })}>
-              <Option key="At Any Time" value="At Any Time">{i18next.t("general:At Any Time")}</Option>
-              <Option key="No Upgrade" value="No Upgrade">{i18next.t("general:No Upgrade")}</Option>
-              <Option key="Half A Hour" value="Half A Hour">{i18next.t("general:Half A Hour")}</Option>
-            </Select>
-          </Col>
-        </Row>
-      </Card>
-    );
-  }
-
-  submitNodeEdit() {
-    const node = Setting.deepCopy(this.state.node);
-    NodeBackend.updateNode(this.state.node.owner, this.state.nodeName, node)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to save: ${res.msg}`);
-          this.updateNodeField("name", this.state.nodeName);
-        } else {
-          Setting.showMessage("success", "Successfully saved");
-          this.setState({
-            nodeName: this.state.node.name,
-          });
-          this.props.history.push(`/nodes/${this.state.node.owner}/${this.state.node.name}`);
-        }
-      })
-      .catch(error => {
-        Setting.showMessage("error", `Failed to connect to server: ${error}`);
-      });
-  }
-
-  render() {
-    return (
-      <div>
-        {
-          this.state.node !== null ? this.renderNode() : null
-        }
-        <div style={{marginTop: "20px", marginLeft: "40px"}}>
-          <Button size="large" onClick={() => this.props.history.push("/nodes")}>{i18next.t("general:Cancel")}</Button>
-        </div>
-      </div>
-    );
-  }
-}
-
-export default NodeEditPage;
diff --git a/web/src/NodeListPage.js b/web/src/NodeListPage.js
deleted file mode 100644
index 0d3e635..0000000
--- a/web/src/NodeListPage.js
+++ /dev/null
@@ -1,246 +0,0 @@
-// Copyright 2025 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 React from "react";
-import {Link} from "react-router-dom";
-import {Button, Popconfirm, Table} from "antd";
-import moment from "moment";
-import * as Setting from "./Setting";
-import * as NodeBackend from "./backend/NodeBackend";
-import i18next from "i18next";
-import BaseListPage from "./BaseListPage";
-
-class NodeListPage extends BaseListPage {
-  constructor(props) {
-    super(props);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.setState({
-      pagination: {
-        ...this.state.pagination,
-        current: 1,
-        pageSize: 1000,
-      },
-    });
-    this.fetch({pagination: this.state.pagination});
-  }
-
-  newNode() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `node_${randomName}`,
-      createdTime: moment().format(),
-      displayName: `New Node - ${randomName}`,
-      tag: "",
-      clientIp: "",
-      upgradeMode: "At Any Time",
-    };
-  }
-
-  addNode() {
-    const newNode = this.newNode();
-    NodeBackend.addNode(newNode)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to add: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Node added successfully");
-          this.setState({
-            data: Setting.prependRow(this.state.data, newNode),
-          });
-          this.fetch();
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Node failed to add: ${error}`);
-      });
-  }
-
-  deleteNode(i) {
-    NodeBackend.deleteNode(this.state.data[i])
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to delete: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Node deleted successfully");
-          this.fetch({
-            pagination: {
-              ...this.state.pagination,
-              current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-            },
-          });
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Node failed to delete: ${error}`);
-      });
-  }
-
-  renderTable(data) {
-    const columns = [
-      {
-        title: i18next.t("general:Owner"),
-        dataIndex: "owner",
-        key: "owner",
-        width: "90px",
-        sorter: (a, b) => a.owner.localeCompare(b.owner),
-      },
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "150px",
-        sorter: (a, b) => a.name.localeCompare(b.name),
-        render: (text, record, index) => {
-          return (
-            <Link to={`/nodes/${record.owner}/${record.name}`}>
-              {text}
-            </Link>
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Create time"),
-        dataIndex: "createdTime",
-        key: "createdTime",
-        width: "180px",
-        sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
-        render: (text, record, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("general:Display name"),
-        dataIndex: "displayName",
-        key: "displayName",
-        // width: "200px",
-        sorter: (a, b) => a.displayName.localeCompare(b.displayName),
-      },
-      {
-        title: i18next.t("general:Tag"),
-        dataIndex: "tag",
-        key: "tag",
-        width: "140px",
-        sorter: (a, b) => a.tag.localeCompare(b.tag),
-      },
-      {
-        title: i18next.t("general:Client IP"),
-        dataIndex: "clientIp",
-        key: "clientIp",
-        width: "150px",
-        sorter: (a, b) => a.clientIp.localeCompare(b.clientIp),
-      },
-      {
-        title: i18next.t("general:Upgrade mode"),
-        dataIndex: "upgradeMode",
-        key: "upgradeMode",
-        width: "150px",
-        sorter: (a, b) => a.upgradeMode.localeCompare(b.upgradeMode),
-      },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "action",
-        key: "action",
-        width: "200px",
-        fixed: (Setting.isMobile()) ? "false" : "right",
-        render: (text, record, index) => {
-          return (
-            <div>
-              <Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/nodes/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
-              <Popconfirm
-                title={`Sure to delete node: ${record.name} ?`}
-                onConfirm={() => this.deleteNode(index)}
-              >
-                <Button style={{marginBottom: "10px"}} type="danger">{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </div>
-          );
-        },
-      },
-    ];
-
-    const paginationProps = {
-      pageSize: this.state.pagination.pageSize,
-      total: this.state.pagination.total,
-      showQuickJumper: true,
-      showSizeChanger: true,
-      showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
-    };
-
-    return (
-      <div>
-        <Table scroll={{x: "max-content"}} columns={columns} dataSource={data} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
-          title={() => (
-            <div>
-              {i18next.t("general:Nodes")}&nbsp;&nbsp;&nbsp;&nbsp;
-              <Button type="primary" size="small" onClick={this.addNode.bind(this)}>{i18next.t("general:Add")}</Button>
-            </div>
-          )}
-          loading={this.state.loading}
-          onChange={this.handleTableChange}
-        />
-      </div>
-    );
-  }
-
-  fetch = (params = {}) => {
-    const field = params.searchedColumn, value = params.searchText;
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (params.pagination === undefined) {
-      params.pagination = this.state.pagination;
-    }
-    this.setState({loading: true});
-    // NodeBackend.getNodes(this.props.account.name, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
-    NodeBackend.getNodes(this.props.account.name, "", "", field, value, sortField, sortOrder)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            loading: false,
-            data: res.data,
-            pagination: {
-              ...params.pagination,
-              total: res.data2,
-            },
-            searchedColumn: params.searchedColumn,
-            searchText: params.searchText,
-          });
-        } else {
-          if (Setting.isResponseDenied(res)) {
-            this.setState({
-              loading: false,
-              isAuthorized: false,
-            });
-          } else {
-            Setting.showMessage("error", res.msg);
-          }
-        }
-      });
-  };
-
-  render() {
-    return (
-      <div>
-        {
-          this.renderTable(this.state.data)
-        }
-      </div>
-    );
-  }
-}
-
-export default NodeListPage;
diff --git a/web/src/NodeTable.js b/web/src/NodeTable.js
deleted file mode 100644
index ad09b6f..0000000
--- a/web/src/NodeTable.js
+++ /dev/null
@@ -1,265 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {CheckCircleOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined, SyncOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Input, Row, Select, Table, Tag, Tooltip} from "antd";
-import * as Setting from "./Setting";
-
-const {TextArea} = Input;
-const {Option} = Select;
-
-class NodeTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-    };
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  parseField(key, value) {
-    if (["no", "port", "processId"].includes(key)) {
-      value = Setting.myParseInt(value);
-    }
-    return value;
-  }
-
-  updateField(table, index, key, value) {
-    value = this.parseField(key, value);
-
-    table[index][key] = value;
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const row = {name: `New Node - ${table.length}`, version: "", diff: "", status: "", message: ""};
-    if (table === undefined) {
-      table = [];
-    }
-
-    if (table.length === 0) {
-      row.name = this.props.account.hostname;
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    this.updateTable(table);
-  }
-
-  renderTable(table) {
-    // Get list of already selected nodes
-    const selectedNodes = table ? table.map(item => item.name) : [];
-
-    // Filter available nodes (exclude already selected ones, but include current node)
-    const getAvailableNodes = (currentName) => {
-      if (!this.props.nodes) {
-        return [];
-      }
-      return this.props.nodes.filter(node =>
-        node.name === currentName || !selectedNodes.includes(node.name)
-      );
-    };
-
-    const columns = [
-      {
-        title: "Name",
-        dataIndex: "name",
-        key: "name",
-        width: "180px",
-        render: (text, record, index) => {
-          const availableNodes = getAvailableNodes(text);
-          return (
-            <Select
-              virtual={false}
-              style={{width: "100%"}}
-              value={text}
-              onChange={value => {
-                this.updateField(table, index, "name", value);
-              }}
-            >
-              {
-                availableNodes.map((node, idx) => (
-                  <Option key={idx} value={node.name}>{node.name}</Option>
-                ))
-              }
-            </Select>
-          );
-        },
-      },
-      {
-        title: "Version",
-        dataIndex: "version",
-        key: "version",
-        width: "160px",
-        render: (text, record, index) => {
-          if (text === "") {
-            return null;
-          }
-
-          const versionInfo = Setting.getVersionInfo(text, this.props.siteName);
-          return (
-            <a target="_blank" rel="noreferrer" href={versionInfo.link}>{versionInfo.text}</a>
-          );
-
-          // return (
-          //   <Input value={text} onChange={e => {
-          //     this.updateField(table, index, "version", e.target.value);
-          //   }} />
-          // );
-        },
-      },
-      {
-        title: "Diff",
-        dataIndex: "diff",
-        key: "diff",
-        render: (text, record, index) => {
-          if (record.status === "") {
-            return null;
-          } else {
-            return (
-              <Tooltip title={
-                <div style={{width: "800px"}}>
-                  <TextArea autoSize={{minRows: 1, maxRows: 30}} value={text} />
-                </div>
-              }>
-                {Setting.getShortText(text)}
-              </Tooltip>
-            );
-          }
-        },
-      },
-      {
-        title: "Pid",
-        dataIndex: "pid",
-        key: "pid",
-        width: "110px",
-        render: (text, record, index) => {
-          if (text === 0) {
-            return null;
-          } else {
-            return text;
-          }
-        },
-      },
-      {
-        title: "Status",
-        dataIndex: "status",
-        key: "status",
-        width: "150px",
-        render: (text, record, index) => {
-          if (record.status === "") {
-            return null;
-          } else if (record.status === "In Progress") {
-            return (
-              <Tag icon={<SyncOutlined spin />} color="processing">{text}</Tag>
-            );
-          } else if (record.status === "Running") {
-            return (
-              <Tag icon={<CheckCircleOutlined />} color="success">{text}</Tag>
-            );
-          } else if (record.status === "Stopped") {
-            return (
-              <Tag icon={<MinusCircleOutlined />} color="error">{text}</Tag>
-            );
-          } else {
-            return text;
-          }
-        },
-      },
-      {
-        title: "Message",
-        dataIndex: "message",
-        key: "message",
-      },
-      {
-        title: "Provider",
-        dataIndex: "provider",
-        key: "provider",
-        width: "250px",
-        render: (text, record, index) => {
-          return (
-            <Input value={text} onChange={e => {
-              this.updateField(table, index, "provider", e.target.value);
-            }} />
-          );
-        },
-      },
-      {
-        title: "Action",
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => {
-          return (
-            <div>
-              <Tooltip placement="bottomLeft" title={"Up"}>
-                <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-              </Tooltip>
-              <Tooltip placement="topLeft" title={"Down"}>
-                <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-              </Tooltip>
-              <Tooltip placement="topLeft" title={"Delete"}>
-                <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-              </Tooltip>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{"Add"}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default NodeTable;
diff --git a/web/src/RecordEditPage.js b/web/src/RecordEditPage.js
deleted file mode 100644
index e8ab5e2..0000000
--- a/web/src/RecordEditPage.js
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Card, Col, Input, Row} from "antd";
-import * as RecordBackend from "./backend/RecordBackend";
-import * as Setting from "./Setting";
-import i18next from "i18next";
-
-// const {Option} = Select;
-
-class RecordEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      owner: props.match.params.owner,
-      id: props.match.params.id,
-      record: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getRecord();
-  }
-
-  getRecord() {
-    RecordBackend.getRecord(this.state.owner, this.state.id)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            record: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get record: ${res.msg}`);
-        }
-      });
-  }
-
-  updateRecordField(key, value) {
-    const record = this.state.record;
-    record[key] = value;
-    this.setState({
-      record: record,
-    });
-  }
-
-  submitRecordEdit() {
-    const record = Setting.deepCopy(this.state.record);
-    RecordBackend.updateRecord(this.state.record.owner, this.state.id, record)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to save: ${res.msg}`);
-          this.updateRecordField("id", this.state.id);
-        } else {
-          Setting.showMessage("success", "Successfully saved");
-          this.setState({
-            id: this.state.record.id,
-          });
-          this.props.history.push(`/records/${this.state.record.owner}/${this.state.record.id}`);
-          this.getRecord();
-        }
-      })
-      .catch(error => {
-        Setting.showMessage("error", `failed to save: ${error}`);
-      });
-  }
-
-  renderRecord() {
-    return (
-      <Card size="small" title={
-        <div>
-          {i18next.t("general:Edit Record")}&nbsp;&nbsp;&nbsp;&nbsp;
-          <Button type="primary" onClick={this.submitRecordEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-        </div>
-      } style={{marginLeft: "5px"}} type="inner">
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Owner")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.owner} onChange={e => {
-              this.updateRecordField("owner", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:CreatedTime")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.createdTime} onChange={e => {
-              this.updateRecordField("createdTime", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Method")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.method} onChange={e => {
-              this.updateRecordField("method", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Host")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.host} onChange={e => {
-              this.updateRecordField("host", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Path")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.path} onChange={e => {
-              this.updateRecordField("path", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Client ip")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.clientIp} onChange={e => {
-              this.updateRecordField("clientIp", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:User-Agent")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.record.userAgent} onChange={e => {
-              this.updateRecordField("userAgent", e.target.value);
-            }} />
-          </Col>
-        </Row>
-      </Card>
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{width: "100%"}}>
-          <Col span={1}>
-          </Col>
-          <Col span={22}>
-            {
-              this.state.record !== null ? this.renderRecord() : null
-            }
-          </Col>
-          <Col span={1}>
-          </Col>
-        </Row>
-        <Row style={{margin: 10}}>
-          <Col span={2}>
-          </Col>
-          <Col span={18}>
-            <Button type="primary" size="large" onClick={this.submitRecordEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-
-}
-
-export default RecordEditPage;
diff --git a/web/src/RecordListPage.js b/web/src/RecordListPage.js
deleted file mode 100644
index 8565f96..0000000
--- a/web/src/RecordListPage.js
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright 2021 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 React from "react";
-import {Button, Popconfirm, Table} from "antd";
-import moment from "moment";
-import * as Setting from "./Setting";
-import * as RecordBackend from "./backend/RecordBackend";
-import i18next from "i18next";
-import BaseListPage from "./BaseListPage";
-
-class RecordListPage extends BaseListPage {
-
-  UNSAFE_componentWillMount() {
-    this.setState({
-      pagination: {
-        ...this.state.pagination,
-        current: 1,
-        pageSize: 10,
-      },
-    });
-    this.fetch({pagination: this.state.pagination});
-  }
-
-  fetch = (params = {}) => {
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (!params.pagination) {
-      params.pagination = {current: 1, pageSize: 10};
-    }
-    this.setState({loading: true});
-    RecordBackend.getRecords(this.props.account.name, params.pagination.current, params.pagination.pageSize, sortField, sortOrder)
-      .then((res) => {
-        this.setState({
-          loading: false,
-        });
-        if (res.status === "ok") {
-          this.setState({
-            data: res.data,
-            pagination: {
-              ...params.pagination,
-              total: res.data2,
-            },
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get records: ${res.msg}`);
-        }
-      });
-  };
-
-  newRecord() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `record_${randomName}`,
-      createdTime: moment().format(),
-      method: "GET",
-      host: "door.casdoor.com",
-      path: "/",
-      userAgent: "",
-    };
-  }
-
-  addRecord() {
-    const newRecord = this.newRecord();
-    RecordBackend.addRecord(newRecord)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to add: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Record added successfully");
-          this.setState({
-            data: Setting.addRow(this.state.data, res.data),
-          });
-          this.fetch();
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Record failed to add: ${error}`);
-      });
-  }
-
-  deleteRecord(i) {
-    RecordBackend.deleteRecord(this.state.data[i])
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to delete: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Record deleted successfully");
-          this.fetch({
-            pagination: {
-              ...this.state.pagination,
-              current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-            },
-          });
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Record failed to delete: ${error}`);
-      });
-  }
-
-  renderTable(data) {
-    const columns = [
-      {
-        title: i18next.t("general:ID"),
-        dataIndex: "id",
-        key: "id",
-        width: "30px",
-        sorter: (a, b) => a.id - b.id,
-      },
-      {
-        title: i18next.t("general:Owner"),
-        dataIndex: "owner",
-        key: "owner",
-        width: "30px",
-        sorter: (a, b) => a.owner.localeCompare(b.owner),
-      },
-      {
-        title: i18next.t("general:Created time"),
-        dataIndex: "createdTime",
-        key: "createdTime",
-        width: "70px",
-        sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
-        render: (text, record, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("general:Method"),
-        dataIndex: "method",
-        key: "method",
-        width: "30px",
-        sorter: (a, b) => a.method.localeCompare(b.method),
-      },
-      {
-        title: i18next.t("general:Host"),
-        dataIndex: "host",
-        key: "host",
-        width: "50px",
-        sorter: (a, b) => a.host.localeCompare(b.host),
-      },
-      {
-        title: i18next.t("general:Path"),
-        dataIndex: "path",
-        key: "path",
-        width: "100px",
-        sorter: (a, b) => a.path.localeCompare(b.path),
-      },
-      {
-        title: i18next.t("general:Client ip"),
-        dataIndex: "clientIp",
-        key: "clientIp",
-        width: "100px",
-        sorter: (a, b) => a.clientIp.localeCompare(b.clientIp),
-      },
-      {
-        title: i18next.t("general:User-Agent"),
-        dataIndex: "userAgent",
-        key: "userAgent",
-        width: "240px",
-        sorter: (a, b) => a.userAgent.localeCompare(b.userAgent),
-      },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "action",
-        key: "action",
-        width: "180px",
-        render: (text, record, index) => {
-          return (
-            <div>
-              <Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/records/${record.owner}/${record.id}`)}>{i18next.t("general:Edit")}</Button>
-              <Popconfirm
-                title={"Sure to delete record?"}
-                onConfirm={() => this.deleteRecord(index)}
-                okText="OK"
-                cancelText="Cancel"
-              >
-                <Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="danger">{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <div>
-        <Table columns={columns} dataSource={data} rowKey="name" size="middle" bordered pagination={this.state.pagination}
-          title={() => (
-            <div>
-              {i18next.t("general:Records")}&nbsp;&nbsp;&nbsp;&nbsp;
-              <Button type="primary" size="small" onClick={this.addRecord.bind(this)}>{i18next.t("general:Add")}</Button>
-            </div>
-          )}
-          loading={this.state.loading}
-          onChange={this.handleTableChange}
-        />
-      </div>
-    );
-  }
-
-}
-
-export default RecordListPage;
diff --git a/web/src/RuleEditPage.js b/web/src/RuleEditPage.js
deleted file mode 100644
index 65b1e47..0000000
--- a/web/src/RuleEditPage.js
+++ /dev/null
@@ -1,295 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
-import * as Setting from "./Setting";
-import * as RuleBackend from "./backend/RuleBackend";
-import i18next from "i18next";
-import WafRuleTable from "./components/WafRuleTable";
-import IpRuleTable from "./components/IpRuleTable";
-import UaRuleTable from "./components/UaRuleTable";
-import IpRateRuleTable from "./components/IpRateRuleTable";
-import CompoundRule from "./components/CompoundRule";
-
-const {Option} = Select;
-
-class RuleEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      owner: props.match.params.owner,
-      ruleName: props.match.params.ruleName,
-      rule: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getRule();
-  }
-
-  getRule() {
-    RuleBackend.getRule(this.state.owner, this.state.ruleName).then((res) => {
-      this.setState({
-        rule: res.data,
-      });
-    });
-  }
-
-  updateRuleField(key, value) {
-    const rule = Setting.deepCopy(this.state.rule);
-    rule[key] = value;
-    if (key === "type") {
-      rule.expressions = [];
-    }
-    this.setState({
-      rule: rule,
-    });
-  }
-
-  updateRuleFieldInExpressions(index, key, value) {
-    const rule = Setting.deepCopy(this.state.rule);
-    rule.expressions[index][key] = value;
-    this.updateRuleField("expressions", rule.expressions);
-    this.setState({
-      rule: rule,
-    });
-  }
-
-  renderRule() {
-    return (
-      <Card size="small" title={
-        <div>
-          {i18next.t("rule:Edit Rule")}&nbsp;&nbsp;&nbsp;&nbsp;
-          <Button type="primary" onClick={this.submitRuleEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-        </div>
-      } style={{marginTop: 10}} type="inner">
-        <Row style={{marginTop: "20px"}}>
-          <Col span={2} style={{marginTop: "5px"}}>
-            {i18next.t("general:Name")}:
-          </Col>
-          <Col span={22}>
-            <Input value={this.state.rule.name} onChange={e => {
-              this.updateRuleField("name", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}}>
-          <Col span={2} style={{marginTop: "5px"}}>
-            {i18next.t("rule:Type")}:
-          </Col>
-          <Col span={22}>
-            <Select virtual={false} value={this.state.rule.type} style={{width: "100%"}} onChange={value => {
-              this.updateRuleField("type", value);
-            }}>
-              {
-                [
-                  {value: "WAF", text: "WAF"},
-                  {value: "IP", text: "IP"},
-                  {value: "User-Agent", text: "User-Agent"},
-                  {value: "URL Path", text: "URL Path"},
-                  {value: "IP Rate Limiting", text: i18next.t("rule:IP Rate Limiting")},
-                  {value: "Compound", text: i18next.t("rule:Compound")},
-                ].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("rule:Expressions")}:
-          </Col>
-          <Col span={22} >
-            {
-              this.state.rule.type === "WAF" ? (
-                <WafRuleTable
-                  title={"Seclang"}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  account={this.props.account}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
-                />
-              ) : null
-            }
-            {
-              this.state.rule.type === "IP" ? (
-                <IpRuleTable
-                  title={"IPs"}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  account={this.props.account}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
-                />
-              ) : null
-            }
-            {
-              this.state.rule.type === "User-Agent" ? (
-                <UaRuleTable
-                  title={"User-Agents"}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  account={this.props.account}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
-                />
-              ) : null
-            }
-            {
-              this.state.rule.type === "URL Path" ? (
-                <UaRuleTable
-                  kind="urlPath"
-                  title={"URL Paths"}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  account={this.props.account}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
-                />
-              ) : null
-            }
-            {
-              this.state.rule.type === "IP Rate Limiting" ? (
-                <IpRateRuleTable
-                  title={i18next.t("rule:IP Rate Limiting")}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  account={this.props.account}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
-                />
-              ) : null
-            }
-            {
-              this.state.rule.type === "Compound" ? (
-                <CompoundRule
-                  title={i18next.t("rule:Compound")}
-                  table={this.state.rule.expressions}
-                  ruleName={this.state.rule.name}
-                  owner={this.state.owner}
-                  onUpdateTable={(value) => {this.updateRuleField("expressions", value);}} />
-              ) : null
-            }
-          </Col>
-        </Row>
-        {
-          this.state.rule.type !== "WAF" && (
-            <Row style={{marginTop: "20px"}}>
-              <Col span={2} style={{marginTop: "5px"}}>
-                {i18next.t("general:Action")}:
-              </Col>
-              <Col span={22}>
-                <Select virtual={false} value={this.state.rule.action} defaultValue={"Block"} style={{width: "100%"}} onChange={(value) => {
-                  this.updateRuleField("action", value);
-                }}>
-                  {
-                    [
-                      {value: "Allow", text: i18next.t("rule:Allow")},
-                      {value: "Block", text: i18next.t("rule:Block")},
-                      {value: "CAPTCHA", text: i18next.t("rule:Captcha")},
-                    ].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
-                  }
-                </Select>
-              </Col>
-            </Row>
-          )
-        }
-        {
-          this.state.rule.type !== "WAF" && (this.state.rule.action === "Allow" || this.state.rule.action === "Block") && (
-            <Row style={{marginTop: "20px"}}>
-              <Col span={2} style={{marginTop: "5px"}}>
-                {i18next.t("rule:Status code")}:
-              </Col>
-              <Col span={22}>
-                <InputNumber value={this.state.rule.statusCode} min={100} max={599} onChange={e => {
-                  this.updateRuleField("statusCode", e);
-                }} />
-              </Col>
-            </Row>
-          )
-        }
-        {
-          <Row style={{marginTop: "20px"}}>
-            <Col span={2} style={{marginTop: "5px"}}>
-              {i18next.t("rule:Reason")}:
-            </Col>
-            <Col span={22}>
-              <Input value={this.state.rule.reason}
-                onChange={e => {
-                  this.updateRuleField("reason", e.target.value);
-                }} />
-            </Col>
-          </Row>
-        }
-        {
-          <Row style={{marginTop: "20px"}}>
-            <Col span={2} style={{marginTop: "5px"}}>
-              {i18next.t("rule:Verbose mode")}:
-            </Col>
-            <Col span={22}>
-              <Switch checked={this.state.rule.isVerbose}
-                onChange={checked => {
-                  this.updateRuleField("isVerbose", checked);
-                }} />
-            </Col>
-          </Row>
-        }
-      </Card>
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{width: "100%"}}>
-          <Col span={1}>
-          </Col>
-          <Col span={22}>
-            {
-              this.state.rule !== null ? this.renderRule() : null
-            }
-          </Col>
-          <Col span={1}>
-          </Col>
-        </Row>
-        <Row style={{margin: 10}}>
-          <Col span={2}>
-          </Col>
-          <Col span={18}>
-            <Button type="primary" size="large" onClick={this.submitRuleEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-
-  submitRuleEdit() {
-    const rule = Setting.deepCopy(this.state.rule);
-    RuleBackend.updateRule(this.state.owner, this.state.ruleName, rule)
-      .then((res) => {
-        if (res.status !== "error") {
-          Setting.showMessage("success", "Rule updated successfully");
-          this.setState({
-            rule: rule,
-          });
-        } else {
-          Setting.showMessage("error", `Rule failed to update: ${res.msg}`);
-          this.setState({
-            ruleName: this.state.rule.name,
-          });
-          this.props.history.push(`/rules/${this.state.rule.owner}/${this.state.rule.name}`);
-          this.getRule();
-        }
-      });
-  }
-}
-
-export default RuleEditPage;
diff --git a/web/src/RuleListPage.js b/web/src/RuleListPage.js
deleted file mode 100644
index bf0c85a..0000000
--- a/web/src/RuleListPage.js
+++ /dev/null
@@ -1,235 +0,0 @@
-// Copyright 2021 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 React from "react";
-import {Button, Popconfirm, Table, Tag} from "antd";
-import moment from "moment";
-import * as Setting from "./Setting";
-import * as RuleBackend from "./backend/RuleBackend";
-import i18next from "i18next";
-import BaseListPage from "./BaseListPage";
-
-class RuleListPage extends BaseListPage {
-  UNSAFE_componentWillMount() {
-    this.setState({
-      pagination: {
-        ...this.state.pagination,
-        current: 1,
-        pageSize: 10,
-      },
-    });
-    this.fetch({pagination: this.state.pagination});
-  }
-
-  fetch = (params = {}) => {
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (!params.pagination) {
-      params.pagination = {current: 1, pageSize: 10};
-    }
-    this.setState({
-      loading: true,
-    });
-    RuleBackend.getRules(this.props.account.name, params.pagination.current, params.pagination.pageSize, sortField, sortOrder).then((res) => {
-      this.setState({
-        loading: false,
-      });
-      if (res.status === "ok") {
-        this.setState({
-          data: res.data,
-          pagination: {
-            ...params.pagination,
-            total: res.data2,
-          },
-        });
-      } else {
-        this.setState({loading: false});
-      }
-    });
-  };
-
-  addRule() {
-    const newRule = this.newRule();
-    RuleBackend.addRule(newRule).then((res) => {
-      if (res.status === "error") {
-        Setting.showMessage("error", `Failed to add: ${res.msg}`);
-      } else {
-        Setting.showMessage("success", "Rule added successfully");
-        this.setState({
-          data: Setting.prependRow(this.state.data, newRule),
-        });
-        this.fetch();
-      }
-    });
-  }
-
-  deleteRule(i) {
-    RuleBackend.deleteRule(this.state.data[i]).then((res) => {
-      if (res.status === "error") {
-        Setting.showMessage("error", `Failed to delete: ${res.msg}`);
-      } else {
-        Setting.showMessage("success", "Deleted successfully");
-        this.fetch({
-          pagination: {
-            ...this.state.pagination,
-            current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-          },
-        });
-      }
-    });
-  }
-
-  newRule() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `rule_${randomName}`,
-      createdTime: moment().format(),
-      type: "User-Agent",
-      expressions: [],
-      action: "Block",
-      reason: "Your request is blocked.",
-    };
-  }
-
-  renderTable(data) {
-    const columns = [
-      {
-        title: i18next.t("general:Owner"),
-        dataIndex: "owner",
-        key: "owner",
-        width: "150px",
-        sorter: (a, b) => a.owner.localeCompare(b.owner),
-      },
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "200px",
-        sorter: (a, b) => a.name.localeCompare(b.name),
-        render: (text, rule, index) => {
-          return <a href={`/rules/${rule.owner}/${text}`}>{text}</a>;
-        },
-      },
-      {
-        title: i18next.t("general:Create time"),
-        dataIndex: "createdTime",
-        key: "createdTime",
-        width: "200px",
-        sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
-        render: (text, rule, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("general:Update time"),
-        dataIndex: "updatedTime",
-        key: "updatedTime",
-        width: "200px",
-        sorter: (a, b) => a.updatedTime.localeCompare(b.updatedTime),
-        render: (text, rule, index) => {
-          return Setting.getFormattedDate(text);
-        },
-      },
-      {
-        title: i18next.t("rule:Type"),
-        dataIndex: "type",
-        key: "type",
-        width: "100px",
-        sorter: (a, b) => a.type.localeCompare(b.type),
-        render: (text, rule, index) => {
-          return (
-            <Tag color="blue">
-              {i18next.t(`rule:${text}`)}
-            </Tag>
-          );
-        },
-      },
-      {
-        title: i18next.t("rule:Expressions"),
-        dataIndex: "expressions",
-        key: "expressions",
-        sorter: (a, b) => a.expressions.localeCompare(b.expressions),
-        render: (text, rule, index) => {
-          return rule.expressions.map((expression, i) => {
-            return (
-              <Tag key={expression} color={"success"}>
-                {expression.operator + " " + expression.value.slice(0, 20)}
-              </Tag>
-            );
-          });
-        },
-      },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "action",
-        key: "action",
-        width: "100px",
-        sorter: (a, b) => a.action.localeCompare(b.action),
-      },
-      {
-        title: i18next.t("rule:Status code"),
-        dataIndex: "statusCode",
-        key: "statusCode",
-        width: "120px",
-        sorter: (a, b) => a.statusCode.localeCompare(b.statusCode),
-      },
-      {
-        title: i18next.t("rule:Reason"),
-        dataIndex: "reason",
-        key: "reason",
-        width: "300px",
-        sorter: (a, b) => a.reason.localeCompare(b.reason),
-      },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "",
-        key: "op",
-        render: (text, rule, index) => {
-          return (
-            <div>
-              <Popconfirm
-                title={`Sure to delete rule: ${rule.name} ?`}
-                onConfirm={() => this.deleteRule(index)}
-              >
-                <Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/rules/${rule.owner}/${rule.name}`)}>{i18next.t("general:Edit")}</Button>
-                <Button type="danger">{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <Table
-        dataSource={data}
-        columns={columns}
-        rowKey="name"
-        pagination={this.state.pagination}
-        loading={this.state.loading}
-        onChange={this.handleTableChange}
-        size="middle"
-        bordered
-        title={() => (
-          <div>
-            {i18next.t("general:Rules")}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button type="primary" size="small" onClick={() => this.addRule()}>{i18next.t("general:Add")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-}
-
-export default RuleListPage;
diff --git a/web/src/SelectLanguageBox.js b/web/src/SelectLanguageBox.js
deleted file mode 100644
index 92151da..0000000
--- a/web/src/SelectLanguageBox.js
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2023 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 React from "react";
-import * as Setting from "./Setting";
-import {Dropdown, Menu} from "antd";
-import {createFromIconfontCN} from "@ant-design/icons";
-import "./App.less";
-
-const IconFont = createFromIconfontCN({
-  scriptUrl: "//at.alicdn.com/t/font_2680620_ffij16fkwdg.js",
-});
-
-const LanguageItems = [
-  {lang: "en", label: "English", icon: "icon-en"},
-  {lang: "zh", label: "中文", icon: "icon-zh"},
-];
-
-class SelectLanguageBox extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-    };
-  }
-
-  render() {
-    return <Dropdown overlay={<Menu>{LanguageItems.map(({lang, label, icon}) => <Menu.Item key={lang} onClick={() => Setting.changeLanguage(lang)}><IconFont type={icon} />{label}</Menu.Item>)}</Menu>}>
-      <div className="language-box"></div>
-    </Dropdown>;
-  }
-}
-
-export default SelectLanguageBox;
diff --git a/web/src/Setting.js b/web/src/Setting.js
deleted file mode 100644
index 937d128..0000000
--- a/web/src/Setting.js
+++ /dev/null
@@ -1,327 +0,0 @@
-// Copyright 2023 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 {Tag, message} from "antd";
-import React from "react";
-import {isMobile as isMobileDevice} from "react-device-detect";
-import i18next from "i18next";
-import moment from "moment";
-import Sdk from "casdoor-js-sdk";
-import * as Conf from "./Conf";
-
-export let ServerUrl = "";
-
-export const StaticBaseUrl = "https://cdn.casbin.org";
-
-export let CasdoorSdk;
-
-export const Countries = [
-  {label: "English", key: "en", country: "US", alt: "English"},
-  {label: "中文", key: "zh", country: "CN", alt: "中文"},
-];
-
-export function initServerUrl() {
-  const hostname = window.location.hostname;
-  if (hostname === "localhost") {
-    ServerUrl = `http://${hostname}:17000`;
-  }
-}
-
-export function initCasdoorSdk(config) {
-  CasdoorSdk = new Sdk(config);
-}
-
-// Casdoor is optional: with no "casdoorEndpoint" in app.conf the backend reports
-// an empty server URL and the app signs in against its own user table instead.
-export function isCasdoorAvailable() {
-  return Conf.AuthConfig.serverUrl !== "";
-}
-
-// A session created by the built-in username/password login, as opposed to one
-// issued by Casdoor. Such a user has no Casdoor profile page to link to.
-export function isBasicLoginMode(account) {
-  return account?.owner === "basic";
-}
-
-function getUrlWithLanguage(url) {
-  if (url.includes("?")) {
-    return `${url}&language=${getLanguage()}`;
-  } else {
-    return `${url}?language=${getLanguage()}`;
-  }
-}
-
-export function getSignupUrl() {
-  if (!isCasdoorAvailable()) {
-    return "";
-  }
-  return getUrlWithLanguage(CasdoorSdk.getSignupUrl());
-}
-
-export function getSigninUrl() {
-  if (!isCasdoorAvailable()) {
-    return "";
-  }
-  return getUrlWithLanguage(CasdoorSdk.getSigninUrl());
-}
-
-export function getUserProfileUrl(userName, account) {
-  if (!isCasdoorAvailable() || isBasicLoginMode(account)) {
-    return "";
-  }
-  return getUrlWithLanguage(CasdoorSdk.getUserProfileUrl(userName, account));
-}
-
-export function getMyProfileUrl(account) {
-  if (!isCasdoorAvailable() || isBasicLoginMode(account)) {
-    return "";
-  }
-  return getUrlWithLanguage(CasdoorSdk.getMyProfileUrl(account));
-}
-
-export function signin() {
-  return CasdoorSdk.signin(ServerUrl);
-}
-
-export function parseJson(s) {
-  if (s === "") {
-    return null;
-  } else {
-    return JSON.parse(s);
-  }
-}
-
-export function myParseInt(i) {
-  const res = parseInt(i);
-  return isNaN(res) ? 0 : res;
-}
-
-export function openLink(link) {
-  // this.props.history.push(link);
-  const w = window.open("about:blank");
-  w.location.href = link;
-}
-
-export function goToLink(link) {
-  window.location.href = link;
-}
-
-export function goToLinkSoft(ths, link) {
-  ths.props.history.push(link);
-}
-
-export function showMessage(type, text) {
-  if (type === "") {
-    return;
-  } else if (type === "success") {
-    message.success(text);
-  } else if (type === "error") {
-    message.error(text);
-  }
-}
-
-export function isAdminUser(account) {
-  return account?.isAdmin;
-}
-
-export function deepCopy(obj) {
-  return Object.assign({}, obj);
-}
-
-export function insertRow(array, row, i) {
-  return [...array.slice(0, i), row, ...array.slice(i)];
-}
-
-export function addRow(array, row) {
-  return [...array, row];
-}
-
-export function prependRow(array, row) {
-  return [row, ...array];
-}
-
-export function deleteRow(array, i) {
-  // return array = array.slice(0, i).concat(array.slice(i + 1));
-  return [...array.slice(0, i), ...array.slice(i + 1)];
-}
-
-export function swapRow(array, i, j) {
-  return [...array.slice(0, i), array[j], ...array.slice(i + 1, j), array[i], ...array.slice(j + 1)];
-}
-
-export function isMobile() {
-  // return getIsMobileView();
-  return isMobileDevice;
-}
-
-export function getFormattedDate(date) {
-  if (date === undefined || date === null) {
-    return null;
-  }
-
-  date = date.replace("T", " ");
-  date = date.replace("+08:00", " ");
-  return date;
-}
-
-export function getFormattedDateShort(date) {
-  return date.slice(0, 10);
-}
-
-export function getShortName(s) {
-  return s.split("/").slice(-1)[0];
-}
-
-export function getShortText(s, maxLength = 35) {
-  if (s.length > maxLength) {
-    return `${s.slice(0, maxLength)}...`;
-  } else {
-    return s;
-  }
-}
-
-export function getRandomName() {
-  return Math.random().toString(36).slice(-6);
-}
-
-function getRandomInt(s) {
-  let hash = 0;
-  if (s.length !== 0) {
-    for (let i = 0; i < s.length; i++) {
-      const char = s.charCodeAt(i);
-      hash = ((hash << 5) - hash) + char;
-      hash = hash & hash;
-    }
-  }
-
-  return hash;
-}
-
-export function getAvatarColor(s) {
-  const colorList = ["#f56a00", "#7265e6", "#ffbf00", "#00a2ae"];
-  let random = getRandomInt(s);
-  if (random < 0) {
-    random = -random;
-  }
-  return colorList[random % 4];
-}
-
-export function getTagColor(s) {
-  return "processing";
-}
-
-export function getTags(tags) {
-  const res = [];
-  if (!tags) {
-    return res;
-  }
-
-  tags.forEach((tag, i) => {
-    res.push(
-      <Tag color={getTagColor(tag)}>
-        {tag}
-      </Tag>
-    );
-  });
-  return res;
-}
-
-export function getLanguage() {
-  return i18next.language;
-}
-
-export function setLanguage(language) {
-  localStorage.setItem("language", language);
-  changeMomentLanguage(language);
-  i18next.changeLanguage(language);
-}
-
-export function changeLanguage(language) {
-  localStorage.setItem("language", language);
-  changeMomentLanguage(language);
-  i18next.changeLanguage(language);
-  window.location.reload(true);
-}
-
-export function changeMomentLanguage(lng) {
-  if (lng === "zh") {
-    moment.locale("zh", {
-      relativeTime: {
-        future: "%s内",
-        past: "%s前",
-        s: "几秒",
-        ss: "%d秒",
-        m: "1分钟",
-        mm: "%d分钟",
-        h: "1小时",
-        hh: "%d小时",
-        d: "1天",
-        dd: "%d天",
-        M: "1个月",
-        MM: "%d个月",
-        y: "1年",
-        yy: "%d年",
-      },
-    });
-  }
-}
-
-export function isResponseDenied(data) {
-  if (data.msg === "Unauthorized operation" || data.msg === "未授权的操作") {
-    return true;
-  }
-  return false;
-}
-
-function getOriginalName(name) {
-  const tokens = name.split("_");
-  if (tokens.length > 0) {
-    return tokens[0];
-  } else {
-    return name;
-  }
-}
-
-export function getRepoUrl(name) {
-  name = getOriginalName(name);
-  if (name === "casdoor") {
-    return "https://github.com/casdoor/casdoor";
-  } else {
-    return `https://github.com/casbin/${name}`;
-  }
-}
-
-export function getVersionInfo(text, siteName) {
-  if (text === "") {
-    return null;
-  }
-
-  const versionInfo = JSON.parse(text);
-  const link = versionInfo?.version !== "" ? `${getRepoUrl(siteName)}/releases/tag/${versionInfo?.version}` : "";
-  let versionText = versionInfo?.version !== "" ? versionInfo?.version : "Unknown version";
-  if (versionInfo?.commitOffset > 0) {
-    versionText += ` (ahead+${versionInfo?.commitOffset})`;
-  }
-
-  return {text: versionText, link: link};
-}
-
-export function getDeduplicatedArray(sourceTable, filterTable, key) {
-  const res = sourceTable.filter(item => !filterTable.some(arrayItem => arrayItem[key] === item[key]));
-  return res;
-}
-
-export function getItemId(item) {
-  return item.owner + "/" + item.name;
-}
diff --git a/web2/src/Setting.tsx b/web/src/Setting.tsx
similarity index 100%
rename from web2/src/Setting.tsx
rename to web/src/Setting.tsx
diff --git a/web/src/SigninPage.js b/web/src/SigninPage.js
deleted file mode 100644
index 75e059f..0000000
--- a/web/src/SigninPage.js
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Form, Input, Result, Spin} from "antd";
-import {LockOutlined, UserOutlined} from "@ant-design/icons";
-import i18next from "i18next";
-import * as AccountBackend from "./backend/AccountBackend";
-import * as Conf from "./Conf";
-import * as Setting from "./Setting";
-
-// Signing in goes one of two ways: redirect to Casdoor when app.conf configures
-// it, otherwise show the built-in username/password form.
-class SigninPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      loading: true,
-      showSignin: false,
-      errorMessage: "",
-      autoSignin: false,
-    };
-  }
-
-  componentDidMount() {
-    AccountBackend.getSigninOptions()
-      .then((res) => {
-        if (res.status === "ok" && res.data?.casdoorAvailable) {
-          // Do not wait for App to publish the config: this page may well have
-          // mounted before that request came back.
-          Conf.setAuthConfig(res.data.authConfig);
-          Setting.initCasdoorSdk(Conf.AuthConfig);
-          window.location.replace(Setting.getSigninUrl());
-          return;
-        }
-
-        this.setState({
-          loading: false,
-          showSignin: res.status === "ok" && res.data?.signinAvailable,
-          errorMessage: res.status === "ok" ? "" : res.msg,
-          autoSignin: res.status === "ok" && res.data?.autoSignin === true,
-        });
-      })
-      .catch((error) => {
-        this.setState({
-          loading: false,
-          showSignin: false,
-          errorMessage: error.message,
-        });
-      });
-  }
-
-  onFinish(values) {
-    AccountBackend.signinWithPassword(values.username, values.password)
-      .then((res) => {
-        if (res.status === "ok") {
-          const from = sessionStorage.getItem("from") || "/";
-          sessionStorage.removeItem("from");
-          window.location.href = from;
-        } else {
-          Setting.showMessage("error", res.msg);
-        }
-      })
-      .catch((error) => Setting.showMessage("error", error.message));
-  }
-
-  render() {
-    if (this.state.loading) {
-      return (
-        <div style={{display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh"}}>
-          <Spin size="large" tip={"Signing in..."} />
-        </div>
-      );
-    }
-
-    if (!this.state.showSignin) {
-      return (
-        <Result
-          status="warning"
-          title={"Login Error"}
-          subTitle={this.state.errorMessage || i18next.t("account:Sign in is unavailable")}
-        />
-      );
-    }
-
-    return (
-      <div style={{display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh"}}>
-        <div style={{width: "340px"}}>
-          <div style={{textAlign: "center", marginBottom: "36px", fontSize: "22px", fontWeight: 600}}>
-            {i18next.t("account:Sign In")}
-          </div>
-          <Form initialValues={{username: "admin", password: this.state.autoSignin ? "123" : undefined}} onFinish={(values) => this.onFinish(values)} requiredMark={false}>
-            <Form.Item name="username" rules={[{required: true, message: i18next.t("account:Please input your username")}]}>
-              <Input
-                prefix={<UserOutlined />}
-                placeholder={i18next.t("account:Username")}
-                style={{height: "42px"}}
-              />
-            </Form.Item>
-            <Form.Item name="password" rules={[{required: true, message: i18next.t("account:Please input your password")}]}>
-              <Input.Password
-                prefix={<LockOutlined />}
-                placeholder={i18next.t("general:Password")}
-                autoFocus
-                style={{height: "42px"}}
-              />
-            </Form.Item>
-            <Button type="primary" htmlType="submit" block style={{height: "42px", marginTop: "8px"}}>
-              {i18next.t("account:Sign In")}
-            </Button>
-          </Form>
-        </div>
-      </div>
-    );
-  }
-}
-
-export default SigninPage;
diff --git a/web/src/SiteEditPage.js b/web/src/SiteEditPage.js
deleted file mode 100644
index aedf028..0000000
--- a/web/src/SiteEditPage.js
+++ /dev/null
@@ -1,506 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
-import {LinkOutlined} from "@ant-design/icons";
-import * as ProviderBackend from "./backend/ProviderBackend";
-import * as SiteBackend from "./backend/SiteBackend";
-import * as CertBackend from "./backend/CertBackend";
-import * as RuleBackend from "./backend/RuleBackend";
-import * as ApplicationBackend from "./backend/ApplicationBackend";
-import * as NodeBackend from "./backend/NodeBackend";
-import * as Setting from "./Setting";
-import i18next from "i18next";
-import NodeTable from "./NodeTable";
-import RuleTable from "./components/RuleTable";
-
-const {Option} = Select;
-
-class SiteEditPage extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      owner: props.match.params.owner,
-      siteName: props.match.params.siteName,
-      rules: [],
-      providers: [],
-      site: null,
-      certs: null,
-      applications: null,
-      nodes: [],
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getSite();
-    this.getCerts();
-    this.getRules();
-    this.getApplications();
-    this.getAlertProviders();
-    this.getNodes();
-  }
-
-  getSite() {
-    SiteBackend.getSite(this.state.owner, this.state.siteName)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            site: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get site: ${res.msg}`);
-        }
-      });
-  }
-
-  getCerts() {
-    CertBackend.getCerts(this.props.account.name)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            certs: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get certs: ${res.msg}`);
-        }
-      });
-  }
-
-  getRules() {
-    RuleBackend.getRules(this.props.account.name)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            rules: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get rules: ${res.msg}`);
-        }
-      });
-  }
-
-  getApplications() {
-    ApplicationBackend.getApplications(this.props.account.name)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            applications: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get applications: ${res.msg}`);
-        }
-      });
-  }
-
-  getAlertProviders() {
-    ProviderBackend.getProviders()
-      .then((res) => {
-        if (res.status === "ok") {
-          const data = [];
-          for (let i = 0; i < res.data.length; i++) {
-            const provider = res.data[i];
-            if (provider.category === "SMS" || provider.category === "Email") {
-              data.push(provider.category + "/" + provider.name);
-            }
-          }
-          this.setState({
-            providers: data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get providers: ${res.msg}`);
-        }
-      });
-  }
-
-  getNodes() {
-    NodeBackend.getNodes(this.props.account.name)
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({
-            nodes: res.data,
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get nodes: ${res.msg}`);
-        }
-      });
-  }
-
-  parseSiteField(key, value) {
-    if (["score"].includes(key)) {
-      value = Setting.myParseInt(value);
-    }
-    return value;
-  }
-
-  updateSiteField(key, value) {
-    value = this.parseSiteField(key, value);
-
-    const site = this.state.site;
-    site[key] = value;
-    this.setState({
-      site: site,
-    });
-  }
-
-  updateHealthCheckField(key, value) {
-    value = this.parseSiteField(key, value);
-
-    const site = this.state.site;
-    site.healthCheck[key] = value;
-    this.setState({
-      site: site,
-    });
-  }
-
-  renderSite() {
-    return (
-      <Card size="small" title={
-        <div>
-          {i18next.t("site:Edit Site")}&nbsp;&nbsp;&nbsp;&nbsp;
-          <Button type="primary" onClick={this.submitSiteEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-        </div>
-      } style={{marginLeft: "5px"}} type="inner">
-        <Row style={{marginTop: "10px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Name")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.site.name} onChange={e => {
-              this.updateSiteField("name", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Display name")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.site.displayName} onChange={e => {
-              this.updateSiteField("displayName", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("general:Tag")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.site.tag} onChange={e => {
-              this.updateSiteField("tag", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Domain")}:
-          </Col>
-          <Col span={22} >
-            <Input value={this.state.site.domain} onChange={e => {
-              this.updateSiteField("domain", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Other domains")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.otherDomains} onChange={(value => {this.updateSiteField("otherDomains", value);})}>
-              {
-                this.state.site.otherDomains?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Need redirect")}:
-          </Col>
-          <Col span={1} >
-            <Switch checked={this.state.site.needRedirect} onChange={checked => {
-              this.updateSiteField("needRedirect", checked);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Disable verbose")}:
-          </Col>
-          <Col span={1} >
-            <Switch checked={this.state.site.disableVerbose} onChange={checked => {
-              this.updateSiteField("disableVerbose", checked);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Rules")}:
-          </Col>
-          <Col span={22} >
-            <RuleTable
-              title={"Rules"}
-              account={this.props.account}
-              sources={this.state.rules}
-              rules={this.state.site.rules}
-              onUpdateRules={(value) => this.updateSiteField("rules", value)}
-            />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={2} style={{marginTop: "5px"}}>
-            {i18next.t("site:Enable alert")}:
-          </Col>
-          <Col span={22} >
-            <Switch checked={this.state.site.enableAlert} onChange={checked => {
-              this.updateSiteField("enableAlert", checked);
-            }} />
-          </Col>
-        </Row>
-        {
-          this.state.site.enableAlert ? (
-            <Row style={{marginTop: "20px"}} >
-              <Col span={2} style={{marginTop: "5px"}}>
-                {i18next.t("site:Alert interval")}:
-              </Col>
-              <Col span={22} >
-                <InputNumber min={1} value={this.state.site.alertInterval} addonAfter={i18next.t("usage:seconds")} onChange={value => {
-                  this.updateSiteField("alertInterval", value);
-                }} />
-              </Col>
-            </Row>
-          ) : null
-        }
-        {
-          this.state.site.enableAlert ? (
-            <Row style={{marginTop: "20px"}} >
-              <Col span={2} style={{marginTop: "5px"}}>
-                {i18next.t("site:Alert try times")}:
-              </Col>
-              <Col span={22} >
-                <InputNumber min={1} value={this.state.site.alertTryTimes} onChange={value => {
-                  this.updateSiteField("alertTryTimes", value);
-                }} />
-              </Col>
-            </Row>
-          ) : null
-        }
-        {
-          this.state.site.enableAlert ? (
-            <Row style={{marginTop: "20px"}} >
-              <Col style={{marginTop: "5px"}} span={2}>
-                {i18next.t("site:Alert providers")}:
-              </Col>
-              <Col span={22} >
-                <Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.alertProviders} onChange={(value => {this.updateSiteField("alertProviders", value);})}>
-                  {
-                    this.state.providers.map((item, index) => <Option key={index} value={item}>{item}</Option>)
-                  }
-                </Select>
-              </Col>
-            </Row>
-          ) : null
-        }
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Challenges")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.challenges} onChange={(value => {this.updateSiteField("challenges", value);})}>
-              {
-                this.state.site.challenges?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Host")}:
-          </Col>
-          <Col span={22} >
-            <Input prefix={<LinkOutlined />} value={this.state.site.host} onChange={e => {
-              this.updateSiteField("host", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Port")}:
-          </Col>
-          <Col span={22} >
-            <InputNumber min={0} max={65535} value={this.state.site.port} onChange={value => {
-              this.updateSiteField("port", value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Hosts")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.hosts} onChange={(value => {this.updateSiteField("hosts", value);})}>
-              {
-                this.state.site.hosts?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Public IP")}:
-          </Col>
-          <Col span={22} >
-            <Input disabled={true} value={this.state.site.publicIp} onChange={e => {
-              this.updateSiteField("publicIp", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Node")}:
-          </Col>
-          <Col span={22} >
-            <Input disabled={true} value={this.state.site.node} onChange={e => {
-              this.updateSiteField("node", e.target.value);
-            }} />
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Mode")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.site.sslMode} onChange={(value => {this.updateSiteField("sslMode", value);})}>
-              {
-                [
-                  {id: "HTTP", name: "HTTP"},
-                  {id: "HTTPS and HTTP", name: "HTTPS and HTTP"},
-                  {id: "HTTPS Only", name: "HTTPS Only"},
-                  {id: "Static Folder", name: "Static Folder"},
-                ].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:SSL cert")}:
-          </Col>
-          <Col span={22} >
-            <Select disabled={true} virtual={false} style={{width: "100%"}} showSearch value={this.state.site.sslCert} onChange={(value => {
-              this.updateSiteField("sslCert", value);
-            })}>
-              {
-                this.state.certs?.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Casdoor app")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} showSearch value={this.state.site.casdoorApplication} onChange={(value => {
-              this.updateSiteField("casdoorApplication", value);
-            })}>
-              {
-                this.state.applications?.map((application, index) => <Option key={index} value={application.name}>{application.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            {i18next.t("site:Status")}:
-          </Col>
-          <Col span={22} >
-            <Select virtual={false} style={{width: "100%"}} value={this.state.site.status} onChange={(value => {this.updateSiteField("status", value);})}>
-              {
-                [
-                  {id: "Active", name: "Active"},
-                  {id: "Inactive", name: "Inactive"},
-                ].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
-              }
-            </Select>
-          </Col>
-        </Row>
-        <Row style={{marginTop: "20px"}} >
-          <Col style={{marginTop: "5px"}} span={2}>
-            Nodes:
-          </Col>
-          <Col span={22} >
-            <NodeTable
-              title={"Nodes"}
-              table={this.state.site.nodes}
-              siteName={this.state.site.name}
-              account={this.props.account}
-              nodes={this.state.nodes}
-              onUpdateTable={(value) => {this.updateSiteField("nodes", value);}}
-            />
-          </Col>
-        </Row>
-      </Card>
-    );
-  }
-
-  submitSiteEdit() {
-    const site = Setting.deepCopy(this.state.site);
-    SiteBackend.updateSite(this.state.site.owner, this.state.siteName, site)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to save: ${res.msg}`);
-          this.updateSiteField("name", this.state.siteName);
-        } else {
-          Setting.showMessage("success", "Successfully saved");
-          this.setState({
-            siteName: this.state.site.name,
-          });
-          this.props.history.push(`/sites/${this.state.site.owner}/${this.state.site.name}`);
-          this.getSite();
-        }
-      })
-      .catch(error => {
-        Setting.showMessage("error", `failed to save: ${error}`);
-      });
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{width: "100%"}}>
-          <Col span={1}>
-          </Col>
-          <Col span={22}>
-            {
-              this.state.site !== null ? this.renderSite() : null
-            }
-          </Col>
-          <Col span={1}>
-          </Col>
-        </Row>
-        <Row style={{margin: 10}}>
-          <Col span={2}>
-          </Col>
-          <Col span={18}>
-            <Button type="primary" size="large" onClick={this.submitSiteEdit.bind(this)}>{i18next.t("general:Save")}</Button>
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default SiteEditPage;
diff --git a/web/src/SiteListPage.js b/web/src/SiteListPage.js
deleted file mode 100644
index b2e8935..0000000
--- a/web/src/SiteListPage.js
+++ /dev/null
@@ -1,492 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Link} from "react-router-dom";
-import {Alert, Button, Popconfirm, Table, Tag, Tooltip} from "antd";
-import moment from "moment";
-import * as Setting from "./Setting";
-import * as SiteBackend from "./backend/SiteBackend";
-import * as SystemBackend from "./backend/SystemBackend";
-import i18next from "i18next";
-import BaseListPage from "./BaseListPage";
-
-class SiteListPage extends BaseListPage {
-  constructor(props) {
-    super(props);
-    this.state = {
-      ...this.state,
-      gatewayEnabled: null,
-    };
-  }
-
-  UNSAFE_componentWillMount() {
-    this.setState({
-      pagination: {
-        ...this.state.pagination,
-        current: 1,
-        pageSize: 1000,
-      },
-    });
-    this.fetch({pagination: this.state.pagination});
-    this.getGatewayStatus();
-  }
-
-  // The sites below only do something when the reverse proxy is running, so the
-  // page has to say when it is off. Otherwise every site here looks configured
-  // and nothing is actually proxied.
-  getGatewayStatus() {
-    SystemBackend.getGatewayStatus()
-      .then((res) => {
-        if (res.status === "ok") {
-          this.setState({gatewayEnabled: res.data.gatewayEnabled});
-        }
-      });
-  }
-
-  renderGatewayAlert() {
-    if (this.state.gatewayEnabled !== false) {
-      return null;
-    }
-
-    return (
-      <Alert
-        style={{marginBottom: "10px"}}
-        type="warning"
-        showIcon
-        message={i18next.t("site:The reverse proxy is not enabled")}
-        description={i18next.t("site:The sites below will not be proxied. Set gatewayEnabled = true in conf/app.conf and restart Casbin Gateway to enable it.")}
-      />
-    );
-  }
-
-  newSite() {
-    const randomName = Setting.getRandomName();
-    return {
-      owner: this.props.account.name,
-      name: `site_${randomName}`,
-      createdTime: moment().format(),
-      displayName: `New Site - ${randomName}`,
-      domain: "door.casdoor.com",
-      otherDomains: [],
-      needRedirect: false,
-      disableVerbose: false,
-      rules: [],
-      enableAlert: false,
-      alertInterval: 60,
-      alertTryTimes: 3,
-      alertProviders: [],
-      challenges: [],
-      host: "",
-      port: 8000,
-      hosts: [],
-      sslMode: "HTTPS Only",
-      sslCert: "",
-      publicIp: "8.131.81.162",
-      node: "",
-      isSelf: false,
-      nodes: [],
-      casdoorApplication: "",
-    };
-  }
-
-  addSite() {
-    const newSite = this.newSite();
-    SiteBackend.addSite(newSite)
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to add: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Site added successfully");
-          this.setState({
-            data: Setting.prependRow(this.state.data, newSite),
-          });
-          this.fetch();
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Site failed to add: ${error}`);
-      });
-  }
-
-  deleteSite(i) {
-    SiteBackend.deleteSite(this.state.data[i])
-      .then((res) => {
-        if (res.status === "error") {
-          Setting.showMessage("error", `Failed to delete: ${res.msg}`);
-        } else {
-          Setting.showMessage("success", "Site deleted successfully");
-          this.fetch({
-            pagination: {
-              ...this.state.pagination,
-              current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
-            },
-          });
-        }
-      }
-      )
-      .catch(error => {
-        Setting.showMessage("error", `Site failed to delete: ${error}`);
-      });
-  }
-
-  renderTable(data) {
-    // const renderExternalLink = () => {
-    //   return (
-    //     <svg style={{marginLeft: "5px"}} width="13.5" height="13.5" aria-hidden="true" viewBox="0 0 24 24" className="iconExternalLink_nPIU">
-    //       <path fill="currentColor" d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path>
-    //     </svg>
-    //   );
-    // };
-
-    const columns = [
-      {
-        title: i18next.t("general:Owner"),
-        dataIndex: "owner",
-        key: "owner",
-        width: "90px",
-        sorter: (a, b) => a.owner.localeCompare(b.owner),
-      },
-      {
-        title: i18next.t("general:Tag"),
-        dataIndex: "tag",
-        key: "tag",
-        width: "140px",
-        sorter: (a, b) => a.tag.localeCompare(b.tag),
-        render: (text, record, index) => {
-          if (text === "") {
-            return null;
-          }
-          return (
-            <Link to={`/nodes/${record.owner}/${text}`}>
-              {text}
-            </Link>
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "120px",
-        sorter: (a, b) => a.name.localeCompare(b.name),
-        render: (text, record, index) => {
-          return (
-            <Link to={`/sites/${record.owner}/${record.name}`}>
-              {text}
-            </Link>
-          );
-        },
-      },
-      // {
-      //   title: i18next.t("general:Create time"),
-      //   dataIndex: "createdTime",
-      //   key: "createdTime",
-      //   width: "180px",
-      //   sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
-      //   render: (text, record, index) => {
-      //     return Setting.getFormattedDate(text);
-      //   },
-      // },
-      {
-        title: i18next.t("general:Display name"),
-        dataIndex: "displayName",
-        key: "displayName",
-        // width: "200px",
-        sorter: (a, b) => a.displayName.localeCompare(b.displayName),
-      },
-      {
-        title: i18next.t("site:Domain"),
-        dataIndex: "domain",
-        key: "domain",
-        width: "150px",
-        sorter: (a, b) => a.domain.localeCompare(b.domain),
-        render: (text, record, index) => {
-          if (record.publicIp === "") {
-            return text;
-          }
-
-          return (
-            <a target="_blank" rel="noreferrer" href={`https://${text}`}>
-              {text}
-            </a>
-          );
-        },
-      },
-      {
-        title: i18next.t("site:Other domains"),
-        dataIndex: "otherDomains",
-        key: "otherDomains",
-        width: "120px",
-        sorter: (a, b) => a.otherDomains.localeCompare(b.otherDomains),
-        render: (text, record, index) => {
-          return record.otherDomains.map(domain => {
-            return (
-              <a key={domain} target="_blank" rel="noreferrer" href={`https://${domain}`}>
-                <Tag color={record.needRedirect ? "default" : "processing"}>
-                  {domain}
-                </Tag>
-              </a>
-            );
-          });
-        },
-      },
-      {
-        title: i18next.t("general:Rules"),
-        dataIndex: "rules",
-        key: "rules",
-        width: "120px",
-        sorter: (a, b) => a.rules.localeCompare(b.rules),
-        render: (text, record, index) => {
-          if (!record.rules) {
-            return null;
-          }
-
-          return record.rules.map(rule => {
-            return (
-              <a key={rule} target="_blank" rel="noreferrer" href={`/rules/${rule}`}>
-                <Tag color={"processing"}>
-                  {rule}
-                </Tag>
-              </a>
-            );
-          });
-        },
-      },
-      {
-        title: i18next.t("site:Host"),
-        dataIndex: "host",
-        key: "host",
-        width: "80px",
-        sorter: (a, b) => a.host.localeCompare(b.host),
-        render: (text, record, index) => {
-          let host = record.port;
-          if (record.host !== "") {
-            host = `${record.host}:${record.port}`;
-          }
-
-          if (record.status === "Active") {
-            return host;
-          }
-
-          return (
-            <Tag color={"warning"}>
-              {host}
-            </Tag>
-          );
-        },
-      },
-      {
-        title: i18next.t("site:Hosts"),
-        dataIndex: "hosts",
-        key: "hosts",
-        width: "200px",
-        sorter: (a, b) => a.hosts.length - b.hosts.length,
-        render: (hosts) => {
-          if (!Array.isArray(hosts)) {
-            return null;
-          }
-          return hosts.map((host, index) => (
-            <Tag color="blue" key={index}>
-              {host}
-            </Tag>
-          ));
-        },
-      },
-      {
-        title: i18next.t("site:Nodes"),
-        dataIndex: "nodes",
-        key: "nodes",
-        width: "180px",
-        sorter: (a, b) => a.nodes.length - b.nodes.length,
-        render: (text, record, index) => {
-          return record.nodes.map(node => {
-            const versionInfo = Setting.getVersionInfo(node.version, record.name);
-            let color = node.message === "" ? "processing" : "error";
-            if (color === "processing" && node.provider !== "") {
-              if (node.version === "") {
-                color = "warning";
-              } else if (node.provider !== "") {
-                color = "success";
-              }
-            }
-
-            const getTag = () => {
-              if (versionInfo === null) {
-                return (
-                  <Tag key={node.name} color={color}>
-                    {node.name}
-                  </Tag>
-                );
-              } else {
-                return (
-                  <a key={node.name} target="_blank" rel="noreferrer" href={versionInfo.link}>
-                    <Tag color={color}>
-                      {`${node.name} (${versionInfo.text})`}
-                    </Tag>
-                  </a>
-                );
-              }
-            };
-
-            if (node.message === "") {
-              return getTag();
-            } else {
-              return (
-                <Tooltip key={node.name} title={node.message}>
-                  {getTag()}
-                </Tooltip>
-              );
-            }
-          });
-        },
-      },
-      // {
-      //   title: i18next.t("site:Public IP"),
-      //   dataIndex: "publicIp",
-      //   key: "publicIp",
-      //   width: "120px",
-      //   sorter: (a, b) => a.publicIp.localeCompare(b.publicIp),
-      // },
-      // {
-      //   title: i18next.t("site:Node"),
-      //   dataIndex: "node",
-      //   key: "node",
-      //   width: "180px",
-      //   sorter: (a, b) => a.node.localeCompare(b.node),
-      //   render: (text, record, index) => {
-      //     return (
-      //       <div>
-      //         {text}
-      //         {
-      //           !record.isSelf ? null : (
-      //             <Tag style={{marginLeft: "10px"}} icon={<CheckCircleOutlined />} color="success">
-      //               {i18next.t("general:Self")}
-      //             </Tag>
-      //           )
-      //         }
-      //       </div>
-      //     );
-      //   },
-      // },
-      // {
-      //   title: i18next.t("site:Mode"),
-      //   dataIndex: "sslMode",
-      //   key: "sslMode",
-      //   width: "100px",
-      //   sorter: (a, b) => a.sslMode.localeCompare(b.sslMode),
-      // },
-      {
-        title: i18next.t("site:SSL cert"),
-        dataIndex: "sslCert",
-        key: "sslCert",
-        width: "130px",
-        sorter: (a, b) => a.sslCert.localeCompare(b.sslCert),
-        render: (text, record, index) => {
-          return (
-            <Link to={`/certs/admin/${text}`}>
-              {text}
-            </Link>
-          );
-        },
-      },
-      // {
-      //   title: i18next.t("site:Casdoor app"),
-      //   dataIndex: "casdoorApplication",
-      //   key: "casdoorApplication",
-      //   width: "140px",
-      //   sorter: (a, b) => a.casdoorApplication.localeCompare(b.casdoorApplication),
-      //   render: (text, record, index) => {
-      //     if (text === "") {
-      //       return null;
-      //     }
-      //
-      //     return (
-      //       <a target="_blank" rel="noreferrer" href={Setting.getMyProfileUrl(this.state.account).replace("/account", `/applications/${this.props.account.owner}/${text}`)}>
-      //         {text}
-      //         {renderExternalLink()}
-      //       </a>
-      //     );
-      //   },
-      // },
-      {
-        title: i18next.t("general:Action"),
-        dataIndex: "action",
-        key: "action",
-        width: "180px",
-        render: (text, record, index) => {
-          return (
-            <div>
-              <Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/sites/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
-              <Popconfirm
-                title={`Sure to delete site: ${record.name} ?`}
-                onConfirm={() => this.deleteSite(index)}
-                okText="OK"
-                cancelText="Cancel"
-              >
-                <Button style={{marginBottom: "10px"}} type="danger">{i18next.t("general:Delete")}</Button>
-              </Popconfirm>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <div>
-        {this.renderGatewayAlert()}
-        <Table columns={columns} dataSource={data} rowKey="name" size="middle" bordered pagination={this.state.pagination}
-          title={() => (
-            <div>
-              {i18next.t("general:Sites")}&nbsp;&nbsp;&nbsp;&nbsp;
-              <Button type="primary" size="small" onClick={this.addSite.bind(this)}>{i18next.t("general:Add")}</Button>
-            </div>
-          )}
-          loading={data === null}
-          onChange={this.handleTableChange}
-        />
-      </div>
-    );
-  }
-
-  fetch = (params = {}) => {
-    const field = params.searchedColumn, value = params.searchText;
-    const sortField = params.sortField, sortOrder = params.sortOrder;
-    if (!params.pagination) {
-      params.pagination = {current: 1, pageSize: 10};
-    }
-    this.setState({loading: true});
-    // SiteBackend.getSites(this.props.account.name, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
-    SiteBackend.getSites(this.props.account.name, "", "", field, value, sortField, sortOrder)
-      .then((res) => {
-        this.setState({
-          loading: false,
-        });
-        if (res.status === "ok") {
-          this.setState({
-            data: res.data,
-            pagination: {
-              ...params.pagination,
-              total: res.data2,
-            },
-          });
-        } else {
-          Setting.showMessage("error", `Failed to get sites: ${res.msg}`);
-        }
-      });
-  };
-}
-
-export default SiteListPage;
diff --git a/web/src/backend/AccountBackend.js b/web/src/backend/AccountBackend.js
deleted file mode 100644
index 7c42e44..0000000
--- a/web/src/backend/AccountBackend.js
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getSigninOptions() {
-  return fetch(`${Setting.ServerUrl}/api/get-signin-options`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getAccount() {
-  return fetch(`${Setting.ServerUrl}/api/get-account?fromPath=${encodeURIComponent(window.location.pathname)}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function signin(code, state) {
-  return fetch(`${Setting.ServerUrl}/api/signin?code=${code}&state=${state}`, {
-    method: "POST",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function signinWithPassword(username, password) {
-  return fetch(`${Setting.ServerUrl}/api/signin`, {
-    method: "POST",
-    credentials: "include",
-    headers: {"Content-Type": "application/json"},
-    body: JSON.stringify({username: username, password: password}),
-  }).then(res => res.json());
-}
-
-export function updateAccount(account) {
-  return fetch(`${Setting.ServerUrl}/api/update-account`, {
-    method: "POST",
-    credentials: "include",
-    headers: {"Content-Type": "application/json"},
-    body: JSON.stringify(account),
-  }).then(res => res.json());
-}
-
-export function signout() {
-  return fetch(`${Setting.ServerUrl}/api/signout`, {
-    method: "POST",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/AccountBackend.ts b/web/src/backend/AccountBackend.ts
similarity index 100%
rename from web2/src/backend/AccountBackend.ts
rename to web/src/backend/AccountBackend.ts
diff --git a/web/src/backend/AgentBackend.js b/web/src/backend/AgentBackend.js
deleted file mode 100644
index 6e49fb8..0000000
--- a/web/src/backend/AgentBackend.js
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright 2025 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 Setting from "../Setting";
-
-function request(url, method = "GET", body = null) {
-  const options = {
-    method: method,
-    credentials: "include",
-  };
-  if (body !== null) {
-    options.body = JSON.stringify(Setting.deepCopy(body));
-  }
-  return fetch(`${Setting.ServerUrl}${url}`, options).then(res => res.json());
-}
-
-export function getAgents(forceRefresh = false) {
-  const query = forceRefresh ? "?refresh=true" : "";
-  return request(`/api/get-agents${query}`);
-}
-
-export function patchAgent(target) {
-  return request("/api/patch-agent", "POST", target);
-}
-
-export function unpatchAgent(target) {
-  return request("/api/unpatch-agent", "POST", target);
-}
-
-export function getAgentRecords(agent = "", eventType = "", outcome = "", session = "", limit = 200) {
-  const params = new URLSearchParams({
-    agent: agent,
-    eventType: eventType,
-    outcome: outcome,
-    session: session,
-    limit: String(limit),
-  });
-  return request(`/api/get-agent-records?${params.toString()}`);
-}
-
-export function getAgentSessions() {
-  return request("/api/get-agent-sessions");
-}
diff --git a/web2/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
similarity index 100%
rename from web2/src/backend/AgentBackend.ts
rename to web/src/backend/AgentBackend.ts
diff --git a/web/src/backend/ApplicationBackend.js b/web/src/backend/ApplicationBackend.js
deleted file mode 100644
index f416438..0000000
--- a/web/src/backend/ApplicationBackend.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getApplications(owner) {
-  return fetch(`${Setting.ServerUrl}/api/get-applications?owner=${owner}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web/src/backend/CertBackend.js b/web/src/backend/CertBackend.js
deleted file mode 100644
index 3eb5637..0000000
--- a/web/src/backend/CertBackend.js
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getGlobalCerts() {
-  return fetch(`${Setting.ServerUrl}/api/get-global-certs`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getCerts(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
-  return fetch(`${Setting.ServerUrl}/api/get-certs?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getCert(owner, name) {
-  return fetch(`${Setting.ServerUrl}/api/get-cert?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function updateCert(owner, name, cert) {
-  const newCert = Setting.deepCopy(cert);
-  return fetch(`${Setting.ServerUrl}/api/update-cert?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newCert),
-  }).then(res => res.json());
-}
-
-export function addCert(cert) {
-  const newCert = Setting.deepCopy(cert);
-  return fetch(`${Setting.ServerUrl}/api/add-cert`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newCert),
-  }).then(res => res.json());
-}
-
-export function deleteCert(cert) {
-  const newCert = Setting.deepCopy(cert);
-  return fetch(`${Setting.ServerUrl}/api/delete-cert`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newCert),
-  }).then(res => res.json());
-}
-
-export function refreshDomainExpire(owner, name) {
-  return fetch(`${Setting.ServerUrl}/api/update-cert-domain-expire?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "POST",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/CertBackend.ts b/web/src/backend/CertBackend.ts
similarity index 100%
rename from web2/src/backend/CertBackend.ts
rename to web/src/backend/CertBackend.ts
diff --git a/web/src/backend/ChannelBackend.js b/web/src/backend/ChannelBackend.js
deleted file mode 100644
index 84359bb..0000000
--- a/web/src/backend/ChannelBackend.js
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-function request(url, method = "GET", body = null) {
-  const options = {
-    method: method,
-    credentials: "include",
-  };
-  if (body !== null) {
-    options.body = JSON.stringify(Setting.deepCopy(body));
-  }
-  return fetch(`${Setting.ServerUrl}${url}`, options).then(res => res.json());
-}
-
-export function getChannels(owner, page = "", pageSize = "", sortField = "", sortOrder = "") {
-  return request(`/api/get-channels?owner=${encodeURIComponent(owner)}&p=${page}&pageSize=${pageSize}&sortField=${sortField}&sortOrder=${sortOrder}`);
-}
-
-export function getChannel(owner, name) {
-  return request(`/api/get-channel?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`);
-}
-
-export function addChannel(channel) {
-  return request("/api/add-channel", "POST", channel);
-}
-
-export function updateChannel(owner, name, channel) {
-  return request(`/api/update-channel?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, "POST", channel);
-}
-
-export function deleteChannel(channel) {
-  return request("/api/delete-channel", "POST", channel);
-}
-
-export function testChannel(owner, name) {
-  return request("/api/test-channel", "POST", {
-    owner: owner,
-    name: name,
-  });
-}
diff --git a/web2/src/backend/ChannelBackend.ts b/web/src/backend/ChannelBackend.ts
similarity index 100%
rename from web2/src/backend/ChannelBackend.ts
rename to web/src/backend/ChannelBackend.ts
diff --git a/web/src/backend/DashboardBackend.js b/web/src/backend/DashboardBackend.js
deleted file mode 100644
index 4b541d6..0000000
--- a/web/src/backend/DashboardBackend.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getMetric(type, rangeType, count, top) {
-  return fetch(`${Setting.ServerUrl}/api/get-metrics?type=${type}&rangeType=${rangeType}&count=${count}&top=${top}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getMetricOverTime(rangeType, count, granularity) {
-  return fetch(`${Setting.ServerUrl}/api/get-metrics-over-time?rangeType=${rangeType}&count=${count}&granularity=${granularity}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web/src/backend/FetchFilter.js b/web/src/backend/FetchFilter.js
deleted file mode 100644
index ab67cfe..0000000
--- a/web/src/backend/FetchFilter.js
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright 2023 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 {Modal} from "antd";
-import {ExclamationCircleFilled} from "@ant-design/icons";
-import i18next from "i18next";
-import * as Conf from "../Conf";
-import * as Setting from "../Setting";
-
-const {confirm} = Modal;
-const {fetch: originalFetch} = window;
-
-const demoModeCallback = (res) => {
-  res.json().then(data => {
-    if (Setting.isResponseDenied(data)) {
-      confirm({
-        title: i18next.t("general:This is a read-only demo site!"),
-        icon: <ExclamationCircleFilled />,
-        content: i18next.t("general:Go to writable demo site?"),
-        okText: i18next.t("general:OK"),
-        cancelText: i18next.t("general:Cancel"),
-        onOk() {
-          Setting.openLink("https://demo.caswaf.com");
-        },
-        onCancel() {},
-      });
-    }
-  });
-};
-
-const requestFilters = [];
-const responseFilters = [];
-
-if (Conf.IsDemoMode) {
-  responseFilters.push(demoModeCallback);
-}
-
-window.fetch = async(url, option = {}) => {
-  requestFilters.forEach(filter => filter(url, option));
-
-  return new Promise((resolve, reject) => {
-    originalFetch(url, option).then(res => {
-      responseFilters.forEach(filter => filter(res.clone()));
-      resolve(res);
-    });
-  });
-};
diff --git a/web2/src/backend/MiscBackend.ts b/web/src/backend/MiscBackend.ts
similarity index 100%
rename from web2/src/backend/MiscBackend.ts
rename to web/src/backend/MiscBackend.ts
diff --git a/web/src/backend/NodeBackend.js b/web/src/backend/NodeBackend.js
deleted file mode 100644
index 05137b8..0000000
--- a/web/src/backend/NodeBackend.js
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright 2025 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 Setting from "../Setting";
-
-export function getGlobalNodes() {
-  return fetch(`${Setting.ServerUrl}/api/get-global-nodes`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getNodes(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
-  return fetch(`${Setting.ServerUrl}/api/get-nodes?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getNode(owner, name) {
-  return fetch(`${Setting.ServerUrl}/api/get-node?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function updateNode(owner, name, node) {
-  const newNode = Setting.deepCopy(node);
-  return fetch(`${Setting.ServerUrl}/api/update-node?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newNode),
-  }).then(res => res.json());
-}
-
-export function addNode(node) {
-  const newNode = Setting.deepCopy(node);
-  return fetch(`${Setting.ServerUrl}/api/add-node`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newNode),
-  }).then(res => res.json());
-}
-
-export function deleteNode(node) {
-  const newNode = Setting.deepCopy(node);
-  return fetch(`${Setting.ServerUrl}/api/delete-node`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newNode),
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/NodeBackend.ts b/web/src/backend/NodeBackend.ts
similarity index 100%
rename from web2/src/backend/NodeBackend.ts
rename to web/src/backend/NodeBackend.ts
diff --git a/web/src/backend/ProviderBackend.js b/web/src/backend/ProviderBackend.js
deleted file mode 100644
index d5d795c..0000000
--- a/web/src/backend/ProviderBackend.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getProviders() {
-  return fetch(`${Setting.ServerUrl}/api/get-providers`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web/src/backend/RecordBackend.js b/web/src/backend/RecordBackend.js
deleted file mode 100644
index ca3f859..0000000
--- a/web/src/backend/RecordBackend.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getRecords(owner, page = "", pageSize = "", sortField = "", sortOrder = "") {
-  return fetch(`${Setting.ServerUrl}/api/get-records?owner=${owner}&p=${page}&pageSize=${pageSize}&sortField=${sortField}&sortOrder=${sortOrder}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function deleteRecord(record) {
-  const newRecord = Setting.deepCopy(record);
-  return fetch(`${Setting.ServerUrl}/api/delete-record`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newRecord),
-  }).then(res => res.json());
-}
-
-export function getRecord(owner, id) {
-  return fetch(`${Setting.ServerUrl}/api/get-record?owner=${owner}&id=${id}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function updateRecord(owner, id, record) {
-  const newRecord = Setting.deepCopy(record);
-  return fetch(`${Setting.ServerUrl}/api/update-record?owner=${owner}&id=${id}`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newRecord),
-  }).then(res => res.json());
-}
-
-export function addRecord(record) {
-  const newRecord = Setting.deepCopy(record);
-  return fetch(`${Setting.ServerUrl}/api/add-record`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newRecord),
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/RecordBackend.ts b/web/src/backend/RecordBackend.ts
similarity index 100%
rename from web2/src/backend/RecordBackend.ts
rename to web/src/backend/RecordBackend.ts
diff --git a/web/src/backend/RuleBackend.js b/web/src/backend/RuleBackend.js
deleted file mode 100644
index b2a097b..0000000
--- a/web/src/backend/RuleBackend.js
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getRules(owner, page = "", pageSize = "", sortField = "", sortOrder = "") {
-  return fetch(`${Setting.ServerUrl}/api/get-rules?owner=${owner}&p=${page}&pageSize=${pageSize}&sortField=${sortField}&sortOrder=${sortOrder}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getRule(owner, name) {
-  return fetch(`${Setting.ServerUrl}/api/get-rule?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function addRule(rule) {
-  return fetch(`${Setting.ServerUrl}/api/add-rule`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(rule),
-  }).then(res => res.json());
-}
-
-export function updateRule(owner, name, rule) {
-  return fetch(`${Setting.ServerUrl}/api/update-rule?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(rule),
-  }).then(res => res.json());
-}
-
-export function deleteRule(rule) {
-  return fetch(`${Setting.ServerUrl}/api/delete-rule`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(rule),
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/RuleBackend.ts b/web/src/backend/RuleBackend.ts
similarity index 100%
rename from web2/src/backend/RuleBackend.ts
rename to web/src/backend/RuleBackend.ts
diff --git a/web/src/backend/SiteBackend.js b/web/src/backend/SiteBackend.js
deleted file mode 100644
index 9a3827c..0000000
--- a/web/src/backend/SiteBackend.js
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright 2023 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 Setting from "../Setting";
-
-export function getGlobalSites() {
-  return fetch(`${Setting.ServerUrl}/api/get-global-sites`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getSites(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
-  return fetch(`${Setting.ServerUrl}/api/get-sites?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function getSite(owner, name) {
-  return fetch(`${Setting.ServerUrl}/api/get-site?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
-
-export function updateSite(owner, name, site) {
-  const newSite = Setting.deepCopy(site);
-  return fetch(`${Setting.ServerUrl}/api/update-site?id=${owner}/${encodeURIComponent(name)}`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newSite),
-  }).then(res => res.json());
-}
-
-export function addSite(site) {
-  const newSite = Setting.deepCopy(site);
-  return fetch(`${Setting.ServerUrl}/api/add-site`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newSite),
-  }).then(res => res.json());
-}
-
-export function deleteSite(site) {
-  const newSite = Setting.deepCopy(site);
-  return fetch(`${Setting.ServerUrl}/api/delete-site`, {
-    method: "POST",
-    credentials: "include",
-    body: JSON.stringify(newSite),
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/SiteBackend.ts b/web/src/backend/SiteBackend.ts
similarity index 100%
rename from web2/src/backend/SiteBackend.ts
rename to web/src/backend/SiteBackend.ts
diff --git a/web/src/backend/SystemBackend.js b/web/src/backend/SystemBackend.js
deleted file mode 100644
index d5ca12b..0000000
--- a/web/src/backend/SystemBackend.js
+++ /dev/null
@@ -1,22 +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 Setting from "../Setting";
-
-export function getGatewayStatus() {
-  return fetch(`${Setting.ServerUrl}/api/get-gateway-status`, {
-    method: "GET",
-    credentials: "include",
-  }).then(res => res.json());
-}
diff --git a/web2/src/backend/request.ts b/web/src/backend/request.ts
similarity index 100%
rename from web2/src/backend/request.ts
rename to web/src/backend/request.ts
diff --git a/web/src/components/AgentIcon.js b/web/src/components/AgentIcon.js
deleted file mode 100644
index 81aa48f..0000000
--- a/web/src/components/AgentIcon.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2025 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 React, {useState} from "react";
-
-const agentSites = {
-  "Claude Code": "claude.com",
-  "Claude Desktop": "claude.ai",
-  "OpenClaw": "openclaw.ai",
-  "Codex": "openai.com",
-  "ChatGPT Desktop (Codex)": "openai.com",
-  "Codex CLI": "openai.com",
-  "Hermes Agent": "hermes-agent.nousresearch.com",
-  "Cursor": "cursor.com",
-  "Cursor Agent": "cursor.com",
-  "Windsurf": "windsurf.com",
-  "OpenAgent": "openagentai.org",
-};
-
-const agentKey = agent => String(agent || "").toLowerCase().replace(/[^a-z0-9]/g, "");
-
-const agentSitesByKey = Object.keys(agentSites).reduce((sites, name) => {
-  sites[agentKey(name)] = agentSites[name];
-  return sites;
-}, {});
-
-export default function AgentIcon({agent, fallback = null, size = 20}) {
-  const [broken, setBroken] = useState(false);
-  const site = agentSitesByKey[agentKey(agent)];
-
-  if (!site || broken) {
-    return fallback;
-  }
-
-  return (
-    <img
-      src={`https://www.google.com/s2/favicons?domain=${site}&sz=64`}
-      alt={agent}
-      width={size}
-      height={size}
-      onError={() => setBroken(true)}
-      style={{display: "block", borderRadius: 6}}
-    />
-  );
-}
diff --git a/web2/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
similarity index 100%
rename from web2/src/components/AgentIcon.tsx
rename to web/src/components/AgentIcon.tsx
diff --git a/web/src/components/BarChartCard.js b/web/src/components/BarChartCard.js
deleted file mode 100644
index b4528b8..0000000
--- a/web/src/components/BarChartCard.js
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Card} from "antd";
-import ReactECharts from "echarts-for-react";
-import i18next from "i18next";
-
-const BarChartCard = ({title, data}) => {
-  const option = {
-    tooltip: {
-      trigger: "axis",
-      axisPointer: {
-        type: "shadow",
-      },
-    },
-    grid: {
-      left: "3%",
-      right: "4%",
-      bottom: "3%",
-      containLabel: true,
-    },
-    xAxis: {
-      type: "category",
-      data: data.map((item) => item.data),
-      axisTick: {
-        alignWithLabel: true,
-      },
-    },
-    yAxis: {
-      type: "value",
-    },
-    series: [
-      {
-        name: title,
-        type: "bar",
-        barWidth: "60%",
-        data: data.map((item) => item.count),
-      },
-    ],
-  };
-
-  return (
-    <Card title={i18next.t(`general:${title}`)}>
-      <ReactECharts option={option}></ReactECharts>
-    </Card>
-  );
-};
-
-export default BarChartCard;
diff --git a/web/src/components/CompoundRule.js b/web/src/components/CompoundRule.js
deleted file mode 100644
index 74f5208..0000000
--- a/web/src/components/CompoundRule.js
+++ /dev/null
@@ -1,192 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Row, Select, Table, Tooltip} from "antd";
-import {getRules} from "../backend/RuleBackend";
-import * as Setting from "../Setting";
-import i18next from "i18next";
-
-const {Option} = Select;
-
-class CompoundRule extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      rules: [],
-      defaultRules: [
-        {
-          name: "Start",
-          operator: "begin",
-          value: "rule1",
-        },
-        {
-          name: "And",
-          operator: "and",
-          value: "rule2",
-        },
-      ],
-    };
-    if (this.props.table.length === 0) {
-      this.restore();
-    }
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getRules();
-  }
-
-  getRules() {
-    getRules(this.props.owner).then((res) => {
-      const rules = [];
-      for (let i = 0; i < res.data.length; i++) {
-        if (Setting.getItemId(res.data[i]) === this.props.owner + "/" + this.props.ruleName) {
-          continue;
-        }
-        rules.push(Setting.getItemId(res.data[i]));
-      }
-      this.setState({
-        rules: rules,
-      });
-    });
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  updateField(table, index, key, value) {
-    table[index][key] = value;
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const row = {name: `New Item - ${table.length}`, operator: "and", value: ""};
-    if (table === undefined) {
-      table = [];
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    this.updateTable(table);
-  }
-
-  restore() {
-    this.updateTable(this.state.defaultRules);
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("rule:Logic"),
-        dataIndex: "operator",
-        key: "operator",
-        width: "180px",
-        render: (text, record, index) => {
-          const options = [];
-          if (index !== 0) {
-            options.push({value: "and", text: i18next.t("rule:and")});
-            options.push({value: "or", text: i18next.t("rule:or")});
-          } else {
-            options.push({value: "begin", text: i18next.t("rule:begin")});
-          }
-          return (
-            <Select value={text} virtual={false} style={{width: "100%"}} onChange={value => {
-              this.updateField(table, index, "operator", value);
-            }}>
-              {
-                options.map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
-              }
-            </Select>
-          );
-        },
-      },
-      {
-        title: i18next.t("rule:Rule"),
-        dataIndex: "value",
-        key: "value",
-        render: (text, record, index) => (
-          <Select value={text} virtual={false} style={{width: "100%"}} onChange={value => {
-            this.updateField(table, index, "value", value);
-          }}>
-            {
-              this.state.rules.map((item, index) => <Option key={index} value={item}>{item}</Option>)
-            }
-          </Select>
-        ),
-      },
-      {
-        title: i18next.t("general:Action"),
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => (
-          <div>
-            <Tooltip placement="bottomLeft" title={"Up"}>
-              <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Down"}>
-              <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Delete"}>
-              <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-            </Tooltip>
-          </div>
-        ),
-      },
-    ];
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.restore()}>{i18next.t("general:Restore")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default CompoundRule;
diff --git a/web2/src/components/DataTable.tsx b/web/src/components/DataTable.tsx
similarity index 100%
rename from web2/src/components/DataTable.tsx
rename to web/src/components/DataTable.tsx
diff --git a/web2/src/components/FormRow.tsx b/web/src/components/FormRow.tsx
similarity index 100%
rename from web2/src/components/FormRow.tsx
rename to web/src/components/FormRow.tsx
diff --git a/web/src/components/IpRateRuleTable.js b/web/src/components/IpRateRuleTable.js
deleted file mode 100644
index 2dd27fd..0000000
--- a/web/src/components/IpRateRuleTable.js
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Button, Col, Input, InputNumber, Row, Table} from "antd";
-import i18next from "i18next";
-
-class IpRateRuleTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      defaultRules: [
-        {
-          name: "Default IP Rate",
-          operator: "100",
-          value: "6000",
-        },
-      ],
-    };
-    if (this.props.table.length === 0) {
-      this.restore();
-    }
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  updateField(table, index, key, value) {
-    table[index][key] = String(value);
-    this.updateTable(table);
-  }
-
-  restore() {
-    this.updateTable(this.state.defaultRules);
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "20%",
-        render: (text, record, index) => (
-          <Input value={record.name} onChange={e => {
-            this.updateField(table, index, "name", e.target.value);
-          }} />
-        ),
-      },
-      {
-        title: i18next.t("rule:Rate"),
-        dataIndex: "operator",
-        key: "operator",
-        width: "40%",
-        render: (text, record, index) => (
-          <InputNumber style={{"width": "100%"}} value={Number(record.operator)} addonAfter="requests / ip / s" onChange={e => {
-            this.updateField(table, index, "operator", e);
-          }} />
-        ),
-      },
-      {
-        title: i18next.t("rule:Block Duration"),
-        dataIndex: "value",
-        key: "value",
-        width: "100%",
-        render: (text, record, index) => (
-          <InputNumber style={{"width": "100%"}} value={Number(record.value)} addonAfter={i18next.t("usage:seconds")} onChange={e => {
-            this.updateField(table, index, "value", e);
-          }} />
-        ),
-      },
-    ];
-
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.restore()}>{i18next.t("general:Restore")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default IpRateRuleTable;
diff --git a/web/src/components/IpRuleTable.js b/web/src/components/IpRuleTable.js
deleted file mode 100644
index 83cc119..0000000
--- a/web/src/components/IpRuleTable.js
+++ /dev/null
@@ -1,197 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Input, Row, Select, Table, Tooltip} from "antd";
-import * as Setting from "../Setting";
-import i18next from "i18next";
-
-const {Option} = Select;
-
-class IpRuleTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      options: [],
-      defaultRules: [
-        {
-          name: "loopback",
-          operator: "is in",
-          value: "127.0.0.1",
-        },
-        {
-          name: "lan cidr",
-          operator: "is in",
-          value: "10.0.0.0/8,192.168.0.0/16",
-        },
-      ],
-    };
-    if (this.props.table.length === 0) {
-      this.restore();
-    }
-    for (let i = 0; i < this.props.table.length; i++) {
-      const values = this.props.table[i].value.split(",");
-      const options = [];
-      for (let j = 0; j < values.length; j++) {
-        options[j] = {value: values[j], label: values[j]};
-      }
-      this.state.options.push(options);
-    }
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  updateField(table, index, key, value) {
-    if (key === "value") {
-      let v = "";
-      for (let i = 0; i < value.length; i++) {
-        v += value[i].trim() + ",";
-      }
-      table[index][key] = v.slice(0, -1);
-    } else {
-      table[index][key] = value;
-    }
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const row = {name: `New IP Rule - ${table.length}`, operator: "is in", value: "127.0.0.1"};
-    if (table === undefined) {
-      table = [];
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    Setting.swapRow(this.state.options, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    Setting.swapRow(this.state.options, i, i + 1);
-    this.updateTable(table);
-  }
-
-  restore() {
-    this.updateTable(this.state.defaultRules);
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "180px",
-        render: (text, record, index) => (
-          <Input value={text} onChange={e => {
-            this.updateField(table, index, "name", e.target.value);
-          }} />
-        ),
-      },
-      {
-        title: i18next.t("rule:Operator"),
-        dataIndex: "operator",
-        key: "operator",
-        width: "180px",
-        render: (text, record, index) => (
-          <Select value={text} virtual={false} style={{width: "100%"}} onChange={value => {
-            this.updateField(table, index, "operator", value);
-          }}>
-            {
-              [
-                {value: "is in", text: i18next.t("rule:is in")},
-                {value: "is not in", text: i18next.t("rule:is not in")},
-                {value: "is abroad", text: i18next.t("rule:is abroad")},
-              ].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
-            }
-          </Select>
-        ),
-      },
-      {
-        title: i18next.t("rule:IP List"),
-        dataIndex: "value",
-        key: "value",
-        render: (text, record, index) => (
-          <Select
-            mode="tags"
-            style={{width: "100%"}}
-            placeholder="Input IP Addresses"
-            value={record.value ? record.value.split(",") : []}
-            onChange={value => this.updateField(table, index, "value", value)}
-            options={this.state.options[index]}
-          />
-        ),
-      },
-      {
-        title: i18next.t("general:Action"),
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => (
-          <div>
-            <Tooltip placement="bottomLeft" title={"Up"}>
-              <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Down"}>
-              <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Delete"}>
-              <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-            </Tooltip>
-          </div>
-        ),
-      },
-    ];
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.restore()}>{i18next.t("general:Restore")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default IpRuleTable;
diff --git a/web2/src/components/NodeTable.tsx b/web/src/components/NodeTable.tsx
similarity index 100%
rename from web2/src/components/NodeTable.tsx
rename to web/src/components/NodeTable.tsx
diff --git a/web/src/components/PieChartCard.js b/web/src/components/PieChartCard.js
deleted file mode 100644
index 29728f4..0000000
--- a/web/src/components/PieChartCard.js
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Card} from "antd";
-import ReactECharts from "echarts-for-react";
-import i18next from "i18next";
-
-const PieChartCard = ({title, data}) => {
-  const option = {
-    tooltip: {
-      trigger: "item",
-    },
-    legend: {
-      top: "5%",
-      left: "right",
-      orient: "vertical",
-    },
-    series: [
-      {
-        name: title,
-        type: "pie",
-        radius: ["40%", "70%"],
-        avoidLabelOverlap: false,
-        itemStyle: {
-          borderRadius: 10,
-          borderColor: "#fff",
-          borderWidth: 2,
-        },
-        label: {
-          show: false,
-          position: "center",
-        },
-        emphasis: {
-          label: {
-            show: true,
-            fontSize: 30,
-            fontWeight: "bold",
-          },
-        },
-        labelLine: {
-          show: false,
-        },
-        data: data,
-      },
-    ],
-  };
-
-  return (
-    <Card title={i18next.t(`general:${title}`)}>
-      <ReactECharts option={option}></ReactECharts>
-    </Card>
-  );
-};
-
-export default PieChartCard;
diff --git a/web2/src/components/Result.tsx b/web/src/components/Result.tsx
similarity index 100%
rename from web2/src/components/Result.tsx
rename to web/src/components/Result.tsx
diff --git a/web/src/components/RuleTable.js b/web/src/components/RuleTable.js
deleted file mode 100644
index 686487f..0000000
--- a/web/src/components/RuleTable.js
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Row, Select, Table, Tooltip} from "antd";
-import * as Setting from "../Setting";
-import i18next from "i18next";
-
-const {Option} = Select;
-
-class RuleTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-    };
-    if (this.props.rules === null) {
-      // rerender
-      this.props.onUpdateRules([]);
-    }
-  }
-
-  updateTable(table) {
-    const rules = [];
-    for (let i = 0; i < table.length; i++) {
-      rules.push(table[i].owner + "/" + table[i].name);
-    }
-    this.props.onUpdateRules(rules);
-  }
-
-  updateField(table, index, key, value) {
-    table[index][key] = value;
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const row = {owner: this.props.account.name, name: ""};
-    if (table === undefined) {
-      table = [];
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    this.updateTable(table);
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "180px",
-        render: (text, record, index) => (
-          <Select value={text} virtual={false} style={{width: "100%"}} onChange={value => {
-            this.updateField(table, index, "name", value);
-          }}>
-            {
-              Setting.getDeduplicatedArray(this.props.sources, table, "name").map((record, index) => {
-                return <Option key={record.name} value={record.name}>{record.name}</Option>;
-              })
-            }
-          </Select>
-        ),
-      },
-      {
-        title: "Action",
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => (
-          <div>
-            <Tooltip placement="bottomLeft" title={"Up"}>
-              <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Down"}>
-              <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Delete"}>
-              <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-            </Tooltip>
-          </div>
-        ),
-      },
-    ];
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.props.rules === null ? null : this.renderTable(this.props.rules.map((item, index) => {
-                const values = item.split("/");
-                return {owner: values[0], name: values[1]};
-              }))
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default RuleTable;
diff --git a/web/src/components/StatisticCard.js b/web/src/components/StatisticCard.js
deleted file mode 100644
index 5ed806c..0000000
--- a/web/src/components/StatisticCard.js
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {Card} from "antd";
-import ReactECharts from "echarts-for-react";
-import i18next from "i18next";
-
-const StatisticCard = ({title, value}) => {
-  const option = {
-    series: [
-      {
-        type: "scatter",
-        data: [[0, 0]],
-        symbolSize: 1,
-        label: {
-          show: true,
-          formatter: [value].join("\n"),
-          color: "#000",
-          fontSize: 64,
-        },
-      },
-    ],
-    xAxis: {
-      axisLabel: {show: false},
-      axisLine: {show: false},
-      splitLine: {show: false},
-      axisTick: {show: false},
-      min: -1,
-      max: 1,
-    },
-    yAxis: {
-      axisLabel: {show: false},
-      axisLine: {show: false},
-      splitLine: {show: false},
-      axisTick: {show: false},
-      min: -1,
-      max: 1,
-    },
-  };
-
-  return (
-    <Card title={i18next.t(`general:${title}`)}>
-      <ReactECharts option={option}></ReactECharts>
-    </Card>
-  );
-};
-
-export default StatisticCard;
diff --git a/web/src/components/UaRuleTable.js b/web/src/components/UaRuleTable.js
deleted file mode 100644
index c5fe6ef..0000000
--- a/web/src/components/UaRuleTable.js
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Input, Row, Select, Table, Tooltip} from "antd";
-import * as Setting from "../Setting";
-import i18next from "i18next";
-
-const {Option} = Select;
-
-class UaRuleTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-    };
-    if (this.props.table.length === 0) {
-      this.restore();
-    }
-  }
-
-  getDefaultRules() {
-    if (this.props.kind === "urlPath") {
-      return [
-        {
-          name: "Example",
-          operator: "contains",
-          value: "/.git/config",
-        },
-      ];
-    }
-    return [
-      {
-        name: "Current User-Agent",
-        operator: "equals",
-        value: window.navigator.userAgent,
-      },
-    ];
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  updateField(table, index, key, value) {
-    table[index][key] = value;
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const isPath = this.props.kind === "urlPath";
-    const row = {
-      name: isPath ? `New URL Path Rule - ${table.length}` : `New UA Rule - ${table.length}`,
-      operator: isPath ? "contains" : "equals",
-      value: "",
-    };
-    if (table === undefined) {
-      table = [];
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    this.updateTable(table);
-  }
-
-  restore() {
-    this.updateTable(this.getDefaultRules());
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "180px",
-        render: (text, record, index) => (
-          <Input value={text} onChange={e => {
-            this.updateField(table, index, "name", e.target.value);
-          }} />
-        ),
-      },
-      {
-        title: i18next.t("rule:Operator"),
-        dataIndex: "operator",
-        key: "operator",
-        width: "180px",
-        render: (text, record, index) => (
-          <Select value={text} virtual={false} style={{width: "100%"}} onChange={value => {
-            this.updateField(table, index, "operator", value);
-          }}>
-            {
-              [
-                {value: "equals", text: i18next.t("rule:equals")},
-                {value: "does not equal", text: i18next.t("rule:does not equal")},
-                {value: "contains", text: i18next.t("rule:contains")},
-                {value: "does not contain", text: i18next.t("rule:does not contain")},
-                {value: "match", text: i18next.t("rule:regex match")},
-              ].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
-            }
-          </Select>
-        ),
-      },
-      {
-        title: i18next.t("rule:Value"),
-        dataIndex: "value",
-        key: "value",
-        render: (text, record, index) => (
-          <Input value={text} onChange={e => {
-            this.updateField(table, index, "value", e.target.value);
-          }} onBlur={e => {
-            this.updateField(table, index, "value", e.target.value.replace(/\s+/g, " ").trim());
-          }} />
-        ),
-      },
-      {
-        title: i18next.t("general:Action"),
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => (
-          <div>
-            <Tooltip placement="bottomLeft" title={"Up"}>
-              <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Down"}>
-              <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-            </Tooltip>
-            <Tooltip placement="topLeft" title={"Delete"}>
-              <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-            </Tooltip>
-          </div>
-        ),
-      },
-    ];
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.restore()}>{i18next.t("general:Restore")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-UaRuleTable.defaultProps = {
-  kind: "userAgent",
-};
-
-export default UaRuleTable;
diff --git a/web/src/components/WafRuleTable.js b/web/src/components/WafRuleTable.js
deleted file mode 100644
index 3e6e750..0000000
--- a/web/src/components/WafRuleTable.js
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright 2023 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 React from "react";
-import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
-import {Button, Col, Input, Row, Table, Tooltip} from "antd";
-import * as Setting from "../Setting";
-import i18next from "i18next";
-
-class WafRuleTable extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      classes: props,
-      defaultRules: [
-        {
-          name: "Enable XML request body parser",
-          operator: "match",
-          value: "SecRule REQUEST_HEADERS:Content-Type \"^(?:application(?:/soap\\+|/)|text/)xml\" \"id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML\"",
-        },
-        {
-          name: "Enable JSON request body parser",
-          operator: "match",
-          value: "SecRule REQUEST_HEADERS:Content-Type \"^application/json\" \"id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON\"",
-        },
-        {
-          name: "Verify that we've correctly processed the request body",
-          operator: "match",
-          value: "SecRule &REQUEST_BODY \"@eq 0\" \"id:'200002',phase:2,t:none,deny,status:400,msg:'Failed to parse request body.'\"",
-        },
-      ],
-    };
-    if (this.props.table.length === 0) {
-      this.restore();
-    }
-  }
-
-  updateTable(table) {
-    this.props.onUpdateTable(table);
-  }
-
-  updateField(table, index, key, value) {
-    table[index][key] = value;
-    this.updateTable(table);
-  }
-
-  addRow(table) {
-    const row = {name: `New WAF Rule - ${table.length}`, operator: "match", value: ""};
-    if (table === undefined) {
-      table = [];
-    }
-
-    table = Setting.addRow(table, row);
-    this.updateTable(table);
-  }
-
-  deleteRow(table, i) {
-    table = Setting.deleteRow(table, i);
-    this.updateTable(table);
-  }
-
-  upRow(table, i) {
-    table = Setting.swapRow(table, i - 1, i);
-    this.updateTable(table);
-  }
-
-  downRow(table, i) {
-    table = Setting.swapRow(table, i, i + 1);
-    this.updateTable(table);
-  }
-
-  restore() {
-    this.updateTable(this.state.defaultRules);
-  }
-
-  renderTable(table) {
-    const columns = [
-      {
-        title: i18next.t("general:Name"),
-        dataIndex: "name",
-        key: "name",
-        width: "180px",
-        render: (text, record, index) => {
-          return (
-            <Input value={text} onChange={e => {
-              this.updateField(table, index, "name", e.target.value);
-            }} />
-          );
-        },
-      },
-      {
-        title: i18next.t("rule:Expression"),
-        dataIndex: "value",
-        key: "value",
-        render: (text, record, index) => {
-          return (
-            <Input value={text} onChange={e => {
-              this.updateField(table, index, "value", e.target.value);
-            }} />
-          );
-        },
-      },
-      {
-        title: i18next.t("general:Action"),
-        key: "action",
-        width: "100px",
-        render: (text, record, index) => {
-          return (
-            <div>
-              <Tooltip placement="bottomLeft" title={"Up"}>
-                <Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
-              </Tooltip>
-              <Tooltip placement="topLeft" title={"Down"}>
-                <Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
-              </Tooltip>
-              <Tooltip placement="topLeft" title={"Delete"}>
-                <Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
-              </Tooltip>
-            </div>
-          );
-        },
-      },
-    ];
-
-    return (
-      <Table rowKey="index" columns={columns} dataSource={table} size="middle" bordered pagination={false}
-        title={() => (
-          <div>
-            {this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
-            <Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.restore()}>{i18next.t("general:Restore")}</Button>
-          </div>
-        )}
-      />
-    );
-  }
-
-  render() {
-    return (
-      <div>
-        <Row style={{marginTop: "20px"}} >
-          <Col span={24}>
-            {
-              this.renderTable(this.props.table)
-            }
-          </Col>
-        </Row>
-      </div>
-    );
-  }
-}
-
-export default WafRuleTable;
diff --git a/web2/src/components/charts/ChartCards.tsx b/web/src/components/charts/ChartCards.tsx
similarity index 100%
rename from web2/src/components/charts/ChartCards.tsx
rename to web/src/components/charts/ChartCards.tsx
diff --git a/web2/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx
similarity index 100%
rename from web2/src/components/layout/Footer.tsx
rename to web/src/components/layout/Footer.tsx
diff --git a/web2/src/components/layout/LanguageSelect.tsx b/web/src/components/layout/LanguageSelect.tsx
similarity index 100%
rename from web2/src/components/layout/LanguageSelect.tsx
rename to web/src/components/layout/LanguageSelect.tsx
diff --git a/web2/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx
similarity index 100%
rename from web2/src/components/layout/Sidebar.tsx
rename to web/src/components/layout/Sidebar.tsx
diff --git a/web2/src/components/rules/CompoundRule.tsx b/web/src/components/rules/CompoundRule.tsx
similarity index 100%
rename from web2/src/components/rules/CompoundRule.tsx
rename to web/src/components/rules/CompoundRule.tsx
diff --git a/web2/src/components/rules/IpRateRuleTable.tsx b/web/src/components/rules/IpRateRuleTable.tsx
similarity index 100%
rename from web2/src/components/rules/IpRateRuleTable.tsx
rename to web/src/components/rules/IpRateRuleTable.tsx
diff --git a/web2/src/components/rules/IpRuleTable.tsx b/web/src/components/rules/IpRuleTable.tsx
similarity index 100%
rename from web2/src/components/rules/IpRuleTable.tsx
rename to web/src/components/rules/IpRuleTable.tsx
diff --git a/web2/src/components/rules/RowActions.tsx b/web/src/components/rules/RowActions.tsx
similarity index 100%
rename from web2/src/components/rules/RowActions.tsx
rename to web/src/components/rules/RowActions.tsx
diff --git a/web2/src/components/rules/SiteRuleTable.tsx b/web/src/components/rules/SiteRuleTable.tsx
similarity index 100%
rename from web2/src/components/rules/SiteRuleTable.tsx
rename to web/src/components/rules/SiteRuleTable.tsx
diff --git a/web2/src/components/rules/UaRuleTable.tsx b/web/src/components/rules/UaRuleTable.tsx
similarity index 100%
rename from web2/src/components/rules/UaRuleTable.tsx
rename to web/src/components/rules/UaRuleTable.tsx
diff --git a/web2/src/components/rules/WafRuleTable.tsx b/web/src/components/rules/WafRuleTable.tsx
similarity index 100%
rename from web2/src/components/rules/WafRuleTable.tsx
rename to web/src/components/rules/WafRuleTable.tsx
diff --git a/web2/src/components/rules/useExpressionTable.ts b/web/src/components/rules/useExpressionTable.ts
similarity index 100%
rename from web2/src/components/rules/useExpressionTable.ts
rename to web/src/components/rules/useExpressionTable.ts
diff --git a/web2/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx
similarity index 100%
rename from web2/src/components/ui/alert-dialog.tsx
rename to web/src/components/ui/alert-dialog.tsx
diff --git a/web2/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
similarity index 100%
rename from web2/src/components/ui/alert.tsx
rename to web/src/components/ui/alert.tsx
diff --git a/web2/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx
similarity index 100%
rename from web2/src/components/ui/avatar.tsx
rename to web/src/components/ui/avatar.tsx
diff --git a/web2/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
similarity index 100%
rename from web2/src/components/ui/badge.tsx
rename to web/src/components/ui/badge.tsx
diff --git a/web2/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
similarity index 100%
rename from web2/src/components/ui/button.tsx
rename to web/src/components/ui/button.tsx
diff --git a/web2/src/components/ui/card.tsx b/web/src/components/ui/card.tsx
similarity index 100%
rename from web2/src/components/ui/card.tsx
rename to web/src/components/ui/card.tsx
diff --git a/web2/src/components/ui/chart.tsx b/web/src/components/ui/chart.tsx
similarity index 100%
rename from web2/src/components/ui/chart.tsx
rename to web/src/components/ui/chart.tsx
diff --git a/web2/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx
similarity index 100%
rename from web2/src/components/ui/combobox.tsx
rename to web/src/components/ui/combobox.tsx
diff --git a/web2/src/components/ui/command.tsx b/web/src/components/ui/command.tsx
similarity index 100%
rename from web2/src/components/ui/command.tsx
rename to web/src/components/ui/command.tsx
diff --git a/web2/src/components/ui/confirm-button.tsx b/web/src/components/ui/confirm-button.tsx
similarity index 100%
rename from web2/src/components/ui/confirm-button.tsx
rename to web/src/components/ui/confirm-button.tsx
diff --git a/web2/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
similarity index 100%
rename from web2/src/components/ui/dialog.tsx
rename to web/src/components/ui/dialog.tsx
diff --git a/web2/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx
similarity index 100%
rename from web2/src/components/ui/dropdown-menu.tsx
rename to web/src/components/ui/dropdown-menu.tsx
diff --git a/web2/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
similarity index 100%
rename from web2/src/components/ui/input.tsx
rename to web/src/components/ui/input.tsx
diff --git a/web2/src/components/ui/label.tsx b/web/src/components/ui/label.tsx
similarity index 100%
rename from web2/src/components/ui/label.tsx
rename to web/src/components/ui/label.tsx
diff --git a/web2/src/components/ui/number-input.tsx b/web/src/components/ui/number-input.tsx
similarity index 100%
rename from web2/src/components/ui/number-input.tsx
rename to web/src/components/ui/number-input.tsx
diff --git a/web2/src/components/ui/password-input.tsx b/web/src/components/ui/password-input.tsx
similarity index 100%
rename from web2/src/components/ui/password-input.tsx
rename to web/src/components/ui/password-input.tsx
diff --git a/web2/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx
similarity index 100%
rename from web2/src/components/ui/popover.tsx
rename to web/src/components/ui/popover.tsx
diff --git a/web2/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
similarity index 100%
rename from web2/src/components/ui/select.tsx
rename to web/src/components/ui/select.tsx
diff --git a/web2/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx
similarity index 100%
rename from web2/src/components/ui/sonner.tsx
rename to web/src/components/ui/sonner.tsx
diff --git a/web2/src/components/ui/spinner.tsx b/web/src/components/ui/spinner.tsx
similarity index 100%
rename from web2/src/components/ui/spinner.tsx
rename to web/src/components/ui/spinner.tsx
diff --git a/web2/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx
similarity index 100%
rename from web2/src/components/ui/switch.tsx
rename to web/src/components/ui/switch.tsx
diff --git a/web2/src/components/ui/table.tsx b/web/src/components/ui/table.tsx
similarity index 100%
rename from web2/src/components/ui/table.tsx
rename to web/src/components/ui/table.tsx
diff --git a/web2/src/components/ui/tags-input.tsx b/web/src/components/ui/tags-input.tsx
similarity index 100%
rename from web2/src/components/ui/tags-input.tsx
rename to web/src/components/ui/tags-input.tsx
diff --git a/web2/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
similarity index 100%
rename from web2/src/components/ui/textarea.tsx
rename to web/src/components/ui/textarea.tsx
diff --git a/web2/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
similarity index 100%
rename from web2/src/components/ui/tooltip.tsx
rename to web/src/components/ui/tooltip.tsx
diff --git a/web/src/font.css b/web/src/font.css
deleted file mode 100644
index 22c6803..0000000
--- a/web/src/font.css
+++ /dev/null
@@ -1,77 +0,0 @@
-/* 默认字体 具体可能需要修改 */
-#editor {
-  font-family: 'Microsoft-YaHei';
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=SimSun]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=SimSun]::before {
-  content: "宋体";
-  font-family: "SimSun";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=SimHei]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=SimHei]::before {
-  content: "黑体";
-  font-family: "SimHei";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Microsoft-YaHei]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Microsoft-YaHei]::before {
-  content: "微软雅黑";
-  font-family: "Microsoft YaHei";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=consolas]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=consolas]::before {
-  content: "consolas";
-  font-family: "consolas";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=KaiTi]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=KaiTi]::before {
-  content: "楷体";
-  font-family: "KaiTi";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=FangSong]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=FangSong]::before {
-  content: "仿宋";
-  font-family: "FangSong";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Arial]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Arial]::before {
-  content: "Arial";
-  font-family: "Arial";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Times-New-Roman]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Times-New-Roman]::before {
-  content: "New Roman";
-  font-family: "Times New Roman";
-}
-.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=sans-serif]::before,
-.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=sans-serif]::before {
-  content: "sans-serif";
-  font-family: "sans-serif";
-}
-
-.ql-font-SimSun {
... 18557 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org