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]