Compare commits

...

26 Commits

Author SHA1 Message Date
yoan
2efe0de0cf UI optimization 2024-09-17 08:59:39 +08:00
yoan
34e40e5e54 UI Optimization 2024-09-16 08:44:36 +08:00
usual2970
e7e269dfb0 Merge pull request #37 from usual2970/feat/access_ui
Merge authorization group into authorization management
2024-09-15 21:59:03 +08:00
yoan
fa85580e35 Merge authorization group into authorization management 2024-09-15 21:58:08 +08:00
usual2970
500fce6180 Update README.md 2024-09-14 22:02:40 +08:00
usual2970
f501df2804 Merge pull request #30 from usual2970/feat/deploy_group
添加部署变量、部署授权组的支持
2024-09-14 15:39:26 +08:00
yoan
6c1b1fb72b Support deploying one certificate to multiple SSH hosts, and support deploying multiple certificates to one SSH host. 2024-09-14 15:36:15 +08:00
yoan
505cfc5c1e Fix godaddy error 2024-09-13 09:56:01 +08:00
yoan
ac14924a73 update ui 2024-09-13 07:55:25 +08:00
yoan
7550aec904 When adding a domain, you can also add a custom email address. 2024-09-13 07:36:26 +08:00
yoan
139a6980ac Add godaddy provider 2024-09-12 21:59:50 +08:00
yoan
d5a6411e26 Merge branch 'main' of github.com:usual2970/certimate 2024-09-12 06:11:34 +08:00
yoan
1fcd9897be update README 2024-09-12 06:11:15 +08:00
usual2970
99d4bf2624 Update README.md 2024-09-11 08:03:18 +08:00
yoan
844347acf9 fix file select 2024-09-11 07:35:19 +08:00
usual2970
eeae9b4405 Merge pull request #16 from gexin1/main
fix: 首页展示问题和授权管理弹窗无法滚动
2024-09-11 06:37:38 +08:00
river
feef94f873 fix: 1. 修复首页控制面板在md和2xl的时候会超出屏幕
2.修复授权管理-添加授权选择ssh 弹窗过长超出屏幕无法滚动
2024-09-10 22:56:54 +08:00
yoan
7ad8cdfe47 add namesilo provider 2024-09-10 21:02:31 +08:00
yoan
b8cff68167 fix missing tzdata 2024-09-10 18:01:34 +08:00
usual2970
719b5f359d Update README.md 2024-09-10 11:27:39 +08:00
usual2970
00afb3bbbf Update README.md 2024-09-09 22:36:02 +08:00
usual2970
f92799cd92 Update README.md 2024-09-09 22:20:58 +08:00
usual2970
a441357858 Update README.md 2024-09-09 22:15:15 +08:00
yoan
51771385f2 Update readme 2024-09-09 10:42:45 +08:00
yoan
45fa3babf4 Update readme 2024-09-08 09:25:23 +08:00
yoan
e722aa9cb6 Update README 2024-09-08 09:13:44 +08:00
62 changed files with 3980 additions and 745 deletions

View File

