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 ( import (
"certimate/internal/domain" "certimate/internal/domain"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"github.com/go-acme/lego/v4/providers/dns/alidns" "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_ACCESS_KEY", access.AccessKeyId)
os.Setenv("ALICLOUD_SECRET_KEY", access.AccessKeySecret) os.Setenv("ALICLOUD_SECRET_KEY", access.AccessKeySecret)
os.Setenv("ALICLOUD_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout))
dnsProvider, err := alidns.NewDNSProvider() dnsProvider, err := alidns.NewDNSProvider()
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -1,6 +1,7 @@
package applicant package applicant
import ( import (
"certimate/internal/domain"
"certimate/internal/utils/app" "certimate/internal/utils/app"
"crypto" "crypto"
"crypto/ecdsa" "crypto/ecdsa"
@ -46,6 +47,8 @@ var sslProviderUrls = map[string]string{
const defaultEmail = "536464346@qq.com" const defaultEmail = "536464346@qq.com"
const defaultTimeout = 60
type Certificate struct { type Certificate struct {
CertUrl string `json:"certUrl"` CertUrl string `json:"certUrl"`
CertStableUrl string `json:"certStableUrl"` CertStableUrl string `json:"certStableUrl"`
@ -60,6 +63,7 @@ type ApplyOption struct {
Domain string `json:"domain"` Domain string `json:"domain"`
Access string `json:"access"` Access string `json:"access"`
Nameservers string `json:"nameservers"` Nameservers string `json:"nameservers"`
Timeout int64 `json:"timeout"`
} }
type MyUser struct { type MyUser struct {
@ -83,8 +87,22 @@ type Applicant interface {
} }
func Get(record *models.Record) (Applicant, error) { 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 == "" { if email == "" {
email = defaultEmail email = defaultEmail
} }
@ -92,7 +110,8 @@ func Get(record *models.Record) (Applicant, error) {
Email: email, Email: email,
Domain: record.GetString("domain"), Domain: record.GetString("domain"),
Access: access.GetString("config"), Access: access.GetString("config"),
Nameservers: record.GetString("nameservers"), Nameservers: applyConfig.Nameservers,
Timeout: applyConfig.Timeout,
} }
switch access.GetString("configType") { switch access.GetString("configType") {
case configTypeAliyun: case configTypeAliyun:

View File

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

View File

@ -3,6 +3,7 @@ package applicant
import ( import (
"certimate/internal/domain" "certimate/internal/domain"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
godaddyProvider "github.com/go-acme/lego/v4/providers/dns/godaddy" 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_KEY", access.ApiKey)
os.Setenv("GODADDY_API_SECRET", access.ApiSecret) os.Setenv("GODADDY_API_SECRET", access.ApiSecret)
os.Setenv("GODADDY_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", a.option.Timeout))
dnsProvider, err := godaddyProvider.NewDNSProvider() dnsProvider, err := godaddyProvider.NewDNSProvider()
if err != nil { if err != nil {

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package applicant
import ( import (
"certimate/internal/domain" "certimate/internal/domain"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud" "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_ID", access.SecretId)
os.Setenv("TENCENTCLOUD_SECRET_KEY", access.SecretKey) os.Setenv("TENCENTCLOUD_SECRET_KEY", access.SecretKey)
os.Setenv("TENCENTCLOUD_PROPAGATION_TIMEOUT", fmt.Sprintf("%d", t.option.Timeout))
dnsProvider, err := tencentcloud.NewDNSProvider() dnsProvider, err := tencentcloud.NewDNSProvider()
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -190,7 +190,7 @@ func (a *aliyun) resource() (*cas20200407.ListCloudResourcesResponseBodyData, er
listCloudResourcesRequest := &cas20200407.ListCloudResourcesRequest{ listCloudResourcesRequest := &cas20200407.ListCloudResourcesRequest{
CloudProduct: tea.String(a.option.Product), 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) resp, err := a.client.ListCloudResources(listCloudResourcesRequest)

View File

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

View File

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

View File

@ -2,8 +2,8 @@ package deployer
import ( import (
"certimate/internal/applicant" "certimate/internal/applicant"
"certimate/internal/domain"
"certimate/internal/utils/app" "certimate/internal/utils/app"
"certimate/internal/utils/variables"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -30,6 +30,7 @@ type DeployerOption struct {
Product string `json:"product"` Product string `json:"product"`
Access string `json:"access"` Access string `json:"access"`
AceessRecord *models.Record `json:"-"` AceessRecord *models.Record `json:"-"`
DeployConfig domain.DeployConfig `json:"deployConfig"`
Certificate applicant.Certificate `json:"certificate"` Certificate applicant.Certificate `json:"certificate"`
Variables map[string]string `json:"variables"` Variables map[string]string `json:"variables"`
} }
@ -42,52 +43,29 @@ type Deployer interface {
func Gets(record *models.Record, cert *applicant.Certificate) ([]Deployer, error) { func Gets(record *models.Record, cert *applicant.Certificate) ([]Deployer, error) {
rs := make([]Deployer, 0) rs := make([]Deployer, 0)
if record.GetString("deployConfig") == "" {
if record.GetString("targetAccess") != "" { return rs, nil
singleDeployer, err := Get(record, cert)
if err != nil {
return nil, err
}
rs = append(rs, singleDeployer)
} }
if record.GetString("group") != "" { deployConfigs := make([]domain.DeployConfig, 0)
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...)
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 { if err != nil {
return nil, err return nil, err
} }
rs = append(rs, deployer) 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{ option := &DeployerOption{
DomainId: record.Id, DomainId: record.Id,
Domain: record.GetString("domain"), Domain: record.GetString("domain"),
Product: getProduct(record), Product: getProduct(deployConfig.Type),
Access: access.GetString("config"), Access: access.GetString("config"),
AceessRecord: access, AceessRecord: access,
Variables: variables.Parse2Map(record.GetString("variables")), DeployConfig: deployConfig,
} }
if cert != nil { if cert != nil {
option.Certificate = *cert 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: case targetAliyunOss:
return NewAliyun(option) return NewAliyun(option)
case targetAliyunCdn: case targetAliyunCdn:
@ -136,16 +120,8 @@ func getWithAccess(record *models.Record, cert *applicant.Certificate, access *m
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func Get(record *models.Record, cert *applicant.Certificate) (Deployer, error) { func getProduct(t string) string {
rs := strings.Split(t, "-")
access := record.ExpandedOne("targetAccess")
return getWithAccess(record, cert, access)
}
func getProduct(record *models.Record) string {
targetType := record.GetString("targetType")
rs := strings.Split(targetType, "-")
if len(rs) < 2 { if len(rs) < 2 {
return "" return ""
} }
@ -159,3 +135,39 @@ func toStr(tag string, data any) string {
byts, _ := json.Marshal(data) byts, _ := json.Marshal(data)
return tag + "" + string(byts) 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 { type localAccess struct {
Command string `json:"command"`
CertPath string `json:"certPath"`
KeyPath string `json:"keyPath"`
} }
type local struct { 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 { if err := json.Unmarshal([]byte(l.option.Access), access); err != nil {
return err 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) 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) 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) 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 { 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{ body := &modifyDomainCertReq{
CertID: certId, CertID: certId,
@ -104,7 +104,7 @@ type domainInfo struct {
} }
func (q *qiuniu) getDomainInfo() (*domainInfo, error) { 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) res, err := q.req(qiniuGateway+path, http.MethodGet, nil)
if err != nil { if err != nil {
@ -135,8 +135,8 @@ func (q *qiuniu) uploadCert() (string, error) {
path := "/sslcert" path := "/sslcert"
body := &uploadCertReq{ body := &uploadCertReq{
Name: q.option.Domain, Name: getDeployString(q.option.DeployConfig, "domain"),
CommonName: q.option.Domain, CommonName: getDeployString(q.option.DeployConfig, "domain"),
Pri: q.option.Certificate.PrivateKey, Pri: q.option.Certificate.PrivateKey,
Ca: q.option.Certificate.Certificate, Ca: q.option.Certificate.Certificate,
} }
@ -166,7 +166,7 @@ type modifyDomainCertReq struct {
} }
func (q *qiuniu) modifyDomainCert(certId string) error { 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{ body := &modifyDomainCertReq{
CertID: certId, CertID: certId,

View File

@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"os" "os"
xpath "path" xpath "path"
"strings"
"github.com/pkg/sftp" "github.com/pkg/sftp"
sshPkg "golang.org/x/crypto/ssh" sshPkg "golang.org/x/crypto/ssh"
@ -19,15 +18,11 @@ type ssh struct {
} }
type sshAccess struct { type sshAccess struct {
Host string `json:"host"` Host string `json:"host"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Key string `json:"key"` Key string `json:"key"`
Port string `json:"port"` Port string `json:"port"`
PreCommand string `json:"preCommand"`
Command string `json:"command"`
CertPath string `json:"certPath"`
KeyPath string `json:"keyPath"`
} }
func NewSSH(option *DeployerOption) (Deployer, error) { 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 { if err := json.Unmarshal([]byte(s.option.Access), access); err != nil {
return err 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) client, err := s.getClient(access)
if err != nil { if err != nil {
@ -70,29 +55,30 @@ func (s *ssh) Deploy(ctx context.Context) error {
s.infos = append(s.infos, toStr("ssh连接成功", nil)) s.infos = append(s.infos, toStr("ssh连接成功", nil))
// 执行前置命令 // 执行前置命令
if access.PreCommand != "" { preCommand := getDeployString(s.option.DeployConfig, "preCommand")
err, stdout, stderr := s.sshExecCommand(client, access.PreCommand) if preCommand != "" {
err, stdout, stderr := s.sshExecCommand(client, preCommand)
if err != nil { if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr) 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) return fmt.Errorf("failed to upload certificate: %w", err)
} }
s.infos = append(s.infos, toStr("ssh上传证书成功", nil)) 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) return fmt.Errorf("failed to upload private key: %w", err)
} }
s.infos = append(s.infos, toStr("ssh上传私钥成功", nil)) 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 { if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) 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 { type hookData struct {
Domain string `json:"domain"` Domain string `json:"domain"`
Certificate string `json:"certificate"` Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"` PrivateKey string `json:"privateKey"`
Variables map[string]string `json:"variables"`
} }
type webhook struct { type webhook struct {
@ -50,6 +51,7 @@ func (w *webhook) Deploy(ctx context.Context) error {
Domain: w.option.Domain, Domain: w.option.Domain,
Certificate: w.option.Certificate.Certificate, Certificate: w.option.Certificate.Certificate,
PrivateKey: w.option.Certificate.PrivateKey, PrivateKey: w.option.Certificate.PrivateKey,
Variables: getDeployVariables(w.option.DeployConfig),
} }
body, _ := json.Marshal(data) 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/deployer"
"certimate/internal/utils/app" "certimate/internal/utils/app"
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
@ -41,18 +40,6 @@ func deploy(ctx context.Context, record *models.Record) error {
return err return err
} }
history.record(checkPhase, "获取记录成功", nil) 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") cert := currRecord.GetString("certificate")
expiredAt := currRecord.GetDateTime("expiredAt").Time() expiredAt := currRecord.GetDateTime("expiredAt").Time()
@ -106,6 +93,13 @@ func deploy(ctx context.Context, record *models.Record) error {
return err return err
} }
// 没有部署配置,也算成功
if len(deployers) == 0 {
history.record(deployPhase, "没有部署配置", &RecordInfo{Info: []string{"没有部署配置"}})
history.setWholeSuccess(true)
return nil
}
for _, deployer := range deployers { for _, deployer := range deployers {
if err = deployer.Deploy(ctx); err != nil { 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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Certimate - Your Trusted SSL Automation Partner</title> <title>Certimate - Your Trusted SSL Automation Partner</title>
<script type="module" crossorigin src="/assets/index-DpHAV802.js"></script> <script type="module" crossorigin src="/assets/index-DbwFzZm1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DOft-CKV.css"> <link rel="stylesheet" crossorigin href="/assets/index-CWUb5Xuf.css">
</head> </head>
<body class="bg-background"> <body class="bg-background">
<div id="root"></div> <div id="root"></div>

28
ui/package-lock.json generated
View File

@ -33,6 +33,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.417.0", "lucide-react": "^0.417.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"nanoid": "^5.0.7",
"pocketbase": "^0.21.4", "pocketbase": "^0.21.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -4159,9 +4160,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "5.0.7",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.0.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -4169,10 +4170,10 @@
} }
], ],
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.js"
}, },
"engines": { "engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^18 || >=20"
} }
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@ -4561,6 +4562,23 @@
"resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", "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", "@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.417.0", "lucide-react": "^0.417.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"nanoid": "^5.0.7",
"pocketbase": "^0.21.4", "pocketbase": "^0.21.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.0.2",
"react-router-dom": "^6.25.1", "react-router-dom": "^6.25.1",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.1", "vaul": "^0.9.1",
"zod": "^3.23.8", "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"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",

View File

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

View File

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

View File

@ -43,10 +43,14 @@ import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import KVList from "./KVList"; import KVList from "./KVList";
import { produce } from "immer"; import { produce } from "immer";
import { nanoid } from "nanoid";
import { z } from "zod";
type DeployEditContextProps = { type DeployEditContextProps = {
deploy: DeployConfig; deploy: DeployConfig;
error: Record<string, string>;
setDeploy: (deploy: DeployConfig) => void; setDeploy: (deploy: DeployConfig) => void;
setError: (error: Record<string, string>) => void;
}; };
const DeployEditContext = createContext<DeployEditContextProps>( const DeployEditContext = createContext<DeployEditContextProps>(
@ -59,53 +63,92 @@ export const useDeployEditContext = () => {
type DeployListProps = { type DeployListProps = {
deploys: DeployConfig[]; deploys: DeployConfig[];
onChange: (deploys: DeployConfig[]) => void;
}; };
const DeployList = ({ deploys }: DeployListProps) => { const DeployList = ({ deploys, onChange }: DeployListProps) => {
const [list, setList] = useState<DeployConfig[]>([]); const [list, setList] = useState<DeployConfig[]>([]);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setList(deploys); setList(deploys);
}, [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 ( return (
<> <>
<Show <Show
when={list.length > 0} when={list.length > 0}
fallback={ fallback={
<Alert className="w-full"> <Alert className="w-full border dark:border-stone-400">
<AlertDescription className="flex flex-col items-center"> <AlertDescription className="flex flex-col items-center">
<div></div> <div>{t("deployment.not.added")}</div>
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-2">
<DeployEditDialog <DeployEditDialog
trigger={<Button size={"sm"}></Button>} onSave={(config: DeployConfig) => {
handleAdd(config);
}}
trigger={<Button size={"sm"}>{t("add")}</Button>}
/> />
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
} }
> >
<div className="flex justify-end py-2 border-b"> <div className="flex justify-end py-2 border-b dark:border-stone-400">
<DeployEditDialog trigger={<Button size={"sm"}></Button>} /> <DeployEditDialog
trigger={<Button size={"sm"}>{t("add")}</Button>}
onSave={(config: DeployConfig) => {
handleAdd(config);
}}
/>
</div> </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="">
<div className="flex justify-between text-sm p-3 items-center text-stone-700"> {list.map((item) => (
<div className="flex space-x-2 items-center"> <DeployItem
<div> key={item.id}
<img src="/imgs/providers/ssh.svg" className="w-9"></img> item={item}
</div> onDelete={() => {
<div className="text-stone-600 flex-col flex space-y-0"> handleDelete(item.id ?? "");
<div>ssh部署</div> }}
<div></div> onSave={(deploy: DeployConfig) => {
</div> handleSave(deploy);
</div> }}
<div className="flex space-x-2"> />
<EditIcon size={16} className="cursor-pointer" /> ))}
<Trash2 size={16} className="cursor-pointer" />
</div>
</div>
</div> </div>
</div> </div>
</Show> </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 = { type DeployEditDialogProps = {
trigger: React.ReactNode; trigger: React.ReactNode;
deployConfig?: DeployConfig; deployConfig?: DeployConfig;
onSave: (deploy: DeployConfig) => void;
}; };
const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => { const DeployEditDialog = ({
trigger,
deployConfig,
onSave,
}: DeployEditDialogProps) => {
const { const {
config: { accesses }, config: { accesses },
} = useConfig(); } = useConfig();
@ -129,6 +248,10 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
type: "", type: "",
}); });
const [error, setError] = useState<Record<string, string>>({});
const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (deployConfig) { if (deployConfig) {
setLocDeployConfig({ ...deployConfig }); setLocDeployConfig({ ...deployConfig });
@ -150,6 +273,7 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
t = locDeployConfig.type; t = locDeployConfig.type;
} }
setDeployType(t as TargetType); setDeployType(t as TargetType);
setError({});
}, [locDeployConfig.type]); }, [locDeployConfig.type]);
const setDeploy = useCallback( const setDeploy = useCallback(
@ -177,23 +301,62 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
return item.configType === types[0]; 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 ( return (
<DeployEditContext.Provider <DeployEditContext.Provider
value={{ value={{
deploy: locDeployConfig, deploy: locDeployConfig,
setDeploy: setDeploy, setDeploy: setDeploy,
error: error,
setError: setError,
}} }}
> >
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{trigger}</DialogTrigger> <DialogTrigger>{trigger}</DialogTrigger>
<DialogContent> <DialogContent className="dark:text-stone-200">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t("deployment")}</DialogTitle>
<DialogDescription></DialogDescription> <DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
{/* 授权类型 */} {/* 授权类型 */}
<div> <div>
<Label></Label> <Label>{t("deployment.access.type")}</Label>
<Select <Select
value={locDeployConfig.type} value={locDeployConfig.type}
@ -227,11 +390,13 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="text-red-500 text-sm mt-1">{error.type}</div>
</div> </div>
{/* 授权 */} {/* 授权 */}
<div> <div>
<Label className="flex justify-between"> <Label className="flex justify-between">
<div></div> <div>{t("deployment.access.config")}</div>
<AccessEdit <AccessEdit
trigger={ trigger={
<div className="font-normal text-primary hover:underline cursor-pointer flex items-center"> <div className="font-normal text-primary hover:underline cursor-pointer flex items-center">
@ -275,12 +440,21 @@ const DeployEditDialog = ({ trigger, deployConfig }: DeployEditDialogProps) => {
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="text-red-500 text-sm mt-1">{error.access}</div>
</div> </div>
<DeployEdit type={deployType!} /> <DeployEdit type={deployType!} />
<DialogFooter> <DialogFooter>
<Button></Button> <Button
onClick={(e) => {
e.stopPropagation();
handleSaveClick();
}}
>
{t("save")}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -317,8 +491,27 @@ const DeployEdit = ({ type }: DeployEditProps) => {
const DeploySSH = () => { const DeploySSH = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
const { deploy: data, setDeploy } = useDeployEditContext(); 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 ( return (
<> <>
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
@ -358,10 +551,11 @@ const DeploySSH = () => {
</div> </div>
<div> <div>
<Label></Label> <Label>{t("access.form.ssh.pre.command")}</Label>
<Textarea <Textarea
className="mt-1" className="mt-1"
value={data?.config?.preCommand} value={data?.config?.preCommand}
placeholder={t("access.form.ssh.pre.command.not.empty")}
onChange={(e) => { onChange={(e) => {
const newData = produce(data, (draft) => { const newData = produce(data, (draft) => {
if (!draft.config) { if (!draft.config) {
@ -375,10 +569,11 @@ const DeploySSH = () => {
</div> </div>
<div> <div>
<Label></Label> <Label>{t("access.form.ssh.command")}</Label>
<Textarea <Textarea
className="mt-1" className="mt-1"
value={data?.config?.command} value={data?.config?.command}
placeholder={t("access.form.ssh.command.not.empty")}
onChange={(e) => { onChange={(e) => {
const newData = produce(data, (draft) => { const newData = produce(data, (draft) => {
if (!draft.config) { if (!draft.config) {
@ -396,25 +591,69 @@ const DeploySSH = () => {
}; };
const DeployCDN = () => { 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 ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div> <div>
<Label></Label> <Label>{t("deployment.access.cdn.deploy.to.domain")}</Label>
<Input <Input
placeholder="部署至域名" placeholder={t("deployment.access.cdn.deploy.to.domain")}
className="w-full mt-1" className="w-full mt-1"
value={data?.config?.domain} value={data?.config?.domain}
onChange={(e) => { 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) => { const newData = produce(data, (draft) => {
if (!draft.config) { if (!draft.config) {
draft.config = {}; draft.config = {};
} }
draft.config.domain = e.target.value; draft.config.domain = temp;
}); });
setDeploy(newData); setDeploy(newData);
}} }}
/> />
<div className="text-red-600 text-sm mt-1">{error?.domain}</div>
</div> </div>
</div> </div>
); );
@ -423,6 +662,12 @@ const DeployCDN = () => {
const DeployWebhook = () => { const DeployWebhook = () => {
const { deploy: data, setDeploy } = useDeployEditContext(); const { deploy: data, setDeploy } = useDeployEditContext();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
return ( return (
<> <>
<KVList <KVList

View File

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

View File

@ -1,7 +1,7 @@
import { Deployment, Pahse } from "./deployment"; import { Deployment, Pahse } from "./deployment";
export type Domain = { export type Domain = {
id: string; id?: string;
domain: string; domain: string;
email?: string; email?: string;
crontab: 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.pre.command.not.empty": "Command to be executed before deploying the certificate",
"access.form.ssh.command": "Command", "access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "Please enter 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.pre.command.not.empty": "在部署证书前执行的前置命令",
"access.form.ssh.command": "Command", "access.form.ssh.command": "Command",
"access.form.ssh.command.not.empty": "请输入要执行的命令", "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"; } from "@/components/ui/select";
import { useConfig } from "@/providers/config"; import { useConfig } from "@/providers/config";
import { useEffect, useState } from "react"; 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 { save, get } from "@/repository/domains";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError } from "pocketbase";
import { PbErrorData } from "@/domain/base"; import { PbErrorData } from "@/domain/base";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Plus, Trash2, Edit as EditIcon } from "lucide-react"; import { Plus } from "lucide-react";
import { AccessEdit } from "@/components/certimate/AccessEdit"; import { AccessEdit } from "@/components/certimate/AccessEdit";
import { accessTypeMap } from "@/domain/access"; import { accessTypeMap } from "@/domain/access";
import EmailsEdit from "@/components/certimate/EmailsEdit"; import EmailsEdit from "@/components/certimate/EmailsEdit";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EmailsSetting } from "@/domain/settings"; import { EmailsSetting } from "@/domain/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import StringList from "@/components/certimate/StringList"; import StringList from "@/components/certimate/StringList";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import DeployList from "@/components/certimate/DeployList"; import DeployList from "@/components/certimate/DeployList";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
const Edit = () => { const Edit = () => {
const { const {
config: { accesses, emails, accessGroups }, config: { accesses, emails },
} = useConfig(); } = useConfig();
const [domain, setDomain] = useState<Domain>(); const [domain, setDomain] = useState<Domain>({} as Domain);
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<"apply" | "deploy">("apply"); const [tab, setTab] = useState<"apply" | "deploy">("apply");
const [targetType, setTargetType] = useState(domain ? domain.targetType : "");
useEffect(() => { useEffect(() => {
// Parsing query parameters // Parsing query parameters
const queryParams = new URLSearchParams(location.search); const queryParams = new URLSearchParams(location.search);
@ -64,7 +69,6 @@ const Edit = () => {
const fetchData = async () => { const fetchData = async () => {
const data = await get(id); const data = await get(id);
setDomain(data); setDomain(data);
setTargetType(data.targetType);
}; };
fetchData(); fetchData();
} }
@ -109,22 +113,8 @@ const Edit = () => {
} }
}, [domain, form]); }, [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 { toast } = useToast();
const navigate = useNavigate();
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data); console.log(data);
const req: Domain = { const req: Domain = {
@ -142,7 +132,7 @@ const Edit = () => {
}; };
try { try {
await save(req); const resp = await save(req);
let description = t("domain.management.edit.succeed.tips"); let description = t("domain.management.edit.succeed.tips");
if (req.id == "") { if (req.id == "") {
description = t("domain.management.add.succeed.tips"); description = t("domain.management.add.succeed.tips");
@ -152,7 +142,44 @@ const Edit = () => {
title: t("succeed"), title: t("succeed"),
description, description,
}); });
if (!domain?.id) setTab("deploy"); 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) { } catch (e) {
const err = e as ClientResponseError; const err = e as ClientResponseError;
@ -174,7 +201,22 @@ const Edit = () => {
<div className=""> <div className="">
<Toaster /> <Toaster />
<div className=" h-5 text-muted-foreground"> <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>
<div className="mt-5 flex w-full justify-center md:space-x-10 flex-col md:flex-row"> <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"> <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" tab == "apply" && "hidden"
)} )}
> >
<DeployList deploys={domain?.deployConfig ?? []} /> <DeployList
deploys={domain?.deployConfig ?? []}
onChange={(list: DeployConfig[]) => {
handelOnDeployListChange(list);
}}
/>
</div> </div>
</div> </div>
</div> </div>

View File

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