diff --git a/backend/controllers/security_client_credentials.go b/backend/controllers/security_client_credentials.go new file mode 100644 index 0000000..814205f --- /dev/null +++ b/backend/controllers/security_client_credentials.go @@ -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) +} diff --git a/backend/db/migration/client_credentials.go b/backend/db/migration/client_credentials.go new file mode 100644 index 0000000..103d49c --- /dev/null +++ b/backend/db/migration/client_credentials.go @@ -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")`, + }, + } +} diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 6be3bf3..1369aa6 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -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()), } } diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index 513ba4a..ed398b1 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -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) { }) } } + + diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index c455a13..1c8c75d 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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) diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 96c59a8..c492a80 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -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)))) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 3a5a5f3..b91389a 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -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": "申请修改外部邮箱", diff --git a/backend/models/client_credential.go b/backend/models/client_credential.go new file mode 100644 index 0000000..c557f66 --- /dev/null +++ b/backend/models/client_credential.go @@ -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" } diff --git a/backend/services/auth/protocol_limiter.go b/backend/services/auth/protocol_limiter.go index bd56153..2630846 100644 --- a/backend/services/auth/protocol_limiter.go +++ b/backend/services/auth/protocol_limiter.go @@ -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() } diff --git a/backend/services/credentials/client.go b/backend/services/credentials/client.go new file mode 100644 index 0000000..c0f54ff --- /dev/null +++ b/backend/services/credentials/client.go @@ -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 +} diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css index 4a124ba..904d320 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css @@ -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; - } -} \ No newline at end of file +} diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js index 5c4752a..a27006e 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js @@ -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, } -} \ No newline at end of file +} diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue index 612bbff..1527d96 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue @@ -1,22 +1,21 @@ -