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 7730f8b  feat(web): create objects through a dialog
7730f8b is described below

commit 7730f8b2acbfae2b52e8c54a8b5ceff23d1c7435
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 20 23:24:53 2026 +0800

    feat(web): create objects through a dialog
---
 web/src/components/FormDialog.tsx | 105 ++++++++++++++++++++++++++++++++++++++
 web/src/lib/rules.ts              |  35 +++++++++++++
 web/src/locales/en/data.json      |   8 ++-
 web/src/locales/zh/data.json      |   8 ++-
 web/src/pages/CertListPage.tsx    |  76 +++++++++++++++++++++++++--
 web/src/pages/ChannelListPage.tsx | 103 +++++++++++++++++++++++++++++++++++--
 web/src/pages/NodeListPage.tsx    |  62 ++++++++++++++++++++--
 web/src/pages/RecordListPage.tsx  |  97 ++++++++++++++++++++++++++++++-----
 web/src/pages/RuleEditPage.tsx    |  17 ++----
 web/src/pages/RuleListPage.tsx    |  84 +++++++++++++++++++++++++++++-
 web/src/pages/SiteListPage.tsx    |  80 +++++++++++++++++++++++++++--
 11 files changed, 632 insertions(+), 43 deletions(-)

diff --git a/web/src/components/FormDialog.tsx 
b/web/src/components/FormDialog.tsx
new file mode 100644
index 0000000..f77e491
--- /dev/null
+++ b/web/src/components/FormDialog.tsx
@@ -0,0 +1,105 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import {cn} from "@/lib/utils";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {Label} from "@/components/ui/label";
+
+/** The modal every list page opens to create an object: chrome here, fields 
in the caller. */
+export function FormDialog({
+  open,
+  onOpenChange,
+  title,
+  children,
+  onSubmit,
+  submitText,
+  submitting = false,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  title: React.ReactNode;
+  children: React.ReactNode;
+  onSubmit: () => void;
+  submitText?: React.ReactNode;
+  submitting?: boolean;
+}) {
+  const handleSubmit = (event: React.FormEvent) => {
+    event.preventDefault();
+    onSubmit();
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>{title}</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="flex min-h-0 flex-col gap-4">
+          <div className="-mx-1 grid max-h-[60vh] gap-4 overflow-y-auto px-1 
py-0.5">{children}</div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => 
onOpenChange(false)}>
+              {i18next.t("general:Cancel")}
+            </Button>
+            <Button type="submit" disabled={submitting}>
+              {submitText ?? i18next.t("general:OK")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/** One labelled control inside a FormDialog. */
+export function Field({
+  label,
+  htmlFor,
+  required = false,
+  hint,
+  error,
+  children,
+  className,
+}: {
+  label?: React.ReactNode;
+  htmlFor?: string;
+  required?: boolean;
+  hint?: React.ReactNode;
+  error?: React.ReactNode;
+  children: React.ReactNode;
+  className?: string;
+}) {
+  return (
+    <div className={cn("grid gap-2", className)}>
+      {label ? (
+        <Label htmlFor={htmlFor}>
+          {label}
+          {required ? <span className="ml-0.5 text-destructive">*</span> : 
null}
+        </Label>
+      ) : null}
+      {children}
+      {hint && !error ? <p className="text-xs 
text-muted-foreground">{hint}</p> : null}
+      {error ? <p className="text-xs text-destructive">{error}</p> : null}
+    </div>
+  );
+}
diff --git a/web/src/lib/rules.ts b/web/src/lib/rules.ts
new file mode 100644
index 0000000..39900a9
--- /dev/null
+++ b/web/src/lib/rules.ts
@@ -0,0 +1,35 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import i18next from "i18next";
+
+/** Labels are read at call time so a language switch re-translates them. */
+export function getRuleTypes() {
+  return [
+    {value: "WAF", label: "WAF"},
+    {value: "IP", label: "IP"},
+    {value: "User-Agent", label: "User-Agent"},
+    {value: "URL Path", label: "URL Path"},
+    {value: "IP Rate Limiting", label: i18next.t("rule:IP Rate Limiting")},
+    {value: "Compound", label: i18next.t("rule:Compound")},
+  ];
+}
+
+export function getRuleActions() {
+  return [
+    {value: "Allow", label: i18next.t("rule:Allow")},
+    {value: "Block", label: i18next.t("rule:Block")},
+    {value: "CAPTCHA", label: i18next.t("rule:Captcha")},
+  ];
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index f004d06..76e0bb6 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -107,6 +107,7 @@
     "Edit Cert": "Edit Cert",
     "Expire time": "Expire time",
     "Failed to refresh domain expire": "Failed to refresh domain expire",
+    "New Cert": "New Cert",
     "Private key": "Private key",
     "Private key copied to clipboard successfully": "Private key copied to 
clipboard successfully",
     "Provider": "Provider",
@@ -181,6 +182,8 @@
     "Login Error": "Login Error",
     "Method": "Method",
     "Name": "Name",
+    "Name cannot be empty": "Name cannot be empty",
+    "New Record": "New Record",
     "No Upgrade": "No Upgrade",
     "No data": "No data",
     "Nodes": "Nodes",
@@ -217,7 +220,8 @@
     "{total} in total": "{total} in total"
   },
   "node": {
-    "Edit Node": "Edit Node"
+    "Edit Node": "Edit Node",
+    "New Node": "New Node"
   },
   "rule": {
     "Allow": "Allow",
@@ -232,6 +236,7 @@
     "IP Rate Limiting": "IP Rate Limiting",
     "IPs": "IPs",
     "Logic": "Logic",
+    "New Rule": "New Rule",
     "Operator": "Operator",
     "Rate": "Rate",
     "Reason": "Reason",
@@ -269,6 +274,7 @@
     "Hosts": "Hosts",
     "Mode": "Mode",
     "Need redirect": "Need redirect",
+    "New Site": "New Site",
     "Node": "Node",
     "Nodes": "Nodes",
     "Other domains": "Other domains",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index aa793aa..00969ae 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -107,6 +107,7 @@
     "Edit Cert": "编辑证书",
     "Expire time": "到期时间",
     "Failed to refresh domain expire": "刷新域名到期时间失败",
+    "New Cert": "新建证书",
     "Private key": "私钥",
     "Private key copied to clipboard successfully": "私钥成功复制到剪贴板",
     "Provider": "提供者",
@@ -181,6 +182,8 @@
     "Login Error": "登录失败",
     "Method": "方法",
     "Name": "名称",
+    "Name cannot be empty": "名称不能为空",
+    "New Record": "新建记录",
     "No Upgrade": "不升级",
     "No data": "暂无数据",
     "Nodes": "节点",
@@ -217,7 +220,8 @@
     "{total} in total": "共 {total} 条"
   },
   "node": {
-    "Edit Node": "编辑节点"
+    "Edit Node": "编辑节点",
+    "New Node": "新建节点"
   },
   "rule": {
     "Allow": "允许",
@@ -232,6 +236,7 @@
     "IP Rate Limiting": "IP 速率限制",
     "IPs": "IP 列表",
     "Logic": "逻辑",
+    "New Rule": "新建规则",
     "Operator": "操作符",
     "Rate": "速率",
     "Reason": "原因",
@@ -269,6 +274,7 @@
     "Hosts": "主机列表",
     "Mode": "模式",
     "Need redirect": "需要重定向",
+    "New Site": "新建站点",
     "Node": "节点",
     "Nodes": "节点",
     "Other domains": "其他域名",
diff --git a/web/src/pages/CertListPage.tsx b/web/src/pages/CertListPage.tsx
index 2cc49f9..6f07484 100644
--- a/web/src/pages/CertListPage.tsx
+++ b/web/src/pages/CertListPage.tsx
@@ -20,8 +20,17 @@ import i18next from "i18next";
 import * as CertBackend from "@/backend/CertBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import type {Account, Cert} from "@/types";
 
 function newCert(owner: string): Cert {
@@ -51,6 +60,10 @@ export default function CertListPage({account}: {account: 
Account}) {
   const [loading, setLoading] = React.useState(false);
   const [page, setPage] = React.useState(1);
   const [pageSize, setPageSize] = React.useState(20);
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Cert>(() => newCert(account.name));
+  const [nameError, setNameError] = React.useState("");
 
   const fetchCerts = React.useCallback(
     (nextPage = page, nextPageSize = pageSize) => {
@@ -76,17 +89,34 @@ export default function CertListPage({account}: {account: 
Account}) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [account.name]);
 
+  const openAddDialog = () => {
+    setForm(newCert(account.name));
+    setNameError("");
+    setAddOpen(true);
+  };
+
   const addCert = () => {
-    CertBackend.addCert(newCert(account.name))
+    const name = form.name.trim();
+    if (name === "") {
+      setNameError(i18next.t("general:Name cannot be empty"));
+      return;
+    }
+    setAdding(true);
+    CertBackend.addCert({...form, name: name})
       .then(res => {
+        setAdding(false);
         if (res.status === "error") {
           Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${res.msg}`);
         } else {
           Setting.showMessage("success", i18next.t("general:Added 
successfully"));
+          setAddOpen(false);
           fetchCerts();
         }
       })
-      .catch(error => Setting.showMessage("error", 
`${i18next.t("general:Failed to add")}: ${error}`));
+      .catch(error => {
+        setAdding(false);
+        Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${error}`);
+      });
   };
 
   const deleteCert = (cert: Cert) => {
@@ -251,11 +281,51 @@ export default function CertListPage({account}: {account: 
Account}) {
         }}
         title={i18next.t("general:Certs")}
         toolbar={
-          <Button size="sm" onClick={addCert}>
+          <Button size="sm" onClick={openAddDialog}>
             {i18next.t("general:Add")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("cert:New Cert")}
+        submitting={adding}
+        onSubmit={addCert}
+      >
+        <Field label={i18next.t("general:Name")} htmlFor="cert-name" required 
error={nameError}>
+          <Input
+            id="cert-name"
+            value={form.name}
+            onChange={event => {
+              setForm({...form, name: event.target.value});
+              setNameError("");
+            }}
+          />
+        </Field>
+        <Field label={i18next.t("general:Display name")} 
htmlFor="cert-display-name">
+          <Input
+            id="cert-display-name"
+            value={form.displayName}
+            onChange={event => setForm({...form, displayName: 
event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("cert:Crypto algorithm")}>
+          <Select
+            value={form.cryptoAlgorithm}
+            onValueChange={value => setForm({...form, cryptoAlgorithm: value})}
+          >
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="RSA">RSA</SelectItem>
+              <SelectItem value="ECC">ECC</SelectItem>
+            </SelectContent>
+          </Select>
+        </Field>
+      </FormDialog>
     </div>
   );
 }
diff --git a/web/src/pages/ChannelListPage.tsx 
b/web/src/pages/ChannelListPage.tsx
index c19762a..2529eeb 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -20,9 +20,20 @@ import i18next from "i18next";
 import * as ChannelBackend from "@/backend/ChannelBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column, type SortOrder} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
+import {PasswordInput} from "@/components/ui/password-input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {TagsInput} from "@/components/ui/tags-input";
 import {Tooltip} from "@/components/ui/tooltip";
 import type {Account, Channel} from "@/types";
 
@@ -52,6 +63,10 @@ export default function ChannelListPage({account}: {account: 
Account}) {
     field: "",
     order: undefined,
   });
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Channel>(() => 
newChannel(account.name));
+  const [nameError, setNameError] = React.useState("");
 
   const fetchChannels = React.useCallback(
     (nextPage = page, nextPageSize = pageSize, nextSort = sort) => {
@@ -88,17 +103,38 @@ export default function ChannelListPage({account}: 
{account: Account}) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [account.name]);
 
+  const openAddDialog = () => {
+    setForm(newChannel(account.name));
+    setNameError("");
+    setAddOpen(true);
+  };
+
+  const setFormField = <K extends keyof Channel>(key: K, value: Channel[K]) => 
{
+    setForm(prev => ({...prev, [key]: value}));
+  };
+
   const addChannel = () => {
-    ChannelBackend.addChannel(newChannel(account.name))
+    const name = form.name.trim();
+    if (name === "") {
+      setNameError(i18next.t("general:Name cannot be empty"));
+      return;
+    }
+    setAdding(true);
+    ChannelBackend.addChannel({...form, name: name})
       .then(res => {
+        setAdding(false);
         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"));
+          setAddOpen(false);
           fetchChannels();
         }
       })
-      .catch(error => Setting.showMessage("error", 
`${i18next.t("channel:Failed to add")}: ${error}`));
+      .catch(error => {
+        setAdding(false);
+        Setting.showMessage("error", `${i18next.t("channel:Failed to add")}: 
${error}`);
+      });
   };
 
   const deleteChannel = (channel: Channel) => {
@@ -251,12 +287,73 @@ export default function ChannelListPage({account}: 
{account: Account}) {
         }}
         title={i18next.t("channel:Channels")}
         toolbar={
-          <Button size="sm" onClick={addChannel}>
+          <Button size="sm" onClick={openAddDialog}>
             <Plus />
             {i18next.t("channel:New Channel")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("channel:New Channel")}
+        submitting={adding}
+        onSubmit={addChannel}
+      >
+        <Field label={i18next.t("general:Name")} htmlFor="channel-name" 
required error={nameError}>
+          <Input
+            id="channel-name"
+            value={form.name}
+            onChange={event => {
+              setFormField("name", event.target.value);
+              setNameError("");
+            }}
+          />
+        </Field>
+        <Field label={i18next.t("general:Display name")} 
htmlFor="channel-display-name">
+          <Input
+            id="channel-display-name"
+            value={form.displayName}
+            onChange={event => setFormField("displayName", event.target.value)}
+          />
+        </Field>
+        <Field label={i18next.t("channel:Type")}>
+          <Select value={form.type} onValueChange={value => 
setFormField("type", value)}>
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="openai">OpenAI</SelectItem>
+              <SelectItem value="anthropic">Anthropic</SelectItem>
+              <SelectItem value="custom">Custom</SelectItem>
+            </SelectContent>
+          </Select>
+        </Field>
+        <Field label={i18next.t("channel:Base URL")} 
htmlFor="channel-base-url">
+          <Input
+            id="channel-base-url"
+            value={form.baseUrl}
+            placeholder={form.type === "anthropic" ? 
"https://api.anthropic.com"; : "https://api.openai.com/v1"}
+            onChange={event => setFormField("baseUrl", event.target.value)}
+          />
+        </Field>
+        <Field label={i18next.t("channel:API Key")} htmlFor="channel-api-key">
+          <PasswordInput
+            id="channel-api-key"
+            placeholder="sk-..."
+            value={form.apiKey}
+            onChange={event => setFormField("apiKey", event.target.value)}
+          />
+        </Field>
+        <Field label={i18next.t("channel:Models")}>
+          <TagsInput
+            value={form.models}
+            placeholder={form.type === "anthropic" ? "claude-opus-5, 
claude-sonnet-5" : "gpt-5, gpt-5-mini"}
+            onChange={value => setFormField("models", value)}
+          />
+        </Field>
+      </FormDialog>
     </div>
   );
 }
diff --git a/web/src/pages/NodeListPage.tsx b/web/src/pages/NodeListPage.tsx
index f67d687..2579f6f 100644
--- a/web/src/pages/NodeListPage.tsx
+++ b/web/src/pages/NodeListPage.tsx
@@ -19,9 +19,11 @@ import i18next from "i18next";
 import * as NodeBackend from "@/backend/NodeBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {UnauthorizedResult} from "@/components/Result";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
 import type {Account, Node} from "@/types";
 
 function newNode(owner: string): Node {
@@ -42,6 +44,10 @@ export default function NodeListPage({account}: {account: 
Account}) {
   const [data, setData] = React.useState<Node[]>([]);
   const [loading, setLoading] = React.useState(false);
   const [authorized, setAuthorized] = React.useState(true);
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Node>(() => newNode(account.name));
+  const [nameError, setNameError] = React.useState("");
 
   const fetchNodes = React.useCallback(() => {
     setLoading(true);
@@ -61,17 +67,34 @@ export default function NodeListPage({account}: {account: 
Account}) {
     fetchNodes();
   }, [fetchNodes]);
 
+  const openAddDialog = () => {
+    setForm(newNode(account.name));
+    setNameError("");
+    setAddOpen(true);
+  };
+
   const addNode = () => {
-    NodeBackend.addNode(newNode(account.name))
+    const name = form.name.trim();
+    if (name === "") {
+      setNameError(i18next.t("general:Name cannot be empty"));
+      return;
+    }
+    setAdding(true);
+    NodeBackend.addNode({...form, name: name})
       .then(res => {
+        setAdding(false);
         if (res.status === "error") {
           Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${res.msg}`);
         } else {
           Setting.showMessage("success", i18next.t("general:Added 
successfully"));
+          setAddOpen(false);
           fetchNodes();
         }
       })
-      .catch(error => Setting.showMessage("error", 
`${i18next.t("general:Failed to add")}: ${error}`));
+      .catch(error => {
+        setAdding(false);
+        Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${error}`);
+      });
   };
 
   const deleteNode = (node: Node) => {
@@ -178,11 +201,44 @@ export default function NodeListPage({account}: {account: 
Account}) {
         pageSize={20}
         title={i18next.t("general:Nodes")}
         toolbar={
-          <Button size="sm" onClick={addNode}>
+          <Button size="sm" onClick={openAddDialog}>
             {i18next.t("general:Add")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("node:New Node")}
+        submitting={adding}
+        onSubmit={addNode}
+      >
+        <Field label={i18next.t("general:Name")} htmlFor="node-name" required 
error={nameError}>
+          <Input
+            id="node-name"
+            value={form.name}
+            onChange={event => {
+              setForm({...form, name: event.target.value});
+              setNameError("");
+            }}
+          />
+        </Field>
+        <Field label={i18next.t("general:Display name")} 
htmlFor="node-display-name">
+          <Input
+            id="node-display-name"
+            value={form.displayName}
+            onChange={event => setForm({...form, displayName: 
event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("general:Tag")} htmlFor="node-tag">
+          <Input
+            id="node-tag"
+            value={form.tag}
+            onChange={event => setForm({...form, tag: event.target.value})}
+          />
+        </Field>
+      </FormDialog>
     </div>
   );
 }
diff --git a/web/src/pages/RecordListPage.tsx b/web/src/pages/RecordListPage.tsx
index d04f426..f19400c 100644
--- a/web/src/pages/RecordListPage.tsx
+++ b/web/src/pages/RecordListPage.tsx
@@ -19,10 +19,34 @@ import i18next from "i18next";
 import * as RecordBackend from "@/backend/RecordBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
 import type {Account, Record as GatewayRecord} from "@/types";
 
+const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
+
+function newRecord(owner: string): Partial<GatewayRecord> {
+  const randomName = Setting.getRandomName();
+  return {
+    owner: owner,
+    name: `record_${randomName}`,
+    createdTime: new Date().toISOString(),
+    method: "GET",
+    host: "door.casdoor.com",
+    path: "/",
+    userAgent: "",
+  };
+}
+
 export default function RecordListPage({account}: {account: Account}) {
   const navigate = useNavigate();
   const [data, setData] = React.useState<GatewayRecord[]>([]);
@@ -30,6 +54,9 @@ export default function RecordListPage({account}: {account: 
Account}) {
   const [loading, setLoading] = React.useState(false);
   const [page, setPage] = React.useState(1);
   const [pageSize, setPageSize] = React.useState(10);
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Partial<GatewayRecord>>(() => 
newRecord(account.name));
 
   const fetchRecords = React.useCallback(
     (nextPage = page, nextPageSize = pageSize) => {
@@ -54,26 +81,28 @@ export default function RecordListPage({account}: {account: 
Account}) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [account.name]);
 
+  const openAddDialog = () => {
+    setForm(newRecord(account.name));
+    setAddOpen(true);
+  };
+
   const addRecord = () => {
-    const randomName = Setting.getRandomName();
-    RecordBackend.addRecord({
-      owner: account.name,
-      name: `record_${randomName}`,
-      createdTime: new Date().toISOString(),
-      method: "GET",
-      host: "door.casdoor.com",
-      path: "/",
-      userAgent: "",
-    })
+    setAdding(true);
+    RecordBackend.addRecord(form)
       .then(res => {
+        setAdding(false);
         if (res.status === "error") {
           Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${res.msg}`);
         } else {
           Setting.showMessage("success", i18next.t("general:Added 
successfully"));
+          setAddOpen(false);
           fetchRecords();
         }
       })
-      .catch(error => Setting.showMessage("error", 
`${i18next.t("general:Failed to add")}: ${error}`));
+      .catch(error => {
+        setAdding(false);
+        Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${error}`);
+      });
   };
 
   const deleteRecord = (record: GatewayRecord) => {
@@ -185,11 +214,55 @@ export default function RecordListPage({account}: 
{account: Account}) {
         }}
         title={i18next.t("general:Records")}
         toolbar={
-          <Button size="sm" onClick={addRecord}>
+          <Button size="sm" onClick={openAddDialog}>
             {i18next.t("general:Add")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("general:New Record")}
+        submitting={adding}
+        onSubmit={addRecord}
+      >
+        <Field label={i18next.t("general:Method")}>
+          <Select value={form.method} onValueChange={value => 
setForm({...form, method: value})}>
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {METHODS.map(method => (
+                <SelectItem key={method} value={method}>
+                  {method}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </Field>
+        <Field label={i18next.t("general:Host")} htmlFor="record-host">
+          <Input
+            id="record-host"
+            value={form.host}
+            onChange={event => setForm({...form, host: event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("general:Path")} htmlFor="record-path">
+          <Input
+            id="record-path"
+            value={form.path}
+            onChange={event => setForm({...form, path: event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("general:User-Agent")} 
htmlFor="record-user-agent">
+          <Input
+            id="record-user-agent"
+            value={form.userAgent}
+            onChange={event => setForm({...form, userAgent: 
event.target.value})}
+          />
+        </Field>
+      </FormDialog>
     </div>
   );
 }
diff --git a/web/src/pages/RuleEditPage.tsx b/web/src/pages/RuleEditPage.tsx
index e6b9f36..2443364 100644
--- a/web/src/pages/RuleEditPage.tsx
+++ b/web/src/pages/RuleEditPage.tsx
@@ -37,6 +37,7 @@ import {
 } from "@/components/ui/select";
 import {PageSpinner} from "@/components/ui/spinner";
 import {Switch} from "@/components/ui/switch";
+import {getRuleActions, getRuleTypes} from "@/lib/rules";
 import type {Rule, RuleExpression} from "@/types";
 
 export default function RuleEditPage() {
@@ -137,20 +138,8 @@ export default function RuleEditPage() {
     }
   };
 
-  const types = [
-    {value: "WAF", label: "WAF"},
-    {value: "IP", label: "IP"},
-    {value: "User-Agent", label: "User-Agent"},
-    {value: "URL Path", label: "URL Path"},
-    {value: "IP Rate Limiting", label: i18next.t("rule:IP Rate Limiting")},
-    {value: "Compound", label: i18next.t("rule:Compound")},
-  ];
-
-  const actions = [
-    {value: "Allow", label: i18next.t("rule:Allow")},
-    {value: "Block", label: i18next.t("rule:Block")},
-    {value: "CAPTCHA", label: i18next.t("rule:Captcha")},
-  ];
+  const types = getRuleTypes();
+  const actions = getRuleActions();
 
   return (
     <div className="p-4 md:p-6">
diff --git a/web/src/pages/RuleListPage.tsx b/web/src/pages/RuleListPage.tsx
index 2dc28f2..99178ef 100644
--- a/web/src/pages/RuleListPage.tsx
+++ b/web/src/pages/RuleListPage.tsx
@@ -19,9 +19,19 @@ import i18next from "i18next";
 import * as RuleBackend from "@/backend/RuleBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {getRuleActions, getRuleTypes} from "@/lib/rules";
 import type {Account, Rule} from "@/types";
 
 function newRule(owner: string): Rule {
@@ -47,6 +57,10 @@ export default function RuleListPage({account}: {account: 
Account}) {
   const [loading, setLoading] = React.useState(false);
   const [page, setPage] = React.useState(1);
   const [pageSize, setPageSize] = React.useState(10);
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Rule>(() => newRule(account.name));
+  const [nameError, setNameError] = React.useState("");
 
   const fetchRules = React.useCallback(
     (nextPage = page, nextPageSize = pageSize) => {
@@ -71,12 +85,26 @@ export default function RuleListPage({account}: {account: 
Account}) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [account.name]);
 
+  const openAddDialog = () => {
+    setForm(newRule(account.name));
+    setNameError("");
+    setAddOpen(true);
+  };
+
   const addRule = () => {
-    RuleBackend.addRule(newRule(account.name)).then(res => {
+    const name = form.name.trim();
+    if (name === "") {
+      setNameError(i18next.t("general:Name cannot be empty"));
+      return;
+    }
+    setAdding(true);
+    RuleBackend.addRule({...form, name: name}).then(res => {
+      setAdding(false);
       if (res.status === "error") {
         Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${res.msg}`);
       } else {
         Setting.showMessage("success", i18next.t("general:Added 
successfully"));
+        setAddOpen(false);
         fetchRules();
       }
     });
@@ -93,6 +121,9 @@ export default function RuleListPage({account}: {account: 
Account}) {
     });
   };
 
+  const types = getRuleTypes();
+  const actions = getRuleActions();
+
   const columns: Column<Rule>[] = [
     {
       title: i18next.t("general:Owner"),
@@ -209,11 +240,60 @@ export default function RuleListPage({account}: {account: 
Account}) {
         }}
         title={i18next.t("general:Rules")}
         toolbar={
-          <Button size="sm" onClick={addRule}>
+          <Button size="sm" onClick={openAddDialog}>
             {i18next.t("general:Add")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("rule:New Rule")}
+        submitting={adding}
+        onSubmit={addRule}
+      >
+        <Field label={i18next.t("general:Name")} htmlFor="rule-name" required 
error={nameError}>
+          <Input
+            id="rule-name"
+            value={form.name}
+            onChange={event => {
+              setForm({...form, name: event.target.value});
+              setNameError("");
+            }}
+          />
+        </Field>
+        <Field label={i18next.t("rule:Type")}>
+          <Select value={form.type} onValueChange={value => setForm({...form, 
type: value})}>
+            <SelectTrigger>
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {types.map(type => (
+                <SelectItem key={type.value} value={type.value}>
+                  {type.label}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </Field>
+        {form.type !== "WAF" && (
+          <Field label={i18next.t("general:Action")}>
+            <Select value={form.action} onValueChange={value => 
setForm({...form, action: value})}>
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                {actions.map(action => (
+                  <SelectItem key={action.value} value={action.value}>
+                    {action.label}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </Field>
+        )}
+      </FormDialog>
     </div>
   );
 }
diff --git a/web/src/pages/SiteListPage.tsx b/web/src/pages/SiteListPage.tsx
index b20b84b..cda04ca 100644
--- a/web/src/pages/SiteListPage.tsx
+++ b/web/src/pages/SiteListPage.tsx
@@ -21,10 +21,13 @@ import * as MiscBackend from "@/backend/MiscBackend";
 import * as SiteBackend from "@/backend/SiteBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/DataTable";
+import {Field, FormDialog} from "@/components/FormDialog";
 import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Input} from "@/components/ui/input";
+import {NumberInput} from "@/components/ui/number-input";
 import {Tooltip} from "@/components/ui/tooltip";
 import type {Account, Site} from "@/types";
 
@@ -64,6 +67,10 @@ export default function SiteListPage({account}: {account: 
Account}) {
   const [data, setData] = React.useState<Site[]>([]);
   const [loading, setLoading] = React.useState(false);
   const [gatewayEnabled, setGatewayEnabled] = React.useState<boolean | 
null>(null);
+  const [addOpen, setAddOpen] = React.useState(false);
+  const [adding, setAdding] = React.useState(false);
+  const [form, setForm] = React.useState<Site>(() => newSite(account.name));
+  const [nameError, setNameError] = React.useState("");
 
   const fetchSites = React.useCallback(() => {
     setLoading(true);
@@ -89,18 +96,34 @@ export default function SiteListPage({account}: {account: 
Account}) {
     });
   }, [fetchSites]);
 
+  const openAddDialog = () => {
+    setForm(newSite(account.name));
+    setNameError("");
+    setAddOpen(true);
+  };
+
   const addSite = () => {
-    const site = newSite(account.name);
-    SiteBackend.addSite(site)
+    const name = form.name.trim();
+    if (name === "") {
+      setNameError(i18next.t("general:Name cannot be empty"));
+      return;
+    }
+    setAdding(true);
+    SiteBackend.addSite({...form, name: name})
       .then(res => {
+        setAdding(false);
         if (res.status === "error") {
           Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${res.msg}`);
         } else {
           Setting.showMessage("success", i18next.t("general:Added 
successfully"));
+          setAddOpen(false);
           fetchSites();
         }
       })
-      .catch(error => Setting.showMessage("error", 
`${i18next.t("general:Failed to add")}: ${error}`));
+      .catch(error => {
+        setAdding(false);
+        Setting.showMessage("error", `${i18next.t("general:Failed to add")}: 
${error}`);
+      });
   };
 
   const deleteSite = (site: Site) => {
@@ -323,11 +346,60 @@ export default function SiteListPage({account}: {account: 
Account}) {
         pageSize={20}
         title={i18next.t("general:Sites")}
         toolbar={
-          <Button size="sm" onClick={addSite}>
+          <Button size="sm" onClick={openAddDialog}>
             {i18next.t("general:Add")}
           </Button>
         }
       />
+
+      <FormDialog
+        open={addOpen}
+        onOpenChange={setAddOpen}
+        title={i18next.t("site:New Site")}
+        submitting={adding}
+        onSubmit={addSite}
+      >
+        <Field label={i18next.t("general:Name")} htmlFor="site-name" required 
error={nameError}>
+          <Input
+            id="site-name"
+            value={form.name}
+            onChange={event => {
+              setForm({...form, name: event.target.value});
+              setNameError("");
+            }}
+          />
+        </Field>
+        <Field label={i18next.t("general:Display name")} 
htmlFor="site-display-name">
+          <Input
+            id="site-display-name"
+            value={form.displayName}
+            onChange={event => setForm({...form, displayName: 
event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("site:Domain")} htmlFor="site-domain">
+          <Input
+            id="site-domain"
+            value={form.domain}
+            onChange={event => setForm({...form, domain: event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("site:Host")} htmlFor="site-host">
+          <Input
+            id="site-host"
+            value={form.host}
+            onChange={event => setForm({...form, host: event.target.value})}
+          />
+        </Field>
+        <Field label={i18next.t("site:Port")}>
+          <NumberInput
+            min={0}
+            max={65535}
+            className="max-w-xs"
+            value={form.port}
+            onChange={value => setForm({...form, port: value})}
+          />
+        </Field>
+      </FormDialog>
     </div>
   );
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to