This commit is contained in:
yoan 2024-10-13 08:15:21 +08:00
parent 19f5348802
commit 1928a47961
37 changed files with 1854 additions and 734 deletions

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
"github.com/go-acme/lego/v4/providers/dns/alidns"
@ -25,6 +26,7 @@ func (a *aliyun) Apply() (*Certificate, error) {
os.Setenv("ALICLOUD_ACCESS_KEY", access.AccessKeyId)
os.Setenv("ALICLOUD_SECRET_KEY", access.AccessKeySecret)
os.Setenv("ALICLOUD_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout))
dnsProvider, err := alidns.NewDNSProvider()
if err != nil {
return nil, err

View File

@ -1,6 +1,7 @@
package applicant
import (
"certimate/internal/domain"
"certimate/internal/utils/app"
"crypto"
"crypto/ecdsa"
@ -46,6 +47,8 @@ var sslProviderUrls = map[string]string{
const defaultEmail = "536464346@qq.com"
const defaultTimeout = 60
type Certificate struct {
CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"`
@ -60,6 +63,7 @@ type ApplyOption struct {
Domain string `json:"domain"`
Access string `json:"access"`
Nameservers string `json:"nameservers"`
Timeout int64 `json:"timeout"`
}
type MyUser struct {
@ -83,8 +87,22 @@ type Applicant interface {
}
func Get(record *models.Record) (Applicant, error) {
access := record.ExpandedOne("access")
email := record.GetString("email")
if record.GetString("applyConfig") == "" {
return nil, errors.New("apply config is empty")
}
applyConfig := &domain.ApplyConfig{}
record.UnmarshalJSONField("applyConfig", applyConfig)
access, err := app.GetApp().Dao().FindRecordById("access", applyConfig.Access)
if err != nil {
return nil, fmt.Errorf("access record not found: %w", err)
}
email := applyConfig.Email
if email == "" {
email = defaultEmail
}
@ -92,7 +110,8 @@ func Get(record *models.Record) (Applicant, error) {
Email: email,
Domain: record.GetString("domain"),
Access: access.GetString("config"),
Nameservers: record.GetString("nameservers"),
Nameservers: applyConfig.Nameservers,
Timeout: applyConfig.Timeout,
}
switch access.GetString("configType") {
case configTypeAliyun:

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
cf "github.com/go-acme/lego/v4/providers/dns/cloudflare"
@ -23,6 +24,7 @@ func (c *cloudflare) Apply() (*Certificate, error) {
json.Unmarshal([]byte(c.option.Access), access)
os.Setenv("CLOUDFLARE_DNS_API_TOKEN", access.DnsApiToken)
os.Setenv("CLOUDFLARE_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", c.option.Timeout))
provider, err := cf.NewDNSProvider()
if err != nil {

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
godaddyProvider "github.com/go-acme/lego/v4/providers/dns/godaddy"
@ -25,6 +26,7 @@ func (a *godaddy) Apply() (*Certificate, error) {
os.Setenv("GODADDY_API_KEY", access.ApiKey)
os.Setenv("GODADDY_API_SECRET", access.ApiSecret)
os.Setenv("GODADDY_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout))
dnsProvider, err := godaddyProvider.NewDNSProvider()
if err != nil {

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
huaweicloudProvider "github.com/go-acme/lego/v4/providers/dns/huaweicloud"
@ -26,6 +27,8 @@ func (t *huaweicloud) Apply() (*Certificate, error) {
os.Setenv("HUAWEICLOUD_REGION", access.Region) // 华为云的 SDK 要求必须传一个区域,实际上 DNS-01 流程里用不到,但不传会报错
os.Setenv("HUAWEICLOUD_ACCESS_KEY_ID", access.AccessKeyId)
os.Setenv("HUAWEICLOUD_SECRET_ACCESS_KEY", access.SecretAccessKey)
os.Setenv("HUAWEICLOUD_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", t.option.Timeout))
dnsProvider, err := huaweicloudProvider.NewDNSProvider()
if err != nil {
return nil, err

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
namesiloProvider "github.com/go-acme/lego/v4/providers/dns/namesilo"
@ -24,6 +25,7 @@ func (a *namesilo) Apply() (*Certificate, error) {
json.Unmarshal([]byte(a.option.Access), access)
os.Setenv("NAMESILO_API_KEY", access.ApiKey)
os.Setenv("NAMESILO_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout))
dnsProvider, err := namesiloProvider.NewDNSProvider()
if err != nil {

View File

@ -3,6 +3,7 @@ package applicant
import (
"certimate/internal/domain"
"encoding/json"
"fmt"
"os"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
@ -25,6 +26,8 @@ func (t *tencent) Apply() (*Certificate, error) {
os.Setenv("TENCENTCLOUD_SECRET_ID", access.SecretId)
os.Setenv("TENCENTCLOUD_SECRET_KEY", access.SecretKey)
os.Setenv("TENCENTCLOUD_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", t.option.Timeout))
dnsProvider, err := tencentcloud.NewDNSProvider()
if err != nil {
return nil, err

View File

@ -190,7 +190,7 @@ func (a *aliyun) resource() (*cas20200407.ListCloudResourcesResponseBodyData, er
listCloudResourcesRequest := &cas20200407.ListCloudResourcesRequest{
CloudProduct: tea.String(a.option.Product),
Keyword: tea.String(a.option.Domain),
Keyword: tea.String(getDeployString(a.option.DeployConfig, "domain")),
}
resp, err := a.client.ListCloudResources(listCloudResourcesRequest)

View File

@ -2,6 +2,7 @@ package deployer
import (
"certimate/internal/domain"
"certimate/internal/utils/rand"
"context"
"encoding/json"
"fmt"
@ -46,9 +47,9 @@ func (a *AliyunCdn) GetInfo() []string {
func (a *AliyunCdn) Deploy(ctx context.Context) error {
certName := fmt.Sprintf("%s-%s", a.option.Domain, a.option.DomainId)
certName := fmt.Sprintf("%s-%s-%s", a.option.Domain, a.option.DomainId, rand.RandStr(6))
setCdnDomainSSLCertificateRequest := &cdn20180510.SetCdnDomainSSLCertificateRequest{
DomainName: tea.String(a.option.Domain),
DomainName: tea.String(getDeployString(a.option.DeployConfig, "domain")),
CertName: tea.String(certName),
CertType: tea.String("upload"),
SSLProtocol: tea.String("on"),

View File

@ -7,6 +7,7 @@ package deployer
import (
"certimate/internal/domain"
"certimate/internal/utils/rand"
"context"
"encoding/json"
"fmt"
@ -51,9 +52,9 @@ func (a *AliyunEsa) GetInfo() []string {
func (a *AliyunEsa) Deploy(ctx context.Context) error {
certName := fmt.Sprintf("%s-%s", a.option.Domain, a.option.DomainId)
certName := fmt.Sprintf("%s-%s-%s", a.option.Domain, a.option.DomainId, rand.RandStr(6))
setDcdnDomainSSLCertificateRequest := &dcdn20180115.SetDcdnDomainSSLCertificateRequest{
DomainName: tea.String(a.option.Domain),
DomainName: tea.String(getDeployString(a.option.DeployConfig, "domain")),
CertName: tea.String(certName),
CertType: tea.String("upload"),
SSLProtocol: tea.String("on"),

View File

@ -2,8 +2,8 @@ package deployer
import (
"certimate/internal/applicant"
"certimate/internal/domain"
"certimate/internal/utils/app"
"certimate/internal/utils/variables"
"context"
"encoding/json"
"errors"
@ -30,6 +30,7 @@ type DeployerOption struct {
Product string `json:"product"`
Access string `json:"access"`
AceessRecord *models.Record `json:"-"`
DeployConfig domain.DeployConfig `json:"deployConfig"`
Certificate applicant.Certificate `json:"certificate"`
Variables map[string]string `json:"variables"`
}
@ -42,52 +43,29 @@ type Deployer interface {
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("deployConfig") == "" {
return rs, nil
}
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...)
deployConfigs := make([]domain.DeployConfig, 0)
err := record.UnmarshalJSONField("deployConfig", &deployConfigs)
if err != nil {
return nil, fmt.Errorf("解析部署配置失败: %w", err)
}
return rs, nil
if len(deployConfigs) == 0 {
return rs, nil
}
}
for _, deployConfig := range deployConfigs {
func getByGroup(record *models.Record, cert *applicant.Certificate, accesses ...*models.Record) ([]Deployer, error) {
deployer, err := getWithDeployConfig(record, cert, deployConfig)
rs := make([]Deployer, 0)
for _, access := range accesses {
deployer, err := getWithAccess(record, cert, access)
if err != nil {
return nil, err
}
rs = append(rs, deployer)
}
@ -95,15 +73,21 @@ func getByGroup(record *models.Record, cert *applicant.Certificate, accesses ...
}
func getWithAccess(record *models.Record, cert *applicant.Certificate, access *models.Record) (Deployer, error) {
func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, deployConfig domain.DeployConfig) (Deployer, error) {
access, err := app.GetApp().Dao().FindRecordById("access", deployConfig.Access)
if err != nil {
return nil, fmt.Errorf("access record not found: %w", err)
}
option := &DeployerOption{
DomainId: record.Id,
Domain: record.GetString("domain"),
Product: getProduct(record),
Product: getProduct(deployConfig.Type),
Access: access.GetString("config"),
AceessRecord: access,
Variables: variables.Parse2Map(record.GetString("variables")),
DeployConfig: deployConfig,
}
if cert != nil {
option.Certificate = *cert
@ -114,7 +98,7 @@ func getWithAccess(record *models.Record, cert *applicant.Certificate, access *m
}
}
switch record.GetString("targetType") {
switch deployConfig.Type {
case targetAliyunOss:
return NewAliyun(option)
case targetAliyunCdn:
@ -136,16 +120,8 @@ func getWithAccess(record *models.Record, cert *applicant.Certificate, access *m
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, "-")
func getProduct(t string) string {
rs := strings.Split(t, "-")
if len(rs) < 2 {
return ""
}
@ -159,3 +135,39 @@ func toStr(tag string, data any) string {
byts, _ := json.Marshal(data)
return tag + "" + string(byts)
}
func getDeployString(conf domain.DeployConfig, key string) string {
if _, ok := conf.Config[key]; !ok {
return ""
}
val, ok := conf.Config[key].(string)
if !ok {
return ""
}
return val
}
func getDeployVariables(conf domain.DeployConfig) map[string]string {
rs := make(map[string]string)
data, ok := conf.Config["variables"]
if !ok {
return rs
}
bts, _ := json.Marshal(data)
kvData := make([]domain.KV, 0)
if err := json.Unmarshal(bts, &kvData); err != nil {
return rs
}
for _, kv := range kvData {
rs[kv.Key] = kv.Value
}
return rs
}

View File

@ -11,9 +11,6 @@ import (
)
type localAccess struct {
Command string `json:"command"`
CertPath string `json:"certPath"`
KeyPath string `json:"keyPath"`
}
type local struct {
@ -41,18 +38,27 @@ func (l *local) Deploy(ctx context.Context) error {
if err := json.Unmarshal([]byte(l.option.Access), access); err != nil {
return err
}
preCommand := getDeployString(l.option.DeployConfig, "preCommand")
if preCommand != "" {
if err := execCmd(preCommand); err != nil {
return fmt.Errorf("执行前置命令失败: %w", err)
}
}
// 复制文件
if err := copyFile(l.option.Certificate.Certificate, access.CertPath); err != nil {
if err := copyFile(l.option.Certificate.Certificate, getDeployString(l.option.DeployConfig, "certPath")); err != nil {
return fmt.Errorf("复制证书失败: %w", err)
}
if err := copyFile(l.option.Certificate.PrivateKey, access.KeyPath); err != nil {
if err := copyFile(l.option.Certificate.PrivateKey, getDeployString(l.option.DeployConfig, "keyPath")); err != nil {
return fmt.Errorf("复制私钥失败: %w", err)
}
// 执行命令
if err := execCmd(access.Command); err != nil {
if err := execCmd(getDeployString(l.option.DeployConfig, "command")); err != nil {
return fmt.Errorf("执行命令失败: %w", err)
}

View File

@ -78,7 +78,7 @@ func (q *qiuniu) Deploy(ctx context.Context) error {
}
func (q *qiuniu) enableHttps(certId string) error {
path := fmt.Sprintf("/domain/%s/sslize", q.option.Domain)
path := fmt.Sprintf("/domain/%s/sslize", getDeployString(q.option.DeployConfig, "domain"))
body := &modifyDomainCertReq{
CertID: certId,
@ -104,7 +104,7 @@ type domainInfo struct {
}
func (q *qiuniu) getDomainInfo() (*domainInfo, error) {
path := fmt.Sprintf("/domain/%s", q.option.Domain)
path := fmt.Sprintf("/domain/%s", getDeployString(q.option.DeployConfig, "domain"))
res, err := q.req(qiniuGateway+path, http.MethodGet, nil)
if err != nil {
@ -135,8 +135,8 @@ func (q *qiuniu) uploadCert() (string, error) {
path := "/sslcert"
body := &uploadCertReq{
Name: q.option.Domain,
CommonName: q.option.Domain,
Name: getDeployString(q.option.DeployConfig, "domain"),
CommonName: getDeployString(q.option.DeployConfig, "domain"),
Pri: q.option.Certificate.PrivateKey,
Ca: q.option.Certificate.Certificate,
}
@ -166,7 +166,7 @@ type modifyDomainCertReq struct {
}
func (q *qiuniu) modifyDomainCert(certId string) error {
path := fmt.Sprintf("/domain/%s/httpsconf", q.option.Domain)
path := fmt.Sprintf("/domain/%s/httpsconf", getDeployString(q.option.DeployConfig, "domain"))
body := &modifyDomainCertReq{
CertID: certId,

View File

@ -7,7 +7,6 @@ import (
"fmt"
"os"
xpath "path"
"strings"
"github.com/pkg/sftp"
sshPkg "golang.org/x/crypto/ssh"
@ -19,15 +18,11 @@ type ssh struct {
}
type sshAccess struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
Key string `json:"key"`
Port string `json:"port"`
PreCommand string `json:"preCommand"`
Command string `json:"command"`
CertPath string `json:"certPath"`
KeyPath string `json:"keyPath"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
Key string `json:"key"`
Port string `json:"port"`
}
func NewSSH(option *DeployerOption) (Deployer, error) {
@ -50,16 +45,6 @@ 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)
access.PreCommand = strings.ReplaceAll(access.PreCommand, key, v)
}
// 连接
client, err := s.getClient(access)
if err != nil {
@ -70,29 +55,30 @@ func (s *ssh) Deploy(ctx context.Context) error {
s.infos = append(s.infos, toStr("ssh连接成功", nil))
// 执行前置命令
if access.PreCommand != "" {
err, stdout, stderr := s.sshExecCommand(client, access.PreCommand)
preCommand := getDeployString(s.option.DeployConfig, "preCommand")
if preCommand != "" {
err, stdout, stderr := s.sshExecCommand(client, preCommand)
if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
}
// 上传证书
if err := s.upload(client, s.option.Certificate.Certificate, access.CertPath); err != nil {
if err := s.upload(client, s.option.Certificate.Certificate, getDeployString(s.option.DeployConfig, "certPath")); err != nil {
return fmt.Errorf("failed to upload certificate: %w", err)
}
s.infos = append(s.infos, toStr("ssh上传证书成功", nil))
// 上传私钥
if err := s.upload(client, s.option.Certificate.PrivateKey, access.KeyPath); err != nil {
if err := s.upload(client, s.option.Certificate.PrivateKey, getDeployString(s.option.DeployConfig, "keyPath")); err != nil {
return fmt.Errorf("failed to upload private key: %w", err)
}
s.infos = append(s.infos, toStr("ssh上传私钥成功", nil))
// 执行命令
err, stdout, stderr := s.sshExecCommand(client, access.Command)
err, stdout, stderr := s.sshExecCommand(client, getDeployString(s.option.DeployConfig, "command"))
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}

View File

@ -14,9 +14,10 @@ type webhookAccess struct {
}
type hookData struct {
Domain string `json:"domain"`
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
Domain string `json:"domain"`
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
Variables map[string]string `json:"variables"`
}
type webhook struct {
@ -50,6 +51,7 @@ func (w *webhook) Deploy(ctx context.Context) error {
Domain: w.option.Domain,
Certificate: w.option.Certificate.Certificate,
PrivateKey: w.option.Certificate.PrivateKey,
Variables: getDeployVariables(w.option.DeployConfig),
}
body, _ := json.Marshal(data)

View File

@ -0,0 +1,20 @@
package domain
type ApplyConfig struct {
Email string `json:"email"`
Access string `json:"access"`
Timeout int64 `json:"timeout"`
Nameservers string `json:"nameservers"`
}
type DeployConfig struct {
Id string `json:"id"`
Access string `json:"access"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
type KV struct {
Key string `json:"key"`
Value string `json:"value"`
}

View File

@ -5,7 +5,6 @@ import (
"certimate/internal/deployer"
"certimate/internal/utils/app"
"context"
"errors"
"fmt"
"time"
@ -41,18 +40,6 @@ 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", "group"}, 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...)
app.GetApp().Logger().Error("展开记录失败", "err", err)
history.record(checkPhase, "获取授权信息失败", &RecordInfo{Err: err})
return err
}
history.record(checkPhase, "获取授权信息成功", nil)
cert := currRecord.GetString("certificate")
expiredAt := currRecord.GetDateTime("expiredAt").Time()
@ -106,6 +93,13 @@ func deploy(ctx context.Context, record *models.Record) error {
return err
}
// 没有部署配置,也算成功
if len(deployers) == 0 {
history.record(deployPhase, "没有部署配置", &RecordInfo{Info: []string{"没有部署配置"}})
history.setWholeSuccess(true)
return nil
}
for _, deployer := range deployers {
if err = deployer.Deploy(ctx); err != nil {

View File

@ -0,0 +1,731 @@
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-10-08 06:50:56.637Z",
"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",
"aliyun-dcdn",
"ssh",
"webhook",
"tencent-cdn",
"qiniu-cdn",
"local"
]
}
},
{
"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
}
},
{
"system": false,
"id": "g65gfh7a",
"name": "nameservers",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "wwrzc3jo",
"name": "applyConfig",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "474iwy8r",
"name": "deployConfig",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
}
],
"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-10-11 13:55:13.777Z",
"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",
"huaweicloud",
"qiniu",
"cloudflare",
"namesilo",
"godaddy",
"local",
"ssh",
"webhook"
]
}
},
{
"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-26 12:29:38.334Z",
"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": ""
}
},
{
"system": false,
"id": "wledpzgb",
"name": "wholeSuccess",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}
],
"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-26 12:29:38.334Z",
"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-26 12:29:38.334Z",
"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-26 12:29:38.334Z",
"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
})
}

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

332
ui/dist/assets/index-DbwFzZm1.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
ui/dist/index.html vendored
View File

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

28
ui/package-lock.json generated
View File

@ -33,6 +33,7 @@
"jszip": "^3.10.1",
"lucide-react": "^0.417.0",
"moment": "^2.30.1",
"nanoid": "^5.0.7",
"pocketbase": "^0.21.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -4159,9 +4160,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "5.0.7",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.0.7.tgz",
"integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
"funding": [
{
"type": "github",
@ -4169,10 +4170,10 @@
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@ -4561,6 +4562,23 @@
"resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@ -29,22 +29,23 @@
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"jszip": "^3.10.1",
"lucide-react": "^0.417.0",
"moment": "^2.30.1",
"nanoid": "^5.0.7",
"pocketbase": "^0.21.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.0.2",
"react-router-dom": "^6.25.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.1",
"zod": "^3.23.8",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"react-i18next": "^15.0.2"
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",

View File

@ -1,10 +1,4 @@
import {
Access,
accessFormType,
getUsageByConfigType,
LocalConfig,
SSHConfig,
} from "@/domain/access";
import { Access, accessFormType, getUsageByConfigType } from "@/domain/access";
import { useConfig } from "@/providers/config";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@ -20,7 +14,7 @@ import {
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { save } from "@/repository/access";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
@ -39,30 +33,19 @@ const AccessLocalForm = ({
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
name: z
.string()
.min(1, "access.form.name.not.empty")
.max(64, t("zod.rule.string.max", { max: 64 })),
configType: accessFormType,
command: z.string().min(1, 'access.form.ssh.command.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
certPath: z.string().min(0, 'access.form.ssh.cert.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
keyPath: z.string().min(0, 'access.form.ssh.key.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
});
let config: LocalConfig = {
command: "sudo service nginx restart",
certPath: "/etc/nginx/ssl/certificate.crt",
keyPath: "/etc/nginx/ssl/private.key",
};
if (data) config = data.config as SSHConfig;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name || '',
name: data?.name || "",
configType: "local",
certPath: config.certPath,
keyPath: config.keyPath,
command: config.command,
},
});
@ -73,15 +56,11 @@ const AccessLocalForm = ({
configType: data.configType,
usage: getUsageByConfigType(data.configType),
config: {
command: data.command,
certPath: data.certPath,
keyPath: data.keyPath,
},
config: {},
};
try {
req.id = op == "copy" ? "" : req.id;
req.id = op == "copy" ? "" : req.id;
const rs = await save(req);
onAfterReq();
@ -128,9 +107,12 @@ const AccessLocalForm = ({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.name.not.empty')} {...field} />
<Input
placeholder={t("access.form.name.not.empty")}
{...field}
/>
</FormControl>
<FormMessage />
@ -143,7 +125,7 @@ const AccessLocalForm = ({
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t('access.form.config.field')}</FormLabel>
<FormLabel>{t("access.form.config.field")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -158,7 +140,7 @@ const AccessLocalForm = ({
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t('access.form.config.field')}</FormLabel>
<FormLabel>{t("access.form.config.field")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -168,55 +150,10 @@ const AccessLocalForm = ({
)}
/>
<FormField
control={form.control}
name="certPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.cert.path')}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.cert.path.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.key.path')}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.key.path.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.command')}</FormLabel>
<FormControl>
<Textarea placeholder={t('access.form.ssh.command.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
<div className="flex justify-end">
<Button type="submit">{t('save')}</Button>
<Button type="submit">{t("save")}</Button>
</div>
</form>
</Form>

View File

@ -18,7 +18,6 @@ import {
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { save } from "@/repository/access";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
@ -66,7 +65,10 @@ const AccessSSHForm = ({
const formSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, 'access.form.name.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
name: z
.string()
.min(1, "access.form.name.not.empty")
.max(64, t("zod.rule.string.max", { max: 64 })),
configType: accessFormType,
host: z.string().refine(
(str) => {
@ -77,16 +79,23 @@ const AccessSSHForm = ({
}
),
group: z.string().optional(),
port: z.string().min(1, 'access.form.ssh.port.not.empty').max(5, t('zod.rule.string.max', { max: 5 })),
username: z.string().min(1, 'username.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
password: z.string().min(0, 'password.not.empty').max(64, t('zod.rule.string.max', { max: 64 })),
key: z.string().min(0, 'access.form.ssh.key.not.empty').max(20480, t('zod.rule.string.max', { max: 20480 })),
port: z
.string()
.min(1, "access.form.ssh.port.not.empty")
.max(5, t("zod.rule.string.max", { max: 5 })),
username: z
.string()
.min(1, "username.not.empty")
.max(64, t("zod.rule.string.max", { max: 64 })),
password: z
.string()
.min(0, "password.not.empty")
.max(64, t("zod.rule.string.max", { max: 64 })),
key: z
.string()
.min(0, "access.form.ssh.key.not.empty")
.max(20480, t("zod.rule.string.max", { max: 20480 })),
keyFile: z.any().optional(),
preCommand: z.string().min(0).max(2048, t('zod.rule.string.max', { max: 2048 })).optional(),
command: z.string().min(1, 'access.form.ssh.command.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
certPath: z.string().min(0, 'access.form.ssh.cert.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
keyPath: z.string().min(0, 'access.form.ssh.key.path.not.empty').max(2048, t('zod.rule.string.max', { max: 2048 })),
});
let config: SSHConfig = {
@ -96,10 +105,6 @@ const AccessSSHForm = ({
password: "",
key: "",
keyFile: "",
preCommand: "",
command: "sudo service nginx restart",
certPath: "/etc/nginx/ssl/certificate.crt",
keyPath: "/etc/nginx/ssl/private.key",
};
if (data) config = data.config as SSHConfig;
@ -107,7 +112,7 @@ const AccessSSHForm = ({
resolver: zodResolver(formSchema),
defaultValues: {
id: data?.id,
name: data?.name || '',
name: data?.name || "",
configType: "ssh",
group: data?.group,
host: config.host,
@ -116,10 +121,6 @@ const AccessSSHForm = ({
password: config.password,
key: config.key,
keyFile: config.keyFile,
certPath: config.certPath,
keyPath: config.keyPath,
command: config.command,
preCommand: config.preCommand,
},
});
@ -139,15 +140,11 @@ const AccessSSHForm = ({
username: data.username,
password: data.password,
key: data.key,
command: data.command,
preCommand: data.preCommand,
certPath: data.certPath,
keyPath: data.keyPath,
},
};
try {
req.id = op == "copy" ? "" : req.id;
req.id = op == "copy" ? "" : req.id;
const rs = await save(req);
onAfterReq();
@ -228,9 +225,12 @@ const AccessSSHForm = ({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.name.not.empty')} {...field} />
<Input
placeholder={t("access.form.name.not.empty")}
{...field}
/>
</FormControl>
<FormMessage />
@ -244,12 +244,12 @@ const AccessSSHForm = ({
render={({ field }) => (
<FormItem>
<FormLabel className="w-full flex justify-between">
<div>{t('access.form.ssh.group.label')}</div>
<div>{t("access.form.ssh.group.label")}</div>
<AccessGroupEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
<Plus size={14} />
{t('add')}
{t("add")}
</div>
}
/>
@ -264,7 +264,9 @@ const AccessSSHForm = ({
}}
>
<SelectTrigger>
<SelectValue placeholder={t('access.group.not.empty')} />
<SelectValue
placeholder={t("access.group.not.empty")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="emptyId">
@ -304,7 +306,7 @@ const AccessSSHForm = ({
name="id"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t('access.form.config.field')}</FormLabel>
<FormLabel>{t("access.form.config.field")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -319,7 +321,7 @@ const AccessSSHForm = ({
name="configType"
render={({ field }) => (
<FormItem className="hidden">
<FormLabel>{t('access.form.config.field')}</FormLabel>
<FormLabel>{t("access.form.config.field")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -334,9 +336,12 @@ const AccessSSHForm = ({
name="host"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>{t('access.form.ssh.host')}</FormLabel>
<FormLabel>{t("access.form.ssh.host")}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.host.not.empty')} {...field} />
<Input
placeholder={t("access.form.ssh.host.not.empty")}
{...field}
/>
</FormControl>
<FormMessage />
@ -349,10 +354,10 @@ const AccessSSHForm = ({
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.port')}</FormLabel>
<FormLabel>{t("access.form.ssh.port")}</FormLabel>
<FormControl>
<Input
placeholder={t('access.form.ssh.port.not.empty')}
placeholder={t("access.form.ssh.port.not.empty")}
{...field}
type="number"
/>
@ -369,9 +374,9 @@ const AccessSSHForm = ({
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t('username')}</FormLabel>
<FormLabel>{t("username")}</FormLabel>
<FormControl>
<Input placeholder={t('username.not.empty')} {...field} />
<Input placeholder={t("username.not.empty")} {...field} />
</FormControl>
<FormMessage />
@ -384,10 +389,10 @@ const AccessSSHForm = ({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormLabel>{t("password")}</FormLabel>
<FormControl>
<Input
placeholder={t('password.not.empty')}
placeholder={t("password.not.empty")}
{...field}
type="password"
/>
@ -403,9 +408,12 @@ const AccessSSHForm = ({
name="key"
render={({ field }) => (
<FormItem hidden>
<FormLabel>{t('access.form.ssh.key')}</FormLabel>
<FormLabel>{t("access.form.ssh.key")}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.key.not.empty')} {...field} />
<Input
placeholder={t("access.form.ssh.key.not.empty")}
{...field}
/>
</FormControl>
<FormMessage />
@ -418,7 +426,7 @@ const AccessSSHForm = ({
name="keyFile"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.key')}</FormLabel>
<FormLabel>{t("access.form.ssh.key")}</FormLabel>
<FormControl>
<div>
<Button
@ -428,10 +436,12 @@ const AccessSSHForm = ({
className="w-48"
onClick={handleSelectFileClick}
>
{fileName ? fileName : t('access.form.ssh.key.file.not.empty')}
{fileName
? fileName
: t("access.form.ssh.key.file.not.empty")}
</Button>
<Input
placeholder={t('access.form.ssh.key.not.empty')}
placeholder={t("access.form.ssh.key.not.empty")}
{...field}
ref={fileInputRef}
className="hidden"
@ -447,70 +457,10 @@ const AccessSSHForm = ({
)}
/>
<FormField
control={form.control}
name="certPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.cert.path')}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.cert.path.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.key.path')}</FormLabel>
<FormControl>
<Input placeholder={t('access.form.ssh.key.path.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="preCommand"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.pre.command')}</FormLabel>
<FormControl>
<Textarea placeholder={t('access.form.ssh.pre.command.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>{t('access.form.ssh.command')}</FormLabel>
<FormControl>
<Textarea placeholder={t('access.form.ssh.command.not.empty')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
<div className="flex justify-end">
<Button type="submit">{t('save')}</Button>
<Button type="submit">{t("save")}</Button>
</div>
</form>
</Form>

View File

@ -43,10 +43,14 @@ import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import KVList from "./KVList";
import { produce } from "immer";
import { nanoid } from "nanoid";
import { z } from "zod";
type DeployEditContextProps = {
deploy: DeployConfig;
error: Record<string, string>;
setDeploy: (deploy: DeployConfig) => void;
setError: (error: Record<string, string>) => void;
};
const DeployEditContext = createContext<DeployEditContextProps>(
@ -59,53 +63,92 @@ export const useDeployEditContext = () => {
type DeployListProps = {
deploys: DeployConfig[];
onChange: (deploys: DeployConfig[]) => void;
};
const DeployList = ({ deploys }: DeployListProps) => {
const DeployList = ({ deploys, onChange }: DeployListProps) => {
const [list, setList] = useState<DeployConfig[]>([]);
const { t } = useTranslation();
useEffect(() => {
setList(deploys);
}, [deploys]);
const handleAdd = (deploy: DeployConfig) => {
deploy.id = nanoid();
const newList = [...list, deploy];
setList(newList);
onChange(newList);
};
const handleDelete = (id: string) => {
const newList = list.filter((item) => item.id !== id);
setList(newList);
onChange(newList);
};
const handleSave = (deploy: DeployConfig) => {
const newList = list.map((item) => {
if (item.id === deploy.id) {
return { ...deploy };
}
return item;
});
setList(newList);
onChange(newList);
};
return (
<>
<Show
when={list.length > 0}
fallback={
<Alert className="w-full">
<Alert className="w-full border dark:border-stone-400">
<AlertDescription className="flex flex-col items-center">
<div></div>
<div>{t("deployment.not.added")}</div>
<div className="flex justify-end mt-2">
<DeployEditDialog
trigger={<Button size={"sm"}></Button>}
onSave={(config: DeployConfig) => {
handleAdd(config);
}}
trigger={<Button size={"sm"}>{t("add")}</Button>}
/>
</div>
</AlertDescription>
</Alert>
}
>
<div className="flex justify-end py-2 border-b">
<DeployEditDialog trigger={<Button size={"sm"}></Button>} />
<div className="flex justify-end py-2 border-b dark:border-stone-400">
<DeployEditDialog
trigger={<Button size={"sm"}>{t("add")}</Button>}
onSave={(config: DeployConfig) => {
handleAdd(config);
}}
/>
</div>
<div className="w-full md:w-[35em] rounded mt-5 border">
<div className="w-full md:w-[35em] rounded mt-5 border dark:border-stone-400">
<div className="">
<div className="flex justify-between text-sm p-3 items-center text-stone-700">
<div className="flex space-x-2 items-center">
<div>
<img src="/imgs/providers/ssh.svg" className="w-9"></img>
</div>
<div className="text-stone-600 flex-col flex space-y-0">
<div>ssh部署</div>
<div></div>
</div>
</div>
<div className="flex space-x-2">
<EditIcon size={16} className="cursor-pointer" />
<Trash2 size={16} className="cursor-pointer" />
</div>
</div>
{list.map((item) => (
<DeployItem
key={item.id}
item={item}
onDelete={() => {
handleDelete(item.id ?? "");
}}
onSave={(deploy: DeployConfig) => {
handleSave(deploy);
}}
/>
))}
</div>
</div>
</Show>
@ -113,11 +156,87 @@ const DeployList = ({ deploys }: DeployListProps) => {
);
};
type DeployItemProps = {
item: DeployConfig;
onDelete: () => void;
onSave: (deploy: DeployConfig) => void;
};
const DeployItem = ({ item, onDelete, onSave }: DeployItemProps) => {
const {
config: { accesses },
} = useConfig();
const { t } = useTranslation();
const access = accesses.find((access) => access.id === item.access);
const getImg = () => {
if (!access) {
return "";
}
const accessType = accessTypeMap.get(access.configType);
if (accessType) {
return accessType[1];
}
return "";
};
const getTypeName = () => {
if (!access) {
return "";
}
const accessType = targetTypeMap.get(item.type);
if (accessType) {
return t(accessType[0]);
}
return "";
};
return (
<div className="flex justify-between text-sm p-3 items-center text-stone-700">
<div className="flex space-x-2 items-center">
<div>
<img src={getImg()} className="w-9"></img>
</div>
<div className="text-stone-600 flex-col flex space-y-0">
<div>{getTypeName()}</div>
<div>{access?.name}</div>
</div>
</div>
<div className="flex space-x-2">
<DeployEditDialog
trigger={<EditIcon size={16} className="cursor-pointer" />}
deployConfig={item}
onSave={(deploy: DeployConfig) => {
onSave(deploy);
}}
/>
<Trash2
size={16}
className="cursor-pointer"
onClick={() => {
onDelete();
}}
/>
</div>
</div>
);
};
type DeployEditDialogProps = {
trigger: React.ReactNode;
deployConfig?: DeployConfig;
onSave: (deploy: DeployConfig) => void;
};
const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
const DeployEditDialog = ({
trigger,
deployConfig,
onSave,
}: DeployEditDialogProps) => {
const {
config: { accesses },
} = useConfig();
@ -129,6 +248,10 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
type: "",
});
const [error, setError] = useState<Record<string, string>>({});
const [open, setOpen] = useState(false);
useEffect(() => {
if (deployConfig) {
setLocDeployConfig({ ...deployConfig });
@ -150,6 +273,7 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
t = locDeployConfig.type;
}
setDeployType(t as TargetType);
setError({});
}, [locDeployConfig.type]);
const setDeploy = useCallback(
@ -177,23 +301,62 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
return item.configType === types[0];
});
const handleSaveClick = () => {
// 验证数据
// 保存数据
// 清理数据
// 关闭弹框
const newError = { ...error };
if (locDeployConfig.type === "") {
newError.type = t("domain.management.edit.access.not.empty.message");
} else {
newError.type = "";
}
if (locDeployConfig.access === "") {
newError.access = t("domain.management.edit.access.not.empty.message");
} else {
newError.access = "";
}
setError(newError);
for (const key in newError) {
if (newError[key] !== "") {
return;
}
}
onSave(locDeployConfig);
setLocDeployConfig({
access: "",
type: "",
});
setError({});
setOpen(false);
};
return (
<DeployEditContext.Provider
value={{
deploy: locDeployConfig,
setDeploy: setDeploy,
error: error,
setError: setError,
}}
>
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent>
<DialogContent className="dark:text-stone-200">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("deployment")}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{/* 授权类型 */}
<div>
<Label></Label>
<Label>{t("deployment.access.type")}</Label>
<Select
value={locDeployConfig.type}
@ -227,11 +390,13 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-500 text-sm mt-1">{error.type}</div>
</div>
{/* 授权 */}
<div>
<Label className="flex justify-between">
<div></div>
<div>{t("deployment.access.config")}</div>
<AccessEdit
trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
@ -275,12 +440,21 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-500 text-sm mt-1">{error.access}</div>
</div>
<DeployEdit type={deployType!} />
<DialogFooter>
<Button></Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleSaveClick();
}}
>
{t("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -317,8 +491,27 @@ const DeployEdit = ({ type }: DeployEditProps) => {
const DeploySSH = () => {
const { t } = useTranslation();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
const { deploy: data, setDeploy } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
certPath: "/etc/nginx/ssl/nginx.crt",
keyPath: "/etc/nginx/ssl/nginx.key",
preCommand: "",
command: "sudo service nginx reload",
},
});
}
}, []);
return (
<>
<div className="flex flex-col space-y-2">
@ -358,10 +551,11 @@ const DeploySSH = () => {
</div>
<div>
<Label></Label>
<Label>{t("access.form.ssh.pre.command")}</Label>
<Textarea
className="mt-1"
value={data?.config?.preCommand}
placeholder={t("access.form.ssh.pre.command.not.empty")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
@ -375,10 +569,11 @@ const DeploySSH = () => {
</div>
<div>
<Label></Label>
<Label>{t("access.form.ssh.command")}</Label>
<Textarea
className="mt-1"
value={data?.config?.command}
placeholder={t("access.form.ssh.command.not.empty")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
@ -396,25 +591,69 @@ const DeploySSH = () => {
};
const DeployCDN = () => {
const { deploy: data, setDeploy } = useDeployEditContext();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
const { t } = useTranslation();
useEffect(() => {
setError({});
}, []);
useEffect(() => {
const resp = domainSchema.safeParse(data.config?.domain);
if (!resp.success) {
setError({
...error,
domain: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
domain: "",
});
}
}, [data]);
const domainSchema = z
.string()
.regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("domain.not.empty.verify.message"),
});
return (
<div className="flex flex-col space-y-2">
<div>
<Label></Label>
<Label>{t("deployment.access.cdn.deploy.to.domain")}</Label>
<Input
placeholder="部署至域名"
placeholder={t("deployment.access.cdn.deploy.to.domain")}
className="w-full mt-1"
value={data?.config?.domain}
onChange={(e) => {
const temp = e.target.value;
const resp = domainSchema.safeParse(temp);
if (!resp.success) {
setError({
...error,
domain: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
domain: "",
});
}
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.domain = e.target.value;
draft.config.domain = temp;
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.domain}</div>
</div>
</div>
);
@ -423,6 +662,12 @@ const DeployCDN = () => {
const DeployWebhook = () => {
const { deploy: data, setDeploy } = useDeployEditContext();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
return (
<>
<KVList

View File

@ -70,8 +70,8 @@ const KVList = ({ variables, onValueChange }: KVListProps) => {
return (
<>
<div className="flex justify-between">
<Label></Label>
<div className="flex justify-between dark:text-stone-200">
<Label>{t("variable")}</Label>
<Show when={!!locVariables?.length}>
<KVEdit
variable={{
@ -97,7 +97,7 @@ const KVList = ({ variables, onValueChange }: KVListProps) => {
fallback={
<div className="border rounded-md p-3 text-sm mt-2 flex flex-col items-center">
<div className="text-muted-foreground">
{t("not.added.yet.variable")}
{t("variable.not.added")}
</div>
<KVEdit
@ -119,7 +119,7 @@ const KVList = ({ variables, onValueChange }: KVListProps) => {
</div>
}
>
<div className="border p-3 rounded-md text-stone-700 text-sm">
<div className="border p-3 rounded-md text-stone-700 text-sm dark:text-stone-200">
{locVariables?.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<div>
@ -175,14 +175,14 @@ const KVEdit = ({ variable, trigger, onSave }: KVEditProps) => {
const handleSaveClick = () => {
if (!locVariable.key) {
setErr({
key: t("name.required"),
key: t("variable.name.required"),
});
return;
}
if (!locVariable.value) {
setErr({
value: t("value.required"),
value: t("variable.value.required"),
});
return;
}
@ -202,14 +202,14 @@ const KVEdit = ({ variable, trigger, onSave }: KVEditProps) => {
}}
>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent>
<DialogContent className="dark:text-stone-200">
<DialogHeader className="flex flex-col">
<DialogTitle></DialogTitle>
<DialogTitle>{t("variable")}</DialogTitle>
<div className="pt-5 flex flex-col items-start">
<Label></Label>
<Label>{t("variable.name")}</Label>
<Input
placeholder="请输入变量名"
placeholder={t("variable.name.placeholder")}
value={locVariable?.key}
onChange={(e) => {
setLocVariable({ ...locVariable, key: e.target.value });
@ -220,9 +220,9 @@ const KVEdit = ({ variable, trigger, onSave }: KVEditProps) => {
</div>
<div className="pt-2 flex flex-col items-start">
<Label></Label>
<Label>{t("variable.value")}</Label>
<Input
placeholder="请输入变量值"
placeholder={t("variable.value.placeholder")}
value={locVariable?.value}
onChange={(e) => {
setLocVariable({ ...locVariable, value: e.target.value });

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -91,23 +91,15 @@ export type GodaddyConfig = {
apiSecret: string;
};
export type LocalConfig = {
command: string;
certPath: string;
keyPath: string;
};
export type LocalConfig = Record<string, string>;
export type SSHConfig = {
host: string;
port: string;
preCommand?: string;
command: string;
username: string;
password?: string;
key?: string;
keyFile?: string;
certPath: string;
keyPath: string;
};
export type WebhookConfig = {

View File

@ -1,7 +1,7 @@
import { Deployment, Pahse } from "./deployment";
export type Domain = {
id: string;
id?: string;
domain: string;
email?: string;
crontab: string;

View File

@ -1 +1 @@
export const version = "Certimate v0.1.19";
export const version = "Certimate v0.2.0";

View File

@ -224,5 +224,20 @@
"access.form.ssh.pre.command.not.empty": "Command to be executed before deploying the certificate",
"access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "Please enter command",
"access.form.ding.access.token.placeholder": "Signature for signed addition"
"access.form.ding.access.token.placeholder": "Signature for signed addition",
"variable": "Variable",
"variable.name": "Name",
"variable.value": "Value",
"variable.not.added": "Variable not added yet",
"variable.name.required": "Variable name cannot be empty",
"variable.value.required": "Variable value cannot be empty",
"variable.name.placeholder": "Variable name",
"variable.value.placeholder": "Variable value",
"deployment": "Deployment",
"deployment.not.added": "Deployment not added yet",
"deployment.access.type": "Access Type",
"deployment.access.config": "Access Configuration",
"deployment.access.cdn.deploy.to.domain": "Deploy to domain"
}

View File

@ -224,5 +224,20 @@
"access.form.ssh.pre.command.not.empty": "在部署证书前执行的前置命令",
"access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "请输入要执行的命令",
"access.form.ding.access.token.placeholder": "加签的签名"
"access.form.ding.access.token.placeholder": "加签的签名",
"variable": "变量",
"variable.name": "变量名",
"variable.value": "值",
"variable.not.added": "尚未添加变量",
"variable.name.required": "变量名不能为空",
"variable.value.required": "变量值不能为空",
"variable.name.placeholder": "请输入变量名",
"variable.value.placeholder": "请输入变量值",
"deployment": "部署",
"deployment.not.added": "暂无部署配置,请添加后开始部署证书吧",
"deployment.access.type": "授权类型",
"deployment.access.config": "授权配置",
"deployment.access.cdn.deploy.to.domain": "部署到域名"
}

View File

@ -23,39 +23,44 @@ import {
} from "@/components/ui/select";
import { useConfig } from "@/providers/config";
import { useEffect, useState } from "react";
import { Domain, targetTypeKeys, targetTypeMap } from "@/domain/domain";
import { DeployConfig, Domain } from "@/domain/domain";
import { save, get } from "@/repository/domains";
import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base";
import { useToast } from "@/components/ui/use-toast";
import { Toaster } from "@/components/ui/toaster";
import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Trash2, Edit as EditIcon } from "lucide-react";
import { useLocation } 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";
import { EmailsSetting } from "@/domain/settings";
import { useTranslation } from "react-i18next";
import StringList from "@/components/certimate/StringList";
import { Input } from "@/components/ui/input";
import DeployList from "@/components/certimate/DeployList";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
const Edit = () => {
const {
config: { accesses, emails, accessGroups },
config: { accesses, emails },
} = useConfig();
const [domain, setDomain] = useState<Domain>();
const [domain, setDomain] = useState<Domain>({} as Domain);
const location = useLocation();
const { t } = useTranslation();
const [tab, setTab] = useState<"apply" | "deploy">("apply");
const [targetType, setTargetType] = useState(domain ? domain.targetType : "");
useEffect(() => {
// Parsing query parameters
const queryParams = new URLSearchParams(location.search);
@ -64,7 +69,6 @@ const Edit = () => {
const fetchData = async () => {
const data = await get(id);
setDomain(data);
setTargetType(data.targetType);
};
fetchData();
}
@ -109,22 +113,8 @@ const Edit = () => {
}
}, [domain, form]);
const targetAccesses = accesses.filter((item) => {
if (item.usage == "apply") {
return false;
}
if (targetType == "") {
return true;
}
const types = targetType.split("-");
return item.configType === types[0];
});
const { toast } = useToast();
const navigate = useNavigate();
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data);
const req: Domain = {
@ -142,7 +132,7 @@ const Edit = () => {
};
try {
await save(req);
const resp = await save(req);
let description = t("domain.management.edit.succeed.tips");
if (req.id == "") {
description = t("domain.management.add.succeed.tips");
@ -152,7 +142,44 @@ const Edit = () => {
title: t("succeed"),
description,
});
if (!domain?.id) setTab("deploy");
setDomain({ ...resp });
} 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;
}
};
const handelOnDeployListChange = async (list: DeployConfig[]) => {
const req = {
...domain,
deployConfig: list,
};
try {
const resp = await save(req);
let description = t("domain.management.edit.succeed.tips");
if (req.id == "") {
description = t("domain.management.add.succeed.tips");
}
toast({
title: t("succeed"),
description,
});
if (!domain?.id) setTab("deploy");
setDomain({ ...resp });
} catch (e) {
const err = e as ClientResponseError;
@ -174,7 +201,22 @@ const Edit = () => {
<div className="">
<Toaster />
<div className=" h-5 text-muted-foreground">
{domain?.id ? t("domain.edit") : t("domain.add")}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#/domains">
{t("domain.management.name")}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{domain?.id ? t("domain.edit") : t("domain.add")}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<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 md:mt-5">
@ -425,7 +467,12 @@ const Edit = () => {
tab == "apply" && "hidden"
)}
>
<DeployList deploys={domain?.deployConfig ?? []} />
<DeployList
deploys={domain?.deployConfig ?? []}
onChange={(list: DeployConfig[]) => {
handelOnDeployListChange(list);
}}
/>
</div>
</div>
</div>

View File

@ -96,7 +96,6 @@ const Home = () => {
const handelCheckedChange = async (id: string) => {
const checkedDomains = domains.filter((domain) => domain.id === id);
const isChecked = checkedDomains[0].enabled;
const data = checkedDomains[0];
data.enabled = !isChecked;
@ -114,8 +113,8 @@ const Home = () => {
const handleRightNowClick = async (domain: Domain) => {
try {
unsubscribeId(domain.id);
subscribeId(domain.id, (resp) => {
unsubscribeId(domain.id ?? "");
subscribeId(domain.id ?? "", (resp) => {
console.log(resp);
const updatedDomains = domains.map((domain) => {
if (domain.id === resp.id) {
@ -283,7 +282,7 @@ const Home = () => {
<Switch
checked={domain.enabled}
onCheckedChange={() => {
handelCheckedChange(domain.id);
handelCheckedChange(domain.id ?? "");
}}
></Switch>
</TooltipTrigger>
@ -299,7 +298,7 @@ const Home = () => {
<Button
variant={"link"}
className="p-0"
onClick={() => handleHistoryClick(domain.id)}
onClick={() => handleHistoryClick(domain.id ?? "")}
>
{t("deployment.log.name")}
</Button>
@ -364,7 +363,7 @@ const Home = () => {
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
handleDeleteClick(domain.id);
handleDeleteClick(domain.id ?? "");
}}
>
{t("confirm")}
@ -377,7 +376,7 @@ const Home = () => {
<Button
variant={"link"}
className="p-0"
onClick={() => handleEditClick(domain.id)}
onClick={() => handleEditClick(domain.id ?? "")}
>
{t("edit")}
</Button>