Merge branch 'main' into feat/cloud-load-balance

This commit is contained in:
Fu Diwei 2024-10-29 09:12:39 +08:00
commit aeaa45b713
39 changed files with 2637 additions and 308 deletions

7
go.mod
View File

@ -19,6 +19,7 @@ require (
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.114
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/nikoksr/notify v1.0.0
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
github.com/pkg/sftp v1.13.6
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.18
@ -26,10 +27,13 @@ require (
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030
golang.org/x/crypto v0.28.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
k8s.io/api v0.31.1
k8s.io/apimachinery v0.31.1
k8s.io/client-go v0.31.1
software.sslmate.com/src/go-pkcs12 v0.5.0
)
require (
@ -58,7 +62,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver v1.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@ -111,7 +114,7 @@ require (
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect

9
go.sum
View File

@ -206,6 +206,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
@ -398,6 +400,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -458,7 +462,6 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 h1:Oymmfm
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017/go.mod h1:gnLxGXlLmF+jDqWR1/RVoF/UUwxQxomQhkc0oN7KeuI=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.992/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017 h1:SXrldOXwgomYuATVAuz5ofpTjB+99qVELgdy5R5kMgI=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 h1:kwiUoCkooUgy7iPyhEEbio7WT21kGJUeZ5JeJfb/dYk=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
@ -531,6 +534,8 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -776,3 +781,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@ -1,16 +1,21 @@
package deployer
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/pavlo-v-chernykh/keystore-go/v4"
"github.com/pocketbase/pocketbase/models"
"software.sslmate.com/src/go-pkcs12"
"github.com/usual2970/certimate/internal/applicant"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
"github.com/usual2970/certimate/internal/utils/app"
)
@ -38,7 +43,6 @@ const (
type DeployerOption struct {
DomainId string `json:"domainId"`
Domain string `json:"domain"`
Product string `json:"product"`
Access string `json:"access"`
AccessRecord *models.Record `json:"-"`
DeployConfig domain.DeployConfig `json:"deployConfig"`
@ -90,7 +94,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
option := &DeployerOption{
DomainId: record.Id,
Domain: record.GetString("domain"),
Product: getProduct(deployConfig.Type),
Access: access.GetString("config"),
AccessRecord: access,
DeployConfig: deployConfig,
@ -145,14 +148,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
return nil, errors.New("unsupported deploy target")
}
func getProduct(t string) string {
rs := strings.Split(t, "-")
if len(rs) < 2 {
return ""
}
return rs[1]
}
func toStr(tag string, data any) string {
if data == nil {
return tag
@ -195,3 +190,57 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string {
return rs
}
func convertPEMToPFX(certificate string, privateKey string, password string) ([]byte, error) {
cert, err := x509.ParseCertificateFromPEM(certificate)
if err != nil {
return nil, err
}
privkey, err := x509.ParsePKCS1PrivateKeyFromPEM(privateKey)
if err != nil {
return nil, err
}
pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password)
if err != nil {
return nil, fmt.Errorf("failed to encode as pfx %w", err)
}
return pfxData, nil
}
func convertPEMToJKS(certificate string, privateKey string, alias string, keypass string, storepass string) ([]byte, error) {
certBlock, _ := pem.Decode([]byte(certificate))
if certBlock == nil {
return nil, errors.New("failed to decode certificate PEM")
}
privkeyBlock, _ := pem.Decode([]byte(privateKey))
if privkeyBlock == nil {
return nil, errors.New("failed to decode private key PEM")
}
ks := keystore.New()
entry := keystore.PrivateKeyEntry{
CreationTime: time.Now(),
PrivateKey: privkeyBlock.Bytes,
CertificateChain: []keystore.Certificate{
{
Type: "X509",
Content: certBlock.Bytes,
},
},
}
if err := ks.SetPrivateKeyEntry(alias, entry, []byte(keypass)); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := ks.Store(&buf, []byte(storepass)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@ -108,7 +108,7 @@ func (d *K8sSecretDeployer) Deploy(ctx context.Context) error {
}
// 更新 Secret 实例
_, err = client.CoreV1().Secrets(namespace).Update(ctx, &secretPayload, k8sMeta.UpdateOptions{})
_, err = client.CoreV1().Secrets(namespace).Update(context.TODO(), &secretPayload, k8sMetaV1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update k8s secret: %w", err)
}

View File

@ -1,15 +1,15 @@
package deployer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/fs"
)
type LocalDeployer struct {
@ -17,6 +17,18 @@ type LocalDeployer struct {
infos []string
}
const (
certFormatPEM = "pem"
certFormatPFX = "pfx"
certFormatJKS = "jks"
)
const (
shellEnvSh = "sh"
shellEnvCmd = "cmd"
shellEnvPowershell = "powershell"
)
func NewLocalDeployer(option *DeployerOption) (Deployer, error) {
return &LocalDeployer{
option: option,
@ -38,74 +50,114 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error {
return err
}
preCommand := getDeployString(d.option.DeployConfig, "preCommand")
// 执行前置命令
preCommand := d.option.DeployConfig.GetConfigAsString("preCommand")
if preCommand != "" {
if err := execCmd(preCommand); err != nil {
return fmt.Errorf("执行前置命令失败: %w", err)
stdout, stderr, err := d.execCommand(preCommand)
if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("执行前置命令成功", stdout))
}
// 复制证书文件
if err := copyFile(getDeployString(d.option.DeployConfig, "certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("复制证书失败: %w", err)
}
// 写入证书和私钥文件
switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) {
case certFormatPEM:
if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
// 复制私钥文件
if err := copyFile(getDeployString(d.option.DeployConfig, "keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("复制私钥失败: %w", err)
d.infos = append(d.infos, toStr("保存证书成功", nil))
if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("failed to save private key file: %w", err)
}
d.infos = append(d.infos, toStr("保存私钥成功", nil))
case certFormatPFX:
pfxData, err := convertPEMToPFX(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("pfxPassword"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
case certFormatJKS:
jksData, err := convertPEMToJKS(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("jksAlias"),
d.option.DeployConfig.GetConfigAsString("jksKeypass"),
d.option.DeployConfig.GetConfigAsString("jksStorepass"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
}
// 执行命令
if err := execCmd(getDeployString(d.option.DeployConfig, "command")); err != nil {
return fmt.Errorf("执行命令失败: %w", err)
command := d.option.DeployConfig.GetConfigAsString("command")
if command != "" {
stdout, stderr, err := d.execCommand(command)
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("执行命令成功", stdout))
}
return nil
}
func execCmd(command string) error {
// 执行命令
func (d *LocalDeployer) execCommand(command string) (string, string, error) {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
switch d.option.DeployConfig.GetConfigAsString("shell") {
case shellEnvSh:
cmd = exec.Command("sh", "-c", command)
case shellEnvCmd:
cmd = exec.Command("cmd", "/C", command)
case shellEnvPowershell:
cmd = exec.Command("powershell", "-Command", command)
case "":
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
cmd = exec.Command("sh", "-c", command)
}
default:
return "", "", fmt.Errorf("unsupported shell")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
var stdoutBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
err := cmd.Run()
if err != nil {
return fmt.Errorf("执行命令失败: %w", err)
return "", "", fmt.Errorf("failed to execute script: %w", err)
}
return nil
}
func copyFile(path string, content string) error {
dir := filepath.Dir(path)
// 如果目录不存在,创建目录
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 创建或打开文件
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer file.Close()
// 写入内容到文件
_, err = file.Write([]byte(content))
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
return stdoutBuf.String(), stderrBuf.String(), err
}

View File

@ -24,7 +24,6 @@ func Test_qiuniu_uploadCert(t *testing.T) {
option: &DeployerOption{
DomainId: "1",
Domain: "example.com",
Product: "test",
Access: `{"bucket":"test","accessKey":"","secretKey":""}`,
Certificate: applicant.Certificate{
Certificate: "",
@ -70,7 +69,6 @@ func Test_qiuniu_modifyDomainCert(t *testing.T) {
option: &DeployerOption{
DomainId: "1",
Domain: "jt1.ikit.fun",
Product: "test",
Access: `{"bucket":"test","accessKey":"","secretKey":""}`,
},
},

View File

@ -6,12 +6,13 @@ import (
"encoding/json"
"fmt"
"os"
xpath "path"
"path/filepath"
"github.com/pkg/sftp"
sshPkg "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/fs"
)
type SSHDeployer struct {
@ -41,49 +42,120 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error {
}
// 连接
client, err := d.createClient(access)
client, err := d.createSshClient(access)
if err != nil {
return err
}
defer client.Close()
d.infos = append(d.infos, toStr("ssh连接成功", nil))
d.infos = append(d.infos, toStr("SSH 连接成功", nil))
// 执行前置命令
preCommand := getDeployString(d.option.DeployConfig, "preCommand")
preCommand := d.option.DeployConfig.GetConfigAsString("preCommand")
if preCommand != "" {
stdout, stderr, err := d.sshExecCommand(client, preCommand)
if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("SSH 执行前置命令成功", stdout))
}
// 上传证书
if err := d.upload(client, d.option.Certificate.Certificate, getDeployString(d.option.DeployConfig, "certPath")); err != nil {
return fmt.Errorf("failed to upload certificate: %w", err)
// 上传证书和私钥文件
switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) {
case certFormatPEM:
if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传证书成功", nil))
if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("failed to upload private key file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil))
case certFormatPFX:
pfxData, err := convertPEMToPFX(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("pfxPassword"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := d.writeSftpFile(client, d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传证书成功", nil))
case certFormatJKS:
jksData, err := convertPEMToJKS(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("jksAlias"),
d.option.DeployConfig.GetConfigAsString("jksKeypass"),
d.option.DeployConfig.GetConfigAsString("jksStorepass"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
}
d.infos = append(d.infos, toStr("ssh上传证书成功", nil))
// 上传私钥
if err := d.upload(client, d.option.Certificate.PrivateKey, getDeployString(d.option.DeployConfig, "keyPath")); err != nil {
return fmt.Errorf("failed to upload private key: %w", err)
}
d.infos = append(d.infos, toStr("ssh上传私钥成功", nil))
// 执行命令
stdout, stderr, err := d.sshExecCommand(client, getDeployString(d.option.DeployConfig, "command"))
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
command := d.option.DeployConfig.GetConfigAsString("command")
if command != "" {
stdout, stderr, err := d.sshExecCommand(client, command)
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("ssh执行命令成功", stdout))
d.infos = append(d.infos, toStr("SSH 执行命令成功", stdout))
}
return nil
}
func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (string, string, error) {
func (d *SSHDeployer) createSshClient(access *domain.SSHAccess) (*ssh.Client, error) {
var authMethod ssh.AuthMethod
if access.Key != "" {
var signer ssh.Signer
var err error
if access.KeyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(access.Key))
}
if err != nil {
return nil, err
}
authMethod = ssh.PublicKeys(signer)
} else {
authMethod = ssh.Password(access.Password)
}
return ssh.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &ssh.ClientConfig{
User: access.Username,
Auth: []ssh.AuthMethod{
authMethod,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
}
func (d *SSHDeployer) sshExecCommand(client *ssh.Client, command string) (string, string, error) {
session, err := client.NewSession()
if err != nil {
return "", "", fmt.Errorf("failed to create ssh session: %w", err)
@ -98,14 +170,18 @@ func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (str
return stdoutBuf.String(), stderrBuf.String(), err
}
func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error {
func (d *SSHDeployer) writeSftpFileString(client *ssh.Client, path string, content string) error {
return d.writeSftpFile(client, path, []byte(content))
}
func (d *SSHDeployer) writeSftpFile(client *ssh.Client, path string, data []byte) error {
sftpCli, err := sftp.NewClient(client)
if err != nil {
return fmt.Errorf("failed to create sftp client: %w", err)
}
defer sftpCli.Close()
if err := sftpCli.MkdirAll(xpath.Dir(path)); err != nil {
if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
@ -115,40 +191,10 @@ func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error
}
defer file.Close()
_, err = file.Write([]byte(content))
_, err = file.Write(data)
if err != nil {
return fmt.Errorf("failed to write to remote file: %w", err)
}
return nil
}
func (d *SSHDeployer) createClient(access *domain.SSHAccess) (*sshPkg.Client, error) {
var authMethod sshPkg.AuthMethod
if access.Key != "" {
var signer sshPkg.Signer
var err error
if access.KeyPassphrase != "" {
signer, err = sshPkg.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase))
} else {
signer, err = sshPkg.ParsePrivateKey([]byte(access.Key))
}
if err != nil {
return nil, err
}
authMethod = sshPkg.PublicKeys(signer)
} else {
authMethod = sshPkg.Password(access.Password)
}
return sshPkg.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &sshPkg.ClientConfig{
User: access.Username,
Auth: []sshPkg.AuthMethod{
authMethod,
},
HostKeyCallback: sshPkg.InsecureIgnoreHostKey(),
})
}

View File

@ -2,10 +2,10 @@ package deployer
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"golang.org/x/exp/slices"
cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
@ -100,16 +100,23 @@ func (d *TencentCDNDeployer) deploy(certId string) error {
// 如果是泛域名就从cdn列表下获取SSL证书中的可用域名
domain := getDeployString(d.option.DeployConfig, "domain")
if strings.Contains(domain, "*") {
list, errGetList := d.getDomainList()
list, errGetList := d.getDomainList(certId)
if errGetList != nil {
return fmt.Errorf("failed to get certificate domain list: %w", errGetList)
}
if list == nil || len(list) == 0 {
return fmt.Errorf("failed to get certificate domain list: empty list.")
if len(list) == 0 {
d.infos = append(d.infos, "没有需要部署的实例")
return nil
}
request.InstanceIdList = common.StringPtrs(list)
} else { // 否则直接使用传入的域名
request.InstanceIdList = common.StringPtrs([]string{domain})
deployed, _ := d.isDomainDeployed(certId, domain)
if(deployed){
d.infos = append(d.infos, "域名已部署")
return nil
}else{
request.InstanceIdList = common.StringPtrs([]string{domain})
}
}
// 返回的resp是一个DeployCertificateInstanceResponse的实例与请求对象对应
@ -121,23 +128,61 @@ func (d *TencentCDNDeployer) deploy(certId string) error {
return nil
}
func (d *TencentCDNDeployer) getDomainList() ([]string, error) {
func (d *TencentCDNDeployer) getDomainList(certId string) ([]string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "cdn.tencentcloudapi.com"
client, _ := cdn.NewClient(d.credential, "", cpf)
request := cdn.NewDescribeCertDomainsRequest()
cert := base64.StdEncoding.EncodeToString([]byte(d.option.Certificate.Certificate))
request.Cert = &cert
request.CertId = common.StringPtr(certId)
response, err := client.DescribeCertDomains(request)
if err != nil {
return nil, fmt.Errorf("failed to get domain list: %w", err)
}
deployedDomains, err := d.getDeployedDomainList(certId)
if err != nil {
return nil, fmt.Errorf("failed to get deployed domain list: %w", err)
}
domains := make([]string, 0)
for _, domain := range response.Response.Domains {
domainStr := *domain
if(slices.Contains(deployedDomains, domainStr)){
domains = append(domains, domainStr)
}
}
return domains, nil
}
func (d *TencentCDNDeployer) isDomainDeployed(certId, domain string) (bool, error) {
deployedDomains, err := d.getDeployedDomainList(certId)
if(err != nil){
return false, err
}
return slices.Contains(deployedDomains, domain), nil
}
func (d *TencentCDNDeployer) getDeployedDomainList(certId string) ([]string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com"
client, _ := ssl.NewClient(d.credential, "", cpf)
request := ssl.NewDescribeDeployedResourcesRequest()
request.CertificateIds = common.StringPtrs([]string{certId})
request.ResourceType = common.StringPtr("cdn")
response, err := client.DescribeDeployedResources(request)
if err != nil {
return nil, fmt.Errorf("failed to get deployed domain list: %w", err)
}
domains := make([]string, 0)
for _, domain := range response.Response.DeployedResources[0].Resources {
domains = append(domains, *domain)
}

View File

@ -6,6 +6,7 @@ const (
NotifyChannelTelegram = "telegram"
NotifyChannelLark = "lark"
NotifyChannelServerChan = "serverchan"
NotifyChannelMail = "mail"
)
type NotifyTestPushReq struct {

58
internal/notify/mail.go Normal file
View File

@ -0,0 +1,58 @@
package notify
import (
"context"
"net/smtp"
)
type Mail struct {
senderAddress string
smtpHostAddr string
smtpHostPort string
smtpAuth smtp.Auth
receiverAddresses string
}
func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort string) *Mail {
if(smtpHostPort == "") {
smtpHostPort = "25"
}
return &Mail{
senderAddress: senderAddress,
smtpHostAddr: smtpHostAddr,
smtpHostPort: smtpHostPort,
receiverAddresses: receiverAddresses,
}
}
func (m *Mail) SetAuth(username, password string) {
m.smtpAuth = smtp.PlainAuth("", username, password, m.smtpHostAddr)
}
func (m *Mail) Send(ctx context.Context, subject, message string) error {
// 构建邮件
from := m.senderAddress
to := []string{m.receiverAddresses}
msg := []byte(
"From: " + from + "\r\n" +
"To: " + m.receiverAddresses + "\r\n" +
"Subject: " + subject + "\r\n" +
"\r\n" +
message + "\r\n")
var smtpAddress string
// 组装邮箱服务器地址
if(m.smtpHostPort == "25"){
smtpAddress = m.smtpHostAddr
}else{
smtpAddress = m.smtpHostAddr + ":" + m.smtpHostPort
}
err := smtp.SendMail(smtpAddress, m.smtpAuth, from, to, msg)
if err != nil {
return err
}
return nil
}

View File

@ -106,6 +106,8 @@ func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, e
return getWebhookNotifier(conf), nil
case domain.NotifyChannelServerChan:
return getServerChanNotifier(conf), nil
case domain.NotifyChannelMail:
return getMailNotifier(conf), nil
}
return nil, fmt.Errorf("notifier not found")
@ -166,6 +168,14 @@ func getLarkNotifier(conf map[string]any) notifyPackage.Notifier {
return lark.NewWebhookService(getString(conf, "webhookUrl"))
}
func getMailNotifier(conf map[string]any) notifyPackage.Notifier {
rs := NewMail(getString(conf, "senderAddress"),getString(conf,"receiverAddress"), getString(conf, "smtpHostAddr"), getString(conf, "smtpHostPort"))
rs.SetAuth(getString(conf, "username"), getString(conf, "password"))
return rs
}
func getString(conf map[string]any, key string) string {
if _, ok := conf[key]; !ok {
return ""

View File

@ -0,0 +1,51 @@
package fs
import (
"fmt"
"os"
"path/filepath"
)
// 与 [WriteFile] 类似,但写入的是字符串内容。
//
// 入参:
// - path: 文件路径。
// - content: 文件内容。
//
// 出参:
// - 错误。
func WriteFileString(path string, content string) error {
return WriteFile(path, []byte(content))
}
// 将数据写入指定路径的文件。
// 如果目录不存在,将会递归创建目录。
// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。
//
// 入参:
// - path: 文件路径。
// - data: 文件数据字节数组。
//
// 出参:
// - 错误。
func WriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
_, err = file.Write(data)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}

View File

@ -2,6 +2,7 @@
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
@ -48,7 +49,7 @@ func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error)
return cert, nil
}
// 从 PEM 编码的私钥字符串解析并返回一个 ECDSA 私钥对象。
// 从 PEM 编码的私钥字符串解析并返回一个 ecdsa.PrivateKey 对象。
//
// 入参:
// - privkeyPem: 私钥 PEM 内容。
@ -72,7 +73,31 @@ func ParseECPrivateKeyFromPEM(privkeyPem string) (privkey *ecdsa.PrivateKey, err
return privkey, nil
}
// 将 ECDSA 私钥转换为 PEM 编码的字符串。
// 从 PEM 编码的私钥字符串解析并返回一个 rsa.PrivateKey 对象。
//
// 入参:
// - privkeyPem: 私钥 PEM 内容。
//
// 出参:
// - privkey: rsa.PrivateKey 对象。
// - err: 错误。
func ParsePKCS1PrivateKeyFromPEM(privkeyPem string) (privkey *rsa.PrivateKey, err error) {
pemData := []byte(privkeyPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privkey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privkey, nil
}
// 将 ecdsa.PrivateKey 对象转换为 PEM 编码的字符串。
//
// 入参:
// - privkey: ecdsa.PrivateKey 对象。

701
ui/package-lock.json generated
View File

@ -12,10 +12,11 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
@ -28,6 +29,7 @@
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
@ -1243,6 +1245,41 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@ -1385,24 +1422,24 @@
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz",
"integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
@ -1419,6 +1456,130 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -1638,6 +1799,166 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
"integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@ -3039,6 +3360,366 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/cmdk/-/cmdk-1.0.0.tgz",
"integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==",
"dependencies": {
"@radix-ui/react-dialog": "1.0.5",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/cmdk/node_modules/@radix-ui/primitive": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz",
"integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==",
"dependencies": {
"@babel/runtime": "^7.13.10"
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
"integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-context": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz",
"integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dialog": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-focus-guards": "1.0.1",
"@radix-ui/react-focus-scope": "1.0.4",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-use-controllable-state": "1.0.1",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz",
"integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-escape-keydown": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
"integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz",
"integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-id": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.0.1.tgz",
"integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-portal": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz",
"integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-presence": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz",
"integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
"integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "1.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz",
"integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
"integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
"integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.3",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz",

View File

@ -14,10 +14,11 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
@ -30,6 +31,7 @@
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",

View File

@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import AccessAliyunForm from "./AccessAliyunForm";
import AccessTencentForm from "./AccessTencentForm";
import AccessHuaweiCloudForm from "./AccessHuaweicloudForm";
@ -20,7 +19,8 @@ import AccessLocalForm from "./AccessLocalForm";
import AccessSSHForm from "./AccessSSHForm";
import AccessWebhookForm from "./AccessWebhookForm";
import AccessKubernetesForm from "./AccessKubernetesForm";
import { Access, accessProvidersMap } from "@/domain/access";
import { Access } from "@/domain/access";
import { AccessTypeSelect } from "./AccessTypeSelect";
type AccessEditProps = {
op: "add" | "edit" | "copy";
@ -194,16 +194,17 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) =>
break;
}
const getOptionCls = (val: string) => {
return val == configType ? "border-primary" : "";
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<Dialog onOpenChange={setOpen} open={open} modal={false}>
<DialogTrigger asChild className={cn(className)}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogContent
className="sm:max-w-[600px] w-full dark:text-stone-200"
onInteractOutside={(event) => {
event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>
{
@ -219,29 +220,15 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) =>
<div className="container py-3">
<div>
<Label>{t("access.authorization.form.type.label")}</Label>
<Select
onValueChange={(val) => {
<AccessTypeSelect
value={configType}
onChange={(val) => {
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder={t("access.authorization.form.type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t("access.authorization.form.type.list")}</SelectLabel>
{Array.from(accessProvidersMap.entries()).map(([key, provider]) => (
<SelectItem value={key} key={key}>
<div className={cn("flex items-center space-x-2 rounded cursor-pointer", getOptionCls(key))}>
<img src={provider.icon} className="h-6 w-6" />
<div>{t(provider.name)}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
className="w-full mt-3"
placeholder={t("access.authorization.form.type.placeholder")}
searchPlaceholder={t("access.authorization.form.type.search.placeholder")}
/>
</div>
<div className="mt-8">{childComponent}</div>

View File

@ -0,0 +1,80 @@
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { accessProvidersMap } from "@/domain/access";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
type AccessTypeSelectProps = {
value: string;
onChange: (value: string) => void;
placeholder: string;
searchPlaceholder: string;
className?: string;
};
export function AccessTypeSelect({ value, onChange, placeholder, searchPlaceholder, className }: AccessTypeSelectProps) {
const [open, setOpen] = useState(false);
const [locValue, setLocValue] = useState("");
const { t } = useTranslation();
const [search, setSearch] = useState("");
const filteredProviders = Array.from(accessProvidersMap.entries());
useEffect(() => {
setLocValue(value);
}, [value]);
const handleOnSelect = (currentValue: string) => {
const newValue = currentValue === locValue ? "" : currentValue;
setLocValue(newValue);
setSearch("");
setOpen(false);
onChange(newValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className={cn("justify-between z-50", className)}>
{locValue ? (
<div className="flex space-x-2 items-center">
<img src={accessProvidersMap.get(locValue)?.icon} className="h-6 w-6" />
<div>{t(accessProvidersMap.get(locValue)?.name ?? "")}</div>
</div>
) : (
<>{placeholder}</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("p-0 w-full")}>
<Command className="">
<CommandInput
placeholder={searchPlaceholder}
value={search}
onValueChange={(val: string) => {
setSearch(val);
}}
/>
<CommandList>
<CommandEmpty>{t("access.authorization.form.type.search.notfound")}</CommandEmpty>
<CommandGroup>
{filteredProviders.map(([key, provider]) => (
<CommandItem key={key} value={key} onSelect={handleOnSelect} keywords={provider.searchContent.split(":")}>
<Check className={cn("mr-2 h-4 w-4", locValue === key ? "opacity-100" : "opacity-0")} />
<div className="flex space-x-2">
<img src={provider.icon} className="h-6 w-6" />
<div className="font-medium">{t(provider.name)}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -21,6 +21,7 @@ import DeployToTencentTEO from "./DeployToTencentTEO";
import DeployToHuaweiCloudCDN from "./DeployToHuaweiCloudCDN";
import DeployToHuaweiCloudELB from "./DeployToHuaweiCloudELB";
import DeployToQiniuCDN from "./DeployToQiniuCDN";
import DeployToLocal from "./DeployToLocal";
import DeployToSSH from "./DeployToSSH";
import DeployToWebhook from "./DeployToWebhook";
import DeployToKubernetesSecret from "./DeployToKubernetesSecret";
@ -153,8 +154,10 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
case "qiniu-cdn":
childComponent = <DeployToQiniuCDN />;
break;
case "ssh":
case "local":
childComponent = <DeployToLocal />;
break;
case "ssh":
childComponent = <DeployToSSH />;
break;
case "webhook":

View File

@ -102,7 +102,7 @@ const DeployToHuaweiCloudCDN = () => {
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.resourceType = value?.trim();
draft.config.resourceType = value;
});
setDeploy(newData);
}}

View File

@ -0,0 +1,450 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useDeployEditContext } from "./DeployEdit";
import { cn } from "@/lib/utils";
const DeployToLocal = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
format: "pem",
certPath: "/etc/nginx/ssl/nginx.crt",
keyPath: "/etc/nginx/ssl/nginx.key",
pfxPassword: "",
jksAlias: "",
jksKeypass: "",
jksStorepass: "",
shell: "sh",
preCommand: "",
command: "sudo service nginx reload",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], {
message: t("domain.deployment.form.file_format.placeholder"),
}),
certPath: z
.string()
.min(1, t("domain.deployment.form.file_cert_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
keyPath: z
.string()
.min(0, t("domain.deployment.form.file_key_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
pfxPassword: z.string().optional(),
jksAlias: z.string().optional(),
jksKeypass: z.string().optional(),
jksStorepass: z.string().optional(),
shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], {
message: t("domain.deployment.form.shell.placeholder"),
}),
preCommand: z.string().optional(),
command: z.string().optional(),
})
.refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), {
message: t("domain.deployment.form.file_key_path.placeholder"),
path: ["keyPath"],
})
.refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), {
message: t("domain.deployment.form.file_pfx_password.placeholder"),
path: ["pfxPassword"],
})
.refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), {
message: t("domain.deployment.form.file_jks_alias.placeholder"),
path: ["jksAlias"],
})
.refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), {
message: t("domain.deployment.form.file_jks_keypass.placeholder"),
path: ["jksKeypass"],
})
.refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), {
message: t("domain.deployment.form.file_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
format: res.error.errors.find((e) => e.path[0] === "format")?.message,
certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message,
keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message,
pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message,
jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message,
jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message,
jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message,
shell: res.error.errors.find((e) => e.path[0] === "shell")?.message,
preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message,
command: res.error.errors.find((e) => e.path[0] === "command")?.message,
});
} else {
setError({
...error,
format: undefined,
certPath: undefined,
keyPath: undefined,
pfxPassword: undefined,
jksAlias: undefined,
jksKeypass: undefined,
jksStorepass: undefined,
shell: undefined,
preCommand: undefined,
command: undefined,
});
}
}, [data]);
useEffect(() => {
if (data.config?.format === "pem") {
if (/(.pfx|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt");
});
setDeploy(newData);
}
} else if (data.config?.format === "pfx") {
if (/(.crt|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx");
});
setDeploy(newData);
}
} else if (data.config?.format === "jks") {
if (/(.crt|.pfx)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks");
});
setDeploy(newData);
}
}
}, [data.config?.format]);
const getOptionCls = (val: string) => {
if (data.config?.shell === val) {
return "border-primary dark:border-primary";
}
return "";
};
const handleUsePresetScript = (key: string) => {
switch (key) {
case "reload_nginx":
{
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = "sh";
draft.config.command = "sudo service nginx reload";
});
setDeploy(newData);
}
break;
case "binding_iis":
{
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = "powershell";
draft.config.command = `
#
$pfxPath = "<your-pfx-path>" # PFX
$pfxPassword = "<your-pfx-password>" # PFX
$siteName = "<your-site-name>" # IIS
$domain = "<your-domain-name>" #
$ipaddr = "<your-binding-ip>" # IP* IP
$port = "<your-binding-port>" #
#
$cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable
# Thumbprint
$thumbprint = $cert.Thumbprint
# WebAdministration
Import-Module WebAdministration
# HTTPS
$existingBinding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -HostHeader "$domain" -ErrorAction SilentlyContinue
if (!$existingBinding) {
# HTTPS
New-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain"
}
#
$binding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain"
# SSL
$binding.AddSslCertificate($thumbprint, "My")
#
Remove-Item -Path "$pfxPath" -Force
`.trim();
});
setDeploy(newData);
}
break;
}
};
return (
<>
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.file_format.label")}</Label>
<Select
value={data?.config?.format}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.format = value;
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.file_format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="pem">PEM</SelectItem>
<SelectItem value="pfx">PFX</SelectItem>
<SelectItem value="jks">JKS</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.format}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_cert_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_cert_path.label")}
className="w-full mt-1"
value={data?.config?.certPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.certPath}</div>
</div>
{data.config?.format === "pem" ? (
<div>
<Label>{t("domain.deployment.form.file_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.keyPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.keyPath}</div>
</div>
) : (
<></>
)}
{data.config?.format === "pfx" ? (
<div>
<Label>{t("domain.deployment.form.file_pfx_password.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_pfx_password.placeholder")}
className="w-full mt-1"
value={data?.config?.pfxPassword}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.pfxPassword = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.pfxPassword}</div>
</div>
) : (
<></>
)}
{data.config?.format === "jks" ? (
<>
<div>
<Label>{t("domain.deployment.form.file_jks_alias.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_alias.placeholder")}
className="w-full mt-1"
value={data?.config?.jksAlias}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksAlias = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksAlias}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_keypass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_keypass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksKeypass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksKeypass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksKeypass}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_storepass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_storepass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksStorepass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksStorepass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksStorepass}</div>
</div>
</>
) : (
<></>
)}
<div>
<Label>{t("domain.deployment.form.shell.label")}</Label>
<RadioGroup
className="flex mt-1"
value={data?.config?.shell}
onValueChange={(val) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = val;
});
setDeploy(newData);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sh" id="shellOptionSh" />
<Label htmlFor="shellOptionSh">
<div className={cn("flex items-center space-x-2 border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("sh"))}>
<div>POSIX Bash (Linux)</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="cmd" id="shellOptionCmd" />
<Label htmlFor="shellOptionCmd">
<div className={cn("border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("cmd"))}>
<div>CMD (Windows)</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="powershell" id="shellOptionPowerShell" />
<Label htmlFor="shellOptionPowerShell">
<div className={cn("border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("powershell"))}>
<div>PowerShell (Windows)</div>
</div>
</Label>
</div>
</RadioGroup>
<div className="text-red-600 text-sm mt-1">{error?.shell}</div>
</div>
<div>
<Label>{t("domain.deployment.form.shell_pre_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.preCommand}
placeholder={t("domain.deployment.form.shell_pre_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.preCommand = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.preCommand}</div>
</div>
<div>
<div className="flex items-center justify-between">
<Label>{t("domain.deployment.form.shell_command.label")}</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<a className="text-xs text-blue-500 cursor-pointer">{t("domain.deployment.form.shell_preset_scripts.trigger")}</a>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleUsePresetScript("reload_nginx")}>
{t("domain.deployment.form.shell_preset_scripts.option.reload_nginx.label")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUsePresetScript("binding_iis")}>
{t("domain.deployment.form.shell_preset_scripts.option.binding_iis.label")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Textarea
className="mt-1"
value={data?.config?.command}
placeholder={t("domain.deployment.form.shell_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.command = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.command}</div>
</div>
</div>
</>
);
};
export default DeployToLocal;

View File

@ -1,29 +1,31 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useDeployEditContext } from "./DeployEdit";
const DeployToSSH = () => {
const { t } = useTranslation();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
const { deploy: data, setDeploy } = useDeployEditContext();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
format: "pem",
certPath: "/etc/nginx/ssl/nginx.crt",
keyPath: "/etc/nginx/ssl/nginx.key",
pfxPassword: "",
jksAlias: "",
jksKeypass: "",
jksStorepass: "",
preCommand: "",
command: "sudo service nginx reload",
},
@ -31,79 +33,287 @@ const DeployToSSH = () => {
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], {
message: t("domain.deployment.form.file_format.placeholder"),
}),
certPath: z
.string()
.min(1, t("domain.deployment.form.file_cert_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
keyPath: z
.string()
.min(0, t("domain.deployment.form.file_key_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
pfxPassword: z.string().optional(),
jksAlias: z.string().optional(),
jksKeypass: z.string().optional(),
jksStorepass: z.string().optional(),
preCommand: z.string().optional(),
command: z.string().optional(),
})
.refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), {
message: t("domain.deployment.form.file_key_path.placeholder"),
path: ["keyPath"],
})
.refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), {
message: t("domain.deployment.form.file_pfx_password.placeholder"),
path: ["pfxPassword"],
})
.refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), {
message: t("domain.deployment.form.file_jks_alias.placeholder"),
path: ["jksAlias"],
})
.refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), {
message: t("domain.deployment.form.file_jks_keypass.placeholder"),
path: ["jksKeypass"],
})
.refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), {
message: t("domain.deployment.form.file_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
format: res.error.errors.find((e) => e.path[0] === "format")?.message,
certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message,
keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message,
pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message,
jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message,
jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message,
jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message,
preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message,
command: res.error.errors.find((e) => e.path[0] === "command")?.message,
});
} else {
setError({
...error,
format: undefined,
certPath: undefined,
keyPath: undefined,
pfxPassword: undefined,
jksAlias: undefined,
jksKeypass: undefined,
jksStorepass: undefined,
preCommand: undefined,
command: undefined,
});
}
}, [data]);
useEffect(() => {
if (data.config?.format === "pem") {
if (/(.pfx|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt");
});
setDeploy(newData);
}
} else if (data.config?.format === "pfx") {
if (/(.crt|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx");
});
setDeploy(newData);
}
} else if (data.config?.format === "jks") {
if (/(.crt|.pfx)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks");
});
setDeploy(newData);
}
}
}, [data.config?.format]);
return (
<>
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.ssh_cert_path.label")}</Label>
<Label>{t("domain.deployment.form.file_format.label")}</Label>
<Select
value={data?.config?.format}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.format = value;
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.file_format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="pem">PEM</SelectItem>
<SelectItem value="pfx">PFX</SelectItem>
<SelectItem value="jks">JKS</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.format}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_cert_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.ssh_cert_path.label")}
placeholder={t("domain.deployment.form.file_cert_path.label")}
className="w-full mt-1"
value={data?.config?.certPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.certPath = e.target.value;
draft.config ??= {};
draft.config.certPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.certPath}</div>
</div>
<div>
<Label>{t("domain.deployment.form.ssh_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.ssh_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.keyPath = e.target.value;
});
setDeploy(newData);
}}
/>
</div>
{data.config?.format === "pem" ? (
<div>
<Label>{t("domain.deployment.form.file_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.keyPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.keyPath}</div>
</div>
) : (
<></>
)}
{data.config?.format === "pfx" ? (
<div>
<Label>{t("domain.deployment.form.file_pfx_password.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_pfx_password.placeholder")}
className="w-full mt-1"
value={data?.config?.pfxPassword}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.pfxPassword = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.pfxPassword}</div>
</div>
) : (
<></>
)}
{data.config?.format === "jks" ? (
<>
<div>
<Label>{t("domain.deployment.form.file_jks_alias.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_alias.placeholder")}
className="w-full mt-1"
value={data?.config?.jksAlias}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksAlias = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksAlias}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_keypass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_keypass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksKeypass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksKeypass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksKeypass}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_storepass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_storepass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksStorepass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksStorepass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksStorepass}</div>
</div>
</>
) : (
<></>
)}
<div>
<Label>{t("domain.deployment.form.ssh_pre_command.label")}</Label>
<Label>{t("domain.deployment.form.shell_pre_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.preCommand}
placeholder={t("domain.deployment.form.ssh_pre_command.placeholder")}
placeholder={t("domain.deployment.form.shell_pre_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config ??= {};
draft.config.preCommand = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.preCommand}</div>
</div>
<div>
<Label>{t("domain.deployment.form.ssh_command.label")}</Label>
<Label>{t("domain.deployment.form.shell_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.command}
placeholder={t("domain.deployment.form.ssh_command.placeholder")}
placeholder={t("domain.deployment.form.shell_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config ??= {};
draft.config.command = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.command}</div>
</div>
</div>
</>

View File

@ -0,0 +1,319 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { getErrMessage } from "@/lib/error";
import { NotifyChannelMail, NotifyChannels } from "@/domain/settings";
import { useNotifyContext } from "@/providers/notify";
import { update } from "@/repository/settings";
import Show from "@/components/Show";
import { notifyTest } from "@/api/notify";
type MailSetting = {
id: string;
name: string;
data: NotifyChannelMail;
};
const Mail = () => {
const { config, setChannels } = useNotifyContext();
const { t } = useTranslation();
const [changed, setChanged] = useState<boolean>(false);
const [mail, setmail] = useState<MailSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
},
});
const [originMail, setoriginMail] = useState<MailSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
},
});
useEffect(() => {
setChanged(false);
}, [config]);
useEffect(() => {
const data = getDetailMail();
setoriginMail({
id: config.id ?? "",
name: "mail",
data,
});
}, [config]);
useEffect(() => {
const data = getDetailMail();
setmail({
id: config.id ?? "",
name: "mail",
data,
});
}, [config]);
const { toast } = useToast();
const getDetailMail = () => {
const df: NotifyChannelMail = {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
};
if (!config.content) {
return df;
}
const chanels = config.content as NotifyChannels;
if (!chanels.mail) {
return df;
}
return chanels.mail as NotifyChannelMail;
};
const checkChanged = (data: NotifyChannelMail) => {
if (data.senderAddress !== originMail.data.senderAddress || data.receiverAddresses !== originMail.data.receiverAddresses || data.smtpHostAddr !== originMail.data.smtpHostAddr || data.smtpHostPort !== originMail.data.smtpHostPort || data.username !== originMail.data.username || data.password !== originMail.data.password) {
setChanged(true);
} else {
setChanged(false);
}
};
const handleSaveClick = async () => {
try {
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
mail: {
...mail.data,
},
},
});
setChannels(resp);
toast({
title: t("common.save.succeeded.message"),
description: t("settings.notification.config.saved.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handlePushTestClick = async () => {
try {
await notifyTest("mail");
toast({
title: t("settings.notification.config.push.test.message.success.message"),
description: t("settings.notification.config.push.test.message.success.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("settings.notification.config.push.test.message.failed.message"),
description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handleSwitchChange = async () => {
const newData = {
...mail,
data: {
...mail.data,
enabled: !mail.data.enabled,
},
};
setmail(newData);
try {
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
mail: {
...newData.data,
},
},
});
setChannels(resp);
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
return (
<div>
<Input
placeholder={t("settings.notification.mail.sender_address.placeholder")}
value={mail.data.senderAddress}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
senderAddress: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.receiver_address.placeholder")}
className="mt-2"
value={mail.data.receiverAddresses}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
receiverAddresses: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.smtp_host.placeholder")}
className="mt-2"
value={mail.data.smtpHostAddr}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpHostAddr: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.smtp_port.placeholder")}
className="mt-2"
value={mail.data.smtpHostPort}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpHostPort: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.username.placeholder")}
className="mt-2"
value={mail.data.username}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
username: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.password.placeholder")}
className="mt-2"
value={mail.data.password}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
password: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={mail.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && mail.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
</div>
</div>
);
};
export default Mail;

View File

@ -0,0 +1,105 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<React.ElementRef<typeof CommandPrimitive>, React.ComponentPropsWithoutRef<typeof CommandPrimitive>>(
({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
{...props}
/>
)
);
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Input>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>>(
({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
);
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<React.ElementRef<typeof CommandPrimitive.List>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>>(
({ className, ...props }, ref) => <CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} />
);
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Empty>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>(
(props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Group>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>>(
({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
);
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => <CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />);
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>>(
({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
)
);
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator };

View File

@ -1,75 +1,122 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
"use client"
import { cn } from "@/lib/utils";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
const Dialog = DialogPrimitive.Root;
import { cn } from "@/lib/utils"
const DialogTrigger = DialogPrimitive.Trigger;
const Dialog = DialogPrimitive.Root
const DialogPortal = DialogPrimitive.Portal;
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
)
);
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
)
);
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />);
DialogDescription.displayName = DialogPrimitive.Description.displayName;
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -7,25 +7,26 @@ type AccessProvider = {
name: string;
icon: string;
usage: AccessUsages;
searchContent: string;
};
export const accessProvidersMap: Map<AccessProvider["type"], AccessProvider> = new Map(
[
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all"],
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all"],
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all"],
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply"],
["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply"],
["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply"],
["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply"],
["local", "common.provider.local", "/imgs/providers/local.svg", "deploy"],
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy"],
["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy"],
].map(([type, name, icon, usage]) => [type, { type, name, icon, usage: usage as AccessUsages }])
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"],
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"],
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"],
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛:qiniu"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"],
["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply", "godaddy"],
["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply", "powerdns:pdns"],
["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply", "httpreq"],
["local", "common.provider.local", "/imgs/providers/local.svg", "deploy", "local:bendi:本地"],
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"],
["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"],
].map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }])
);
export const accessTypeFormSchema = z.union(

View File

@ -23,9 +23,10 @@ export type NotifyChannels = {
telegram?: NotifyChannel;
webhook?: NotifyChannel;
serverchan?: NotifyChannel;
mail?: NotifyChannelMail;
};
export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan;
export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan | NotifyChannelMail;
export type NotifyChannelDingTalk = {
accessToken: string;
@ -54,6 +55,16 @@ export type NotifyChannelServerChan = {
enabled: boolean;
};
export type NotifyChannelMail = {
senderAddress: string;
receiverAddresses: string;
smtpHostAddr: string;
smtpHostPort: string;
username:string;
password:string;
enabled: boolean;
};
export const defaultNotifyTemplate: NotifyTemplate = {
title: "您有 {COUNT} 张证书即将过期",
content: "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!",

View File

@ -12,6 +12,8 @@
"access.authorization.form.type.label": "Provider",
"access.authorization.form.type.placeholder": "Please select a provider",
"access.authorization.form.type.search.placeholder": "Search provider ...",
"access.authorization.form.type.search.notfound": "Provider not found",
"access.authorization.form.type.list": "Authorization List",
"access.authorization.form.name.label": "Name",
"access.authorization.form.name.placeholder": "Please enter authorization name",
@ -88,3 +90,4 @@
"access.group.domains": "All Authorizations",
"access.group.domains.nodata": "Please add a domain to start deploying the certificate."
}

View File

@ -85,5 +85,6 @@
"common.provider.kubernetes.secret": "Kubernetes - Secret",
"common.provider.dingtalk": "DingTalk",
"common.provider.telegram": "Telegram",
"common.provider.lark": "Lark"
"common.provider.lark": "Lark",
"common.provider.mail": "Mail"
}

View File

@ -120,14 +120,32 @@
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "Please enter ELB loadbalancer ID",
"domain.deployment.form.huaweicloud_elb_listener_id.label": "Listener ID",
"domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "Please enter ELB listener ID",
"domain.deployment.form.ssh_key_path.label": "Private Key Save Path",
"domain.deployment.form.ssh_key_path.placeholder": "Please enter private key save path",
"domain.deployment.form.ssh_cert_path.label": "Certificate Save Path",
"domain.deployment.form.ssh_cert_path.placeholder": "Please enter certificate save path",
"domain.deployment.form.ssh_pre_command.label": "Pre-deployment Command",
"domain.deployment.form.ssh_pre_command.placeholder": "Command to be executed before deploying the certificate",
"domain.deployment.form.ssh_command.label": "Command",
"domain.deployment.form.ssh_command.placeholder": "Please enter command",
"domain.deployment.form.file_format.label": "Certificate Format",
"domain.deployment.form.file_format.placeholder": "Please select certificate format",
"domain.deployment.form.file_cert_path.label": "Certificate Save Path",
"domain.deployment.form.file_cert_path.placeholder": "Please enter certificate save path",
"domain.deployment.form.file_key_path.label": "Private Key Save Path",
"domain.deployment.form.file_key_path.placeholder": "Please enter private key save path",
"domain.deployment.form.file_pfx_password.label": "PFX Output Password",
"domain.deployment.form.file_pfx_password.placeholder": "Please enter PFX output password",
"domain.deployment.form.file_jks_alias.label": "JKS Alias (KeyStore Alias)",
"domain.deployment.form.file_jks_alias.placeholder": "Please enter JKS alias",
"domain.deployment.form.file_jks_keypass.label": "JKS Key Password (KeyStore Keypass)",
"domain.deployment.form.file_jks_keypass.placeholder": "Please enter JKS key password",
"domain.deployment.form.file_jks_storepass.label": "JKS Store Password (KeyStore Storepass)",
"domain.deployment.form.file_jks_storepass.placeholder": "Please enter JKS store password",
"domain.deployment.form.shell.label": "Shell",
"domain.deployment.form.shell.placeholder": "Please select shell environment",
"domain.deployment.form.shell.option.sh.label": "POSIX Bash (Linux)",
"domain.deployment.form.shell.option.cmd.label": "CMD (Windows)",
"domain.deployment.form.shell.option.powershell.label": "PowerShell (Windows)",
"domain.deployment.form.shell_pre_command.label": "Pre-deployment Command",
"domain.deployment.form.shell_pre_command.placeholder": "Command to be executed before deploying the certificate",
"domain.deployment.form.shell_command.label": "Command",
"domain.deployment.form.shell_command.placeholder": "Please enter command",
"domain.deployment.form.shell_preset_scripts.trigger": "Use Preset Scripts",
"domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - Reload Nginx",
"domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - Binding IIS",
"domain.deployment.form.k8s_namespace.label": "Namespace",
"domain.deployment.form.k8s_namespace.placeholder": "Please enter namespace",
"domain.deployment.form.k8s_secret_name.label": "Secret Name",

View File

@ -36,6 +36,12 @@
"settings.notification.dingtalk.secret.placeholder": "Signature for signed addition",
"settings.notification.url.errmsg.invalid": "Invalid Url format",
"settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send",
"settings.notification.mail.sender_address.placeholder": "Sender email address",
"settings.notification.mail.receiver_address.placeholder": "Receiver email address",
"settings.notification.mail.smtp_host.placeholder": "SMTP server address",
"settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25",
"settings.notification.mail.username.placeholder": "username",
"settings.notification.mail.password.placeholder": "password",
"settings.ca.tab": "Certificate Authority",
"settings.ca.provider.errmsg.empty": "Please select a Certificate Authority",

View File

@ -12,6 +12,8 @@
"access.authorization.form.type.label": "服务商",
"access.authorization.form.type.placeholder": "请选择服务商",
"access.authorization.form.type.search.placeholder": "搜索服务商",
"access.authorization.form.type.search.notfound": "未找到服务商",
"access.authorization.form.type.list": "服务商列表",
"access.authorization.form.name.label": "名称",
"access.authorization.form.name.placeholder": "请输入授权名称",
@ -88,3 +90,4 @@
"access.group.domains": "所有授权",
"access.group.domains.nodata": "请添加域名开始部署证书吧。"
}

View File

@ -85,6 +85,7 @@
"common.provider.kubernetes.secret": "Kubernetes - Secret",
"common.provider.dingtalk": "钉钉",
"common.provider.telegram": "Telegram",
"common.provider.lark": "飞书"
"common.provider.lark": "飞书",
"common.provider.mail": "电子邮件"
}

View File

@ -104,8 +104,8 @@
"domain.deployment.form.tencent_clb_domain.label": "部署到域名(支持泛域名)",
"domain.deployment.form.tencent_clb_domain.placeholder": "请输入部署到的域名, 如未开启 SNI, 可置空忽略此项",
"domain.deployment.form.tencent_teo_zone_id.label": "Zone ID",
"domain.deployment.form.tencent_teo_zone_id.placeholder": "请输入zoneid, 形如: zone-xxxxxxxxx",
"domain.deployment.form.tencent_teo_domain.label": "部署到域名(支持泛域名, 应与服务器上配置的域名完全一致, 每行一个域名)",
"domain.deployment.form.tencent_teo_zone_id.placeholder": "请输入 Zone ID",
"domain.deployment.form.tencent_teo_domain.label": "部署到域名(支持泛域名, 应与服务器上配置的域名完全一致, 每行一个域名)",
"domain.deployment.form.tencent_teo_domain.placeholder": "请输入部署到的域名",
"domain.deployment.form.huaweicloud_elb_region.label": "地域",
"domain.deployment.form.huaweicloud_elb_region.placeholder": "请输入地域(如 cn-north-1",
@ -120,14 +120,29 @@
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "请输入负载均衡器 ID",
"domain.deployment.form.huaweicloud_elb_listener_id.label": "监听器 ID",
"domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "请输入监听器 ID",
"domain.deployment.form.ssh_key_path.label": "私钥保存路径",
"domain.deployment.form.ssh_key_path.placeholder": "请输入私钥保存路径",
"domain.deployment.form.ssh_cert_path.label": "证书保存路径",
"domain.deployment.form.ssh_cert_path.placeholder": "请输入证书保存路径",
"domain.deployment.form.ssh_pre_command.label": "前置命令",
"domain.deployment.form.ssh_pre_command.placeholder": "在部署证书前执行的命令",
"domain.deployment.form.ssh_command.label": "命令",
"domain.deployment.form.ssh_command.placeholder": "请输入要执行的命令",
"domain.deployment.form.file_format.label": "证书格式",
"domain.deployment.form.file_format.placeholder": "请选择证书格式",
"domain.deployment.form.file_cert_path.label": "证书保存路径",
"domain.deployment.form.file_cert_path.placeholder": "请输入证书保存路径",
"domain.deployment.form.file_key_path.label": "私钥保存路径",
"domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径",
"domain.deployment.form.file_pfx_password.label": "PFX 导出密码",
"domain.deployment.form.file_pfx_password.placeholder": "请输入 PFX 导出密码",
"domain.deployment.form.file_jks_alias.label": "JKS 别名KeyStore Alias",
"domain.deployment.form.file_jks_alias.placeholder": "请输入 JKS 别名",
"domain.deployment.form.file_jks_keypass.label": "JKS 私钥访问口令KeyStore Keypass",
"domain.deployment.form.file_jks_keypass.placeholder": "请输入 JKS 私钥访问口令",
"domain.deployment.form.file_jks_storepass.label": "JKS 密钥库存储口令KeyStore Storepass",
"domain.deployment.form.file_jks_storepass.placeholder": "请输入 JKS 密钥库存储口令",
"domain.deployment.form.shell.label": "Shell",
"domain.deployment.form.shell.placeholder": "请选择命令执行环境",
"domain.deployment.form.shell_pre_command.label": "前置命令",
"domain.deployment.form.shell_pre_command.placeholder": "在部署证书前执行的命令",
"domain.deployment.form.shell_command.label": "命令",
"domain.deployment.form.shell_command.placeholder": "请输入要执行的命令",
"domain.deployment.form.shell_preset_scripts.trigger": "使用预设脚本",
"domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - 重启 nginx",
"domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - 导入并绑定到 IIS需管理员权限",
"domain.deployment.form.k8s_namespace.label": "命名空间",
"domain.deployment.form.k8s_namespace.placeholder": "请输入 K8S 命名空间",
"domain.deployment.form.k8s_secret_name.label": "Secret 名称",

View File

@ -36,6 +36,12 @@
"settings.notification.dingtalk.secret.placeholder": "加签的签名",
"settings.notification.url.errmsg.invalid": "URL 格式不正确",
"settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send",
"settings.notification.mail.sender_address.placeholder": "发送邮箱地址",
"settings.notification.mail.receiver_address.placeholder": "接收邮箱地址",
"settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址",
"settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25",
"settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名",
"settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码",
"settings.ca.tab": "证书颁发机构CA",
"settings.ca.provider.errmsg.empty": "请选择证书分发机构",

View File

@ -151,4 +151,3 @@ export default function Dashboard() {
</>
);
}

View File

@ -209,7 +209,7 @@ const Dashboard = () => {
{t("history.log")}
</SheetTitle>
</SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh] overflow-y-auto">
{deployment.log.check && (
<>
{deployment.log.check.map((item: Log) => {

View File

@ -104,7 +104,7 @@ const History = () => {
{t("history.log")}
</SheetTitle>
</SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh] overflow-y-auto">
{deployment.log.check && (
<>
{deployment.log.check.map((item: Log) => {

View File

@ -7,6 +7,7 @@ import NotifyTemplate from "@/components/notify/NotifyTemplate";
import Telegram from "@/components/notify/Telegram";
import Webhook from "@/components/notify/Webhook";
import ServerChan from "@/components/notify/ServerChan";
import Mail from "@/components/notify/Mail";
import { NotifyProvider } from "@/providers/notify";
const Notify = () => {
@ -61,6 +62,12 @@ const Notify = () => {
<ServerChan />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-7" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.mail")}</AccordionTrigger>
<AccordionContent>
<Mail />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</NotifyProvider>