Add webhook pages.

This commit is contained in:
Gucheng Wang 2021-11-07 15:41:24 +08:00
parent cbf973882d
commit 0e71e603ac
9 changed files with 742 additions and 1 deletions

107
controllers/webhook.go Normal file
View File

@ -0,0 +1,107 @@
// 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.
package controllers
import (
"encoding/json"
"github.com/astaxie/beego/utils/pagination"
"github.com/casbin/casdoor/object"
"github.com/casbin/casdoor/util"
)
// GetWebhooks
// @Title GetWebhooks
// @Description get webhooks
// @Param owner query string true "The owner of webhooks"
// @Success 200 {array} object.Webhook The Response object
// @router /get-webhooks [get]
func (c *ApiController) GetWebhooks() {
owner := c.Input().Get("owner")
limit := c.Input().Get("pageSize")
page := c.Input().Get("p")
if limit == "" || page == "" {
c.Data["json"] = object.GetWebhooks(owner)
c.ServeJSON()
} else {
limit := util.ParseInt(limit)
paginator := pagination.SetPaginator(c.Ctx, limit, int64(object.GetWebhookCount(owner)))
webhooks := object.GetPaginationWebhooks(owner, paginator.Offset(), limit)
c.ResponseOk(webhooks, paginator.Nums())
}
}
// @Title GetWebhook
// @Description get webhook
// @Param id query string true "The id of the webhook"
// @Success 200 {object} object.Webhook The Response object
// @router /get-webhook [get]
func (c *ApiController) GetWebhook() {
id := c.Input().Get("id")
c.Data["json"] = object.GetWebhook(id)
c.ServeJSON()
}
// @Title UpdateWebhook
// @Description update webhook
// @Param id query string true "The id of the webhook"
// @Param body body object.Webhook true "The details of the webhook"
// @Success 200 {object} controllers.Response The Response object
// @router /update-webhook [post]
func (c *ApiController) UpdateWebhook() {
id := c.Input().Get("id")
var webhook object.Webhook
err := json.Unmarshal(c.Ctx.Input.RequestBody, &webhook)
if err != nil {
panic(err)
}
c.Data["json"] = wrapActionResponse(object.UpdateWebhook(id, &webhook))
c.ServeJSON()
}
// @Title AddWebhook
// @Description add webhook
// @Param body body object.Webhook true "The details of the webhook"
// @Success 200 {object} controllers.Response The Response object
// @router /add-webhook [post]
func (c *ApiController) AddWebhook() {
var webhook object.Webhook
err := json.Unmarshal(c.Ctx.Input.RequestBody, &webhook)
if err != nil {
panic(err)
}
c.Data["json"] = wrapActionResponse(object.AddWebhook(&webhook))
c.ServeJSON()
}
// @Title DeleteWebhook
// @Description delete webhook
// @Param body body object.Webhook true "The details of the webhook"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-webhook [post]
func (c *ApiController) DeleteWebhook() {
var webhook object.Webhook
err := json.Unmarshal(c.Ctx.Input.RequestBody, &webhook)
if err != nil {
panic(err)
}
c.Data["json"] = wrapActionResponse(object.DeleteWebhook(&webhook))
c.ServeJSON()
}

View File

@ -138,11 +138,17 @@ func (a *Adapter) createTable() {
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Records))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Webhook))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Ldap))
if err != nil {
panic(err)

122
object/webhook.go Normal file
View File

@ -0,0 +1,122 @@
// 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.
package object
import (
"fmt"
"github.com/casbin/casdoor/util"
"xorm.io/core"
)
type Webhook struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
Url string `xorm:"varchar(100)" json:"url"`
ContentType string `xorm:"varchar(100)" json:"contentType"`
Events []string `xorm:"varchar(100)" json:"events"`
Organization string `xorm:"varchar(100)" json:"organization"`
}
func GetWebhookCount(owner string) int {
count, err := adapter.Engine.Count(&Webhook{Owner: owner})
if err != nil {
panic(err)
}
return int(count)
}
func GetWebhooks(owner string) []*Webhook {
webhooks := []*Webhook{}
err := adapter.Engine.Desc("created_time").Find(&webhooks, &Webhook{Owner: owner})
if err != nil {
panic(err)
}
return webhooks
}
func GetPaginationWebhooks(owner string, offset, limit int) []*Webhook {
webhooks := []*Webhook{}
err := adapter.Engine.Desc("created_time").Limit(limit, offset).Find(&webhooks, &Webhook{Owner: owner})
if err != nil {
panic(err)
}
return webhooks
}
func getWebhook(owner string, name string) *Webhook {
if owner == "" || name == "" {
return nil
}
webhook := Webhook{Owner: owner, Name: name}
existed, err := adapter.Engine.Get(&webhook)
if err != nil {
panic(err)
}
if existed {
return &webhook
} else {
return nil
}
}
func GetWebhook(id string) *Webhook {
owner, name := util.GetOwnerAndNameFromId(id)
return getWebhook(owner, name)
}
func UpdateWebhook(id string, webhook *Webhook) bool {
owner, name := util.GetOwnerAndNameFromId(id)
if getWebhook(owner, name) == nil {
return false
}
affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(webhook)
if err != nil {
panic(err)
}
return affected != 0
}
func AddWebhook(webhook *Webhook) bool {
affected, err := adapter.Engine.Insert(webhook)
if err != nil {
panic(err)
}
return affected != 0
}
func DeleteWebhook(webhook *Webhook) bool {
affected, err := adapter.Engine.ID(core.PK{webhook.Owner, webhook.Name}).Delete(&Webhook{})
if err != nil {
panic(err)
}
return affected != 0
}
func (p *Webhook) GetId() string {
return fmt.Sprintf("%s/%s", p.Owner, p.Name)
}

