Support deploying one certificate to multiple SSH hosts, and support deploying multiple certificates to one SSH host.

This commit is contained in:
yoan 2024-09-14 15:36:15 +08:00
parent 505cfc5c1e
commit 6c1b1fb72b
29 changed files with 2167 additions and 569 deletions

View File

@ -38,6 +38,10 @@ func NewAliyun(option *DeployerOption) (Deployer, error) {
}
func (a *aliyun) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (a *aliyun) GetInfo() []string {
return a.infos
}

View File

@ -36,6 +36,10 @@ func NewAliyunCdn(option *DeployerOption) (*AliyunCdn, error) {
}, nil
}
func (a *AliyunCdn) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (a *AliyunCdn) GetInfo() []string {
return a.infos
}

View File

@ -2,9 +2,12 @@ package deployer
import (
"certimate/internal/applicant"
"certimate/internal/utils/app"
"certimate/internal/utils/variables"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/pocketbase/pocketbase/models"
@ -24,21 +27,81 @@ type DeployerOption struct {
Domain string `json:"domain"`
Product string `json:"product"`
Access string `json:"access"`
AceessRecord *models.Record `json:"-"`
Certificate applicant.Certificate `json:"certificate"`
Variables map[string]string `json:"variables"`
}
type Deployer interface {
Deploy(ctx context.Context) error
GetInfo() []string
GetID() string
}
func Get(record *models.Record, cert *applicant.Certificate) (Deployer, error) {
access := record.ExpandedOne("targetAccess")
func Gets(record *models.Record, cert *applicant.Certificate) ([]Deployer, error) {
rs := make([]Deployer, 0)
if record.GetString("targetAccess") != "" {
singleDeployer, err := Get(record, cert)
if err != nil {
return nil, err
}
rs = append(rs, singleDeployer)
}
if record.GetString("group") != "" {
group := record.ExpandedOne("group")
if errs := app.GetApp().Dao().ExpandRecord(group, []string{"access"}, nil); len(errs) > 0 {
errList := make([]error, 0)
for name, err := range errs {
errList = append(errList, fmt.Errorf("展开记录失败,%s: %w", name, err))
}
err := errors.Join(errList...)
return nil, err
}
records := group.ExpandedAll("access")
deployers, err := getByGroup(record, cert, records...)
if err != nil {
return nil, err
}
rs = append(rs, deployers...)
}
return rs, nil
}
func getByGroup(record *models.Record, cert *applicant.Certificate, accesses ...*models.Record) ([]Deployer, error) {
rs := make([]Deployer, 0)
for _, access := range accesses {
deployer, err := getWithAccess(record, cert, access)
if err != nil {
return nil, err
}
rs = append(rs, deployer)
}
return rs, nil
}
func getWithAccess(record *models.Record, cert *applicant.Certificate, access *models.Record) (Deployer, error) {
option := &DeployerOption{
DomainId: record.Id,
Domain: record.GetString("domain"),
Product: getProduct(record),
Access: access.GetString("config"),
AceessRecord: access,
Variables: variables.Parse2Map(record.GetString("variables")),
}
if cert != nil {
option.Certificate = *cert
@ -66,6 +129,13 @@ func Get(record *models.Record, cert *applicant.Certificate) (Deployer, error) {
return nil, errors.New("not implemented")
}
func Get(record *models.Record, cert *applicant.Certificate) (Deployer, error) {
access := record.ExpandedOne("targetAccess")
return getWithAccess(record, cert, access)
}
func getProduct(record *models.Record) string {
targetType := record.GetString("targetType")
rs := strings.Split(targetType, "-")

View File

@ -33,6 +33,10 @@ func NewQiNiu(option *DeployerOption) (*qiuniu, error) {
}, nil
}
func (a *qiuniu) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (q *qiuniu) GetInfo() []string {
return q.info
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"os"
xpath "path"
"strings"
"github.com/pkg/sftp"
sshPkg "golang.org/x/crypto/ssh"
@ -35,6 +36,10 @@ func NewSSH(option *DeployerOption) (Deployer, error) {
}, nil
}
func (a *ssh) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (s *ssh) GetInfo() []string {
return s.infos
}
@ -44,6 +49,15 @@ func (s *ssh) Deploy(ctx context.Context) error {
if err := json.Unmarshal([]byte(s.option.Access), access); err != nil {
return err
}
// 将证书路径和命令中的变量替换为实际值
for k, v := range s.option.Variables {
key := fmt.Sprintf("${%s}", k)
access.CertPath = strings.ReplaceAll(access.CertPath, key, v)
access.KeyPath = strings.ReplaceAll(access.KeyPath, key, v)
access.Command = strings.ReplaceAll(access.Command, key, v)
}
// 连接
client, err := s.getClient(access)
if err != nil {

View File

@ -39,6 +39,10 @@ func NewTencentCdn(option *DeployerOption) (Deployer, error) {
}, nil
}
func (a *tencentCdn) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (t *tencentCdn) GetInfo() []string {
return t.infos
}

View File

@ -32,6 +32,10 @@ func NewWebhook(option *DeployerOption) (Deployer, error) {
}, nil
}
func (a *webhook) GetID() string {
return fmt.Sprintf("%s-%s", a.option.AceessRecord.GetString("name"), a.option.AceessRecord.Id)
}
func (w *webhook) GetInfo() []string {
return w.infos
}

View File

@ -41,7 +41,7 @@ func deploy(ctx context.Context, record *models.Record) error {
return err
}
history.record(checkPhase, "获取记录成功", nil)
if errs := app.GetApp().Dao().ExpandRecord(currRecord, []string{"access", "targetAccess"}, nil); len(errs) > 0 {
if errs := app.GetApp().Dao().ExpandRecord(currRecord, []string{"access", "targetAccess", "group"}, nil); len(errs) > 0 {
errList := make([]error, 0)
for name, err := range errs {
@ -96,24 +96,28 @@ func deploy(ctx context.Context, record *models.Record) error {
// ############3.部署证书
history.record(deployPhase, "开始部署", nil, false)
deployer, err := deployer.Get(currRecord, certificate)
deployers, err := deployer.Gets(currRecord, certificate)
if err != nil {
history.record(deployPhase, "获取deployer失败", &RecordInfo{Err: err})
app.GetApp().Logger().Error("获取deployer失败", "err", err)
return err
}
for _, deployer := range deployers {
if err = deployer.Deploy(ctx); err != nil {
app.GetApp().Logger().Error("部署失败", "err", err)
history.record(deployPhase, "部署失败", &RecordInfo{Err: err, Info: deployer.GetInfo()})
return err
}
history.record(deployPhase, fmt.Sprintf("[%s]-部署成功", deployer.GetID()), &RecordInfo{
Info: deployer.GetInfo(),
}, false)
}
app.GetApp().Logger().Info("部署成功")
history.record(deployPhase, "部署成功", &RecordInfo{
Info: deployer.GetInfo(),
}, true)
history.record(deployPhase, "部署成功", nil, true)
return nil
}

View File

@ -0,0 +1,30 @@
package variables
import "strings"
// Parse2Map 将变量赋值字符串解析为map
func Parse2Map(str string) map[string]string {
m := make(map[string]string)
lines := strings.Split(str, ";")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
kv := strings.Split(line, "=")
if len(kv) != 2 {
continue
}
m[kv[0]] = kv[1]
}
return m
}

View File

@ -0,0 +1,56 @@
package variables
import (
"reflect"
"testing"
)
func TestParse2Map(t *testing.T) {
type args struct {
str string
}
tests := []struct {
name string
args args
want map[string]string
}{
{
name: "test1",
args: args{
str: "a=1;b=2;c=3",
},
want: map[string]string{
"a": "1",
"b": "2",
"c": "3",
},
},
{
name: "test2",
args: args{
str: `a=1;
b=2;
c=`,
},
want: map[string]string{
"a": "1",
"b": "2",
"c": "",
},
},
{
name: "test3",
args: args{
str: "1",
},
want: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Parse2Map(tt.args.str); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Parse2Map() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,679 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "z3p974ainxjqlvs",
"created": "2024-07-29 10:02:48.334Z",
"updated": "2024-09-14 02:53:22.520Z",
"name": "domains",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "iuaerpl2",
"name": "domain",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "ukkhuw85",
"name": "email",
"type": "email",
"required": false,
"presentable": false,
"unique": false,
"options": {
"exceptDomains": null,
"onlyDomains": null
}
},
{
"system": false,
"id": "v98eebqq",
"name": "crontab",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "alc8e9ow",
"name": "access",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "4yzbv8urny5ja1e",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "topsc9bj",
"name": "certUrl",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "vixgq072",
"name": "certStableUrl",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "g3a3sza5",
"name": "privateKey",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "gr6iouny",
"name": "certificate",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "tk6vnrmn",
"name": "issuerCertificate",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "sjo6ibse",
"name": "csr",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "x03n1bkj",
"name": "expiredAt",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "srybpixz",
"name": "targetType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"aliyun-oss",
"aliyun-cdn",
"ssh",
"webhook",
"tencent-cdn",
"qiniu-cdn"
]
}
},
{
"system": false,
"id": "xy7yk0mb",
"name": "targetAccess",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "4yzbv8urny5ja1e",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "6jqeyggw",
"name": "enabled",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "hdsjcchf",
"name": "deployed",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "aiya3rev",
"name": "rightnow",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "ixznmhzc",
"name": "lastDeployedAt",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "ghtlkn5j",
"name": "lastDeployment",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "0a1o4e6sstp694f",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "zfnyj9he",
"name": "variables",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "1bspzuku",
"name": "group",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "teolp9pl72dxlxq",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_4ABO6EQ` + "`" + ` ON ` + "`" + `domains` + "`" + ` (` + "`" + `domain` + "`" + `)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "4yzbv8urny5ja1e",
"created": "2024-07-29 10:04:39.685Z",
"updated": "2024-09-13 23:47:27.173Z",
"name": "access",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "geeur58v",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "iql7jpwx",
"name": "config",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "hwy7m03o",
"name": "configType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"aliyun",
"tencent",
"ssh",
"webhook",
"cloudflare",
"qiniu",
"namesilo",
"godaddy"
]
}
},
{
"system": false,
"id": "lr33hiwg",
"name": "deleted",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "hsxcnlvd",
"name": "usage",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"apply",
"deploy",
"all"
]
}
},
{
"system": false,
"id": "c8egzzwj",
"name": "group",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "teolp9pl72dxlxq",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_wkoST0j` + "`" + ` ON ` + "`" + `access` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "0a1o4e6sstp694f",
"created": "2024-07-30 06:30:27.801Z",
"updated": "2024-09-13 12:52:50.804Z",
"name": "deployments",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "farvlzk7",
"name": "domain",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "z3p974ainxjqlvs",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "jx5f69i3",
"name": "log",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "qbxdtg9q",
"name": "phase",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"check",
"apply",
"deploy"
]
}
},
{
"system": false,
"id": "rglrp1hz",
"name": "phaseSuccess",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
},
{
"system": false,
"id": "lt1g1blu",
"name": "deployedAt",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-09-12 13:09:54.234Z",
"updated": "2024-09-12 23:34:40.687Z",
"name": "users",
"type": "auth",
"system": false,
"schema": [
{
"system": false,
"id": "users_name",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "users_avatar",
"name": "avatar",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp"
],
"thumbs": null,
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}
],
"indexes": [],
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": "",
"updateRule": "id = @request.auth.id",
"deleteRule": "id = @request.auth.id",
"options": {
"allowEmailAuth": true,
"allowOAuth2Auth": true,
"allowUsernameAuth": true,
"exceptEmailDomains": null,
"manageRule": null,
"minPasswordLength": 8,
"onlyEmailDomains": null,
"onlyVerified": false,
"requireEmail": false
}
},
{
"id": "dy6ccjb60spfy6p",
"created": "2024-09-12 23:12:21.677Z",
"updated": "2024-09-12 23:34:40.687Z",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "1tcmdsdf",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "f9wyhypi",
"name": "content",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_RO7X9Vw` + "`" + ` ON ` + "`" + `settings` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "teolp9pl72dxlxq",
"created": "2024-09-13 12:51:05.611Z",
"updated": "2024-09-14 00:01:58.239Z",
"name": "access_groups",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "7sajiv6i",
"name": "name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "xp8admif",
"name": "access",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "4yzbv8urny5ja1e",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_RgRXp0R` + "`" + ` ON ` + "`" + `access_groups` + "`" + ` (` + "`" + `name` + "`" + `)"
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
}
]`
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
return nil
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
ui/dist/assets/index-DXJTf3ck.css vendored Normal file

File diff suppressed because one or more lines are too long

291
ui/dist/assets/index-pPAQ4idS.js vendored Normal file

File diff suppressed because one or more lines are too long

4
ui/dist/index.html vendored
View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Certimate - Your Trusted SSL Automation Partner</title>
<script type="module" crossorigin src="/assets/index-B_74ZyCB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cg0yCJnh.css">
<script type="module" crossorigin src="/assets/index-pPAQ4idS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXJTf3ck.css">
</head>
<body class="bg-background">
<div id="root"></div>

View File

@ -0,0 +1,120 @@
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { useConfig } from "@/providers/config";
import { update } from "@/repository/access_group";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { useState } from "react";
type AccessGroupEditProps = {
className?: string;
trigger: React.ReactNode;
};
const AccessGroupEdit = ({ className, trigger }: AccessGroupEditProps) => {
const { reloadAccessGroups } = useConfig();
const [open, setOpen] = useState(false);
const formSchema = z.object({
name: z.string().min(1).max(64),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
try {
await update({
name: data.name,
});
// 更新本地状态
reloadAccessGroups();
setOpen(false);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(
([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
}
);
}
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild className={cn(className)}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="container py-3">
<Form {...form}>
<form
onSubmit={(e) => {
console.log(e);
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入组名" {...field} type="text" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default AccessGroupEdit;

View File

@ -1,4 +1,9 @@
import { Access, accessFormType, getUsageByConfigType, SSHConfig } from "@/domain/access";
import {
Access,
accessFormType,
getUsageByConfigType,
SSHConfig,
} from "@/domain/access";
import { useConfig } from "@/providers/config";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@ -19,6 +24,17 @@ import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { readFileContent } from "@/lib/file";
import { useRef, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { cn } from "@/lib/utils";
import AccessGroupEdit from "./AccessGroupEdit";
import { Plus } from "lucide-react";
import { updateById } from "@/repository/access_group";
const AccessSSHForm = ({
data,
@ -27,12 +43,19 @@ const AccessSSHForm = ({
data?: Access;
onAfterReq: () => void;
}) => {
const { addAccess, updateAccess } = useConfig();
const {
addAccess,
updateAccess,
reloadAccessGroups,
config: { accessGroups },
} = useConfig();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState("");
const originGroup = data ? (data.group ? data.group : "") : "";
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(64),
@ -40,6 +63,7 @@ const AccessSSHForm = ({
host: z.string().ip({
message: "请输入合法的IP地址",
}),
group: z.string().optional(),
port: z.string().min(1).max(5),
username: z.string().min(1).max(64),
password: z.string().min(0).max(64),
@ -69,6 +93,7 @@ const AccessSSHForm = ({
id: data?.id,
name: data?.name,
configType: "ssh",
group: data?.group,
host: config.host,
port: config.port,
username: config.username,
@ -83,11 +108,15 @@ const AccessSSHForm = ({
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
let group = data.group;
if (group == "emptyId") group = "";
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
group: group,
config: {
host: data.host,
port: data.port,
@ -110,9 +139,28 @@ const AccessSSHForm = ({
req.updated = rs.updated;
if (data.id) {
updateAccess(req);
return;
}
} else {
addAccess(req);
}
// 同步更新授权组
if (group != originGroup) {
if (originGroup) {
await updateById({
id: originGroup,
"access-": req.id,
});
}
if (group) {
await updateById({
id: group,
"access+": req.id,
});
}
}
reloadAccessGroups();
} catch (e) {
const err = e as ClientResponseError;
@ -172,6 +220,67 @@ const AccessSSHForm = ({
)}
/>
<FormField
control={form.control}
name="group"
render={({ field }) => (
<FormItem>
<FormLabel className="w-full flex justify-between">
<div>( ssh )</div>
<AccessGroupEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
defaultValue="emptyId"
onValueChange={(value) => {
form.setValue("group", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择分组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="emptyId">
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer"
)}
>
--
</div>
</SelectItem>
{accessGroups.map((item) => (
<SelectItem
value={item.id ? item.id : ""}
key={item.id}
>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer"
)}
>
{item.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"

View File

@ -11,6 +11,10 @@ export const accessTypeMap: Map<string, [string, string]> = new Map([
["webhook", ["Webhook", "/imgs/providers/webhook.svg"]],
]);
export const getProviderInfo = (t: string) => {
return accessTypeMap.get(t);
};
export const accessFormType = z.union(
[
z.literal("aliyun"),
@ -32,6 +36,7 @@ export type Access = {
name: string;
configType: string;
usage: AccessUsage;
group?: string;
config:
| TencentConfig
| AliyunConfig

View File

@ -0,0 +1,10 @@
import { Access } from "./access";
export type AccessGroup = {
id?: string;
name?: string;
access?: string[];
expand?: {
access: Access[];
};
};

View File

@ -6,12 +6,14 @@ export type Domain = {
email?: string;
crontab: string;
access: string;
targetAccess: string;
targetAccess?: string;
targetType: string;
expiredAt?: string;
phase?: Pahse;
phaseSuccess?: boolean;
lastDeployedAt?: string;
variables?: string;
group?: string;
enabled?: boolean;
created?: string;
updated?: string;

View File

@ -9,6 +9,7 @@ import {
BookOpen,
CircleUser,
Earth,
Group,
History,
Home,
Menu,
@ -100,6 +101,17 @@ export default function Dashboard() {
</Link>
<Link
to="/access_groups"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary",
getClass("/access_groups")
)}
>
<Group className="h-4 w-4" />
</Link>
<Link
to="/history"
className={cn(
@ -161,13 +173,24 @@ export default function Dashboard() {
to="/access"
className={cn(
"mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground",
getClass("/dns_provider")
getClass("/access")
)}
>
<Server className="h-5 w-5" />
</Link>
<Link
to="/access_groups"
className={cn(
"mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 hover:text-foreground",
getClass("/access_groups")
)}
>
<Group className="h-5 w-5" />
</Link>
<Link
to="/history"
className={cn(
@ -227,7 +250,7 @@ export default function Dashboard() {
href="https://github.com/usual2970/certimate/releases"
target="_blank"
>
Certimate v0.1.5
Certimate v0.1.6
</a>
</div>
</div>

View File

@ -23,6 +23,8 @@ const Access = () => {
const page = query.get("page");
const pageNumber = page ? Number(page) : 1;
const accessGroupId = query.get("accessGroupId");
const startIndex = (pageNumber - 1) * perPage;
const endIndex = startIndex + perPage;
@ -65,7 +67,12 @@ const Access = () => {
<div className="sm:hidden flex text-sm text-muted-foreground">
</div>
{accesses.slice(startIndex, endIndex).map((access) => (
{accesses
.filter((item) => {
return accessGroupId ? item.group == accessGroupId : true;
})
.slice(startIndex, endIndex)
.map((access) => (
<div
className="flex flex-col sm:flex-row text-secondary-foreground border-b dark:border-stone-500 sm:p-2 hover:bg-muted/50 text-sm"
key={access.id}

View File

@ -0,0 +1,219 @@
import AccessGroupEdit from "@/components/certimate/AccessGroupEdit";
import Show from "@/components/Show";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getProviderInfo } from "@/domain/access";
import { getErrMessage } from "@/lib/error";
import { useConfig } from "@/providers/config";
import { remove } from "@/repository/access_group";
import { Group } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { Toaster } from "@/components/ui/toaster";
import { useNavigate } from "react-router-dom";
const AccessGroups = () => {
const {
config: { accessGroups },
reloadAccessGroups,
} = useConfig();
const { toast } = useToast();
const navigate = useNavigate();
const handleRemoveClick = async (id: string) => {
try {
await remove(id);
reloadAccessGroups();
} catch (e) {
toast({
title: "删除失败",
description: getErrMessage(e),
variant: "destructive",
});
return;
}
};
const handleAddAccess = () => {
navigate("/access");
};
return (
<div>
<Toaster />
<div className="flex justify-between items-center">
<div className="text-muted-foreground"></div>
<AccessGroupEdit trigger={<Button></Button>} />
</div>
<div className="mt-10">
<Show when={accessGroups.length == 0}>
<>
<div className="flex flex-col items-center mt-10">
<span className="bg-orange-100 p-5 rounded-full">
<Group size={40} className="text-primary" />
</span>
<div className="text-center text-sm text-muted-foreground mt-3">
</div>
<AccessGroupEdit
trigger={<Button></Button>}
className="mt-3"
/>
</div>
</>
</Show>
<ScrollArea className="h-[75vh] overflow-hidden">
<div className="flex gap-5 flex-wrap">
{accessGroups.map((accessGroup) => (
<Card className="w-full md:w-[350px]">
<CardHeader>
<CardTitle>{accessGroup.name}</CardTitle>
<CardDescription>
{accessGroup.expand ? accessGroup.expand.access.length : 0}
</CardDescription>
</CardHeader>
<CardContent className="min-h-[180px]">
{accessGroup.expand ? (
<>
{accessGroup.expand.access.slice(0, 3).map((access) => (
<div key={access.id} className="flex flex-col mb-3">
<div className="flex items-center">
<div className="">
<img
src={getProviderInfo(access.configType)![1]}
alt="provider"
className="w-8 h-8"
></img>
</div>
<div className="ml-3">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{access.name}
</div>
<div className="text-xs text-muted-foreground">
{getProviderInfo(access.configType)![0]}
</div>
</div>
</div>
</div>
))}
</>
) : (
<>
<div className="flex text-gray-700 dark:text-gray-200 items-center">
<div>
<Group size={40} />
</div>
<div className="ml-2">
使
</div>
</div>
</>
)}
</CardContent>
<CardFooter>
<div className="flex justify-end w-full">
<Show
when={
accessGroup.expand &&
accessGroup.expand.access.length > 0
? true
: false
}
>
<div>
<Button
size="sm"
variant={"link"}
onClick={() => {
navigate(`/access?accessGroupId=${accessGroup.id}`);
}}
>
</Button>
</div>
</Show>
<Show
when={
!accessGroup.expand ||
accessGroup.expand.access.length == 0
? true
: false
}
>
<div>
<Button size="sm" onClick={handleAddAccess}>
</Button>
</div>
</Show>
<div className="ml-3">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant={"destructive"} size={"sm"}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="dark:text-gray-200">
</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="dark:text-gray-200">
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
handleRemoveClick(
accessGroup.id ? accessGroup.id : ""
);
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</CardFooter>
</Card>
))}
</div>
</ScrollArea>
</div>
</div>
);
};
export default AccessGroups;

View File

@ -35,16 +35,22 @@ import { Plus } from "lucide-react";
import { AccessEdit } from "@/components/certimate/AccessEdit";
import { accessTypeMap } from "@/domain/access";
import EmailsEdit from "@/components/certimate/EmailsEdit";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
const Edit = () => {
const {
config: { accesses, emails },
config: { accesses, emails, accessGroups },
} = useConfig();
const [domain, setDomain] = useState<Domain>();
const location = useLocation();
const [tab, setTab] = useState<"base" | "advance">("base");
const [targetType, setTargetType] = useState(domain ? domain.targetType : "");
useEffect(() => {
// Parsing query parameters
const queryParams = new URLSearchParams(location.search);
@ -53,6 +59,7 @@ const Edit = () => {
const fetchData = async () => {
const data = await get(id);
setDomain(data);
setTargetType(data.targetType);
};
fetchData();
}
@ -67,12 +74,12 @@ const Edit = () => {
access: z.string().regex(/^[a-zA-Z0-9]+$/, {
message: "请选择DNS服务商授权配置",
}),
targetAccess: z.string().regex(/^[a-zA-Z0-9]+$/, {
message: "请选择部署服务商配置",
}),
targetAccess: z.string().optional(),
targetType: z.string().regex(/^[a-zA-Z0-9-]+$/, {
message: "请选择部署服务类型",
}),
variables: z.string().optional(),
group: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
@ -84,6 +91,8 @@ const Edit = () => {
access: "",
targetAccess: "",
targetType: "",
variables: "",
group: "",
},
});
@ -96,12 +105,12 @@ const Edit = () => {
access: domain.access,
targetAccess: domain.targetAccess,
targetType: domain.targetType,
variables: domain.variables,
group: domain.group,
});
}
}, [domain, form]);
const [targetType, setTargetType] = useState(domain ? domain.targetType : "");
const targetAccesses = accesses.filter((item) => {
if (item.usage == "apply") {
return false;
@ -110,7 +119,7 @@ const Edit = () => {
if (targetType == "") {
return true;
}
const types = form.getValues().targetType.split("-");
const types = targetType.split("-");
return item.configType === types[0];
});
@ -119,14 +128,31 @@ const Edit = () => {
const navigate = useNavigate();
const onSubmit = async (data: z.infer<typeof formSchema>) => {
const group = data.group == "emptyId" ? "" : data.group;
const targetAccess =
data.targetAccess === "emptyId" ? "" : data.targetAccess;
if (group == "" && targetAccess == "") {
form.setError("group", {
type: "manual",
message: "部署授权和部署授权组至少选一个",
});
form.setError("targetAccess", {
type: "manual",
message: "部署授权和部署授权组至少选一个",
});
return;
}
const req: Domain = {
id: data.id as string,
crontab: "0 0 * * *",
domain: data.domain,
email: data.email,
access: data.access,
targetAccess: data.targetAccess,
group: group,
targetAccess: targetAccess,
targetType: data.targetType,
variables: data.variables,
};
try {
@ -161,10 +187,36 @@ const Edit = () => {
<>
<div className="">
<Toaster />
<div className="border-b dark:border-stone-500 h-10 text-muted-foreground">
<div className=" h-5 text-muted-foreground">
{domain?.id ? "编辑" : "新增"}
</div>
<div className="max-w-[35em] mx-auto mt-10">
<div className="mt-5 flex w-full justify-center md:space-x-10 flex-col md:flex-row">
<div className="w-full md:w-[200px] text-muted-foreground space-x-3 md:space-y-3 flex-row md:flex-col flex">
<div
className={cn(
"cursor-pointer text-right",
tab === "base" ? "text-primary" : ""
)}
onClick={() => {
setTab("base");
}}
>
</div>
<div
className={cn(
"cursor-pointer text-right",
tab === "advance" ? "text-primary" : ""
)}
onClick={() => {
setTab("advance");
}}
>
</div>
</div>
<div className="w-full md:w-[35em] bg-gray-100 dark:bg-gray-900 p-5 rounded mt-3 md:mt-0">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@ -174,7 +226,7 @@ const Edit = () => {
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormItem hidden={tab != "base"}>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入域名" {...field} />
@ -184,12 +236,11 @@ const Edit = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between">
<div>Email</div>
<EmailsEdit
@ -229,12 +280,11 @@ const Edit = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="access"
render={({ field }) => (
<FormItem>
<FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between">
<div>DNS </div>
<AccessEdit
@ -269,7 +319,9 @@ const Edit = () => {
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
accessTypeMap.get(
item.configType
)?.[1]
}
/>
<div>{item.name}</div>
@ -285,12 +337,11 @@ const Edit = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetType"
render={({ field }) => (
<FormItem>
<FormItem hidden={tab != "base"}>
<FormLabel></FormLabel>
<FormControl>
<Select
@ -326,12 +377,11 @@ const Edit = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetAccess"
render={({ field }) => (
<FormItem>
<FormItem hidden={tab != "base"}>
<FormLabel className="w-full flex justify-between">
<div></div>
<AccessEdit
@ -356,7 +406,14 @@ const Edit = () => {
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
<SelectLabel>
{form.getValues().targetAccess}
</SelectLabel>
<SelectItem value="emptyId">
<div className="flex items-center space-x-2">
--
</div>
</SelectItem>
{targetAccesses.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
@ -380,6 +437,84 @@ const Edit = () => {
)}
/>
<FormField
control={form.control}
name="group"
render={({ field }) => (
<FormItem hidden={tab != "advance" || targetType != "ssh"}>
<FormLabel className="w-full flex justify-between">
<div>
( ssh )
</div>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
defaultValue="emptyId"
onValueChange={(value) => {
form.setValue("group", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择分组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="emptyId">
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer"
)}
>
--
</div>
</SelectItem>
{accessGroups
.filter((item) => {
return (
item.expand && item.expand?.access.length > 0
);
})
.map((item) => (
<SelectItem
value={item.id ? item.id : ""}
key={item.id}
>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer"
)}
>
{item.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="variables"
render={({ field }) => (
<FormItem hidden={tab != "advance"}>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder={`可在SSH部署中使用,形如:\nkey=val;\nkey2=val2;`}
{...field}
className="placeholder:whitespace-pre-wrap"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
@ -387,6 +522,7 @@ const Edit = () => {
</Form>
</div>
</div>
</div>
</>
);
};

View File

@ -1,6 +1,6 @@
import { Access } from "@/domain/access";
import { list } from "@/repository/access";
import { list as getAccessGroups } from "@/repository/access_group";
import {
createContext,
ReactNode,
@ -12,10 +12,12 @@ import {
import { configReducer } from "./reducer";
import { getEmails } from "@/repository/settings";
import { Setting } from "@/domain/settings";
import { AccessGroup } from "@/domain/access_groups";
export type ConfigData = {
accesses: Access[];
emails: Setting;
accessGroups: AccessGroup[];
};
export type ConfigContext = {
@ -24,6 +26,8 @@ export type ConfigContext = {
addAccess: (access: Access) => void;
updateAccess: (access: Access) => void;
setEmails: (email: Setting) => void;
setAccessGroups: (accessGroups: AccessGroup[]) => void;
reloadAccessGroups: () => void;
};
const Context = createContext({} as ConfigContext);
@ -38,6 +42,7 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
const [config, dispatchConfig] = useReducer(configReducer, {
accesses: [],
emails: { content: { emails: [] } },
accessGroups: [],
});
useEffect(() => {
@ -56,6 +61,19 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
featchEmails();
}, []);
useEffect(() => {
const featchAccessGroups = async () => {
const accessGroups = await getAccessGroups();
dispatchConfig({ type: "SET_ACCESS_GROUPS", payload: accessGroups });
};
featchAccessGroups();
}, []);
const reloadAccessGroups = useCallback(async () => {
const accessGroups = await getAccessGroups();
dispatchConfig({ type: "SET_ACCESS_GROUPS", payload: accessGroups });
}, []);
const setEmails = useCallback((emails: Setting) => {
dispatchConfig({ type: "SET_EMAILS", payload: emails });
}, []);
@ -72,17 +90,24 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
dispatchConfig({ type: "UPDATE_ACCESS", payload: access });
}, []);
const setAccessGroups = useCallback((accessGroups: AccessGroup[]) => {
dispatchConfig({ type: "SET_ACCESS_GROUPS", payload: accessGroups });
}, []);
return (
<Context.Provider
value={{
config: {
accesses: config.accesses,
emails: config.emails,
accessGroups: config.accessGroups,
},
deleteAccess,
addAccess,
setEmails,
updateAccess,
setAccessGroups,
reloadAccessGroups,
}}
>
{children && children}

View File

@ -1,6 +1,7 @@
import { Access } from "@/domain/access";
import { ConfigData } from ".";
import { Setting } from "@/domain/settings";
import { AccessGroup } from "@/domain/access_groups";
type Action =
| { type: "ADD_ACCESS"; payload: Access }
@ -8,7 +9,8 @@ type Action =
| { type: "UPDATE_ACCESS"; payload: Access }
| { type: "SET_ACCESSES"; payload: Access[] }
| { type: "SET_EMAILS"; payload: Setting }
| { type: "ADD_EMAIL"; payload: string };
| { type: "ADD_EMAIL"; payload: string }
| { type: "SET_ACCESS_GROUPS"; payload: AccessGroup[] };
export const configReducer = (
state: ConfigData,
@ -60,6 +62,12 @@ export const configReducer = (
},
};
}
case "SET_ACCESS_GROUPS": {
return {
...state,
accessGroups: action.payload,
};
}
default:
return state;
}

View File

@ -0,0 +1,49 @@
import { AccessGroup } from "@/domain/access_groups";
import { getPb } from "./api";
import { Access } from "@/domain/access";
export const list = async () => {
const resp = await getPb()
.collection("access_groups")
.getFullList<AccessGroup>({
sort: "-created",
expand: "access",
});
return resp;
};
export const remove = async (id: string) => {
const pb = getPb();
// 查询有没有关联的access
const accessGroup = await pb.collection("access").getList<Access>(1, 1, {
filter: `group='${id}' && deleted=null`,
});
if (accessGroup.items.length > 0) {
throw new Error("该分组下有授权配置,无法删除");
}
await pb.collection("access_groups").delete(id);
};
export const update = async (accessGroup: AccessGroup) => {
const pb = getPb();
if (accessGroup.id) {
return await pb
.collection("access_groups")
.update(accessGroup.id, accessGroup);
}
return await pb.collection("access_groups").create(accessGroup);
};
type UpdateByIdReq = {
id: string;
[key: string]: string | string[];
};
export const updateById = async (req: UpdateByIdReq) => {
const pb = getPb();
return await pb.collection("access_groups").update(req.id, req);
};

View File

@ -10,6 +10,7 @@ import LoginLayout from "./pages/LoginLayout";
import Password from "./pages/setting/Password";
import SettingLayout from "./pages/SettingLayout";
import Dashboard from "./pages/dashboard/Dashboard";
import AccessGroups from "./pages/access_groups/AccessGroups";
export const router = createHashRouter([
{
@ -32,6 +33,10 @@ export const router = createHashRouter([
path: "/access",
element: <Access />,
},
{
path: "/access_groups",
element: <AccessGroups />,
},
{
path: "/history",
element: <History />,