diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go
index 2efca68..b2e2d29 100644
--- a/apps/api/internal/app/app.go
+++ b/apps/api/internal/app/app.go
@@ -305,19 +305,73 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
- now := time.Now().UTC().Format(time.RFC3339Nano)
- _, err := a.db.ExecContext(ctx, `
- UPDATE mailboxes
- SET status='disabled', updated_at=?
- WHERE address=?
- AND display_name='LanQin Admin'
- AND EXISTS (
- SELECT 1 FROM users
- WHERE users.id=mailboxes.user_id
- AND users.email=?
- AND users.role='admin'
- )`, now, adminEmail, adminEmail)
- return err
+ rows, err := a.db.QueryContext(ctx, `
+ SELECT mb.id, mb.domain_id
+ FROM mailboxes mb
+ JOIN users u ON u.id=mb.user_id
+ WHERE mb.address=?
+ AND mb.display_name='LanQin Admin'
+ AND u.email=?
+ AND u.role='admin'`, adminEmail, adminEmail)
+ if err != nil {
+ return err
+ }
+ type legacyMailbox struct {
+ id string
+ domainID string
+ }
+ items := []legacyMailbox{}
+ for rows.Next() {
+ var item legacyMailbox
+ if err := rows.Scan(&item.id, &item.domainID); err != nil {
+ rows.Close()
+ return err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return err
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ for _, item := range items {
+ messageRows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=?`, item.id)
+ if err != nil {
+ return err
+ }
+ messageIDs := []string{}
+ for messageRows.Next() {
+ var messageID string
+ if err := messageRows.Scan(&messageID); err != nil {
+ messageRows.Close()
+ return err
+ }
+ messageIDs = append(messageIDs, messageID)
+ }
+ if err := messageRows.Err(); err != nil {
+ messageRows.Close()
+ return err
+ }
+ if err := messageRows.Close(); err != nil {
+ return err
+ }
+ for _, messageID := range messageIDs {
+ a.deleteMessageFiles(ctx, messageID)
+ }
+ if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
+ return err
+ }
+ if _, err := a.db.ExecContext(ctx, `
+ DELETE FROM domains
+ WHERE id=?
+ AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE domain_id=domains.id)
+ AND NOT EXISTS (SELECT 1 FROM aliases WHERE domain_id=domains.id)`, item.domainID); err != nil {
+ return err
+ }
+ }
+ return nil
}
func (a *App) migrateMailRulesBuilder(ctx context.Context) error {
diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go
index aa4e97c..4fd631e 100644
--- a/apps/api/internal/app/app_test.go
+++ b/apps/api/internal/app/app_test.go
@@ -332,6 +332,54 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
}
}
+func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
+ dir := t.TempDir()
+ cfg := Config{
+ Addr: ":0",
+ DBPath: filepath.Join(dir, "lanqin.db"),
+ DataDir: filepath.Join(dir, "data"),
+ CookieName: "lanqin_test",
+ SessionTTLHours: 24,
+ AdminEmail: "lanqinnet@gmail.com",
+ AdminPassword: "ChangeMe123!",
+ PublicHostname: "mail.example.test",
+ PublicBaseURL: "http://localhost:5173",
+ AllowInsecureHTTP: true,
+ }
+ a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = a.Close() })
+
+ ctx := context.Background()
+ var adminID string
+ if err := a.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email=?`, cfg.AdminEmail).Scan(&adminID); err != nil {
+ t.Fatal(err)
+ }
+ domainID, err := a.createDomainTx(ctx, nil, "gmail.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.createMailbox(ctx, adminID, domainID, "lanqinnet", "LanQin Admin", "Password123!", 1024, "active"); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ var count int
+ if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("admin user count=%d err=%v", count, err)
+ }
+ if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("legacy mailbox count=%d err=%v", count, err)
+ }
+ if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("legacy domain count=%d err=%v", count, err)
+ }
+}
+
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go
new file mode 100644
index 0000000..570d566
--- /dev/null
+++ b/apps/api/internal/app/auth_handlers.go
@@ -0,0 +1,242 @@
+package app
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ TurnstileToken string `json:"turnstileToken"`
+ ChallengeToken string `json:"challengeToken"`
+ TwoFactorCode string `json:"twoFactorCode"`
+ }
+ if err := decodeJSON(r, &req); err != nil {
+ badRequest(w, err)
+ return
+ }
+ if strings.TrimSpace(req.ChallengeToken) != "" {
+ challenge, err := a.loginChallengeByToken(r.Context(), req.ChallengeToken)
+ if err != nil {
+ respondError(w, http.StatusUnauthorized, "invalid verification challenge")
+ return
+ }
+ user, secret, err := a.loadUserAuthByID(r.Context(), challenge.UserID)
+ if err != nil || user.Disabled || !user.TwoFactorEnabled || strings.TrimSpace(secret) == "" {
+ a.deleteLoginChallenge(r.Context(), challenge.ID)
+ respondError(w, http.StatusUnauthorized, "invalid verification challenge")
+ return
+ }
+ if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
+ respondError(w, http.StatusUnauthorized, "invalid verification code")
+ return
+ }
+ a.deleteLoginChallenge(r.Context(), challenge.ID)
+ if err := a.issueSession(w, r, user.ID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to create session")
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]any{"user": user})
+ return
+ }
+ if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
+ respondError(w, http.StatusUnauthorized, "human verification failed")
+ return
+ }
+ email := normalizeEmail(req.Email)
+ user, passwordHash, err := a.userByEmail(r.Context(), email)
+ if err != nil || user.Disabled {
+ respondError(w, http.StatusUnauthorized, "invalid email or password")
+ return
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
+ respondError(w, http.StatusUnauthorized, "invalid email or password")
+ return
+ }
+ if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
+ challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to create verification challenge")
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]any{"twoFactorRequired": true, "challengeToken": challengeToken})
+ return
+ }
+ if err := a.issueSession(w, r, user.ID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to create session")
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]any{"user": user})
+}
+
+func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
+ if !a.cfg.OpenRegistration {
+ respondError(w, http.StatusForbidden, "registration is closed")
+ return
+ }
+ var req struct {
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Password string `json:"password"`
+ TurnstileToken string `json:"turnstileToken"`
+ }
+ if err := decodeJSON(r, &req); err != nil {
+ badRequest(w, err)
+ return
+ }
+ if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
+ respondError(w, http.StatusUnauthorized, "human verification failed")
+ return
+ }
+ email := normalizeEmail(req.Email)
+ if email == "" || !strings.Contains(email, "@") {
+ badRequest(w, errors.New("invalid email"))
+ return
+ }
+ if len(req.Password) < 8 {
+ badRequest(w, errors.New("password must be at least 8 characters"))
+ return
+ }
+ displayName := strings.TrimSpace(req.DisplayName)
+ if displayName == "" {
+ displayName = strings.Split(email, "@")[0]
+ }
+ if len([]rune(displayName)) > 80 {
+ badRequest(w, errors.New("displayName must be at most 80 characters"))
+ return
+ }
+ if _, _, err := a.userByEmail(r.Context(), email); err == nil {
+ respondError(w, http.StatusConflict, "email already registered")
+ return
+ } else if !errors.Is(err, errNotFound) {
+ respondError(w, http.StatusInternalServerError, "failed to check user")
+ return
+ }
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to hash password")
+ return
+ }
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ userID := newID("usr")
+ if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "unique") {
+ respondError(w, http.StatusConflict, "email already registered")
+ return
+ }
+ respondError(w, http.StatusInternalServerError, "failed to create user")
+ return
+ }
+ user, err := a.userByID(r.Context(), userID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load user")
+ return
+ }
+ if err := a.issueSession(w, r, user.ID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to create session")
+ return
+ }
+ respondJSON(w, http.StatusCreated, map[string]any{"user": user})
+}
+
+func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
+ _, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
+ }
+ http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ respondJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, http.StatusOK, map[string]any{"user": currentUser(r)})
+}
+
+func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
+ user := currentUser(r)
+ var req struct {
+ DisplayName string `json:"displayName"`
+ }
+ if err := decodeJSON(r, &req); err != nil {
+ badRequest(w, err)
+ return
+ }
+ displayName := strings.TrimSpace(req.DisplayName)
+ if displayName == "" {
+ badRequest(w, errors.New("displayName is required"))
+ return
+ }
+ if len([]rune(displayName)) > 80 {
+ badRequest(w, errors.New("displayName must be at most 80 characters"))
+ return
+ }
+ _, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, updated_at=? WHERE id=?`,
+ displayName, a.now().UTC().Format(time.RFC3339Nano), user.ID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to update profile")
+ return
+ }
+ updated, err := a.userByID(r.Context(), user.ID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load profile")
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]any{"user": updated})
+}
+
+func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
+ user := currentUser(r)
+ var req struct {
+ CurrentPassword string `json:"currentPassword"`
+ NewPassword string `json:"newPassword"`
+ }
+ if err := decodeJSON(r, &req); err != nil {
+ badRequest(w, err)
+ return
+ }
+ if len(req.NewPassword) < 8 {
+ badRequest(w, errors.New("newPassword must be at least 8 characters"))
+ return
+ }
+ row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
+ var currentHash string
+ if err := row.Scan(¤tHash); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to load user")
+ return
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
+ respondError(w, http.StatusUnauthorized, "current password is incorrect")
+ return
+ }
+ newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to hash password")
+ return
+ }
+ now := a.now().UTC().Format(time.RFC3339Nano)
+ tx, err := a.db.BeginTx(r.Context(), nil)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to start transaction")
+ return
+ }
+ defer tx.Rollback()
+ if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(newHash), now, user.ID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to update password")
+ return
+ }
+ if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(newHash), now, user.ID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to update mailbox password")
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to save password")
+ return
+ }
+ respondJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go
index 0016b65..acf4541 100644
--- a/apps/api/internal/app/router_auth.go
+++ b/apps/api/internal/app/router_auth.go
@@ -10,7 +10,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- "golang.org/x/crypto/bcrypt"
)
type contextKey string
@@ -130,238 +129,6 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
})
}
-func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
- var req struct {
- Email string `json:"email"`
- Password string `json:"password"`
- TurnstileToken string `json:"turnstileToken"`
- ChallengeToken string `json:"challengeToken"`
- TwoFactorCode string `json:"twoFactorCode"`
- }
- if err := decodeJSON(r, &req); err != nil {
- badRequest(w, err)
- return
- }
- if strings.TrimSpace(req.ChallengeToken) != "" {
- challenge, err := a.loginChallengeByToken(r.Context(), req.ChallengeToken)
- if err != nil {
- respondError(w, http.StatusUnauthorized, "invalid verification challenge")
- return
- }
- user, secret, err := a.loadUserAuthByID(r.Context(), challenge.UserID)
- if err != nil || user.Disabled || !user.TwoFactorEnabled || strings.TrimSpace(secret) == "" {
- a.deleteLoginChallenge(r.Context(), challenge.ID)
- respondError(w, http.StatusUnauthorized, "invalid verification challenge")
- return
- }
- if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
- respondError(w, http.StatusUnauthorized, "invalid verification code")
- return
- }
- a.deleteLoginChallenge(r.Context(), challenge.ID)
- if err := a.issueSession(w, r, user.ID); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to create session")
- return
- }
- respondJSON(w, http.StatusOK, map[string]any{"user": user})
- return
- }
- if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
- respondError(w, http.StatusUnauthorized, "human verification failed")
- return
- }
- email := normalizeEmail(req.Email)
- user, passwordHash, err := a.userByEmail(r.Context(), email)
- if err != nil || user.Disabled {
- respondError(w, http.StatusUnauthorized, "invalid email or password")
- return
- }
- if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
- respondError(w, http.StatusUnauthorized, "invalid email or password")
- return
- }
- if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
- challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to create verification challenge")
- return
- }
- respondJSON(w, http.StatusOK, map[string]any{"twoFactorRequired": true, "challengeToken": challengeToken})
- return
- }
- if err := a.issueSession(w, r, user.ID); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to create session")
- return
- }
- respondJSON(w, http.StatusOK, map[string]any{"user": user})
-}
-
-func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
- if !a.cfg.OpenRegistration {
- respondError(w, http.StatusForbidden, "registration is closed")
- return
- }
- var req struct {
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- Password string `json:"password"`
- TurnstileToken string `json:"turnstileToken"`
- }
- if err := decodeJSON(r, &req); err != nil {
- badRequest(w, err)
- return
- }
- if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
- respondError(w, http.StatusUnauthorized, "human verification failed")
- return
- }
- email := normalizeEmail(req.Email)
- if email == "" || !strings.Contains(email, "@") {
- badRequest(w, errors.New("invalid email"))
- return
- }
- if len(req.Password) < 8 {
- badRequest(w, errors.New("password must be at least 8 characters"))
- return
- }
- displayName := strings.TrimSpace(req.DisplayName)
- if displayName == "" {
- displayName = strings.Split(email, "@")[0]
- }
- if len([]rune(displayName)) > 80 {
- badRequest(w, errors.New("displayName must be at most 80 characters"))
- return
- }
- if _, _, err := a.userByEmail(r.Context(), email); err == nil {
- respondError(w, http.StatusConflict, "email already registered")
- return
- } else if !errors.Is(err, errNotFound) {
- respondError(w, http.StatusInternalServerError, "failed to check user")
- return
- }
- passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to hash password")
- return
- }
- now := a.now().UTC().Format(time.RFC3339Nano)
- userID := newID("usr")
- if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
- if strings.Contains(strings.ToLower(err.Error()), "unique") {
- respondError(w, http.StatusConflict, "email already registered")
- return
- }
- respondError(w, http.StatusInternalServerError, "failed to create user")
- return
- }
- user, err := a.userByID(r.Context(), userID)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to load user")
- return
- }
- if err := a.issueSession(w, r, user.ID); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to create session")
- return
- }
- respondJSON(w, http.StatusCreated, map[string]any{"user": user})
-}
-
-func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
- if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
- _, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
- }
- http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
- respondJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
- respondJSON(w, http.StatusOK, map[string]any{"user": currentUser(r)})
-}
-
-func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
- user := currentUser(r)
- var req struct {
- DisplayName string `json:"displayName"`
- }
- if err := decodeJSON(r, &req); err != nil {
- badRequest(w, err)
- return
- }
- displayName := strings.TrimSpace(req.DisplayName)
- if displayName == "" {
- badRequest(w, errors.New("displayName is required"))
- return
- }
- if len([]rune(displayName)) > 80 {
- badRequest(w, errors.New("displayName must be at most 80 characters"))
- return
- }
- _, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, updated_at=? WHERE id=?`,
- displayName, a.now().UTC().Format(time.RFC3339Nano), user.ID)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to update profile")
- return
- }
- updated, err := a.userByID(r.Context(), user.ID)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to load profile")
- return
- }
- respondJSON(w, http.StatusOK, map[string]any{"user": updated})
-}
-
-func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
- user := currentUser(r)
- var req struct {
- CurrentPassword string `json:"currentPassword"`
- NewPassword string `json:"newPassword"`
- }
- if err := decodeJSON(r, &req); err != nil {
- badRequest(w, err)
- return
- }
- if len(req.NewPassword) < 8 {
- badRequest(w, errors.New("newPassword must be at least 8 characters"))
- return
- }
- row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
- var currentHash string
- if err := row.Scan(¤tHash); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to load user")
- return
- }
- if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
- respondError(w, http.StatusUnauthorized, "current password is incorrect")
- return
- }
- newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to hash password")
- return
- }
- now := a.now().UTC().Format(time.RFC3339Nano)
- tx, err := a.db.BeginTx(r.Context(), nil)
- if err != nil {
- respondError(w, http.StatusInternalServerError, "failed to start transaction")
- return
- }
- defer tx.Rollback()
- if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(newHash), now, user.ID); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to update password")
- return
- }
- if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(newHash), now, user.ID); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to update mailbox password")
- return
- }
- if err := tx.Commit(); err != nil {
- respondError(w, http.StatusInternalServerError, "failed to save password")
- return
- }
- respondJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
func (a *App) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticateRequest(r)
diff --git a/apps/api/internal/app/session.go b/apps/api/internal/app/session.go
new file mode 100644
index 0000000..d6f4702
--- /dev/null
+++ b/apps/api/internal/app/session.go
@@ -0,0 +1,27 @@
+package app
+
+import (
+ "net/http"
+ "time"
+)
+
+func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
+ token := randomToken()
+ sessionID := newID("ses")
+ expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
+ if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
+ sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
+ return err
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: a.cfg.CookieName,
+ Value: token,
+ Path: "/",
+ Expires: expires,
+ MaxAge: int(time.Until(expires).Seconds()),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: !a.cfg.AllowInsecureHTTP,
+ })
+ return nil
+}
diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go
index d45f51b..769c19a 100644
--- a/apps/api/internal/app/two_factor.go
+++ b/apps/api/internal/app/two_factor.go
@@ -90,27 +90,6 @@ func generateTOTPForCounter(key []byte, counter int64) string {
return fmt.Sprintf("%06d", value%1000000)
}
-func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
- token := randomToken()
- sessionID := newID("ses")
- expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
- if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
- sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
- return err
- }
- http.SetCookie(w, &http.Cookie{
- Name: a.cfg.CookieName,
- Value: token,
- Path: "/",
- Expires: expires,
- MaxAge: int(time.Until(expires).Seconds()),
- HttpOnly: true,
- SameSite: http.SameSiteLaxMode,
- Secure: !a.cfg.AllowInsecureHTTP,
- })
- return nil
-}
-
func (a *App) createLoginChallenge(ctx context.Context, userID string) (string, error) {
token := randomToken()
now := a.now().UTC()
diff --git a/apps/web/src/components/admin-only.tsx b/apps/web/src/components/admin-only.tsx
new file mode 100644
index 0000000..ea60390
--- /dev/null
+++ b/apps/web/src/components/admin-only.tsx
@@ -0,0 +1,11 @@
+import React from "react"
+import { Navigate } from "react-router-dom"
+import { useMe } from "@/hooks/use-me"
+
+export function AdminOnly({ children }: { children: React.ReactNode }) {
+ const me = useMe()
+ if (me.isLoading) return null
+ if (!me.data?.user) return
+ if (me.data.user.role !== "admin") return
+ return <>{children}>
+}
diff --git a/apps/web/src/components/auth-guard.tsx b/apps/web/src/components/auth-guard.tsx
new file mode 100644
index 0000000..e632012
--- /dev/null
+++ b/apps/web/src/components/auth-guard.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Navigate, useLocation } from "react-router-dom"
+import { useMe, isTimeoutError } from "@/hooks/use-me"
+import { AuthLoading, AuthError } from "@/components/auth-states"
+
+export function AuthGuard({ children }: { children: React.ReactNode }) {
+ const me = useMe()
+ const location = useLocation()
+
+ if (me.isLoading) return
+ if (me.isError && isTimeoutError(me.error)) return me.refetch()} />
+ if (me.isError || !me.data?.user) return
+
+ return <>{children}>
+}
diff --git a/apps/web/src/components/auth-states.tsx b/apps/web/src/components/auth-states.tsx
new file mode 100644
index 0000000..cf9b865
--- /dev/null
+++ b/apps/web/src/components/auth-states.tsx
@@ -0,0 +1,17 @@
+import { Button } from "@/components/ui/button"
+
+export function AuthLoading() {
+ return 加载中...
+}
+
+export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
+ return (
+
+
+
无法连接后端服务
+
{message}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx
index d215701..46c8c03 100644
--- a/apps/web/src/components/protected-layout.tsx
+++ b/apps/web/src/components/protected-layout.tsx
@@ -1,9 +1,9 @@
import * as React from "react"
-import { Navigate, Outlet, Link, useLocation, useNavigate } from "react-router-dom"
+import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
-import { useQueryClient } from "@tanstack/react-query"
-import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
+import { useLogout } from "@/hooks/use-logout"
+import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
@@ -34,27 +34,24 @@ const adminSections = [
]
export function ProtectedLayout() {
+ return (
+
+
+
+ )
+}
+
+function ProtectedContent() {
const me = useMe()
const location = useLocation()
- const navigate = useNavigate()
- const qc = useQueryClient()
+ const logout = useLogout()
- if (me.isLoading) return
- if (me.isError && me.error.message.includes("请求超时")) return me.refetch()} />
- if (me.isError || !me.data?.user) return
-
- const user = me.data.user
+ const user = me.data!.user
const isMailRoute = location.pathname === "/" || location.pathname.startsWith("/mail")
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
- async function logout() {
- await api.logout().catch(() => undefined)
- qc.clear()
- navigate("/login", { replace: true })
- }
-
if (isMailRoute || isProfileRoute) {
return
}
@@ -138,19 +135,3 @@ export function ProtectedLayout() {
)
}
-
-function AuthLoading() {
- return 加载中...
-}
-
-function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
- return (
-
-
-
无法连接后端服务
-
{message}
-
-
-
- )
-}
diff --git a/apps/web/src/components/turnstile-box.tsx b/apps/web/src/components/turnstile-box.tsx
new file mode 100644
index 0000000..442fbf9
--- /dev/null
+++ b/apps/web/src/components/turnstile-box.tsx
@@ -0,0 +1,50 @@
+import * as React from "react"
+
+declare global {
+ interface Window {
+ turnstile?: {
+ render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
+ remove: (widgetId: string) => void
+ }
+ }
+}
+
+export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (!siteKey || !ref.current) return
+ let cancelled = false
+ let widgetId = ""
+ function render() {
+ if (cancelled || !ref.current || !window.turnstile) return
+ ref.current.innerHTML = ""
+ widgetId = window.turnstile.render(ref.current, {
+ sitekey: siteKey,
+ callback: onToken,
+ "expired-callback": () => onToken(""),
+ "error-callback": () => onToken(""),
+ })
+ }
+ if (window.turnstile) {
+ render()
+ } else {
+ const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
+ if (existing) {
+ existing.addEventListener("load", render, { once: true })
+ } else {
+ const script = document.createElement("script")
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
+ script.async = true
+ script.defer = true
+ script.addEventListener("load", render, { once: true })
+ document.head.appendChild(script)
+ }
+ }
+ return () => {
+ cancelled = true
+ onToken("")
+ if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
+ }
+ }, [siteKey, onToken])
+ return
+}
diff --git a/apps/web/src/hooks/use-logout.ts b/apps/web/src/hooks/use-logout.ts
new file mode 100644
index 0000000..d91d115
--- /dev/null
+++ b/apps/web/src/hooks/use-logout.ts
@@ -0,0 +1,14 @@
+import { useCallback } from "react"
+import { useNavigate } from "react-router-dom"
+import { useQueryClient } from "@tanstack/react-query"
+import { api } from "@/lib/api"
+
+export function useLogout() {
+ const qc = useQueryClient()
+ const navigate = useNavigate()
+ return useCallback(async () => {
+ await api.logout().catch(() => undefined)
+ qc.clear()
+ navigate("/login", { replace: true })
+ }, [qc, navigate])
+}
diff --git a/apps/web/src/hooks/use-me.ts b/apps/web/src/hooks/use-me.ts
index 3f4c3d3..e9e8165 100644
--- a/apps/web/src/hooks/use-me.ts
+++ b/apps/web/src/hooks/use-me.ts
@@ -1,6 +1,20 @@
-import { useQuery } from "@tanstack/react-query"
+import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { api } from "@/lib/api"
+import type { User } from "@/lib/api"
-export function useMe() {
- return useQuery({ queryKey: ["me"], queryFn: api.me, retry: 1 })
+type MeResponse = { user: User }
+
+export function useMe(
+ options?: Omit, "queryKey" | "queryFn">,
+) {
+ return useQuery({
+ queryKey: ["me"],
+ queryFn: api.me,
+ retry: 1,
+ ...options,
+ })
+}
+
+export function isTimeoutError(error: unknown): boolean {
+ return error instanceof Error && error.message.includes("请求超时")
}
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
new file mode 100644
index 0000000..600c1fb
--- /dev/null
+++ b/apps/web/src/lib/api-types.ts
@@ -0,0 +1,54 @@
+export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
+export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
+export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
+export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
+export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
+export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
+export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
+export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
+export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
+export type MailMessage = {
+ id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
+ labels?: MailLabel[]
+}
+export type DNSRecord = { type: string; name: string; value: string; ttl: number }
+export type DNSCheckResult = { domain: string; status: string; checks: Record }
+export type ListResponse = { items: T[]; nextCursor?: string }
+export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
+export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
+export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
+export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
+export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
+export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
+export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
+export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
+export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
+export type SystemSettings = {
+ publicHostname: string
+ publicBaseUrl: string
+ smtpHost: string
+ smtpPort: string
+ smtpUsername: string
+ smtpPasswordSet: boolean
+ smtpRequireTls: boolean
+ maildirRoot: string
+ maildirScanSeconds: number
+ sessionTtlHours: number
+ allowInsecureHttp: boolean
+ openRegistration: boolean
+ twoFactorEnabled: boolean
+ turnstileEnabled: boolean
+ turnstileSiteKey: string
+ turnstileSecretSet: boolean
+ catchAllEnabled: boolean
+ mailAutoRefresh: boolean
+ mailRefreshSeconds: number
+ userMailboxApplyEnabled: boolean
+ userMailboxDomainIds: string[]
+ reservedMailboxPrefixes: string
+}
+export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string }
+export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
+export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
+export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
+export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string }
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 338fa84..1fb5712 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,57 +1,5 @@
-export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
-export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
-export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
-export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
-export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
-export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
-export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
-export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
-export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
-export type MailMessage = {
- id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
- labels?: MailLabel[]
-}
-export type DNSRecord = { type: string; name: string; value: string; ttl: number }
-export type DNSCheckResult = { domain: string; status: string; checks: Record }
-export type ListResponse = { items: T[]; nextCursor?: string }
-export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
-export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
-export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
-export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
-export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
-export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
-export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
-export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
-export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
-export type SystemSettings = {
- publicHostname: string
- publicBaseUrl: string
- smtpHost: string
- smtpPort: string
- smtpUsername: string
- smtpPasswordSet: boolean
- smtpRequireTls: boolean
- maildirRoot: string
- maildirScanSeconds: number
- sessionTtlHours: number
- allowInsecureHttp: boolean
- openRegistration: boolean
- twoFactorEnabled: boolean
- turnstileEnabled: boolean
- turnstileSiteKey: string
- turnstileSecretSet: boolean
- catchAllEnabled: boolean
- mailAutoRefresh: boolean
- mailRefreshSeconds: number
- userMailboxApplyEnabled: boolean
- userMailboxDomainIds: string[]
- reservedMailboxPrefixes: string
-}
-export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string }
-export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
-export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
-export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
-export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string }
+import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, Contact, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
+export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
diff --git a/apps/web/src/lib/validation.ts b/apps/web/src/lib/validation.ts
new file mode 100644
index 0000000..9aab1dd
--- /dev/null
+++ b/apps/web/src/lib/validation.ts
@@ -0,0 +1,9 @@
+export function validatePasswordConfirm(
+ password: string,
+ confirmPassword: string,
+ message?: string,
+): void {
+ if (password !== confirmPassword) {
+ throw new Error(message || "两次输入的密码不一致")
+ }
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 93cd9a5..0ae4e86 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -4,12 +4,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { ProtectedLayout } from "@/components/protected-layout"
+import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
import { RegisterPage } from "@/pages/register"
import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile"
-import { useMe } from "@/hooks/use-me"
import "./index.css"
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
@@ -25,14 +25,6 @@ const router = createBrowserRouter([
] },
])
-function AdminOnly({ children }: { children: React.ReactNode }) {
- const me = useMe()
- if (me.isLoading) return null
- if (!me.data?.user) return
- if (me.data.user.role !== "admin") return
- return <>{children}>
-}
-
ReactDOM.createRoot(document.getElementById("root")!).render(
diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx
index 726bb1c..2dcb723 100644
--- a/apps/web/src/pages/login.tsx
+++ b/apps/web/src/pages/login.tsx
@@ -3,6 +3,7 @@ import { Link, Navigate } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me"
+import { TurnstileBox } from "@/components/turnstile-box"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -73,51 +74,3 @@ export function LoginPage() {
)
}
-declare global {
- interface Window {
- turnstile?: {
- render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
- remove: (widgetId: string) => void
- }
- }
-}
-
-export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
- const ref = React.useRef(null)
- React.useEffect(() => {
- if (!siteKey || !ref.current) return
- let cancelled = false
- let widgetId = ""
- function render() {
- if (cancelled || !ref.current || !window.turnstile) return
- ref.current.innerHTML = ""
- widgetId = window.turnstile.render(ref.current, {
- sitekey: siteKey,
- callback: onToken,
- "expired-callback": () => onToken(""),
- "error-callback": () => onToken(""),
- })
- }
- if (window.turnstile) {
- render()
- } else {
- const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
- if (existing) {
- existing.addEventListener("load", render, { once: true })
- } else {
- const script = document.createElement("script")
- script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
- script.async = true
- script.defer = true
- script.addEventListener("load", render, { once: true })
- document.head.appendChild(script)
- }
- }
- return () => {
- cancelled = true
- onToken("")
- if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
- }
- }, [siteKey, onToken])
- return
-}
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx
index 7297fad..a2ca0bb 100644
--- a/apps/web/src/pages/profile.tsx
+++ b/apps/web/src/pages/profile.tsx
@@ -9,6 +9,8 @@ import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
import { useMe } from "@/hooks/use-me"
+import { useLogout } from "@/hooks/use-logout"
+import { validatePasswordConfirm } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -75,7 +77,7 @@ export function ProfilePage() {
const password = useMutation({
mutationFn: (form: FormData) => {
const newPassword = String(form.get("newPassword") || "")
- if (newPassword !== String(form.get("confirmPassword") || "")) throw new Error("两次输入的新密码不一致")
+ validatePasswordConfirm(newPassword, String(form.get("confirmPassword") || ""), "两次输入的新密码不一致")
return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword })
},
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
@@ -159,7 +161,7 @@ export function ProfilePage() {
React.useEffect(() => { if (mailboxId) localStorage.setItem("lanqin:selected-mailbox", mailboxId); else localStorage.removeItem("lanqin:selected-mailbox") }, [mailboxId])
React.useEffect(() => { applyTheme(darkMode, themeMountedRef.current); themeMountedRef.current = true }, [darkMode])
- async function logout() { await api.logout().catch(() => undefined); qc.clear(); navigate("/login", { replace: true }) }
+ const logout = useLogout()
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx
index 6e8074f..d848330 100644
--- a/apps/web/src/pages/register.tsx
+++ b/apps/web/src/pages/register.tsx
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/hooks/use-toast"
-import { TurnstileBox } from "@/pages/login"
+import { TurnstileBox } from "@/components/turnstile-box"
+import { validatePasswordConfirm } from "@/lib/validation"
export function RegisterPage() {
const me = useMe()
@@ -20,7 +21,7 @@ export function RegisterPage() {
mutationFn: (form: FormData) => {
const password = String(form.get("password") || "")
const confirmPassword = String(form.get("confirmPassword") || "")
- if (password !== confirmPassword) throw new Error("两次输入的密码不一致")
+ validatePasswordConfirm(password, confirmPassword)
return api.register({
email: String(form.get("email") || ""),
displayName: String(form.get("displayName") || ""),