@@ -1,21 +1,23 @@
<p align="center">
<a href="#" target="_blank" rel="noopener">
<img src="https://i.imgur.com/bWY8Vsg.jpeg" alt="Certimate - Your Trusted SSL Automation Partner" />
</a>
</p>
# 🔒Certimate
做个人产品或在小企业负责运维的同学,会遇到要管理多个域名的情况,需要给域名申请证书。但手动申请证书有以下缺点:
做个人产品或在小企业负责运维的同学,要管理多个域名要给域名申请证书。但手动申请证书有以下缺点:
1. 😱麻烦:申请证书并部署到服务的流程虽不复杂,但也挺麻烦的,其是你有多个域名需要维护的时候。
2. 😭易忘:另外当前免费证书有效期只有90天这就要求定期操作,增加工作量的同时,也很容易忘掉续期,从而导致网站访问不了
1. 😱麻烦:申请、部署证书虽不困难,但也挺麻烦的,其是维护多个域名的时候。
2. 😭易忘:当前免费证书有效期90天这就要求定期操作增加工作量的同时也很容易忘掉,导致网站无法访问
Certimate 就是为了解决上述问题而产生的,它具有以下特点:
1. 支持私有部署:部署方法简单,只需下载二进制文件并执行即可完成安装
2. 数据安全:由于是私有部署,所有数据均存储在本地,不会保存在服务商的服务器上,确保数据的安全性
3. 操作方便:通过简单的配置即可轻松申请 SSL 证书,并且在证书即将过期时自动续期,无需人工干预
1. 操作简单:自动申请、部署、续期 SSL 证书,全程无需人工干预
2. 支持私有部署部署方法简单只需下载二进制文件执行即可。二进制文件、docker 镜像全部用 github actions 生成,过程透明,可自行审计
3. 数据安全:由于是私有部署,所有数据均存储在本地,不会保存在服务商的服务器,确保数据的安全性
相关文章:
* [Why Certimate?](https://docs.certimate.me/blog/why-certimate)
* [域名变量及部署授权组介绍](https://docs.certimate.me/blog/multi-deployer)
Certimate 旨在为用户提供一个安全、简便的 SSL 证书管理解决方案。使用文档请访问[https://docs.certimate.me](https://docs.certimate.me)
@@ -25,12 +27,14 @@ Certimate 旨在为用户提供一个安全、简便的 SSL 证书管理解决
- [2. Docker 安装](#2-docker-安装)
- [3. 源代码安装](#3-源代码安装)
- [二、使用](#二使用)
- [三、概念](#三概念)
- [三、支持的服务商列表](#三支持的服务商列表)
- [四、系统截图](#四系统截图)
- [五、概念](#五概念)
- [1. 域名](#1-域名)
- [2. dns 服务商授权信息](#2-dns-服务商授权信息)
- [3. 部署服务商授权信息](#3-部署服务商授权信息)
- [、常见问题](#常见问题)
- [五、许可证](#五许可证)
- [、常见问题](#常见问题)
- [七、贡献](#七贡献)
@@ -78,8 +82,34 @@ go run main.go serve
![usage.gif](https://i.imgur.com/zpCoLVM.gif)
## 三、支持的服务商列表
## 三、概念
| 服务商 | 是否域名服务商 | 是否部署服务 | 备注 |
|------|------|-----|------|
| 阿里云| 是 | 是 | 支持阿里云注册的域名,支持部署到阿里云 CDN,OSS |
| 腾讯云| 是 | 是 | 支持腾讯云注册的域名,支持部署到腾讯云 CDN |
| 七牛云| 否 | 是 | 七牛云没有注册域名服务,支持部署到七牛云 CDN |
|CloudFlare| 是 | 否 | 支持 CloudFlare 注册的域名CloudFlare 服务自带SSL证书 |
|SSH| 否 | 是 | 支持部署到 SSH 服务器 |
|WEBHOOK| 否 | 是 | 支持回调到 WEBHOOK |
## 四、系统截图
![login](https://i.imgur.com/SYjjbql.jpeg)
![dashboard](https://i.imgur.com/WMVbBId.jpeg)
![domains](https://i.imgur.com/8wit3ZA.jpeg)
![accesses](https://i.imgur.com/EWtOoJ0.jpeg)
![history](https://i.imgur.com/aaPtSW7.jpeg)
## 五、概念
Certimate 的工作流程如下:
@@ -120,7 +150,7 @@ Certimate 申请证书后,会自动将证书部署到你指定的目标上,
部署服务商授权信息和 dns 服务商授权信息一致,区别在于 dns 服务商授权信息用于证明域名是你的,部署服务商授权信息用于提供证书部署的授权信息。
## 、常见问题
## 、常见问题
Q: 提供saas服务吗
@@ -137,6 +167,15 @@ Q: 自动续期证书?
## 五、许可证
## 七、贡献
Certimate 是一个免费且开源的项目,采用 [MIT 开源协议](LICENSE.md)。你可以使用它做任何你想做的事,甚至把它当作一个付费服务提供给用户。
你可以通过以下方式来支持 Certimate 的开发:
* 提交代码:如果你发现了 bug 或有新的功能需求,而你又有相关经验,可以提交代码给我们。
* 提交 issue功能建议或者 bug 可以[提交 issue](https://github.com/usual2970/certimate/issues) 给我们。
支持更多服务商、UI 的优化改进、BUG 修复、文档完善等,欢迎大家提交 PR。
Certimate 采用 MIT 许可证,详情请查看 [LICENSE](LICENSE.md) 文件。

1
go.mod
View File

@@ -91,6 +91,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect

3
go.sum
View File

@@ -205,6 +205,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -290,6 +291,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg=
github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=

View File

@@ -19,8 +19,12 @@ const (
configTypeTencent = "tencent"
configTypeAliyun = "aliyun"
configTypeCloudflare = "cloudflare"
configTypeNamesilo = "namesilo"
configTypeGodaddy = "godaddy"
)
const defaultEmail = "536464346@qq.com"
type Certificate struct {
CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"`
@@ -58,8 +62,12 @@ type Applicant interface {
func Get(record *models.Record) (Applicant, error) {
access := record.ExpandedOne("access")
email := record.GetString("email")
if email == "" {
email = defaultEmail
}
option := &ApplyOption{
Email: "536464346@qq.com",
Email: email,
Domain: record.GetString("domain"),
Access: access.GetString("config"),
}
@@ -70,6 +78,10 @@ func Get(record *models.Record) (Applicant, error) {
return NewAliyun(option), nil
case configTypeCloudflare:
return NewCloudflare(option), nil
case configTypeNamesilo:
return NewNamesilo(option), nil
case configTypeGodaddy:
return NewGodaddy(option), nil
default:
return nil, errors.New("unknown config type")
}

View File

@@ -0,0 +1,35 @@
package applicant
import (
"certimate/internal/domain"
"encoding/json"
"os"
godaddyProvider "github.com/go-acme/lego/v4/providers/dns/godaddy"
)
type godaddy struct {
option *ApplyOption
}
func NewGodaddy(option *ApplyOption) Applicant {
return &godaddy{
option: option,
}
}
func (a *godaddy) Apply() (*Certificate, error) {
access := &domain.GodaddyAccess{}
json.Unmarshal([]byte(a.option.Access), access)
os.Setenv("GODADDY_API_KEY", access.ApiKey)
os.Setenv("GODADDY_API_SECRET", access.ApiSecret)
dnsProvider, err := godaddyProvider.NewDNSProvider()
if err != nil {
return nil, err
}
return apply(a.option, dnsProvider)
}

View File

@@ -0,0 +1,34 @@
package applicant
import (
"certimate/internal/domain"
"encoding/json"
"os"
namesiloProvider "github.com/go-acme/lego/v4/providers/dns/namesilo"
)
type namesilo struct {
option *ApplyOption
}
func NewNamesilo(option *ApplyOption) Applicant {
return &namesilo{
option: option,
}
}
func (a *namesilo) Apply() (*Certificate, error) {
access := &domain.NameSiloAccess{}
json.Unmarshal([]byte(a.option.Access), access)
os.Setenv("NAMESILO_API_KEY", access.ApiKey)
dnsProvider, err := namesiloProvider.NewDNSProvider()
if err != nil {
return nil, err
}
return apply(a.option, dnsProvider)
}

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"
@@ -20,25 +23,85 @@ const (
)
type DeployerOption struct {
DomainId string `json:"domainId"`
Domain string `json:"domain"`
Product string `json:"product"`
Access string `json:"access"`
Certificate applicant.Certificate `json:"certificate"`
DomainId string `json:"domainId"`
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"),
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

@@ -18,3 +18,12 @@ type QiniuAccess struct {
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}
type NameSiloAccess struct {
ApiKey string `json:"apiKey"`
}
type GodaddyAccess struct {
ApiKey string `json:"apiKey"`
ApiSecret string `json:"apiSecret"`
}

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
}
if err = deployer.Deploy(ctx); err != nil {
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().Error("部署失败", "err", err)
history.record(deployPhase, "部署失败", &RecordInfo{Err: err, Info: deployer.GetInfo()})
return err
}
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

@@ -14,6 +14,8 @@ import (
"certimate/internal/utils/app"
_ "certimate/migrations"
"certimate/ui"
_ "time/tzdata"
)
func main() {

View File

@@ -12,73 +12,10 @@ import (
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `[
{
"id": "_pb_users_auth_",
"created": "2024-07-29 09:44:56.398Z",
"updated": "2024-09-02 14:02:40.191Z",
"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": "z3p974ainxjqlvs",
"created": "2024-07-29 10:02:48.334Z",
"updated": "2024-09-04 15:10:19.440Z",
"updated": "2024-09-12 13:09:54.500Z",
"name": "domains",
"type": "base",
"system": false,
@@ -333,7 +270,7 @@ func init() {
{
"id": "4yzbv8urny5ja1e",
"created": "2024-07-29 10:04:39.685Z",
"updated": "2024-09-04 15:09:46.789Z",
"updated": "2024-09-12 13:18:00.093Z",
"name": "access",
"type": "base",
"system": false,
@@ -380,7 +317,9 @@ func init() {
"ssh",
"webhook",
"cloudflare",
"qiniu"
"qiniu",
"namesilo",
"godaddy"
]
}
},
@@ -396,6 +335,23 @@ func init() {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "hsxcnlvd",
"name": "usage",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"apply",
"deploy",
"all"
]
}
}
],
"indexes": [
@@ -411,7 +367,7 @@ func init() {
{
"id": "0a1o4e6sstp694f",
"created": "2024-07-30 06:30:27.801Z",
"updated": "2024-09-02 14:02:40.191Z",
"updated": "2024-09-12 13:09:54.500Z",
"name": "deployments",
"type": "base",
"system": false,
@@ -492,6 +448,69 @@ func init() {
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "_pb_users_auth_",
"created": "2024-09-12 13:09:54.234Z",
"updated": "2024-09-12 13:09:54.500Z",
"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
}
}
]`

View File

@@ -0,0 +1,20 @@
package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
db.NewQuery("update access set usage='all' where configType in ('aliyun', 'tencent')").Execute()
db.NewQuery("update access set usage='deploy' where configType in ('ssh', 'webhook','qiniu')").Execute()
db.NewQuery("update access set usage='apply' where configType in ('cloudflare','namesilo','godaddy')").Execute()
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}

View File

@@ -0,0 +1,584 @@
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-12 23:13:12.119Z",
"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
}
}
],
"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-12 23:08:52.810Z",
"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"
]
}
}
],
"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-12 23:08:52.810Z",
"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:08:52.811Z",
"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:19:09.110Z",
"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": {}
}
]`
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
})
}

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
})
}

View File

File diff suppressed because one or more lines are too long

1
ui/dist/assets/index-ChWRjRip.css vendored Normal file
View File

File diff suppressed because one or more lines are too long

291
ui/dist/assets/index-DoSOua_N.js vendored Normal file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

5
ui/dist/imgs/providers/godaddy.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#1bdbdb"/>
<path d="M697.6 315.9c-53.2-33.2-123.3-25.3-185.6 13.9-62.4-39.3-132.4-47.2-185.6-13.9-84.1 52.5-94.3 187.8-22.8 302.2 52.7 84.3 135.1 133.7 208.4 132.8 73.3.9 155.7-48.5 208.4-132.8 71.5-114.4 61.3-249.7-22.8-302.2M342.2 594c-15-24.1-26.1-49.5-33-75.5-6.5-24.5-8.9-48.5-7.1-71.2 3.2-42.3 20.4-75.2 48.4-92.7s65.2-18.6 104.5-2.9c5.9 2.4 11.8 5.1 17.6 8.1-21 19-40.3 41.9-56.7 68.1-43.4 69.5-56.6 146.7-41.5 208.4-11.8-12.8-22.6-27-32.2-42.3m372.6-75.6c-6.9 26.1-17.9 51.5-33 75.5-9.6 15.4-20.4 29.5-32.3 42.3 13.5-55.2 4.4-122.9-28.9-186.3-2.3-4.5-7.7-5.9-12-3.3l-103.5 64.7c-4 2.5-5.2 7.7-2.7 11.7l15.2 24.3c2.5 4 7.7 5.2 11.7 2.7l67.1-41.9c2.2 6.4 4.3 12.9 6 19.5 6.5 24.5 8.9 48.5 7.1 71.2-3.2 42.3-20.4 75.2-48.4 92.7-14 8.8-30.3 13.4-48 13.9h-2.2c-17.7-.5-34-5.1-48-13.9-28-17.5-45.2-50.4-48.4-92.7-1.7-22.7.7-46.7 7.1-71.2 6.8-26.1 17.9-51.5 33-75.5 15-24.1 33-45.2 53.4-62.8 19.2-16.6 39.7-29.2 60.9-37.6 39.4-15.7 76.5-14.6 104.5 2.9s45.2 50.4 48.4 92.7c1.8 22.6-.6 46.6-7 71.1" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

8
ui/dist/imgs/providers/namesilo.svg vendored Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#031b4e" />
<path
d="M668 415.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v85c0 17.1 69.9 30.7 156 30.7s156-13.6 156-30.7v-85zM512 556.4c86.1 0 156-13.6 156-30.2v85.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v-85.5c0 16.6 69.9 30.2 156 30.2zm156 80.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v85.5c0 16.6 69.9 30.2 156 30.2s156-13.6 156-30.2v-85.5zm0-296.9L512 239.4 356 340v55.4c0 14.1 69.9 25.2 156 25.2s156-11.1 156-25.2V340z"
style="fill:#fff" />
</svg>

After

Width:  |  Height:  |  Size: 743 B

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-BGMLTQZA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Kh_0Jotc.css">
<script type="module" crossorigin src="/assets/index-DoSOua_N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChWRjRip.css">
</head>
<body class="bg-background">
<div id="root"></div>

30
ui/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
@@ -1762,6 +1763,35 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz",
"integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-roving-focus": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.1.tgz",

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#1bdbdb"/>
<path d="M697.6 315.9c-53.2-33.2-123.3-25.3-185.6 13.9-62.4-39.3-132.4-47.2-185.6-13.9-84.1 52.5-94.3 187.8-22.8 302.2 52.7 84.3 135.1 133.7 208.4 132.8 73.3.9 155.7-48.5 208.4-132.8 71.5-114.4 61.3-249.7-22.8-302.2M342.2 594c-15-24.1-26.1-49.5-33-75.5-6.5-24.5-8.9-48.5-7.1-71.2 3.2-42.3 20.4-75.2 48.4-92.7s65.2-18.6 104.5-2.9c5.9 2.4 11.8 5.1 17.6 8.1-21 19-40.3 41.9-56.7 68.1-43.4 69.5-56.6 146.7-41.5 208.4-11.8-12.8-22.6-27-32.2-42.3m372.6-75.6c-6.9 26.1-17.9 51.5-33 75.5-9.6 15.4-20.4 29.5-32.3 42.3 13.5-55.2 4.4-122.9-28.9-186.3-2.3-4.5-7.7-5.9-12-3.3l-103.5 64.7c-4 2.5-5.2 7.7-2.7 11.7l15.2 24.3c2.5 4 7.7 5.2 11.7 2.7l67.1-41.9c2.2 6.4 4.3 12.9 6 19.5 6.5 24.5 8.9 48.5 7.1 71.2-3.2 42.3-20.4 75.2-48.4 92.7-14 8.8-30.3 13.4-48 13.9h-2.2c-17.7-.5-34-5.1-48-13.9-28-17.5-45.2-50.4-48.4-92.7-1.7-22.7.7-46.7 7.1-71.2 6.8-26.1 17.9-51.5 33-75.5 15-24.1 33-45.2 53.4-62.8 19.2-16.6 39.7-29.2 60.9-37.6 39.4-15.7 76.5-14.6 104.5 2.9s45.2 50.4 48.4 92.7c1.8 22.6-.6 46.6-7 71.1" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#031b4e" />
<path
d="M668 415.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v85c0 17.1 69.9 30.7 156 30.7s156-13.6 156-30.7v-85zM512 556.4c86.1 0 156-13.6 156-30.2v85.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v-85.5c0 16.6 69.9 30.2 156 30.2zm156 80.5c0 16.6-69.9 30.2-156 30.2s-156-13.6-156-30.2v85.5c0 16.6 69.9 30.2 156 30.2s156-13.6 156-30.2v-85.5zm0-296.9L512 239.4 356 340v55.4c0 14.1 69.9 25.2 156 25.2s156-11.1 156-25.2V340z"
style="fill:#fff" />
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, AliyunConfig } from "@/domain/access";
import { Access, accessFormType, AliyunConfig, getUsageByConfigType } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
@@ -59,6 +59,7 @@ const AccessAliyunForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
accessKeyId: data.accessKeyId,
accessKeySecret: data.accessSecretId,

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, CloudflareConfig } from "@/domain/access";
import { Access, accessFormType, CloudflareConfig, getUsageByConfigType } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -56,6 +56,7 @@ const AccessCloudflareForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
dnsApiToken: data.dnsApiToken,
},

View File

@@ -5,6 +5,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useState } from "react";
@@ -29,6 +30,8 @@ import {
} from "../ui/select";
import AccessCloudflareForm from "./AccessCloudflareForm";
import AccessQiniuForm from "./AccessQiniuForm";
import AccessNamesiloForm from "./AccessNamesiloForm";
import AccessGodaddyFrom from "./AccessGodaddyForm";
type TargetConfigEditProps = {
op: "add" | "edit";
@@ -110,6 +113,26 @@ export function AccessEdit({
/>
);
break;
case "namesilo":
form = (
<AccessNamesiloForm
data={data}
onAfterReq={() => {
setOpen(false);
}}
/>
);
break;
case "godaddy":
form = (
<AccessGodaddyFrom
data={data}
onAfterReq={() => {
setOpen(false);
}}
/>
);
break;
}
const getOptionCls = (val: string) => {
@@ -125,44 +148,46 @@ export function AccessEdit({
<DialogHeader>
<DialogTitle>{op == "add" ? "添加" : "编辑"}</DialogTitle>
</DialogHeader>
<div className="container">
<Label></Label>
<ScrollArea className="max-h-[80vh]">
<div className="container py-3">
<Label></Label>
<Select
onValueChange={(val) => {
console.log(val);
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder="请选择服务商" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{typeKeys.map((key) => (
<SelectItem value={key} key={key}>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer",
getOptionCls(key)
)}
>
<img
src={accessTypeMap.get(key)?.[1]}
className="h-6 w-6"
/>
<div>{accessTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
onValueChange={(val) => {
console.log(val);
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder="请选择服务商" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{typeKeys.map((key) => (
<SelectItem value={key} key={key}>
<div
className={cn(
"flex items-center space-x-2 rounded cursor-pointer",
getOptionCls(key)
)}
>
<img
src={accessTypeMap.get(key)?.[1]}
className="h-6 w-6"
/>
<div>{accessTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{form}
</div>
{form}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,198 @@
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import {
Access,
accessFormType,
getUsageByConfigType,
GodaddyConfig,
} from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
const AccessGodaddyFrom = ({
data,
onAfterReq,
}: {
data?: Access;
onAfterReq: () => void;
}) => {
const { addAccess, updateAccess } = useConfig();
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(64),
configType: accessFormType,
apiKey: z.string().min(1).max(64),
apiSecret: z.string().min(1).max(64),
});
let config: GodaddyConfig = {
apiKey: "",
apiSecret: "",
};
if (data) config = data.config as GodaddyConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name,
configType: "godaddy",
apiKey: config.apiKey,
apiSecret: config.apiSecret,
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
apiKey: data.apiKey,
apiSecret: data.apiSecret,
},
};
try {
const rs = await save(req);
onAfterReq();
req.id = rs.id;
req.created = rs.created;
req.updated = rs.updated;
if (data.id) {
updateAccess(req);
return;
}
addAccess(req);
} 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 (
<>
<div className="max-w-[35em] mx-auto mt-10">
<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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>GODADDY_API_KEY</FormLabel>
<FormControl>
<Input placeholder="请输入GODADDY_API_KEY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiSecret"
render={({ field }) => (
<FormItem>
<FormLabel>GODADDY_API_SECRET</FormLabel>
<FormControl>
<Input placeholder="请输入GODADDY_API_SECRET" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</>
);
};
export default AccessGodaddyFrom;

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

@@ -0,0 +1,217 @@
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 { useNavigate } from "react-router-dom";
const AccessGroupList = () => {
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 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}&tab=access`,
{
replace: true,
}
);
}}
>
</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>
);
};
export default AccessGroupList;

View File

@@ -0,0 +1,174 @@
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, getUsageByConfigType, NamesiloConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
const AccessNamesiloForm = ({
data,
onAfterReq,
}: {
data?: Access;
onAfterReq: () => void;
}) => {
const { addAccess, updateAccess } = useConfig();
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(64),
configType: accessFormType,
apiKey: z.string().min(1).max(64),
});
let config: NamesiloConfig = {
apiKey: "",
};
if (data) config = data.config as NamesiloConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name,
configType: "namesilo",
apiKey: config.apiKey,
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Access = {
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
apiKey: data.apiKey,
},
};
try {
const rs = await save(req);
onAfterReq();
req.id = rs.id;
req.created = rs.created;
req.updated = rs.updated;
if (data.id) {
updateAccess(req);
return;
}
addAccess(req);
} 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 (
<>
<div className="max-w-[35em] mx-auto mt-10">
<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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>NAMESILO_API_KEY</FormLabel>
<FormControl>
<Input placeholder="请输入NAMESILO_API_KEY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</>
);
};
export default AccessNamesiloForm;

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, QiniuConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, QiniuConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
@@ -59,6 +59,7 @@ const AccessQiniuForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
accessKey: data.accessKey,
secretKey: data.secretKey,

View File

@@ -1,4 +1,9 @@
import { Access, accessFormType, 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";
@@ -18,6 +23,18 @@ import { save } from "@/repository/access";
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,
@@ -26,7 +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),
@@ -34,11 +63,12 @@ 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),
key: z.string().min(0).max(20480),
keyFile: z.string().optional(),
keyFile: z.any().optional(),
command: z.string().min(1).max(2048),
certPath: z.string().min(0).max(2048),
keyPath: z.string().min(0).max(2048),
@@ -63,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,
@@ -77,10 +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,
@@ -103,9 +139,28 @@ const AccessSSHForm = ({
req.updated = rs.updated;
if (data.id) {
updateAccess(req);
return;
} else {
addAccess(req);
}
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;
@@ -127,9 +182,16 @@ const AccessSSHForm = ({
) => {
const file = event.target.files?.[0];
if (!file) return;
const content = await readFileContent(file);
const savedFile = file;
setFileName(savedFile.name);
const content = await readFileContent(savedFile);
form.setValue("key", content);
form.setValue("keyFile", "");
};
const handleSelectFileClick = () => {
console.log(fileInputRef.current);
fileInputRef.current?.click();
};
return (
@@ -158,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"
@@ -279,12 +402,26 @@ const AccessSSHForm = ({
<FormItem>
<FormLabel>Key使</FormLabel>
<FormControl>
<Input
placeholder="请输入Key"
{...field}
type="file"
onChange={handleFileChange}
/>
<div>
<Button
type={"button"}
variant={"secondary"}
size={"sm"}
className="w-48"
onClick={handleSelectFileClick}
>
{fileName ? fileName : "请选择文件"}
</Button>
<Input
placeholder="请输入Key"
{...field}
ref={fileInputRef}
className="hidden"
hidden
type="file"
onChange={handleFileChange}
/>
</div>
</FormControl>
<FormMessage />

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, TencentConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, TencentConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -58,6 +58,7 @@ const AccessTencentForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
secretId: data.secretId,
secretKey: data.secretKey,

View File

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Access, accessFormType, WebhookConfig } from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType, WebhookConfig } from "@/domain/access";
import { save } from "@/repository/access";
import { useConfig } from "@/providers/config";
import { ClientResponseError } from "pocketbase";
@@ -56,6 +56,7 @@ const WebhookForm = ({
id: data.id as string,
name: data.name,
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
url: data.url,
},

View File

@@ -0,0 +1,141 @@
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/settings";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { useState } from "react";
type EmailsEditProps = {
className?: string;
trigger: React.ReactNode;
};
const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
const {
config: { emails },
setEmails,
} = useConfig();
const [open, setOpen] = useState(false);
const formSchema = z.object({
email: z.string().email(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});
const onSubmit = async (data: z.infer<typeof formSchema>) => {
if (emails.content.emails.includes(data.email)) {
form.setError("email", {
message: "邮箱已存在",
});
return;
}
// 保存到 config
const newEmails = [...emails.content.emails, data.email];
try {
const resp = await update({
...emails,
name: "emails",
content: {
emails: newEmails,
},
});
// 更新本地状态
setEmails(resp);
// 关闭弹窗
form.reset();
form.clearErrors();
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="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入邮箱" {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EmailsEdit;

View File

@@ -0,0 +1,31 @@
import { BookOpen } from "lucide-react";
import { Separator } from "../ui/separator";
import { version } from "@/domain/version";
const Version = () => {
return (
<div className="fixed right-0 bottom-0 w-full flex justify-between p-5">
<div className=""></div>
<div className="text-muted-foreground text-sm hover:text-stone-900 dark:hover:text-stone-200 flex">
<a
href="https://docs.certimate.me"
target="_blank"
className="flex items-center"
>
<BookOpen size={16} />
<div className="ml-1"></div>
</a>
<Separator orientation="vertical" className="mx-2" />
<a
href="https://github.com/usual2970/certimate/releases"
target="_blank"
>
{version}
</a>
</div>
</div>
);
};
export default Version;

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -4,11 +4,17 @@ export const accessTypeMap: Map<string, [string, string]> = new Map([
["tencent", ["腾讯云", "/imgs/providers/tencent.svg"]],
["aliyun", ["阿里云", "/imgs/providers/aliyun.svg"]],
["cloudflare", ["Cloudflare", "/imgs/providers/cloudflare.svg"]],
["namesilo", ["Namesilo", "/imgs/providers/namesilo.svg"]],
["godaddy", ["GoDaddy", "/imgs/providers/godaddy.svg"]],
["qiniu", ["七牛云", "/imgs/providers/qiniu.svg"]],
["ssh", ["SSH部署", "/imgs/providers/ssh.svg"]],
["webhook", ["Webhook", "/imgs/providers/webhook.svg"]],
]);
export const getProviderInfo = (t: string) => {
return accessTypeMap.get(t);
};
export const accessFormType = z.union(
[
z.literal("aliyun"),
@@ -17,21 +23,30 @@ export const accessFormType = z.union(
z.literal("webhook"),
z.literal("cloudflare"),
z.literal("qiniu"),
z.literal("namesilo"),
z.literal("godaddy"),
],
{ message: "请选择云服务商" }
);
type AccessUsage = "apply" | "deploy" | "all";
export type Access = {
id: string;
name: string;
configType: string;
usage: AccessUsage;
group?: string;
config:
| TencentConfig
| AliyunConfig
| SSHConfig
| WebhookConfig
| CloudflareConfig
| QiniuConfig;
| QiniuConfig
| NamesiloConfig
| GodaddyConfig;
deleted?: string;
created?: string;
updated?: string;
@@ -60,6 +75,14 @@ export type AliyunConfig = {
accessKeySecret: string;
};
export type NamesiloConfig = {
apiKey: string;
};
export type GodaddyConfig = {
apiKey: string;
apiSecret: string;
};
export type SSHConfig = {
host: string;
port: string;
@@ -71,3 +94,22 @@ export type SSHConfig = {
certPath: string;
keyPath: string;
};
export const getUsageByConfigType = (configType: string): AccessUsage => {
switch (configType) {
case "aliyun":
case "tencent":
return "all";
case "ssh":
case "webhook":
case "qiniu":
return "deploy";
case "cloudflare":
case "namesilo":
case "godaddy":
return "apply";
default:
return "all";
}
};

View File

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

View File

@@ -3,14 +3,17 @@ import { Deployment, Pahse } from "./deployment";
export type Domain = {
id: string;
domain: string;
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

@@ -0,0 +1,9 @@
export type Setting = {
id?: string;
name?: string;
content: EmailsSetting;
};
type EmailsSetting = {
emails: string[];
};

1
ui/src/domain/version.ts Normal file
View File

@@ -0,0 +1 @@
export const version = "Certimate v0.1.7";

View File

@@ -5,15 +5,7 @@ import {
useLocation,
useNavigate,
} from "react-router-dom";
import {
BookOpen,
CircleUser,
Earth,
History,
Home,
Menu,
Server,
} from "lucide-react";
import { CircleUser, Earth, History, Home, Menu, Server } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -30,7 +22,8 @@ import { cn } from "@/lib/utils";
import { ConfigProvider } from "@/providers/config";
import { getPb } from "@/repository/api";
import { ThemeToggle } from "@/components/ThemeToggle";
import { Separator } from "@/components/ui/separator";
import Version from "@/components/certimate/Version";
export default function Dashboard() {
const navigate = useNavigate();
@@ -161,7 +154,7 @@ 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" />
@@ -211,26 +204,7 @@ export default function Dashboard() {
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6 relative">
<Outlet />
<div className="fixed right-0 bottom-0 w-full flex justify-between p-5">
<div className=""></div>
<div className="text-muted-foreground text-sm hover:text-stone-900 dark:hover:text-stone-200 flex">
<a
href="https://docs.certimate.me"
target="_blank"
className="flex items-center"
>
<BookOpen size={16} />
<div className="ml-1"></div>
</a>
<Separator orientation="vertical" className="mx-2" />
<a
href="https://github.com/usual2970/certimate/releases"
target="_blank"
>
Certimate v0.1.0
</a>
</div>
</div>
<Version />
</main>
</div>
</div>

View File

@@ -1,4 +1,7 @@
import Version from "@/components/certimate/Version";
import { getPb } from "@/repository/api";
import { Navigate, Outlet } from "react-router-dom";
const LoginLayout = () => {
@@ -8,6 +11,8 @@ const LoginLayout = () => {
return (
<div className="container">
<Outlet />
<Version />
</div>
);
};

View File

@@ -1,7 +1,10 @@
import { AccessEdit } from "@/components/certimate/AccessEdit";
import AccessGroupEdit from "@/components/certimate/AccessGroupEdit";
import AccessGroupList from "@/components/certimate/AccessGroupList";
import XPagination from "@/components/certimate/XPagination";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Access as AccessType, accessTypeMap } from "@/domain/access";
import { convertZulu2Beijing } from "@/lib/time";
import { useConfig } from "@/providers/config";
@@ -23,6 +26,10 @@ const Access = () => {
const page = query.get("page");
const pageNumber = page ? Number(page) : 1;
const tab = query.get("tab");
const accessGroupId = query.get("accessGroupId");
const startIndex = (pageNumber - 1) * perPage;
const endIndex = startIndex + perPage;
@@ -31,95 +38,141 @@ const Access = () => {
deleteAccess(rs.id);
};
const handleTabItemClick = (tab: string) => {
query.set("tab", tab);
navigate({ search: query.toString() });
};
return (
<div className="">
<div className="flex justify-between items-center">
<div className="text-muted-foreground"></div>
<AccessEdit trigger={<Button></Button>} op="add" />
{tab != "access_group" ? (
<AccessEdit trigger={<Button></Button>} op="add" />
) : (
<AccessGroupEdit trigger={<Button></Button>} />
)}
</div>
{accesses.length === 0 ? (
<div className="flex flex-col items-center mt-10">
<span className="bg-orange-100 p-5 rounded-full">
<Key size={40} className="text-primary" />
</span>
<div className="text-center text-sm text-muted-foreground mt-3">
</div>
<AccessEdit
trigger={<Button></Button>}
op="add"
className="mt-3"
/>
</div>
) : (
<>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-48"></div>
<div className="w-48"></div>
<div className="w-52"></div>
<div className="w-52"></div>
<div className="grow"></div>
</div>
<div className="sm:hidden flex text-sm text-muted-foreground">
</div>
{accesses.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}
>
<div className="sm:w-48 w-full pt-1 sm:pt-0 flex items-center">
{access.name}
</div>
<div className="sm:w-48 w-full pt-1 sm:pt-0 flex items-center space-x-2">
<img
src={accessTypeMap.get(access.configType)?.[1]}
className="w-6"
/>
<div>{accessTypeMap.get(access.configType)?.[0]}</div>
</div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center">
{access.created && convertZulu2Beijing(access.created)}
</div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center">
{access.updated && convertZulu2Beijing(access.updated)}
</div>
<div className="flex items-center grow justify-start pt-1 sm:pt-0">
<AccessEdit
trigger={
<Button variant={"link"} className="p-0">
</Button>
}
op="edit"
data={access}
/>
<Separator orientation="vertical" className="h-4 mx-2" />
<Button
variant={"link"}
className="p-0"
onClick={() => {
handleDelete(access);
}}
>
</Button>
</div>
</div>
))}
<XPagination
totalPages={totalPages}
currentPage={pageNumber}
onPageChange={(page) => {
query.set("page", page.toString());
navigate({ search: query.toString() });
<Tabs
defaultValue={tab ? tab : "access"}
value={tab ? tab : "access"}
className="w-full mt-5"
>
<TabsList className="space-x-5 px-3">
<TabsTrigger
value="access"
onClick={() => {
handleTabItemClick("access");
}}
/>
</>
)}
>
</TabsTrigger>
<TabsTrigger
value="access_group"
onClick={() => {
handleTabItemClick("access_group");
}}
>
</TabsTrigger>
</TabsList>
<TabsContent value="access">
{accesses.length === 0 ? (
<div className="flex flex-col items-center mt-10">
<span className="bg-orange-100 p-5 rounded-full">
<Key size={40} className="text-primary" />
</span>
<div className="text-center text-sm text-muted-foreground mt-3">
</div>
<AccessEdit
trigger={<Button></Button>}
op="add"
className="mt-3"
/>
</div>
) : (
<>
<div className="hidden sm:flex sm:flex-row text-muted-foreground text-sm border-b dark:border-stone-500 sm:p-2 mt-5">
<div className="w-48"></div>
<div className="w-48"></div>
<div className="w-52"></div>
<div className="w-52"></div>
<div className="grow"></div>
</div>
<div className="sm:hidden flex text-sm text-muted-foreground">
</div>
{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}
>
<div className="sm:w-48 w-full pt-1 sm:pt-0 flex items-center">
{access.name}
</div>
<div className="sm:w-48 w-full pt-1 sm:pt-0 flex items-center space-x-2">
<img
src={accessTypeMap.get(access.configType)?.[1]}
className="w-6"
/>
<div>{accessTypeMap.get(access.configType)?.[0]}</div>
</div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center">
{" "}
{access.created && convertZulu2Beijing(access.created)}
</div>
<div className="sm:w-52 w-full pt-1 sm:pt-0 flex items-center">
{" "}
{access.updated && convertZulu2Beijing(access.updated)}
</div>
<div className="flex items-center grow justify-start pt-1 sm:pt-0">
<AccessEdit
trigger={
<Button variant={"link"} className="p-0">
</Button>
}
op="edit"
data={access}
/>
<Separator orientation="vertical" className="h-4 mx-2" />
<Button
variant={"link"}
className="p-0"
onClick={() => {
handleDelete(access);
}}
>
</Button>
</div>
</div>
))}
<XPagination
totalPages={totalPages}
currentPage={pageNumber}
onPageChange={(page) => {
query.set("page", page.toString());
navigate({ search: query.toString() });
}}
/>
</>
)}
</TabsContent>
<TabsContent value="access_group">
<AccessGroupList />
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -58,8 +58,8 @@ const Dashboard = () => {
<div className="flex justify-between items-center">
<div className="text-muted-foreground"></div>
</div>
<div className="flex mt-10 gap-5 flex-col md:flex-row">
<div className="w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="flex mt-10 gap-5 flex-col flex-wrap md:flex-row">
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="p-3">
<SquareSigma size={48} strokeWidth={1} className="text-blue-400" />
</div>
@@ -80,7 +80,7 @@ const Dashboard = () => {
</div>
</div>
<div className="w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg border">
<div className="p-3">
<CalendarX2 size={48} strokeWidth={1} className="text-red-400" />
</div>
@@ -101,7 +101,7 @@ const Dashboard = () => {
</div>
</div>
<div className="border w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="p-3">
<LoaderPinwheel
size={48}
@@ -126,7 +126,7 @@ const Dashboard = () => {
</div>
</div>
<div className="border w-full md:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="border w-full md:w-[250px] 3xl:w-[300px] flex items-center rounded-md p-3 shadow-lg">
<div className="p-3">
<Ban size={48} strokeWidth={1} className="text-gray-400" />
</div>
@@ -171,7 +171,7 @@ const Dashboard = () => {
<div className="mt-2 flex justify-end">
<Button
onClick={() => {
navigate("/");
navigate("/edit");
}}
>

View File

@@ -34,16 +34,23 @@ import { useLocation, useNavigate } from "react-router-dom";
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 },
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);
@@ -52,6 +59,7 @@ const Edit = () => {
const fetchData = async () => {
const data = await get(id);
setDomain(data);
setTargetType(data.targetType);
};
fetchData();
}
@@ -62,15 +70,16 @@ const Edit = () => {
domain: z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: "请输入正确的域名",
}),
email: z.string().email().optional(),
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>>({
@@ -78,9 +87,12 @@ const Edit = () => {
defaultValues: {
id: "",
domain: "",
email: "",
access: "",
targetAccess: "",
targetType: "",
variables: "",
group: "",
},
});
@@ -89,20 +101,25 @@ const Edit = () => {
form.reset({
id: domain.id,
domain: domain.domain,
email: domain.email,
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;
}
if (targetType == "") {
return true;
}
const types = form.getValues().targetType.split("-");
const types = targetType.split("-");
return item.configType === types[0];
});
@@ -111,13 +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 {
@@ -131,7 +166,7 @@ const Edit = () => {
title: "成功",
description,
});
navigate("/");
navigate("/domains");
} catch (e) {
const err = e as ClientResponseError;
@@ -152,183 +187,340 @@ 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">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 dark:text-stone-200"
<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");
}}
>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入域名" {...field} />
</FormControl>
</div>
<div
className={cn(
"cursor-pointer text-right",
tab === "advance" ? "text-primary" : ""
)}
onClick={() => {
setTab("advance");
}}
>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
<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)}
className="space-y-8 dark:text-stone-200"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem hidden={tab != "base"}>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入域名" {...field} />
</FormControl>
<FormField
control={form.control}
name="access"
render={({ field }) => (
<FormItem>
<FormLabel className="flex w-full justify-between">
<div>DNS </div>
<AccessEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
op="add"
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("access", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择授权配置" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{accesses.map((item) => (
<SelectItem key={item.id} value={item.id}>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between">
<div>Email</div>
<EmailsEdit
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}
onValueChange={(value) => {
form.setValue("email", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择邮箱" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{emails.content.emails.map((item) => (
<SelectItem key={item} value={item}>
<div>{item}</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="access"
render={({ field }) => (
<FormItem hidden={tab != "base"}>
<FormLabel className="flex w-full justify-between">
<div>DNS </div>
<AccessEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
op="add"
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("access", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择授权配置" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{accesses
.filter((item) => item.usage != "deploy")
.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(
item.configType
)?.[1]
}
/>
<div>{item.name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetType"
render={({ field }) => (
<FormItem hidden={tab != "base"}>
<FormLabel></FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
setTargetType(value);
form.setValue("targetType", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择部署服务类型" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{targetTypeKeys.map((key) => (
<SelectItem key={key} value={key}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={targetTypeMap.get(key)?.[1]}
/>
<div>{targetTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetAccess"
render={({ field }) => (
<FormItem hidden={tab != "base"}>
<FormLabel className="w-full flex justify-between">
<div></div>
<AccessEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
op="add"
/>
</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
form.setValue("targetAccess", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择授权配置" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>
{form.getValues().targetAccess}
</SelectLabel>
<SelectItem value="emptyId">
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
}
/>
<div>{item.name}</div>
--
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
{targetAccesses.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
}
/>
<div>{item.name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetType"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
setTargetType(value);
form.setValue("targetType", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择部署服务类型" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{targetTypeKeys.map((key) => (
<SelectItem key={key} value={key}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={targetTypeMap.get(key)?.[1]}
/>
<div>{targetTypeMap.get(key)?.[0]}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<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>
)}
/>
<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>
<FormField
control={form.control}
name="targetAccess"
render={({ field }) => (
<FormItem>
<FormLabel className="w-full flex justify-between">
<div></div>
<AccessEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
</div>
}
op="add"
/>
</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
form.setValue("targetAccess", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="请选择授权配置" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{targetAccesses.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img
className="w-6"
src={
accessTypeMap.get(item.configType)?.[1]
}
/>
<div>{item.name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</Form>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit"></Button>
</div>
</form>
</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,
@@ -10,9 +10,14 @@ import {
useReducer,
} from "react";
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 = {
@@ -20,6 +25,9 @@ export type ConfigContext = {
deleteAccess: (id: string) => void;
addAccess: (access: Access) => void;
updateAccess: (access: Access) => void;
setEmails: (email: Setting) => void;
setAccessGroups: (accessGroups: AccessGroup[]) => void;
reloadAccessGroups: () => void;
};
const Context = createContext({} as ConfigContext);
@@ -31,7 +39,11 @@ interface ContainerProps {
}
export const ConfigProvider = ({ children }: ContainerProps) => {
const [config, dispatchConfig] = useReducer(configReducer, { accesses: [] });
const [config, dispatchConfig] = useReducer(configReducer, {
accesses: [],
emails: { content: { emails: [] } },
accessGroups: [],
});
useEffect(() => {
const featchData = async () => {
@@ -41,6 +53,31 @@ export const ConfigProvider = ({ children }: ContainerProps) => {
featchData();
}, []);
useEffect(() => {
const featchEmails = async () => {
const emails = await getEmails();
dispatchConfig({ type: "SET_EMAILS", payload: emails });
};
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 });
}, []);
const deleteAccess = useCallback((id: string) => {
dispatchConfig({ type: "DELETE_ACCESS", payload: id });
}, []);
@@ -53,15 +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,11 +1,16 @@
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 }
| { type: "DELETE_ACCESS"; payload: string }
| { type: "UPDATE_ACCESS"; payload: Access }
| { type: "SET_ACCESSES"; payload: Access[] };
| { type: "SET_ACCESSES"; payload: Access[] }
| { type: "SET_EMAILS"; payload: Setting }
| { type: "ADD_EMAIL"; payload: string }
| { type: "SET_ACCESS_GROUPS"; payload: AccessGroup[] };
export const configReducer = (
state: ConfigData,
@@ -40,6 +45,29 @@ export const configReducer = (
),
};
}
case "SET_EMAILS": {
return {
...state,
emails: action.payload,
};
}
case "ADD_EMAIL": {
return {
...state,
emails: {
...state.emails,
content: {
emails: [...state.emails.content.emails, action.payload],
},
},
};
}
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

@@ -0,0 +1,26 @@
import { Setting } from "@/domain/settings";
import { getPb } from "./api";
export const getEmails = async () => {
try {
const resp = await getPb()
.collection("settings")
.getFirstListItem<Setting>("name='emails'");
return resp;
} catch (e) {
return {
content: { emails: [] },
};
}
};
export const update = async (setting: Setting) => {
const pb = getPb();
let resp: Setting;
if (setting.id) {
resp = await pb.collection("settings").update(setting.id, setting);
} else {
resp = await pb.collection("settings").create(setting);
}
return resp;
};

View File

@@ -14,6 +14,7 @@ module.exports = {
padding: "2rem",
screens: {
"2xl": "1400px",
'3xl': '1920px',
},
},
extend: {