feat(security): 支持按客户端独立设置专属授权码并重构紧凑单屏无滚动条布局
This commit is contained in:
parent
744e2584ef
commit
102ddca245
111
backend/controllers/security_client_credentials.go
Normal file
111
backend/controllers/security_client_credentials.go
Normal file
@ -0,0 +1,111 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
// ListClientCredentials 返回当前用户的客户端授权码列表
|
||||
func ListClientCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := service.ListClientCredentials(ctx.UserID)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ParamsError, "读取客户端授权码失败")
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(items).FPrint(w)
|
||||
}
|
||||
|
||||
// CreateClientCredential 为当前用户创建客户端授权码
|
||||
func CreateClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ClientName string `json:"client_name"`
|
||||
}
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.ClientName) == "" {
|
||||
writeSecurityError(w, response.ParamsError, "请输入客户端名称")
|
||||
return
|
||||
}
|
||||
code, item, err := service.CreateClientCredential(ctx.UserID, input.ClientName)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ParamsError, err.Error())
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{"code": code, "client": item}).FPrint(w)
|
||||
}
|
||||
|
||||
// RegenerateClientCredential 重新生成客户端专属授权码
|
||||
func RegenerateClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ClientID int `json:"client_id"`
|
||||
}
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 {
|
||||
writeSecurityError(w, response.ParamsError, "客户端标识无效")
|
||||
return
|
||||
}
|
||||
code, err := service.RegenerateClientCredential(ctx.UserID, input.ClientID)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ParamsError, err.Error())
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{"code": code, "client_id": input.ClientID}).FPrint(w)
|
||||
}
|
||||
|
||||
// RevokeClientCredential 撤销客户端授权码
|
||||
func RevokeClientCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ClientID int `json:"client_id"`
|
||||
}
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil || input.ClientID <= 0 {
|
||||
writeSecurityError(w, response.ParamsError, "客户端标识无效")
|
||||
return
|
||||
}
|
||||
if err := service.RevokeClientCredential(ctx.UserID, input.ClientID); err != nil {
|
||||
writeSecurityError(w, response.ParamsError, err.Error())
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{"client_id": input.ClientID, "revoked": true}).FPrint(w)
|
||||
}
|
||||
|
||||
// RevealClientCredentials 在安全身份验证成功后解密返回所有客户端授权码
|
||||
func RevealClientCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Password) == "" {
|
||||
writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证")
|
||||
return
|
||||
}
|
||||
if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil {
|
||||
writeSecurityError(w, response.ParamsError, "密码验证失败")
|
||||
return
|
||||
}
|
||||
codes, err := service.RevealClientCredentials(ctx.UserID)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ServerError, "解密授权码失败")
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(codes).FPrint(w)
|
||||
}
|
||||
19
backend/db/migration/client_credentials.go
Normal file
19
backend/db/migration/client_credentials.go
Normal file
@ -0,0 +1,19 @@
|
||||
package migration
|
||||
|
||||
func clientCredentialStatements() map[Dialect][]string {
|
||||
return map[Dialect][]string{
|
||||
DialectSQLite: {
|
||||
`CREATE TABLE IF NOT EXISTS "client_credential" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "client_name" TEXT NOT NULL, "code_hash" TEXT NOT NULL DEFAULT '', "code_ciphertext" TEXT NULL, "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" DATETIME NOT NULL, "updated_at" DATETIME NOT NULL, "last_used_at" DATETIME NULL, "revoked_at" DATETIME NULL, UNIQUE("user_id", "client_name"))`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_client_credential_user_id" ON "client_credential"("user_id")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_client_credential_revoked" ON "client_credential"("revoked_at")`,
|
||||
},
|
||||
DialectMySQL: {
|
||||
"CREATE TABLE IF NOT EXISTS `client_credential` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `client_name` VARCHAR(64) NOT NULL, `code_hash` VARCHAR(255) NOT NULL DEFAULT '', `code_ciphertext` VARCHAR(512) NULL, `code_version` INT NOT NULL DEFAULT 1, `created_at` DATETIME(6) NOT NULL, `updated_at` DATETIME(6) NOT NULL, `last_used_at` DATETIME(6) NULL, `revoked_at` DATETIME(6) NULL, UNIQUE KEY `uq_client_credential_user_client` (`user_id`, `client_name`), KEY `idx_client_credential_user_id` (`user_id`), KEY `idx_client_credential_revoked` (`revoked_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
},
|
||||
DialectPostgres: {
|
||||
`CREATE TABLE IF NOT EXISTS "client_credential" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "client_name" VARCHAR(64) NOT NULL, "code_hash" VARCHAR(255) NOT NULL DEFAULT '', "code_ciphertext" VARCHAR(512) NULL, "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" TIMESTAMPTZ NOT NULL, "updated_at" TIMESTAMPTZ NOT NULL, "last_used_at" TIMESTAMPTZ NULL, "revoked_at" TIMESTAMPTZ NULL, UNIQUE("user_id", "client_name"))`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_client_credential_user_id" ON "client_credential"("user_id")`,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_client_credential_revoked" ON "client_credential"("revoked_at")`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package migration
|
||||
package migration
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@ -31,6 +31,7 @@ func DefaultMigrations() []Migration {
|
||||
DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()),
|
||||
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
|
||||
DefineCustom(21, "expand_trusted_sender_blacklist", "expand_trusted_sender_blacklist_v1", applyTrustedSenderBlacklistSchema),
|
||||
DefineForDialects(22, "create_client_credentials", clientCredentialStatements()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -295,6 +295,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
|
||||
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
|
||||
21: "ac1a0b3be90128c0e0512d2cec858f8cd7e70abc5ff4734d7113b9674f08dbb0",
|
||||
22: "1047cb22f5a32c8a09ba4ccedff8713a5225c14fdb98357db86a6e0723c30d8b",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
@ -355,3 +356,5 @@ func TestDefaultMigrationsResumePublishedVersionOne(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -39,6 +39,11 @@ func registerV1Routes(mux *http.ServeMux) {
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/generate", "/api/security/credentials/protocols/generate", http.MethodPost, 16<<10, controllers.GenerateProtocolCredential)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/revoke", "/api/security/credentials/protocols/revoke", http.MethodPost, 16<<10, controllers.RevokeProtocolCredential)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/reveal", "/api/security/credentials/protocols/reveal", http.MethodPost, 16<<10, controllers.RevealProtocolCredentials)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients", "/api/security/credentials/clients", http.MethodGet, 0, controllers.ListClientCredentials)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/create", "/api/security/credentials/clients/create", http.MethodPost, 16<<10, controllers.CreateClientCredential)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/regenerate", "/api/security/credentials/clients/regenerate", http.MethodPost, 16<<10, controllers.RegenerateClientCredential)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/revoke", "/api/security/credentials/clients/revoke", http.MethodPost, 16<<10, controllers.RevokeClientCredential)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/credentials/clients/reveal", "/api/security/credentials/clients/reveal", http.MethodPost, 16<<10, controllers.RevealClientCredentials)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)
|
||||
|
||||
@ -72,6 +72,11 @@ func router(mux *http.ServeMux) error {
|
||||
mux.HandleFunc("/api/security/credentials/protocols/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateProtocolCredential))))
|
||||
mux.HandleFunc("/api/security/credentials/protocols/revoke", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevokeProtocolCredential))))
|
||||
mux.HandleFunc("/api/security/credentials/protocols/reveal", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevealProtocolCredentials))))
|
||||
mux.HandleFunc("/api/security/credentials/clients", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListClientCredentials)))
|
||||
mux.HandleFunc("/api/security/credentials/clients/create", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.CreateClientCredential))))
|
||||
mux.HandleFunc("/api/security/credentials/clients/regenerate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RegenerateClientCredential))))
|
||||
mux.HandleFunc("/api/security/credentials/clients/revoke", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevokeClientCredential))))
|
||||
mux.HandleFunc("/api/security/credentials/clients/reveal", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevealClientCredentials))))
|
||||
mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails)))
|
||||
mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail))))
|
||||
mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))
|
||||
|
||||
@ -41,6 +41,10 @@ var loggedOperations = map[string]string{
|
||||
"/api/security/credentials/protocols/generate": "生成邮件客户端安全码",
|
||||
"/api/security/credentials/protocols/revoke": "撤销邮件客户端安全码",
|
||||
"/api/security/credentials/protocols/reveal": "查看邮件客户端安全码",
|
||||
"/api/security/credentials/clients/create": "创建客户端专属授权码",
|
||||
"/api/security/credentials/clients/regenerate": "重新生成客户端授权码",
|
||||
"/api/security/credentials/clients/revoke": "撤销客户端专属授权码",
|
||||
"/api/security/credentials/clients/reveal": "查看客户端专属授权码",
|
||||
"/api/security/external-email/bind": "申请绑定外部邮箱",
|
||||
"/api/security/external-email/verify": "验证外部邮箱",
|
||||
"/api/security/external-email/change": "申请修改外部邮箱",
|
||||
|
||||
19
backend/models/client_credential.go
Normal file
19
backend/models/client_credential.go
Normal file
@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ClientCredential 保存用户为特定客户端(如 iPhone、Foxmail 等)创建的专属授权码
|
||||
type ClientCredential struct {
|
||||
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"id"`
|
||||
UserID int `xorm:"user_id bigint not null index" json:"user_id"`
|
||||
ClientName string `xorm:"client_name varchar(64) notnull" json:"client_name"`
|
||||
CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"`
|
||||
CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"`
|
||||
CodeVersion int `xorm:"code_version int notnull default(1)" json:"code_version"`
|
||||
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
|
||||
UpdatedAt time.Time `xorm:"updated_at datetime updated notnull" json:"updated_at"`
|
||||
LastUsedAt *time.Time `xorm:"last_used_at datetime null" json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `xorm:"revoked_at datetime null index" json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
func (ClientCredential) TableName() string { return "client_credential" }
|
||||
@ -1,4 +1,4 @@
|
||||
package auth
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
@ -66,16 +66,30 @@ func AuthenticateProtocol(protocol string, remoteAddress string, account string,
|
||||
if protocolErr != nil || db.Instance == nil {
|
||||
return fail()
|
||||
}
|
||||
|
||||
identity, err := ResolveIdentifier(account)
|
||||
if err != nil || identity.Kind != IdentifierEmail || identity.UserID <= 0 {
|
||||
if err != nil {
|
||||
return fail()
|
||||
}
|
||||
userID := identity.UserID
|
||||
if identity.Kind == IdentifierAccount {
|
||||
var u models.User
|
||||
has, err := db.Instance.Where("account = ? AND disabled = 0", identity.Account).Get(&u)
|
||||
if err != nil || !has || u.ID <= 0 {
|
||||
return fail()
|
||||
}
|
||||
userID = u.ID
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fail()
|
||||
}
|
||||
|
||||
service, err := credentials.NewService(db.Instance)
|
||||
if err != nil || !service.VerifyProtocolCode(identity.UserID, normalizedProtocol, plain) {
|
||||
if err != nil || !service.VerifyClientOrProtocolCode(userID, normalizedProtocol, plain) {
|
||||
return fail()
|
||||
}
|
||||
var user models.User
|
||||
has, err := db.Instance.ID(identity.UserID).Where("disabled = 0").Get(&user)
|
||||
has, err := db.Instance.ID(userID).Where("disabled = 0").Get(&user)
|
||||
if err != nil || !has || user.ID <= 0 {
|
||||
return fail()
|
||||
}
|
||||
|
||||
346
backend/services/credentials/client.go
Normal file
346
backend/services/credentials/client.go
Normal file
@ -0,0 +1,346 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
"git.pcux.cn/xuans/Smail/utils/password"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientNameRequired = errors.New("client name is required")
|
||||
ErrClientNotFound = errors.New("client credential not found")
|
||||
)
|
||||
|
||||
// ClientCredentialItem 表示返回给前端的客户端授权码视图
|
||||
type ClientCredentialItem struct {
|
||||
ID int `json:"id"`
|
||||
ClientName string `json:"client_name"`
|
||||
Configured bool `json:"configured"`
|
||||
CanReveal bool `json:"can_reveal"`
|
||||
CodeVersion int `json:"code_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
// CleanAuthCode 统一授权码格式:去除短横线、空格并转大写,提升各类客户端容错率
|
||||
func CleanAuthCode(raw string) string {
|
||||
raw = strings.ToUpper(strings.TrimSpace(raw))
|
||||
raw = strings.ReplaceAll(raw, "-", "")
|
||||
raw = strings.ReplaceAll(raw, " ", "")
|
||||
return raw
|
||||
}
|
||||
|
||||
// FormatAuthCode 将清洗后的授权码格式化为 4 字符一组带横线的易读格式
|
||||
func FormatAuthCode(raw string) string {
|
||||
cleaned := CleanAuthCode(raw)
|
||||
if len(cleaned) == 0 {
|
||||
return ""
|
||||
}
|
||||
var groups []string
|
||||
for len(cleaned) > 4 {
|
||||
groups = append(groups, cleaned[:4])
|
||||
cleaned = cleaned[4:]
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
groups = append(groups, cleaned)
|
||||
}
|
||||
return strings.Join(groups, "-")
|
||||
}
|
||||
|
||||
// ListClientCredentials 返回指定用户的所有有效(未撤销)客户端授权凭证
|
||||
func (s *Service) ListClientCredentials(userID int) ([]ClientCredentialItem, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, ErrInvalidUser
|
||||
}
|
||||
var items []models.ClientCredential
|
||||
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).OrderBy("id ASC").Find(&items); err != nil {
|
||||
return nil, fmt.Errorf("load client credentials: %w", err)
|
||||
}
|
||||
result := make([]ClientCredentialItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, ClientCredentialItem{
|
||||
ID: item.ID,
|
||||
ClientName: item.ClientName,
|
||||
Configured: item.CodeHash != "",
|
||||
CanReveal: item.CodeCiphertext != "",
|
||||
CodeVersion: item.CodeVersion,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
LastUsedAt: item.LastUsedAt,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateClientCredential 为指定客户端名称创建专属授权码
|
||||
func (s *Service) CreateClientCredential(userID int, clientName string) (string, *ClientCredentialItem, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return "", nil, ErrUnavailable
|
||||
}
|
||||
if userID <= 0 {
|
||||
return "", nil, ErrInvalidUser
|
||||
}
|
||||
clientName = strings.TrimSpace(clientName)
|
||||
if clientName == "" {
|
||||
return "", nil, ErrClientNameRequired
|
||||
}
|
||||
if len([]rune(clientName)) > 32 {
|
||||
return "", nil, errors.New("client name is too long (max 32 chars)")
|
||||
}
|
||||
|
||||
plain, err := newProtocolCode()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
normalizedCode := CleanAuthCode(plain)
|
||||
|
||||
ciphertext := ""
|
||||
if s.vaultRoot != "" {
|
||||
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("protect client authorization code: %w", err)
|
||||
}
|
||||
}
|
||||
hash, err := password.Hash(normalizedCode)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("hash client authorization code: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var existing models.ClientCredential
|
||||
has, err := s.engine.Where("user_id = ? AND client_name = ?", userID, clientName).Get(&existing)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("check existing client credential: %w", err)
|
||||
}
|
||||
if has {
|
||||
// 如果此前被撤销,重新启用并更新密钥;如果未被撤销则不允许重名创建
|
||||
if existing.RevokedAt == nil {
|
||||
return "", nil, errors.New("client name already exists")
|
||||
}
|
||||
version := existing.CodeVersion + 1
|
||||
existing.CodeHash = hash
|
||||
existing.CodeCiphertext = ciphertext
|
||||
existing.CodeVersion = version
|
||||
existing.RevokedAt = nil
|
||||
existing.LastUsedAt = nil
|
||||
existing.UpdatedAt = s.now()
|
||||
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "revoked_at", "last_used_at", "updated_at").Update(&existing); err != nil {
|
||||
return "", nil, fmt.Errorf("re-activate client credential: %w", err)
|
||||
}
|
||||
item := &ClientCredentialItem{
|
||||
ID: existing.ID,
|
||||
ClientName: existing.ClientName,
|
||||
Configured: true,
|
||||
CanReveal: existing.CodeCiphertext != "",
|
||||
CodeVersion: existing.CodeVersion,
|
||||
CreatedAt: existing.CreatedAt,
|
||||
UpdatedAt: existing.UpdatedAt,
|
||||
}
|
||||
return plain, item, nil
|
||||
}
|
||||
|
||||
record := models.ClientCredential{
|
||||
UserID: userID,
|
||||
ClientName: clientName,
|
||||
CodeHash: hash,
|
||||
CodeCiphertext: ciphertext,
|
||||
CodeVersion: 1,
|
||||
CreatedAt: s.now(),
|
||||
UpdatedAt: s.now(),
|
||||
}
|
||||
if _, err := s.engine.Insert(&record); err != nil {
|
||||
return "", nil, fmt.Errorf("insert client credential: %w", err)
|
||||
}
|
||||
|
||||
item := &ClientCredentialItem{
|
||||
ID: record.ID,
|
||||
ClientName: record.ClientName,
|
||||
Configured: true,
|
||||
CanReveal: record.CodeCiphertext != "",
|
||||
CodeVersion: record.CodeVersion,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
}
|
||||
return plain, item, nil
|
||||
}
|
||||
|
||||
// RegenerateClientCredential 重新生成已有客户端专属授权码
|
||||
func (s *Service) RegenerateClientCredential(userID int, clientID int) (string, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
if userID <= 0 || clientID <= 0 {
|
||||
return "", ErrInvalidUser
|
||||
}
|
||||
|
||||
plain, err := newProtocolCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedCode := CleanAuthCode(plain)
|
||||
|
||||
ciphertext := ""
|
||||
if s.vaultRoot != "" {
|
||||
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("protect client authorization code: %w", err)
|
||||
}
|
||||
}
|
||||
hash, err := password.Hash(normalizedCode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash client authorization code: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var existing models.ClientCredential
|
||||
has, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).Get(&existing)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load client credential: %w", err)
|
||||
}
|
||||
if !has {
|
||||
return "", ErrClientNotFound
|
||||
}
|
||||
|
||||
version := existing.CodeVersion + 1
|
||||
existing.CodeHash = hash
|
||||
existing.CodeCiphertext = ciphertext
|
||||
existing.CodeVersion = version
|
||||
existing.LastUsedAt = nil
|
||||
existing.UpdatedAt = s.now()
|
||||
|
||||
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "last_used_at", "updated_at").Update(&existing); err != nil {
|
||||
return "", fmt.Errorf("update client authorization code: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// RevokeClientCredential 撤销指定客户端授权码
|
||||
func (s *Service) RevokeClientCredential(userID int, clientID int) error {
|
||||
if s == nil || s.engine == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if userID <= 0 || clientID <= 0 {
|
||||
return ErrInvalidUser
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := s.now()
|
||||
affected, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).
|
||||
Cols("revoked_at", "code_hash", "code_ciphertext", "updated_at").
|
||||
Update(&models.ClientCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke client credential: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrClientNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevealClientCredentials 解密当前用户所有有效客户端授权码明文,仅在密码验证后调用
|
||||
func (s *Service) RevealClientCredentials(userID int) (map[int]string, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, ErrInvalidUser
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
vaultRoot := s.vaultRoot
|
||||
if vaultRoot == "" {
|
||||
return make(map[int]string), nil
|
||||
}
|
||||
var items []models.ClientCredential
|
||||
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).Find(&items); err != nil {
|
||||
return nil, fmt.Errorf("load client credentials for reveal: %w", err)
|
||||
}
|
||||
result := make(map[int]string, len(items))
|
||||
for _, item := range items {
|
||||
if item.CodeCiphertext == "" || item.CodeHash == "" {
|
||||
continue
|
||||
}
|
||||
plain, err := credential.Decrypt(vaultRoot, item.CodeCiphertext)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[item.ID] = plain
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// VerifyClientOrProtocolCode 同时支持客户端专属授权码以及遗留的协议授权码
|
||||
func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPlain string) bool {
|
||||
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
|
||||
password.VerifyDummy(rawPlain)
|
||||
return false
|
||||
}
|
||||
cleanedPlain := CleanAuthCode(rawPlain)
|
||||
if cleanedPlain == "" {
|
||||
password.VerifyDummy(rawPlain)
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先校验客户端专属授权码
|
||||
var clients []models.ClientCredential
|
||||
err := s.engine.Where("user_id = ? AND revoked_at IS NULL AND code_hash != ''", userID).Find(&clients)
|
||||
if err == nil && len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
// 比对清洗后的统一大写无横线授权码
|
||||
if password.Verify(client.CodeHash, cleanedPlain) {
|
||||
now := s.now()
|
||||
_, _ = s.engine.ID(client.ID).
|
||||
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
|
||||
Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
|
||||
protocol, err = NormalizeProtocol(protocol)
|
||||
if err != nil {
|
||||
password.VerifyDummy(rawPlain)
|
||||
return false
|
||||
}
|
||||
var item models.ProtocolCredential
|
||||
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
|
||||
if err != nil || !has || item.CodeHash == "" {
|
||||
password.VerifyDummy(rawPlain)
|
||||
return false
|
||||
}
|
||||
|
||||
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
|
||||
matched := password.Verify(item.CodeHash, strings.TrimSpace(rawPlain)) || password.Verify(item.CodeHash, cleanedPlain)
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
affected, err := s.engine.ID(item.ID).
|
||||
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
|
||||
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
|
||||
if err == nil && affected == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
valid, err := s.engine.ID(item.ID).
|
||||
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
|
||||
Exist(&models.ProtocolCredential{})
|
||||
return err == nil && valid
|
||||
}
|
||||
@ -1,163 +1,283 @@
|
||||
.third-party-authorization {
|
||||
.third-party-authorization {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.authorization-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.authorization-header .header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.authorization-header h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--pm-text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authorization-desc {
|
||||
margin: 0;
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.authorization-header .header-actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.authorization-section > .el-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.protocol-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.protocol-item {
|
||||
.authorization-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 顶部轻量工具条 */
|
||||
.compact-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--pm-radius-md);
|
||||
border: 1px solid var(--pm-border-color);
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--pm-bg-secondary);
|
||||
border: 1px solid var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-md);
|
||||
}
|
||||
|
||||
.protocol-info {
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.protocol-title {
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--pm-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--pm-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 客户端选择与管理主卡片 */
|
||||
.client-main-card {
|
||||
padding: 12px 14px;
|
||||
background: var(--pm-bg-secondary);
|
||||
border: 1px solid var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.protocol-title strong {
|
||||
color: var(--pm-text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.protocol-info .protocol-guide {
|
||||
margin: 6px 0 0;
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.protocol-credential-slot {
|
||||
.client-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--pm-radius-sm);
|
||||
background: var(--pm-bg-primary);
|
||||
border: 1px dashed var(--pm-border-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
.picker-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picker-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--pm-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.client-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 授权码详情框 */
|
||||
.client-detail-box {
|
||||
padding: 10px 12px;
|
||||
background: var(--pm-bg-primary);
|
||||
border: 1px dashed var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-sm);
|
||||
}
|
||||
|
||||
.code-display-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--pm-text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-value-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.masked-code {
|
||||
letter-spacing: 2px;
|
||||
color: var(--pm-text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: var(--pm-text-secondary);
|
||||
}
|
||||
|
||||
.revealed-code {
|
||||
padding: 2px 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--pm-primary-color) 12%, var(--pm-bg-secondary));
|
||||
color: var(--pm-primary-color);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.reveal-btn, .copy-btn {
|
||||
padding: 0 4px;
|
||||
.action-btn {
|
||||
font-size: 13px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.unconfigured-tip {
|
||||
.text-tip {
|
||||
font-size: 12px;
|
||||
color: var(--pm-text-placeholder, #909399);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.protocol-actions {
|
||||
.client-meta {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--pm-text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.empty-client-box {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
background: var(--pm-bg-primary);
|
||||
border: 1px dashed var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-sm);
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 13px;
|
||||
color: var(--pm-text-placeholder, #909399);
|
||||
}
|
||||
|
||||
/* 紧凑指引网格 */
|
||||
.compact-guide-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.verify-dialog-tip {
|
||||
margin: 0 0 16px;
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
.guide-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--pm-text-primary);
|
||||
}
|
||||
|
||||
.verify-dialog-footer {
|
||||
.guide-sub {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: var(--pm-text-secondary);
|
||||
}
|
||||
|
||||
.guide-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
padding: 10px 12px;
|
||||
background: var(--pm-bg-secondary);
|
||||
border: 1px solid var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-proto {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--pm-text-primary);
|
||||
}
|
||||
|
||||
.card-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cl-label {
|
||||
color: var(--pm-text-secondary);
|
||||
}
|
||||
|
||||
.cl-val {
|
||||
font-family: monospace;
|
||||
color: var(--pm-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cl-val.highlight {
|
||||
color: var(--pm-primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 弹窗通用紧凑样式 */
|
||||
.compact-dialog .dialog-tip {
|
||||
margin: 0 0 14px;
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.authorization-header {
|
||||
@media (max-width: 800px) {
|
||||
.guide-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.compact-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.protocol-item {
|
||||
align-items: stretch;
|
||||
.client-picker-row {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.protocol-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,194 +1,231 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { ElMessageBox, ElNotification } from 'element-plus'
|
||||
|
||||
import { http } from '@/utils/axios'
|
||||
|
||||
export function usePage() {
|
||||
const protocolLoading = ref(false)
|
||||
const protocolCredentials = ref([])
|
||||
const generatedProtocolCode = ref('')
|
||||
const generatedProtocol = ref('')
|
||||
const loading = ref(false)
|
||||
const clientList = ref([])
|
||||
const selectedClientId = ref(null)
|
||||
const currentDomain = ref('')
|
||||
|
||||
// 解密后的各协议授权码明文 { imap: 'xxxx', pop3: 'yyyy', smtp: 'zzzz' }
|
||||
// 解密后的各客户端授权码明文 { [clientId]: 'xxxx-yyyy-...' }
|
||||
const revealedCodes = reactive({})
|
||||
// 是否已完成密码安全验证并展示
|
||||
const codesRevealed = ref(false)
|
||||
|
||||
// 新增客户端弹窗
|
||||
const createDialogVisible = ref(false)
|
||||
const newClientName = ref('')
|
||||
const createLoading = ref(false)
|
||||
|
||||
// 密码验证查看弹窗
|
||||
const verifyDialogVisible = ref(false)
|
||||
const verifyPassword = ref('')
|
||||
const verifyLoading = ref(false)
|
||||
|
||||
const currentClient = computed(() => {
|
||||
if (!selectedClientId.value || !Array.isArray(clientList.value)) return null
|
||||
return clientList.value.find(c => c.id === selectedClientId.value) || null
|
||||
})
|
||||
|
||||
const notify = (message, type = 'error') => {
|
||||
ElNotification({
|
||||
title: type === 'success' ? '操作成功' : '操作失败',
|
||||
title: type === 'success' ? '操作成功' : (type === 'info' ? '提示' : '操作失败'),
|
||||
message,
|
||||
type,
|
||||
})
|
||||
}
|
||||
|
||||
const protocolLabel = (protocol = '') => {
|
||||
const map = {
|
||||
smtp: 'SMTP (代发/发信)',
|
||||
imap: 'IMAP (收信/同步)',
|
||||
pop3: 'POP3 (收信)',
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return '--'
|
||||
try {
|
||||
const d = new Date(timeStr)
|
||||
if (isNaN(d.getTime())) return timeStr
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return timeStr
|
||||
}
|
||||
return map[protocol.toLowerCase()] || protocol.toUpperCase()
|
||||
}
|
||||
|
||||
const loadProtocolCredentials = async () => {
|
||||
protocolLoading.value = true
|
||||
const loadBaseInfo = async () => {
|
||||
try {
|
||||
const response = await http.get('/api/v1/security/credentials/protocols')
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '读取邮件客户端协议设置失败')
|
||||
const infoRes = await http.get('/api/v1/user/info')
|
||||
if (infoRes?.data?.email && infoRes.data.email.includes('@')) {
|
||||
currentDomain.value = infoRes.data.email.split('@')[1]
|
||||
}
|
||||
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
|
||||
} catch (error) {
|
||||
notify(error?.message || '读取邮件客户端协议设置失败')
|
||||
} finally {
|
||||
protocolLoading.value = false
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
const setProtocolEnabled = async (item, enabled) => {
|
||||
if (protocolLoading.value || item.enabled === enabled) return
|
||||
const previous = item.enabled
|
||||
item.enabled = enabled
|
||||
protocolLoading.value = true
|
||||
const loadClientCredentials = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await http.put('/api/v1/security/credentials/protocols/enabled', {
|
||||
protocol: item.protocol,
|
||||
enabled,
|
||||
const response = await http.get('/api/v1/security/credentials/clients')
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '读取客户端授权码列表失败')
|
||||
}
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
clientList.value = list
|
||||
if (list.length > 0) {
|
||||
// 如果当前选中的不在列表中,默认选中第一个
|
||||
if (!selectedClientId.value || !list.some(c => c.id === selectedClientId.value)) {
|
||||
selectedClientId.value = list[0].id
|
||||
}
|
||||
} else {
|
||||
selectedClientId.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
notify(error?.message || '读取客户端授权码列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClientSelect = (val) => {
|
||||
selectedClientId.value = val
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
newClientName.value = ''
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeCreateDialog = () => {
|
||||
createDialogVisible.value = false
|
||||
newClientName.value = ''
|
||||
}
|
||||
|
||||
const submitCreateClient = async () => {
|
||||
const name = newClientName.value.trim()
|
||||
if (!name) {
|
||||
notify('请输入客户端名称')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/clients/create', {
|
||||
client_name: name,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '更新协议开启状态失败')
|
||||
throw new Error(response.errorMsg || '创建客户端专属授权码失败')
|
||||
}
|
||||
if (!enabled) {
|
||||
if (generatedProtocol.value === item.protocol) {
|
||||
generatedProtocol.value = ''
|
||||
generatedProtocolCode.value = ''
|
||||
}
|
||||
delete revealedCodes[item.protocol]
|
||||
const code = response.data?.code || ''
|
||||
const item = response.data?.client
|
||||
closeCreateDialog()
|
||||
await loadClientCredentials()
|
||||
|
||||
if (item && item.id) {
|
||||
selectedClientId.value = item.id
|
||||
revealedCodes[item.id] = code
|
||||
codesRevealed.value = true
|
||||
}
|
||||
notify(`${protocolLabel(item.protocol)} 当前账号客户端访问已${enabled ? '开启' : '关闭'}`, 'success')
|
||||
await loadProtocolCredentials()
|
||||
notify(`客户端「${name}」专属授权码已创建并就绪`, 'success')
|
||||
} catch (error) {
|
||||
item.enabled = previous
|
||||
notify(error?.message || '更新协议开启状态失败')
|
||||
notify(error?.message || '创建客户端授权码失败')
|
||||
} finally {
|
||||
protocolLoading.value = false
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyProtocolCode = async (codeToCopy) => {
|
||||
const targetCode = typeof codeToCopy === 'string' && codeToCopy
|
||||
? codeToCopy
|
||||
: generatedProtocolCode.value
|
||||
|
||||
if (!targetCode) {
|
||||
notify('未找到可复制的授权码')
|
||||
const regenerateClientCode = async (client) => {
|
||||
if (!client) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定重新生成客户端「${client.client_name}」的专属授权码吗?旧授权码将立即失效。`,
|
||||
'安全提示',
|
||||
{
|
||||
confirmButtonText: '确定重新生成',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/clients/regenerate', {
|
||||
client_id: client.id,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '重新生成客户端授权码失败')
|
||||
}
|
||||
const newCode = response.data?.code || ''
|
||||
revealedCodes[client.id] = newCode
|
||||
codesRevealed.value = true
|
||||
notify(`客户端「${client.client_name}」专属授权码已重新生成`, 'success')
|
||||
await loadClientCredentials()
|
||||
} catch (error) {
|
||||
notify(error?.message || '重新生成失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const revokeClientCode = async (client) => {
|
||||
if (!client) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定撤销并删除客户端「${client.client_name}」吗?撤销后该设备将无法通过授权码登录收发信。`,
|
||||
'安全提示',
|
||||
{
|
||||
confirmButtonText: '确定撤销删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'danger',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/clients/revoke', {
|
||||
client_id: client.id,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '撤销客户端失败')
|
||||
}
|
||||
delete revealedCodes[client.id]
|
||||
notify(`客户端「${client.client_name}」已成功撤销`, 'success')
|
||||
await loadClientCredentials()
|
||||
} catch (error) {
|
||||
notify(error?.message || '撤销失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyCode = async (codeText) => {
|
||||
if (!codeText) {
|
||||
notify('未找到可复制的授权码')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetCode)
|
||||
await navigator.clipboard.writeText(codeText)
|
||||
} else {
|
||||
const input = document.createElement('input')
|
||||
input.value = targetCode
|
||||
input.value = codeText
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
notify('协议授权码已复制到剪贴板', 'success')
|
||||
} catch (error) {
|
||||
notify('专属授权码已复制到剪贴板', 'success')
|
||||
} catch {
|
||||
notify('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
const generateProtocolCode = async (item) => {
|
||||
const actionText = item.configured ? '重新生成' : '生成'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定为 ${protocolLabel(item.protocol)} ${actionText}专属授权码吗?旧授权码将立即失效并更新。`,
|
||||
'安全提示',
|
||||
{
|
||||
confirmButtonText: '确定生成',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
protocolLoading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/protocols/generate', {
|
||||
protocol: item.protocol,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '生成协议授权码失败')
|
||||
}
|
||||
|
||||
const newCode = response?.data?.code || ''
|
||||
generatedProtocol.value = item.protocol
|
||||
generatedProtocolCode.value = newCode
|
||||
|
||||
// 自动同步到已解密展示状态,无需再次密码验证
|
||||
revealedCodes[item.protocol] = newCode
|
||||
codesRevealed.value = true
|
||||
|
||||
notify(`${protocolLabel(item.protocol)} 授权码已生成并持久保存`, 'success')
|
||||
await loadProtocolCredentials()
|
||||
} catch (error) {
|
||||
notify(error?.message || '生成协议授权码失败')
|
||||
} finally {
|
||||
protocolLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const revokeProtocolCode = async (item) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定撤销 ${protocolLabel(item.protocol)} 的授权码吗?撤销后客户端将无法使用该授权码登录。`,
|
||||
'安全提示',
|
||||
{
|
||||
confirmButtonText: '确定撤销',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
protocolLoading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/protocols/revoke', {
|
||||
protocol: item.protocol,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '撤销协议授权码失败')
|
||||
}
|
||||
delete revealedCodes[item.protocol]
|
||||
if (generatedProtocol.value === item.protocol) {
|
||||
generatedProtocol.value = ''
|
||||
generatedProtocolCode.value = ''
|
||||
}
|
||||
notify(`${protocolLabel(item.protocol)} 授权码已撤销`, 'success')
|
||||
await loadProtocolCredentials()
|
||||
} catch (error) {
|
||||
notify(error?.message || '撤销协议授权码失败')
|
||||
} finally {
|
||||
protocolLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 密码验证弹窗控制
|
||||
const openVerifyDialog = () => {
|
||||
verifyPassword.value = ''
|
||||
verifyDialogVisible.value = true
|
||||
@ -199,33 +236,30 @@ export function usePage() {
|
||||
verifyPassword.value = ''
|
||||
}
|
||||
|
||||
// 提交登录密码验证并解密回显
|
||||
const submitVerifyAndReveal = async () => {
|
||||
const password = verifyPassword.value.trim()
|
||||
if (!password) {
|
||||
notify('请输入当前账号登录密码')
|
||||
return
|
||||
}
|
||||
|
||||
verifyLoading.value = true
|
||||
try {
|
||||
const response = await http.post('/api/v1/security/credentials/protocols/reveal', {
|
||||
const response = await http.post('/api/v1/security/credentials/clients/reveal', {
|
||||
password,
|
||||
})
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || '密码验证失败,无法查看授权码')
|
||||
}
|
||||
|
||||
const codes = response.data?.codes || response.data || {}
|
||||
const map = response.data || {}
|
||||
Object.keys(revealedCodes).forEach(k => delete revealedCodes[k])
|
||||
Object.assign(revealedCodes, codes)
|
||||
Object.assign(revealedCodes, map)
|
||||
codesRevealed.value = true
|
||||
closeVerifyDialog()
|
||||
const count = Object.keys(codes).length
|
||||
const count = Object.keys(map).length
|
||||
if (count > 0) {
|
||||
notify(`安全验证通过,已解密显示 ${count} 个协议授权码`, 'success')
|
||||
notify(`安全验证通过,已解密显示客户端专属授权码`, 'success')
|
||||
} else {
|
||||
notify('安全验证通过,当前授权码未保留加密密文,可点击重新生成', 'warning')
|
||||
notify('安全验证通过,当前未发现加密密文,可重新生成', 'warning')
|
||||
}
|
||||
} catch (error) {
|
||||
notify(error?.message || '验证失败,请检查密码是否正确')
|
||||
@ -234,35 +268,42 @@ export function usePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 安全隐藏授权码
|
||||
const hideRevealedCodes = () => {
|
||||
Object.keys(revealedCodes).forEach(k => delete revealedCodes[k])
|
||||
codesRevealed.value = false
|
||||
notify('已恢复安全隐藏保护', 'info')
|
||||
notify('已恢复安全隐藏状态', 'info')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProtocolCredentials()
|
||||
loadBaseInfo()
|
||||
loadClientCredentials()
|
||||
})
|
||||
|
||||
return {
|
||||
protocolLoading,
|
||||
protocolCredentials,
|
||||
generatedProtocolCode,
|
||||
generatedProtocol,
|
||||
loading,
|
||||
clientList,
|
||||
selectedClientId,
|
||||
currentClient,
|
||||
currentDomain,
|
||||
revealedCodes,
|
||||
codesRevealed,
|
||||
createDialogVisible,
|
||||
newClientName,
|
||||
createLoading,
|
||||
verifyDialogVisible,
|
||||
verifyPassword,
|
||||
verifyLoading,
|
||||
protocolLabel,
|
||||
setProtocolEnabled,
|
||||
generateProtocolCode,
|
||||
copyProtocolCode,
|
||||
revokeProtocolCode,
|
||||
handleClientSelect,
|
||||
openCreateDialog,
|
||||
closeCreateDialog,
|
||||
submitCreateClient,
|
||||
regenerateClientCode,
|
||||
revokeClientCode,
|
||||
copyCode,
|
||||
formatTime,
|
||||
openVerifyDialog,
|
||||
closeVerifyDialog,
|
||||
submitVerifyAndReveal,
|
||||
hideRevealedCodes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,21 @@
|
||||
<template>
|
||||
<div class="third-party-authorization" v-loading="protocolLoading">
|
||||
<template>
|
||||
<div class="third-party-authorization" v-loading="loading">
|
||||
<section class="authorization-section">
|
||||
<div class="authorization-header">
|
||||
<div class="header-main">
|
||||
<h3>邮件客户端协议</h3>
|
||||
<p class="authorization-desc">
|
||||
为 IMAP、POP3、SMTP 配置专属授权码,平时安全隐藏,验证密码后可查看或复制。
|
||||
</p>
|
||||
<!-- 顶部轻量工具条:紧凑一行 -->
|
||||
<div class="compact-header">
|
||||
<div class="header-left">
|
||||
<span class="header-title">客户端专属授权码</span>
|
||||
<span class="header-subtitle">为手机、Foxmail 等设备创建独立授权码,随时查看或撤销</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-right">
|
||||
<el-button
|
||||
v-if="hasConfiguredProtocols && !codesRevealed"
|
||||
v-if="hasClients && !codesRevealed"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="openVerifyDialog"
|
||||
>
|
||||
安全验证查看授权码
|
||||
安全验证查看明文
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="codesRevealed"
|
||||
@ -25,125 +24,219 @@
|
||||
size="small"
|
||||
@click="hideRevealedCodes"
|
||||
>
|
||||
安全隐藏授权码
|
||||
安全隐藏明文
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
+ 新增客户端
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
title="使用完整邮箱地址作为登录名,专属授权码作为密码。授权码平时处于加密保护状态,验证密码后可完整查看与一键复制。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div class="protocol-list">
|
||||
<div
|
||||
v-for="item in protocolCredentials"
|
||||
:key="item.protocol"
|
||||
class="protocol-item"
|
||||
>
|
||||
<div class="protocol-info">
|
||||
<div class="protocol-title">
|
||||
<strong>{{ protocolLabel(item.protocol) }}</strong>
|
||||
<el-tag size="small" :type="item.enabled ? 'success' : 'info'">
|
||||
{{ item.enabled ? '已开启' : '已关闭' }}
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="item.configured ? 'success' : 'warning'">
|
||||
{{ item.configured ? '已配置授权码' : '未生成授权码' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="protocol-guide">登录名:完整邮箱地址;密码:本协议专属授权码。</p>
|
||||
|
||||
<div class="protocol-credential-slot">
|
||||
<span class="slot-label">当前授权码:</span>
|
||||
<template v-if="item.configured">
|
||||
<template v-if="codesRevealed && revealedCodes[item.protocol]">
|
||||
<code class="revealed-code">{{ revealedCodes[item.protocol] }}</code>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="copy-btn"
|
||||
@click="copyProtocolCode(revealedCodes[item.protocol])"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="codesRevealed && !revealedCodes[item.protocol]">
|
||||
<span class="unconfigured-tip">旧版授权码未保留密文,请点击重新生成</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="copy-btn"
|
||||
@click="generateProtocolCode(item)"
|
||||
>
|
||||
重新生成
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="masked-code">••••••••••••••••</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="reveal-btn"
|
||||
@click="openVerifyDialog"
|
||||
>
|
||||
验证查看
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="unconfigured-tip">尚未生成专属授权码,请点击右侧生成</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 客户端选择与管理主卡片 -->
|
||||
<div class="client-main-card">
|
||||
<div class="client-picker-row">
|
||||
<div class="picker-group">
|
||||
<span class="picker-label">选择客户端:</span>
|
||||
<el-select
|
||||
v-model="selectedClientId"
|
||||
placeholder="请选择或添加客户端"
|
||||
size="default"
|
||||
class="client-select"
|
||||
@change="handleClientSelect"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in clientList"
|
||||
:key="item.id"
|
||||
:label="item.client_name"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="client-option-item">
|
||||
<span>{{ item.client_name }}</span>
|
||||
<el-tag size="small" type="success">已就绪</el-tag>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="protocol-actions">
|
||||
<el-switch
|
||||
:model-value="item.enabled"
|
||||
:loading="protocolLoading"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
:aria-label="`${protocolLabel(item.protocol)} 当前账号客户端访问`"
|
||||
@change="value => setProtocolEnabled(item, value)"
|
||||
/>
|
||||
<div class="picker-actions" v-if="currentClient">
|
||||
<el-button
|
||||
type="primary"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="!item.enabled"
|
||||
@click="generateProtocolCode(item)"
|
||||
@click="regenerateClientCode(currentClient)"
|
||||
>
|
||||
{{ item.configured ? '重新生成授权码' : '生成授权码' }}
|
||||
重新生成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="item.configured"
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="revokeProtocolCode(item)"
|
||||
@click="revokeClientCode(currentClient)"
|
||||
>
|
||||
撤销授权码
|
||||
撤销删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前选中客户端的授权码展示条 -->
|
||||
<div class="client-detail-box" v-if="currentClient">
|
||||
<div class="code-display-row">
|
||||
<span class="code-label">当前专属授权码:</span>
|
||||
<div class="code-value-area">
|
||||
<template v-if="codesRevealed && revealedCodes[currentClient.id]">
|
||||
<code class="revealed-code">{{ revealedCodes[currentClient.id] }}</code>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="action-btn"
|
||||
@click="copyCode(revealedCodes[currentClient.id])"
|
||||
>
|
||||
一键复制
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="codesRevealed && !revealedCodes[currentClient.id]">
|
||||
<span class="text-tip">未保留加密密文,可点击重新生成</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="masked-code">••••••••••••••••••••</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="action-btn"
|
||||
@click="openVerifyDialog"
|
||||
>
|
||||
验证查看
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="client-meta">
|
||||
<span class="meta-item">创建时间:{{ formatTime(currentClient.created_at) }}</span>
|
||||
<span class="meta-item" v-if="currentClient.last_used_at">
|
||||
最近活跃:{{ formatTime(currentClient.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-client-box" v-else>
|
||||
<span class="empty-tip">暂无可用客户端,请点击上方「+ 新增客户端」为您的设备配置专属授权码</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客户端配置指引紧凑网格(一屏显示完整,无滚动条) -->
|
||||
<div class="compact-guide-section">
|
||||
<div class="guide-title">
|
||||
<span>通用客户端配置参数</span>
|
||||
<span class="guide-sub">支持用户名直接登录或完整邮箱登录,密码统一使用上方专属授权码</span>
|
||||
</div>
|
||||
<div class="guide-grid">
|
||||
<div class="guide-card">
|
||||
<div class="card-head">
|
||||
<span class="card-proto">IMAP 收信服务</span>
|
||||
<el-tag size="small" type="primary">SSL/TLS</el-tag>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务器地址:</span>
|
||||
<span class="cl-val">imap.{{ currentDomain || 'solit.cn' }}</span>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务端口:</span>
|
||||
<span class="cl-val highlight">993</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-head">
|
||||
<span class="card-proto">POP3 收信服务</span>
|
||||
<el-tag size="small" type="primary">SSL/TLS</el-tag>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务器地址:</span>
|
||||
<span class="cl-val">pop.{{ currentDomain || 'solit.cn' }}</span>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务端口:</span>
|
||||
<span class="cl-val highlight">995</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-head">
|
||||
<span class="card-proto">SMTP 发信服务</span>
|
||||
<el-tag size="small" type="primary">SSL / STARTTLS</el-tag>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务器地址:</span>
|
||||
<span class="cl-val">smtp.{{ currentDomain || 'solit.cn' }}</span>
|
||||
</div>
|
||||
<div class="card-line">
|
||||
<span class="cl-label">服务端口:</span>
|
||||
<span class="cl-val highlight">465 (SSL) / 587</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 新增客户端弹窗 -->
|
||||
<el-dialog
|
||||
v-model="createDialogVisible"
|
||||
title="新增客户端专属授权码"
|
||||
width="min(440px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
class="compact-dialog"
|
||||
@close="closeCreateDialog"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-tip">
|
||||
为您的设备(例如“iPhone 自带邮件”、“公司 Foxmail”、“Mac Mail”)创建独立授权码:
|
||||
</p>
|
||||
<el-form @submit.prevent="submitCreateClient">
|
||||
<el-form-item label="客户端名称">
|
||||
<el-input
|
||||
v-model="newClientName"
|
||||
placeholder="例如:iPhone 邮件、Foxmail 办公本"
|
||||
maxlength="32"
|
||||
show-word-limit
|
||||
autocomplete="off"
|
||||
@keyup.enter="submitCreateClient"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="closeCreateDialog">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="createLoading"
|
||||
:disabled="!newClientName.trim()"
|
||||
@click="submitCreateClient"
|
||||
>
|
||||
立即生成
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 密码验证弹窗 -->
|
||||
<el-dialog
|
||||
v-model="verifyDialogVisible"
|
||||
title="安全身份验证"
|
||||
width="min(440px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
class="verify-reveal-dialog"
|
||||
class="compact-dialog"
|
||||
@close="closeVerifyDialog"
|
||||
>
|
||||
<div class="verify-dialog-body">
|
||||
<p class="verify-dialog-tip">
|
||||
查看已持久保存的协议授权码前,请先验证当前账号的登录密码:
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-tip">
|
||||
查看已保存的客户端专属授权码明文前,请先验证当前账号的登录密码:
|
||||
</p>
|
||||
<el-form @submit.prevent="submitVerifyAndReveal">
|
||||
<el-form-item label="登录密码">
|
||||
@ -159,7 +252,7 @@
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="verify-dialog-footer">
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="closeVerifyDialog">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@ -167,7 +260,7 @@
|
||||
:disabled="!verifyPassword.trim()"
|
||||
@click="submitVerifyAndReveal"
|
||||
>
|
||||
验证并查看授权码
|
||||
验证并查看明文
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@ -180,28 +273,36 @@ import { computed } from 'vue'
|
||||
import { usePage } from './ThirdPartyAuthorization.js'
|
||||
|
||||
const {
|
||||
protocolLoading,
|
||||
protocolCredentials,
|
||||
loading,
|
||||
clientList,
|
||||
selectedClientId,
|
||||
currentClient,
|
||||
currentDomain,
|
||||
revealedCodes,
|
||||
codesRevealed,
|
||||
createDialogVisible,
|
||||
newClientName,
|
||||
createLoading,
|
||||
verifyDialogVisible,
|
||||
verifyPassword,
|
||||
verifyLoading,
|
||||
protocolLabel,
|
||||
setProtocolEnabled,
|
||||
generateProtocolCode,
|
||||
copyProtocolCode,
|
||||
revokeProtocolCode,
|
||||
handleClientSelect,
|
||||
openCreateDialog,
|
||||
closeCreateDialog,
|
||||
submitCreateClient,
|
||||
regenerateClientCode,
|
||||
revokeClientCode,
|
||||
copyCode,
|
||||
formatTime,
|
||||
openVerifyDialog,
|
||||
closeVerifyDialog,
|
||||
submitVerifyAndReveal,
|
||||
hideRevealedCodes,
|
||||
} = usePage()
|
||||
|
||||
const hasConfiguredProtocols = computed(() =>
|
||||
Array.isArray(protocolCredentials.value) &&
|
||||
protocolCredentials.value.some(item => item.configured)
|
||||
const hasClients = computed(() =>
|
||||
Array.isArray(clientList.value) && clientList.value.length > 0
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped src="./ThirdPartyAuthorization.css"></style>
|
||||
<style scoped src="./ThirdPartyAuthorization.css"></style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user