From e124a9ea0459287957c309f8a859d415a3084b03 Mon Sep 17 00:00:00 2001
From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com>
Date: Mon, 31 Aug 2026 09:58:36 +0800
Subject: [PATCH] feat: add password recovery flow (#358)
---
backend/internal/handler/auth.go | 48 ++++
backend/internal/handler/security_test.go | 11 +
backend/internal/repository/repository.go | 32 +++
backend/internal/service/email.go | 17 +-
backend/internal/service/password_reset.go | 157 ++++++++++++
.../internal/service/password_reset_test.go | 225 ++++++++++++++++++
backend/internal/service/service.go | 1 +
.../content/docs/backend/backend-database.mdx | 10 +
docs/content/docs/overview/features.mdx | 12 +
docs/content/docs/progress/pending-test.mdx | 9 +
web/package.json | 2 +-
.../admin/components/email-settings-panel.tsx | 22 +-
web/src/pages/auth/auth-scene.tsx | 42 ++--
web/src/pages/auth/forgot-password.tsx | 217 +++++++++++++++++
web/src/pages/auth/login.tsx | 64 ++++-
web/src/router.tsx | 20 +-
web/src/services/api/auth.ts | 8 +
web/test/admin-ui-regressions.test.ts | 6 +-
web/test/password-reset.test.ts | 24 ++
19 files changed, 875 insertions(+), 52 deletions(-)
create mode 100644 backend/internal/service/password_reset.go
create mode 100644 backend/internal/service/password_reset_test.go
create mode 100644 web/src/pages/auth/forgot-password.tsx
create mode 100644 web/test/password-reset.test.ts
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index 6194a55ec..5dc6c2075 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -2,6 +2,7 @@ package handler
import (
"bytes"
+ "crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -66,6 +67,48 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"sent": true})
})
+ r.POST("/auth/password-reset-code", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
+ var req struct {
+ Email string `json:"email"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ policy, available := loadRuntimePolicy(c, svc)
+ if !available || !enforceRateLimit(c, "password-reset-code-ip:"+c.ClientIP(), policy.Request.EmailCodePerHour, time.Hour) {
+ return
+ }
+ if !enforceRateLimit(c, "password-reset-code-account:"+passwordResetRateLimitSubject(req.Email), policy.Request.EmailCodePerHour, time.Hour) {
+ return
+ }
+ if err := svc.SendPasswordResetEmailCode(req.Email); err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"sent": true})
+ })
+ r.POST("/auth/password-reset", func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
+ var req service.PasswordResetRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ fail(c, http.StatusBadRequest, err)
+ return
+ }
+ policy, available := loadRuntimePolicy(c, svc)
+ if !available || !enforceRateLimit(c, "password-reset-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) {
+ return
+ }
+ if !enforceRateLimit(c, "password-reset-account:"+passwordResetRateLimitSubject(req.Email), policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
+ return
+ }
+ if err := svc.ResetPassword(req); err != nil {
+ failService(c, err)
+ return
+ }
+ ok(c, gin.H{"reset": true})
+ })
r.POST("/auth/login", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.LoginRequest
@@ -1069,6 +1112,11 @@ func sessionCookie(c *gin.Context) string {
return value
}
+func passwordResetRateLimitSubject(value string) string {
+ sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(value))))
+ return fmt.Sprintf("%x", sum[:])
+}
+
func setSessionCookie(c *gin.Context, value string, maxAge int) {
secure := c.Request.TLS != nil || strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https")
http.SetCookie(c.Writer, &http.Cookie{
diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go
index 82506ca26..047047817 100644
--- a/backend/internal/handler/security_test.go
+++ b/backend/internal/handler/security_test.go
@@ -10,6 +10,17 @@ import (
"infinite-canvas/backend/internal/model"
)
+func TestPasswordResetRateLimitSubjectNormalizesAndHashesEmail(t *testing.T) {
+ first := passwordResetRateLimitSubject(" Creator@Example.com ")
+ second := passwordResetRateLimitSubject("creator@example.com")
+ if first != second {
+ t.Fatalf("normalized subjects differ: %q != %q", first, second)
+ }
+ if first == "creator@example.com" || strings.Contains(first, "@") || len(first) != 64 {
+ t.Fatalf("rate limit subject is not a SHA-256 digest: %q", first)
+ }
+}
+
func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
body := []byte(`{"model":"gpt-image-1","prompt":"test"}`)
diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go
index 23acfe9db..eb5b4adc7 100644
--- a/backend/internal/repository/repository.go
+++ b/backend/internal/repository/repository.go
@@ -26,6 +26,8 @@ var ErrTextReplayQuotaExceeded = errors.New("text replay quota exceeded")
var ErrTextReplayClosed = errors.New("text replay task is closed")
+var ErrEmailVerificationCodeInvalid = errors.New("email verification code is no longer valid")
+
var ErrProjectAssetFolderNotEmpty = errors.New("project asset folder is not empty")
var ErrProjectHasActiveTasks = errors.New("project has active tasks")
@@ -307,6 +309,36 @@ func (r *Repository) CreateUserWithEmailVerification(user *model.User, verificat
})
}
+func (r *Repository) ResetUserPasswordWithEmailVerification(userID string, email string, purpose string, verificationCodeID string, passwordHash string, usedAt time.Time) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ codeResult := tx.Model(&model.EmailVerificationCode{}).
+ Where("id = ? AND email = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, email, purpose, usedAt).
+ Update("used_at", usedAt)
+ if codeResult.Error != nil {
+ return codeResult.Error
+ }
+ if codeResult.RowsAffected != 1 {
+ return ErrEmailVerificationCodeInvalid
+ }
+
+ userResult := tx.Model(&model.User{}).
+ Where("id = ? AND email <> '' AND lower(email) = lower(?) AND status = ? AND password_hash <> ''", userID, email, model.UserStatusActive).
+ Updates(map[string]any{"password_hash": passwordHash, "updated_at": usedAt})
+ if userResult.Error != nil {
+ return userResult.Error
+ }
+ if userResult.RowsAffected != 1 {
+ return ErrEmailVerificationCodeInvalid
+ }
+ if err := tx.Delete(&model.AuthSession{}, "user_id = ?", userID).Error; err != nil {
+ return err
+ }
+ return tx.Model(&model.EmailVerificationCode{}).
+ Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).
+ Update("used_at", usedAt).Error
+ })
+}
+
func (r *Repository) DeleteExpiredEmailVerificationCodes(now time.Time) error {
return r.db.Delete(&model.EmailVerificationCode{}, "expires_at <= ? OR used_at IS NOT NULL", now).Error
}
diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go
index ba33637c6..dded80aa7 100644
--- a/backend/internal/service/email.go
+++ b/backend/internal/service/email.go
@@ -159,7 +159,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
if err != nil {
return err
}
- codeHash, err := s.emailVerificationCodeHash(email, code)
+ codeHash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
if err != nil {
return err
}
@@ -168,7 +168,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
if err := s.repo.Create(&record); err != nil {
return err
}
- if err := sendSMTPMail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil {
+ if err := s.deliverEmail(setting, email, "影策注册验证码", registrationEmailBody(code)); err != nil {
cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID)
if cleanupErr != nil {
return errors.Join(
@@ -206,7 +206,7 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo
if time.Now().After(record.ExpiresAt) {
return nil, BadAuthRequest("邮箱验证码已过期,请重新获取")
}
- hash, err := s.emailVerificationCodeHash(email, code)
+ hash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
if err != nil {
return nil, err
}
@@ -216,13 +216,13 @@ func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*mo
return record, nil
}
-func (s *Service) emailVerificationCodeHash(email string, code string) (string, error) {
+func (s *Service) emailVerificationCodeHash(purpose string, email string, code string) (string, error) {
key, err := s.settingsEncryptionKey()
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, key)
- _, _ = mac.Write([]byte(registrationEmailPurpose + ":" + email + ":" + code))
+ _, _ = mac.Write([]byte(strings.TrimSpace(purpose) + ":" + normalizeEmail(email) + ":" + strings.TrimSpace(code)))
return hex.EncodeToString(mac.Sum(nil)), nil
}
@@ -347,6 +347,13 @@ func sendSMTPMail(setting emailSettingValue, recipient string, subject string, b
return client.Quit()
}
+func (s *Service) deliverEmail(setting emailSettingValue, recipient string, subject string, body string) error {
+ if s.mailSender != nil {
+ return s.mailSender(setting, recipient, subject, body)
+ }
+ return sendSMTPMail(setting, recipient, subject, body)
+}
+
func randomNumericCode(length int) (string, error) {
limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
value, err := rand.Int(rand.Reader, limit)
diff --git a/backend/internal/service/password_reset.go b/backend/internal/service/password_reset.go
new file mode 100644
index 000000000..f5bfdd196
--- /dev/null
+++ b/backend/internal/service/password_reset.go
@@ -0,0 +1,157 @@
+package service
+
+import (
+ "crypto/hmac"
+ "errors"
+ "log"
+ "strings"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/repository"
+
+ "gorm.io/gorm"
+)
+
+const passwordResetEmailPurpose = "password_reset"
+const passwordResetCodeTTL = 10 * time.Minute
+
+type PasswordResetRequest struct {
+ Email string `json:"email"`
+ EmailCode string `json:"emailCode"`
+ Password string `json:"password"`
+}
+
+func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
+ email := normalizeEmail(rawEmail)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ _, setting, err := s.readEmailSetting()
+ if err != nil {
+ return err
+ }
+ if !setting.Enabled || setting.Host == "" || setting.Port < 1 || setting.FromEmail == "" {
+ return Forbidden("管理员尚未启用密码找回,请联系管理员")
+ }
+
+ s.emailCodeMu.Lock()
+ defer s.emailCodeMu.Unlock()
+
+ user, err := s.repo.UserByEmail(email)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
+ return nil
+ }
+ if latest, latestErr := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose); latestErr == nil && time.Since(latest.CreatedAt) < time.Minute {
+ return nil
+ } else if latestErr != nil && !errors.Is(latestErr, gorm.ErrRecordNotFound) {
+ return latestErr
+ }
+
+ code, err := randomNumericCode(6)
+ if err != nil {
+ return err
+ }
+ codeHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ record := model.EmailVerificationCode{
+ ID: newID(),
+ Email: email,
+ CodeHash: codeHash,
+ Purpose: passwordResetEmailPurpose,
+ ExpiresAt: now.Add(passwordResetCodeTTL),
+ CreatedAt: now,
+ }
+ if err := s.repo.Create(&record); err != nil {
+ return err
+ }
+ if err := s.deliverEmail(setting, email, "影策密码重置验证码", passwordResetEmailBody(code)); err != nil {
+ if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil {
+ log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr)
+ }
+ log.Printf("password reset email delivery failed: recipient=%s error=%v", maskedEmail(email), err)
+ return nil
+ }
+ if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil {
+ log.Printf("expired password reset code cleanup failed: error=%v", cleanupErr)
+ }
+ return nil
+}
+
+func (s *Service) ResetPassword(req PasswordResetRequest) error {
+ email := normalizeEmail(req.Email)
+ if err := validateEmail(email); err != nil {
+ return err
+ }
+ if err := validatePassword(req.Password); err != nil {
+ return err
+ }
+ code := strings.TrimSpace(req.EmailCode)
+ if len(code) != 6 {
+ return invalidPasswordResetCode()
+ }
+
+ user, err := s.repo.UserByEmail(email)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return invalidPasswordResetCode()
+ }
+ return err
+ }
+ if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
+ return invalidPasswordResetCode()
+ }
+ record, err := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return invalidPasswordResetCode()
+ }
+ return err
+ }
+ if time.Now().After(record.ExpiresAt) {
+ return invalidPasswordResetCode()
+ }
+ expectedHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
+ if err != nil {
+ return err
+ }
+ if !hmac.Equal([]byte(expectedHash), []byte(record.CodeHash)) {
+ return invalidPasswordResetCode()
+ }
+ passwordHash, err := hashPassword(req.Password)
+ if err != nil {
+ return err
+ }
+ if err := s.repo.ResetUserPasswordWithEmailVerification(user.ID, email, passwordResetEmailPurpose, record.ID, passwordHash, time.Now()); err != nil {
+ if errors.Is(err, repository.ErrEmailVerificationCodeInvalid) {
+ return invalidPasswordResetCode()
+ }
+ return err
+ }
+ return nil
+}
+
+func invalidPasswordResetCode() *AuthError {
+ return BadAuthRequest("验证码无效或已过期")
+}
+
+func passwordResetEmailBody(code string) string {
+ return "你正在重置影策账号密码。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件,并确保邮箱账号安全。"
+}
+
+func maskedEmail(email string) string {
+ parts := strings.SplitN(email, "@", 2)
+ if len(parts) != 2 || parts[0] == "" {
+ return "***"
+ }
+ return string([]rune(parts[0])[0]) + "***@" + parts[1]
+}
diff --git a/backend/internal/service/password_reset_test.go b/backend/internal/service/password_reset_test.go
new file mode 100644
index 000000000..ce9979214
--- /dev/null
+++ b/backend/internal/service/password_reset_test.go
@@ -0,0 +1,225 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "infinite-canvas/backend/internal/model"
+ "infinite-canvas/backend/internal/repository"
+
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T) {
+ svc, db := newPasswordResetTestService(t)
+ oldHash, err := hashPassword("old-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash}
+ if err := db.Create(&user).Error; err != nil {
+ t.Fatal(err)
+ }
+ sessions := []model.AuthSession{
+ {ID: "session-1", UserID: user.ID, TokenHash: "one", ExpiresAt: time.Now().Add(time.Hour)},
+ {ID: "session-2", UserID: user.ID, TokenHash: "two", ExpiresAt: time.Now().Add(time.Hour)},
+ }
+ if err := db.Create(&sessions).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ var deliveredCode string
+ svc.mailSender = func(_ emailSettingValue, recipient string, subject string, body string) error {
+ if recipient != user.Email || subject != "影策密码重置验证码" {
+ t.Fatalf("unexpected reset email: recipient=%q subject=%q", recipient, subject)
+ }
+ deliveredCode = codeFromEmailBody(body)
+ return nil
+ }
+ if err := svc.SendPasswordResetEmailCode(" Creator@Example.com "); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
+ t.Fatalf("repeat send should keep the public response generic: %v", err)
+ }
+ if len(deliveredCode) != 6 {
+ t.Fatalf("delivered code = %q", deliveredCode)
+ }
+ var storedCode model.EmailVerificationCode
+ if err := db.Where("email = ? AND purpose = ?", user.Email, passwordResetEmailPurpose).First(&storedCode).Error; err != nil {
+ t.Fatal(err)
+ }
+ if storedCode.CodeHash == deliveredCode || storedCode.CodeHash == "" {
+ t.Fatalf("verification code was not hashed: %#v", storedCode)
+ }
+
+ if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "new-password"}); err != nil {
+ t.Fatal(err)
+ }
+ var updated model.User
+ if err := db.First(&updated, "id = ?", user.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")) != nil {
+ t.Fatal("new password hash does not match")
+ }
+ if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("old-password")) == nil {
+ t.Fatal("old password still matches")
+ }
+ var sessionCount int64
+ if err := db.Model(&model.AuthSession{}).Where("user_id = ?", user.ID).Count(&sessionCount).Error; err != nil {
+ t.Fatal(err)
+ }
+ if sessionCount != 0 {
+ t.Fatalf("auth session count = %d, want 0", sessionCount)
+ }
+ if err := db.First(&storedCode, "id = ?", storedCode.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if storedCode.UsedAt == nil {
+ t.Fatal("password reset code was not consumed")
+ }
+ if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "another-password"}); !isInvalidPasswordResetError(err) {
+ t.Fatalf("reused code error = %v", err)
+ }
+}
+
+func TestPasswordResetSendDoesNotRevealAccountEligibility(t *testing.T) {
+ svc, db := newPasswordResetTestService(t)
+ hash, err := hashPassword("strong-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := []model.User{
+ {ID: "oauth-user", Username: "oauth-user", Email: "oauth@example.com", Status: model.UserStatusActive},
+ {ID: "disabled-user", Username: "disabled-user", Email: "disabled@example.com", Status: model.UserStatusDisabled, PasswordHash: hash},
+ }
+ if err := db.Create(&users).Error; err != nil {
+ t.Fatal(err)
+ }
+ deliveries := 0
+ svc.mailSender = func(_ emailSettingValue, _ string, _ string, _ string) error {
+ deliveries++
+ return nil
+ }
+ for _, email := range []string{"missing@example.com", "oauth@example.com", "disabled@example.com"} {
+ if err := svc.SendPasswordResetEmailCode(email); err != nil {
+ t.Fatalf("SendPasswordResetEmailCode(%q) error = %v", email, err)
+ }
+ }
+ if deliveries != 0 {
+ t.Fatalf("mail deliveries = %d, want 0", deliveries)
+ }
+ var codeCount int64
+ if err := db.Model(&model.EmailVerificationCode{}).Count(&codeCount).Error; err != nil {
+ t.Fatal(err)
+ }
+ if codeCount != 0 {
+ t.Fatalf("verification code count = %d, want 0", codeCount)
+ }
+}
+
+func TestPasswordResetRejectsWrongExpiredAndPurposeMismatchedCodes(t *testing.T) {
+ svc, db := newPasswordResetTestService(t)
+ hash, err := hashPassword("old-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
+ if err := db.Create(&user).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ resetHash, err := svc.emailVerificationCodeHash(passwordResetEmailPurpose, user.Email, "123456")
+ if err != nil {
+ t.Fatal(err)
+ }
+ registrationHash, err := svc.emailVerificationCodeHash(registrationEmailPurpose, user.Email, "123456")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resetHash == registrationHash {
+ t.Fatal("email verification hashes must be separated by purpose")
+ }
+ record := model.EmailVerificationCode{ID: "expired", Email: user.Email, Purpose: passwordResetEmailPurpose, CodeHash: resetHash, ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-11 * time.Minute)}
+ if err := db.Create(&record).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "123456", Password: "new-password"}); !isInvalidPasswordResetError(err) {
+ t.Fatalf("expired code error = %v", err)
+ }
+ record.ID = "active"
+ record.ExpiresAt = time.Now().Add(time.Minute)
+ record.CreatedAt = time.Now()
+ if err := db.Create(&record).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "654321", Password: "new-password"}); !isInvalidPasswordResetError(err) {
+ t.Fatalf("wrong code error = %v", err)
+ }
+}
+
+func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) {
+ svc, db := newPasswordResetTestService(t)
+ hash, err := hashPassword("old-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
+ if err := db.Create(&user).Error; err != nil {
+ t.Fatal(err)
+ }
+ svc.mailSender = func(_ emailSettingValue, _ string, _ string, _ string) error { return errors.New("smtp unavailable") }
+ if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
+ t.Fatalf("delivery failure should keep the public response generic: %v", err)
+ }
+ var count int64
+ if err := db.Model(&model.EmailVerificationCode{}).Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatalf("verification code count = %d, want 0", count)
+ }
+}
+
+func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil {
+ t.Fatal(err)
+ }
+ settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&model.SystemSetting{Key: emailSettingKey, ValueJSON: string(settingJSON)}).Error; err != nil {
+ t.Fatal(err)
+ }
+ return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db
+}
+
+func codeFromEmailBody(body string) string {
+ marker := "验证码:"
+ start := strings.Index(body, marker)
+ if start < 0 {
+ return ""
+ }
+ value := body[start+len(marker):]
+ if end := strings.IndexByte(value, '\n'); end >= 0 {
+ value = value[:end]
+ }
+ return strings.TrimSpace(value)
+}
+
+func isInvalidPasswordResetError(err error) bool {
+ var appErr *AppError
+ return errors.As(err, &appErr) && appErr.Status == 400 && appErr.Message == "验证码无效或已过期"
+}
diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go
index bb404db09..38d6e57bf 100644
--- a/backend/internal/service/service.go
+++ b/backend/internal/service/service.go
@@ -53,6 +53,7 @@ type Service struct {
routeHealthBlocked map[string]time.Time
workers *workerRuntime
updateManager UpdateManager
+ mailSender func(emailSettingValue, string, string, string) error
}
const taskWorkerConcurrency = 3
diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx
index cf370d2c3..25948a275 100644
--- a/docs/content/docs/backend/backend-database.mdx
+++ b/docs/content/docs/backend/backend-database.mdx
@@ -17,6 +17,16 @@ description: 影策后端核心持久化表与状态边界
当前结构版本为 2。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引,用于验证在线更新中的顺序迁移、事务提交和版本健康检查。
+## 账号认证与密码找回
+
+| 表 | 用途 | 关键边界 |
+| --- | --- | --- |
+| `users` | 保存用户名、唯一非空邮箱、本地密码哈希、角色和状态 | 密码只保存 bcrypt 哈希;密码找回只面向状态正常且已有本地密码的账号 |
+| `auth_sessions` | 保存登录 Cookie 对应的服务端会话和令牌哈希 | 密码重置成功时删除该用户全部会话,旧 Cookie 立即失效 |
+| `email_verification_codes` | 保存注册和密码重置的一次性邮箱验证码 | 只保存带用途隔离的 HMAC 哈希;验证码 10 分钟过期、只能消费一次,明文不进入数据库 |
+
+密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。
+
## 技能包与版本
| 表 | 用途 | 关键边界 |
diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx
index 1c1df30b0..8506d727a 100644
--- a/docs/content/docs/overview/features.mdx
+++ b/docs/content/docs/overview/features.mdx
@@ -5,6 +5,18 @@ description: 影策当前已实现的核心工作台能力与使用入口
# 功能清单
+## 邮箱找回密码
+
+登录页在密码字段标题行提供“忘记密码?”入口。已配置账户邮件服务时,本地密码账号可以通过绑定邮箱获取 6 位验证码,并在 10 分钟内设置新密码;发送接口对存在和不存在的邮箱返回相同结果,避免公开账号状态。
+
+密码更新、验证码消费和该账号全部旧登录会话撤销在同一个数据库事务内完成。验证码按注册与密码重置用途隔离,不能跨流程复用;Linux.do 单点登录账号继续通过原身份提供方登录,不会由找回流程隐式创建本地密码。
+
+### 相关实现
+
+- `web/src/pages/auth/login.tsx`、`web/src/pages/auth/forgot-password.tsx`:登录入口与两阶段邮箱验证界面。
+- `backend/internal/service/password_reset.go`:发送策略、验证码校验和密码重置业务边界。
+- `backend/internal/repository/repository.go`:密码更新、验证码消费和登录会话撤销事务。
+
## AI 提示词优化器
提示词优化器是一个内置 AI 能力插件,用于把模糊的生图或生视频想法整理成可以直接使用的提示词。它复用宿主已经配置的文本模型和渠道,不在浏览器中保存或拼接第三方密钥。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index b8265f145..28b33f5df 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界
# 待测试
+## 邮箱找回密码
+
+- [ ] 后端专项测试验证验证码用途隔离、错误和过期验证码拒绝、单次消费、bcrypt 密码更新,以及重置事务撤销全部旧登录会话。
+- [x] 前端专项测试和生产构建验证登录页入口、独立找回路由、验证码倒计时、新密码表单及账户邮件设置文案。
+- [ ] 使用真实 SMTP 账号发送一次注册验证码和一次密码重置验证码,确认发件身份、正文、投递结果和 10 分钟有效期符合配置。
+- [ ] 在桌面、窄屏和键盘操作下确认“忘记密码?”与密码标签同行右对齐,找回页没有横向溢出,焦点顺序和提示信息清晰。
+- [ ] 使用两个浏览器会话登录同一账号后重置密码,确认两个旧会话都失效,旧密码无法登录且新密码可登录。
+- [ ] 分别使用未知邮箱、禁用账号和 Linux.do-only 账号请求验证码,确认公开响应不泄露账号是否存在或是否可找回。
+
## 服务升级运行基础
- 完整架构、状态机、Release 清单、备份和回退边界见 `docs/design/dynamic-update.md`。
diff --git a/web/package.json b/web/package.json
index 0eb42fff0..458adcfb2 100644
--- a/web/package.json
+++ b/web/package.json
@@ -7,7 +7,7 @@
"build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts",
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "bun run build:bridge && tsc --noEmit && vite build",
- "pretest": "bun test test/admin-ui-regressions.test.ts",
+ "pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts",
"typecheck": "tsc --noEmit",
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts",
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
diff --git a/web/src/pages/admin/components/email-settings-panel.tsx b/web/src/pages/admin/components/email-settings-panel.tsx
index b8553310c..b791f3cee 100644
--- a/web/src/pages/admin/components/email-settings-panel.tsx
+++ b/web/src/pages/admin/components/email-settings-panel.tsx
@@ -141,7 +141,7 @@ export default function EmailSettingsPanel() {
form.setFieldsValue(toEmailFormValues(result.setting));
setDraftEnabled(result.setting.enabled);
setDirty(false);
- message.success("注册邮件配置已保存");
+ message.success("邮件服务配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存邮件配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
@@ -219,7 +219,7 @@ export default function EmailSettingsPanel() {
-
{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}
+
{dirty ? "有未保存的邮件调整" : `账户邮件:${setting.enabled ? "已启用" : "未启用"}`}
{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}
@@ -246,15 +246,15 @@ export default function EmailSettingsPanel() {
}
- title="1. 是否发送注册邮箱验证码"
- description="这是邮件服务的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
+ title="1. 是否发送账户安全邮件"
+ description="这是注册验证码和密码找回邮件的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
status={}
footer={
!draftEnabled ? (
<>
- 关闭后,普通邮箱注册不再要求邮箱验证码。
+ 关闭后,普通邮箱注册和密码找回都无法发送验证码。
{dirty ? (
@@ -276,13 +276,13 @@ export default function EmailSettingsPanel() {
-
发送注册邮箱验证码
+
发送注册与密码找回验证码
-
启用后,普通邮箱注册需要获取并校验 6 位验证码;邮件发送失败时不会创建可用验证码。
-
关闭只停止后续验证码邮件,不改变新用户注册开关,也不影响已有账号。
+
启用后,普通邮箱注册和密码找回使用 6 位验证码;邮件发送失败时不会保留可用验证码。
+
关闭只停止后续账户邮件,不改变新用户注册开关,也不影响已有登录会话。
-
+
@@ -372,7 +372,7 @@ export default function EmailSettingsPanel() {
-
} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
+
} title="发件人身份" description="这组名称和地址会显示在注册与密码重置邮件的 From 信息中。" />
65535 || !draft.fromEmail) return "启用注册邮件前请完整填写 SMTP 主机、端口和发件邮箱";
+ if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用邮件服务前请完整填写 SMTP 主机、端口和发件邮箱";
if (!isValidEmail(draft.fromEmail)) return "发件邮箱格式不正确";
if (draft.username && !draft.password && !setting?.hasPassword) return "SMTP 用户名已填写,请同时填写密码或服务商授权码";
return "";
diff --git a/web/src/pages/auth/auth-scene.tsx b/web/src/pages/auth/auth-scene.tsx
index 74499c7a8..78f4098f5 100644
--- a/web/src/pages/auth/auth-scene.tsx
+++ b/web/src/pages/auth/auth-scene.tsx
@@ -24,6 +24,11 @@ const authCopy = {
title: "建立你的创作空间",
description: "一个账号管理画布、素材、技能和模型偏好。",
},
+ recovery: {
+ eyebrow: "ACCOUNT RECOVERY",
+ title: "重新设置密码",
+ description: "验证账号邮箱后,设置一个新的登录密码。",
+ },
} as const;
export function LinuxDOIcon() {
@@ -43,23 +48,15 @@ export function AuthScene() {
const location = useLocation();
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
+ const recovery = location.pathname === "/forgot-password";
const activeTab = location.pathname === "/register" ? "register" : "login";
- const copy = activeTab === "register" ? authCopy.register : authCopy.login;
+ const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
return (
-
+
@@ -80,14 +77,18 @@ export function AuthScene() {
>
YINGCE STUDIO
- 让一个故事,
+ 让一个故事,
+
从文字走向银幕。
-
+
返回首页
@@ -101,20 +102,17 @@ export function AuthScene() {
>
-
+
{copy.eyebrow}
{copy.title}
{copy.description}
-
- navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
- />
-
+ {!recovery ? (
+
+ navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })} />
+
+ ) : null}
diff --git a/web/src/pages/auth/forgot-password.tsx b/web/src/pages/auth/forgot-password.tsx
new file mode 100644
index 000000000..2d2d9ae73
--- /dev/null
+++ b/web/src/pages/auth/forgot-password.tsx
@@ -0,0 +1,217 @@
+import { type FormEvent, useEffect, useState, type ReactNode } from "react";
+import { App, Button, Input } from "antd";
+import { ArrowLeft, ArrowRight, LockKeyhole, Mail, ShieldCheck, TriangleAlert } from "lucide-react";
+import { Link, useNavigate, useSearchParams } from "react-router";
+
+import { getAuthSettings, resetPassword, sendPasswordResetEmailCode } from "@/services/api/auth";
+
+type RecoveryStage = "request" | "reset";
+
+export default function ForgotPasswordPage() {
+ const navigate = useNavigate();
+ const [params] = useSearchParams();
+ const { message } = App.useApp();
+ const [stage, setStage] = useState("request");
+ const [email, setEmail] = useState("");
+ const [emailCode, setEmailCode] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [emailEnabled, setEmailEnabled] = useState(null);
+ const [sendingCode, setSendingCode] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [countdown, setCountdown] = useState(0);
+ const next = safeNext(params.get("next"));
+ const loginURL = `/login?next=${encodeURIComponent(next)}`;
+
+ useEffect(() => {
+ let cancelled = false;
+ void getAuthSettings()
+ .then((settings) => !cancelled && setEmailEnabled(settings.emailEnabled))
+ .catch(() => !cancelled && setEmailEnabled(null));
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (countdown <= 0) return;
+ const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1000);
+ return () => window.clearInterval(timer);
+ }, [countdown]);
+
+ const sendCode = async (advance: boolean) => {
+ const normalizedEmail = email.trim();
+ if (!normalizedEmail) {
+ message.warning("请先输入邮箱");
+ return;
+ }
+ setSendingCode(true);
+ try {
+ await sendPasswordResetEmailCode(normalizedEmail);
+ setEmail(normalizedEmail);
+ setCountdown(60);
+ if (advance) setStage("reset");
+ message.success("如果该邮箱已绑定可找回的账号,验证码将发送到邮箱");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "发送验证码失败");
+ } finally {
+ setSendingCode(false);
+ }
+ };
+
+ const requestCode = (event: FormEvent) => {
+ event.preventDefault();
+ void sendCode(true);
+ };
+
+ const submitReset = async (event: FormEvent) => {
+ event.preventDefault();
+ if (password !== confirmPassword) {
+ message.error("两次输入的密码不一致");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await resetPassword({ email: email.trim(), emailCode, password });
+ message.success("密码已重置,请使用新密码登录");
+ navigate(loginURL, { replace: true });
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "密码重置失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const editEmail = () => {
+ setStage("request");
+ setEmailCode("");
+ setPassword("");
+ setConfirmPassword("");
+ setCountdown(0);
+ };
+
+ if (stage === "request") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function AuthField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function BackToLogin({ to }: { to: string }) {
+ return (
+
+ );
+}
+
+function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+function safeNext(value: string | null) {
+ if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
+ return value;
+}
diff --git a/web/src/pages/auth/login.tsx b/web/src/pages/auth/login.tsx
index 163739b46..26b0185c1 100644
--- a/web/src/pages/auth/login.tsx
+++ b/web/src/pages/auth/login.tsx
@@ -1,7 +1,7 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { App, Button, Divider, Input } from "antd";
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
-import { useNavigate, useSearchParams } from "react-router";
+import { Link, useNavigate, useSearchParams } from "react-router";
import { applyUserSession } from "@/lib/user-session";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
@@ -17,6 +17,7 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const next = safeNext(params.get("next"));
+ const forgotPasswordURL = `/forgot-password?next=${encodeURIComponent(next)}`;
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
@@ -28,7 +29,9 @@ export default function LoginPage() {
}, [hydrated, user, next, navigate]);
useEffect(() => {
- void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined);
+ void getAuthSettings()
+ .then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
+ .catch(() => undefined);
const oauthError = params.get("oauth_error");
if (oauthError) message.error(oauthError);
}, [message, params]);
@@ -50,16 +53,61 @@ export default function LoginPage() {
return (
);
}
-function AuthField({ label, children }: { label: string; children: ReactNode }) {
- return ;
+function AuthField({ label, htmlFor, action, children }: { label: string; htmlFor: string; action?: ReactNode; children: ReactNode }) {
+ return (
+
+
+
+ {action}
+
+ {children}
+
+ );
}
function safeNext(value: string | null) {
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 2e9f34de4..c1d794720 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -34,6 +34,7 @@ const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
const AssetsPage = lazy(loadAssetsPage);
const LoginPage = lazy(() => import("@/pages/auth/login"));
const RegisterPage = lazy(() => import("@/pages/auth/register"));
+const ForgotPasswordPage = lazy(() => import("@/pages/auth/forgot-password"));
const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
@@ -82,6 +83,7 @@ export const router = createBrowserRouter([
children: [
{ path: "/login", element: fullScreenDeferred() },
{ path: "/register", element: fullScreenDeferred() },
+ { path: "/forgot-password", element: fullScreenDeferred() },
],
},
{ path: "/share/canvas/:token", element: fullScreenDeferred(), errorElement: },
@@ -107,8 +109,22 @@ export const router = createBrowserRouter([
},
{ path: "/assets", element: {deferred()} },
{ path: "/skills", element: {deferred()} },
- { path: "/plugins", element: {deferred()} },
- { path: "/plugins/eagle", element: {deferred()} },
+ {
+ path: "/plugins",
+ element: (
+
+ {deferred()}
+
+ ),
+ },
+ {
+ path: "/plugins/eagle",
+ element: (
+
+ {deferred()}
+
+ ),
+ },
{
path: "/wallet",
element: (
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index a39f2b34b..068e5b23e 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -417,6 +417,14 @@ export function sendRegistrationEmailCode(email: string) {
return request<{ sent: boolean }>(api.post("/auth/email-code", { email }));
}
+export function sendPasswordResetEmailCode(email: string) {
+ return request<{ sent: boolean }>(api.post("/auth/password-reset-code", { email }));
+}
+
+export function resetPassword(input: { email: string; emailCode: string; password: string }) {
+ return request<{ reset: boolean }>(api.post("/auth/password-reset", input));
+}
+
export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string }) {
return request<{ user: LocalUser }>(api.post("/auth/register", input));
}
diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts
index f5e4af48c..36ee072ec 100644
--- a/web/test/admin-ui-regressions.test.ts
+++ b/web/test/admin-ui-regressions.test.ts
@@ -44,7 +44,7 @@ test("analytics keeps fixed range presets distinct and uses enabled channel mode
expect(source).toContain("onValuesChange={handlePricingValuesChange}");
expect(source).toContain("onChange={handlePricingModelChange}");
expect(source).toContain('hasOwnProperty.call(changedValues, "model")');
- expect(source).toContain("if (matchingChannels.length) form.setFieldValue(\"channelId\", matchingChannels[0].id)");
+ expect(source).toContain('if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id)');
expect(source).toContain("const sourceChannels = channels.filter((channel) => channel.enabled !== false)");
expect(source).toContain('inputMode="decimal"');
expect(source).toContain('className="admin-analytics-price-input"');
@@ -53,7 +53,7 @@ test("analytics keeps fixed range presets distinct and uses enabled channel mode
expect(source).toContain("zIndex={1200}");
expect(source).toContain("setPricingWorkspaceOpen(false)");
expect(source).toContain("validator: validatePriceInput");
- expect(source).toContain('请输入非负价格,最多 6 位小数');
+ expect(source).toContain("请输入非负价格,最多 6 位小数");
expect(source).toContain("function formatPriceInput(micros: number)");
expect(source).toContain("function toMicros(value?: string | number)");
expect(source).not.toContain(" {
+ const [loginSource, recoverySource, routerSource, sceneSource, authAPISource, emailSettingsSource] = await Promise.all([
+ Bun.file(new URL("../src/pages/auth/login.tsx", import.meta.url)).text(),
+ Bun.file(new URL("../src/pages/auth/forgot-password.tsx", import.meta.url)).text(),
+ Bun.file(new URL("../src/router.tsx", import.meta.url)).text(),
+ Bun.file(new URL("../src/pages/auth/auth-scene.tsx", import.meta.url)).text(),
+ Bun.file(new URL("../src/services/api/auth.ts", import.meta.url)).text(),
+ Bun.file(new URL("../src/pages/admin/components/email-settings-panel.tsx", import.meta.url)).text(),
+ ]);
+
+ expect(loginSource).toContain('label="密码"');
+ expect(loginSource).toContain("忘记密码?");
+ expect(loginSource).toContain("forgotPasswordURL");
+ expect(routerSource).toContain('{ path: "/forgot-password"');
+ expect(sceneSource).toContain('eyebrow: "ACCOUNT RECOVERY"');
+ expect(recoverySource).toContain("如果该邮箱已绑定可找回的账号,验证码将发送到邮箱");
+ expect(recoverySource).toContain('autoComplete="one-time-code"');
+ expect(recoverySource).toContain('autoComplete="new-password"');
+ expect(authAPISource).toContain('api.post("/auth/password-reset-code"');
+ expect(authAPISource).toContain('api.post("/auth/password-reset"');
+ expect(emailSettingsSource).toContain("发送注册与密码找回验证码");
+});