View File

@ -106,6 +106,12 @@ func initAPI() {
beego.Router("/api/get-records", &controllers.ApiController{}, "GET:GetRecords")
beego.Router("/api/get-records-filter", &controllers.ApiController{}, "POST:GetRecordsByFilter")
beego.Router("/api/get-webhooks", &controllers.ApiController{}, "GET:GetWebhooks")
beego.Router("/api/get-webhook", &controllers.ApiController{}, "GET:GetWebhook")
beego.Router("/api/update-webhook", &controllers.ApiController{}, "POST:UpdateWebhook")
beego.Router("/api/add-webhook", &controllers.ApiController{}, "POST:AddWebhook")
beego.Router("/api/delete-webhook", &controllers.ApiController{}, "POST:DeleteWebhook")
beego.Router("/api/send-email", &controllers.ApiController{}, "POST:SendEmail")
beego.Router("/api/send-sms", &controllers.ApiController{}, "POST:SendSms")

View File

@ -34,6 +34,8 @@ import LdapSyncPage from "./LdapSyncPage";
import TokenListPage from "./TokenListPage";
import TokenEditPage from "./TokenEditPage";
import RecordListPage from "./RecordListPage";
import WebhookListPage from "./WebhookListPage";
import WebhookEditPage from "./WebhookEditPage";
import AccountPage from "./account/AccountPage";
import HomePage from "./basic/HomePage";
import CustomGithubCorner from "./CustomGithubCorner";
@ -107,6 +109,8 @@ class App extends Component {
this.setState({ selectedMenuKey: '/tokens' });
} else if (uri.includes('/records')) {
this.setState({ selectedMenuKey: '/records' });
} else if (uri.includes('/webhooks')) {
this.setState({ selectedMenuKey: '/webhooks' });
} else if (uri.includes('/signup')) {
this.setState({ selectedMenuKey: '/signup' });
} else if (uri.includes('/login')) {
@ -354,6 +358,13 @@ class App extends Component {
</Link>
</Menu.Item>
);
res.push(
<Menu.Item key="/webhooks">
<Link to="/webhooks">
{i18next.t("general:Webhooks")}
</Link>
</Menu.Item>
);
res.push(
<Menu.Item key="/swagger">
<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>
@ -414,6 +425,8 @@ class App extends Component {
<Route exact path="/ldap/sync/:ldapId" render={(props) => this.renderLoginIfNotLoggedIn(<LdapSyncPage account={this.state.account} {...props} />)}/>
<Route exact path="/tokens" render={(props) => this.renderLoginIfNotLoggedIn(<TokenListPage account={this.state.account} {...props} />)}/>
<Route exact path="/tokens/:tokenName" render={(props) => this.renderLoginIfNotLoggedIn(<TokenEditPage account={this.state.account} {...props} />)}/>
<Route exact path="/webhooks" render={(props) => this.renderLoginIfNotLoggedIn(<WebhookListPage account={this.state.account} {...props} />)}/>
<Route exact path="/webhooks/:webhookName" render={(props) => this.renderLoginIfNotLoggedIn(<WebhookEditPage account={this.state.account} {...props} />)}/>
<Route exact path="/records" render={(props) => this.renderLoginIfNotLoggedIn(<RecordListPage account={this.state.account} {...props} />)}/>
<Route exact path="/.well-known/openid-configuration" render={(props) => <OdicDiscoveryPage />}/>
<Route path="" render={() => <Result status="404" title="404 NOT FOUND" subTitle={i18next.t("general:Sorry, the page you visited does not exist.")}

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import {message, Tooltip} from "antd";
import {message, Tag, Tooltip} from "antd";
import {QuestionCircleTwoTone} from "@ant-design/icons";
import React from "react";
import {isMobile as isMobileDevice} from "react-device-detect";
@ -511,3 +511,19 @@ export function getDeduplicatedArray(array, filterArray, key) {
const res = array.filter(item => filterArray.filter(filterItem => filterItem[key] === item[key]).length === 0);
return res;
}
export function getTagColor(s) {
return "success";
}
export function getTags(tags) {
let res = [];
tags.forEach((tag, i) => {
res.push(
<Tag color={getTagColor(tag)}>
{tag}
</Tag>
);
});
return res;
}

191
web/src/WebhookEditPage.js Normal file
View File

@ -0,0 +1,191 @@
// 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, Card, Col, Input, Row, Select} from 'antd';
import {LinkOutlined} from "@ant-design/icons";
import * as WebhookBackend from "./backend/WebhookBackend";
import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
const { Option } = Select;
class WebhookEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
webhookName: props.match.params.webhookName,
webhook: null,
organizations: [],
};
}
UNSAFE_componentWillMount() {
this.getWebhook();
this.getOrganizations();
}
getWebhook() {
WebhookBackend.getWebhook("admin", this.state.webhookName)
.then((webhook) => {
this.setState({
webhook: webhook,
});
});
}
getOrganizations() {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: (res.msg === undefined) ? res : [],
});
});
}
parseWebhookField(key, value) {
if (["port"].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateWebhookField(key, value) {
value = this.parseWebhookField(key, value);
let webhook = this.state.webhook;
webhook[key] = value;
this.setState({
webhook: webhook,
});
}
renderWebhook() {
return (
<Card size="small" title={
<div>
{i18next.t("webhook:Edit Webhook")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" onClick={this.submitWebhookEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</div>
} style={(Setting.isMobile())? {margin: '5px'}:{}} type="inner">
<Row style={{marginTop: '10px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: '100%'}} value={this.state.webhook.organization} onChange={(value => {this.updateWebhookField('organization', value);})}>
{
this.state.organizations.map((organization, index) => <Option key={index} value={organization.name}>{organization.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: '20px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Name"), i18next.t("general:Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={this.state.webhook.name} onChange={e => {
this.updateWebhookField('name', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: '20px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:URL"), i18next.t("webhook:URL - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined/>} value={this.state.webhook.url} onChange={e => {
this.updateWebhookField('url', e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: '20px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:Content type"), i18next.t("webhook:Content type - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: '100%'}} value={this.state.webhook.contentType} onChange={(value => {this.updateWebhookField('contentType', value);})}>
{
[
{id: 'application/json', name: 'application/json'},
{id: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded'},
].map((contentType, index) => <Option key={index} value={contentType.id}>{contentType.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: '20px'}} >
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:Events"), i18next.t("webhook:Events - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: '100%'}}
value={this.state.webhook.events}
onChange={value => {
this.updateWebhookField('events', value);
}} >
{
(
["signup", "login", "logout", "update-user"].map((option, index) => {
return (
<Option key={option} value={option}>{option}</Option>
)
})
)
}
</Select>
</Col>
</Row>
</Card>
)
}
submitWebhookEdit() {
let webhook = Setting.deepCopy(this.state.webhook);
WebhookBackend.updateWebhook(this.state.webhook.owner, this.state.webhookName, webhook)
.then((res) => {
if (res.msg === "") {
Setting.showMessage("success", `Successfully saved`);
this.setState({
webhookName: this.state.webhook.name,
});
this.props.history.push(`/webhooks/${this.state.webhook.name}`);
} else {
Setting.showMessage("error", res.msg);
this.updateWebhookField('name', this.state.webhookName);
}
})
.catch(error => {
Setting.showMessage("error", `Failed to connect to server: ${error}`);
});
}
render() {
return (
<div>
{
this.state.webhook !== null ? this.renderWebhook() : null
}
<div style={{marginTop: '20px', marginLeft: '40px'}}>
<Button type="primary" size="large" onClick={this.submitWebhookEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</div>
</div>
);
}
}
export default WebhookEditPage;

224
web/src/WebhookListPage.js Normal file
View File

@ -0,0 +1,224 @@
// 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 {Link} from "react-router-dom";
import {Button, Popconfirm, Table} from 'antd';
import moment from "moment";
import * as Setting from "./Setting";
import * as WebhookBackend from "./backend/WebhookBackend";
import i18next from "i18next";
class WebhookListPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
webhooks: null,
total: 0,
};
}
UNSAFE_componentWillMount() {
this.getWebhooks(1, 10);
}
getWebhooks(page, pageSize) {
WebhookBackend.getWebhooks("admin", page, pageSize)
.then((res) => {
if (res.status === "ok") {
this.setState({
webhooks: res.data,
total: res.data2
});
}
});
}
newWebhook() {
var randomName = Math.random().toString(36).slice(-6)
return {
owner: "admin", // this.props.account.webhookname,
name: `webhook_${randomName}`,
createdTime: moment().format(),
url: "https://example.com/callback",
contentType: "application/json",
events: [],
organization: "built-in",
}
}
addWebhook() {
const newWebhook = this.newWebhook();
WebhookBackend.addWebhook(newWebhook)
.then((res) => {
Setting.showMessage("success", `Webhook added successfully`);
this.setState({
webhooks: Setting.prependRow(this.state.webhooks, newWebhook),
total: this.state.total + 1
});
}
)
.catch(error => {
Setting.showMessage("error", `Webhook failed to add: ${error}`);
});
}
deleteWebhook(i) {
WebhookBackend.deleteWebhook(this.state.webhooks[i])
.then((res) => {
Setting.showMessage("success", `Webhook deleted successfully`);
this.setState({
webhooks: Setting.deleteRow(this.state.webhooks, i),
total: this.state.total - 1
});
}
)
.catch(error => {
Setting.showMessage("error", `Webhook failed to delete: ${error}`);
});
}
renderTable(webhooks) {
const columns = [
{
title: i18next.t("general:Organization"),
dataIndex: 'organization',
key: 'organization',
width: '80px',
sorter: (a, b) => a.organization.localeCompare(b.organization),
render: (text, record, index) => {
return (
<Link to={`/organizations/${text}`}>
{text}
</Link>
)
}
},
{
title: i18next.t("general:Name"),
dataIndex: 'name',
key: 'name',
width: '150px',
fixed: 'left',
sorter: (a, b) => a.name.localeCompare(b.name),
render: (text, record, index) => {
return (
<Link to={`/webhooks/${text}`}>
{text}
</Link>
)
}
},
{
title: i18next.t("general:Created 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("webhook:URL"),
dataIndex: 'url',
key: 'url',
width: '300px',
sorter: (a, b) => a.url.localeCompare(b.url),
render: (text, record, index) => {
return (
<a target="_blank" rel="noreferrer" href={text}>
{
Setting.getShortText(text)
}
</a>
)
}
},
{
title: i18next.t("webhook:Content type"),
dataIndex: 'contentType',
key: 'contentType',
width: '150px',
sorter: (a, b) => a.contentType.localeCompare(b.contentType),
},
{
title: i18next.t("webhook:Events"),
dataIndex: 'events',
key: 'events',
// width: '100px',
sorter: (a, b) => a.events.localeCompare(b.events),
render: (text, record, index) => {
return Setting.getTags(text);
}
},
{
title: i18next.t("general:Action"),
dataIndex: '',
key: 'op',
width: '170px',
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(`/webhooks/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<Popconfirm
title={`Sure to delete webhook: ${record.name} ?`}
onConfirm={() => this.deleteWebhook(index)}
>
<Button style={{marginBottom: '10px'}} type="danger">{i18next.t("general:Delete")}</Button>
</Popconfirm>
</div>
)
}
},
];
const paginationProps = {
total: this.state.total,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.total),
onChange: (page, pageSize) => this.getWebhooks(page, pageSize),
onShowSizeChange: (current, size) => this.getWebhooks(current, size),
};
return (
<div>
<Table scroll={{x: 'max-content'}} columns={columns} dataSource={webhooks} rowKey="name" size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Webhooks")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addWebhook.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={webhooks === null}
/>
</div>
);
}
render() {
return (
<div>
{
this.renderTable(this.state.webhooks)
}
</div>
);
}
}
export default WebhookListPage;

View File

@ -0,0 +1,56 @@
// 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 * as Setting from "../Setting";
export function getWebhooks(owner, page = "", pageSize = "") {
return fetch(`${Setting.ServerUrl}/api/get-webhooks?owner=${owner}&p=${page}&pageSize=${pageSize}`, {
method: "GET",
credentials: "include"
}).then(res => res.json());
}
export function getWebhook(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-webhook?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include"
}).then(res => res.json());
}
export function updateWebhook(owner, name, webhook) {
let newWebhook = Setting.deepCopy(webhook);
return fetch(`${Setting.ServerUrl}/api/update-webhook?id=${owner}/${encodeURIComponent(name)}`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newWebhook),
}).then(res => res.json());
}
export function addWebhook(webhook) {
let newWebhook = Setting.deepCopy(webhook);
return fetch(`${Setting.ServerUrl}/api/add-webhook`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newWebhook),
}).then(res => res.json());
}
export function deleteWebhook(webhook) {
let newWebhook = Setting.deepCopy(webhook);
return fetch(`${Setting.ServerUrl}/api/delete-webhook`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newWebhook),
}).then(res => res.json());
}