From 45122162d0d2f42d08dffc760cbe00594eb8bfcd Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Tue, 16 Jun 2026 10:10:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=87=8D=E6=9E=84=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E6=B5=81=E7=A8=8B=E5=B9=B6=E8=A1=A5=E5=85=85=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=99=BB=E5=BD=95=E6=80=81=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分后端认证处理与会话创建逻辑,支持登录、注册、退出、个人资料和改密接口 - 增加二次验证挑战、Turnstile 人机校验与旧版邮箱迁移清理 - 新增前端认证守卫、管理员访问控制、退出 Hook 与验证工具 - 统一抽离接口类型,优化登录页、注册页和个人资料页的认证交互 --- apps/api/internal/app/app.go | 80 +++++- apps/api/internal/app/app_test.go | 48 ++++ apps/api/internal/app/auth_handlers.go | 242 +++++++++++++++++++ apps/api/internal/app/router_auth.go | 233 ------------------ apps/api/internal/app/session.go | 27 +++ apps/api/internal/app/two_factor.go | 21 -- apps/web/src/components/admin-only.tsx | 11 + apps/web/src/components/auth-guard.tsx | 15 ++ apps/web/src/components/auth-states.tsx | 17 ++ apps/web/src/components/protected-layout.tsx | 45 +--- apps/web/src/components/turnstile-box.tsx | 50 ++++ apps/web/src/hooks/use-logout.ts | 14 ++ apps/web/src/hooks/use-me.ts | 20 +- apps/web/src/lib/api-types.ts | 54 +++++ apps/web/src/lib/api.ts | 56 +---- apps/web/src/lib/validation.ts | 9 + apps/web/src/main.tsx | 10 +- apps/web/src/pages/login.tsx | 49 +--- apps/web/src/pages/profile.tsx | 6 +- apps/web/src/pages/register.tsx | 5 +- 20 files changed, 595 insertions(+), 417 deletions(-) create mode 100644 apps/api/internal/app/auth_handlers.go create mode 100644 apps/api/internal/app/session.go create mode 100644 apps/web/src/components/admin-only.tsx create mode 100644 apps/web/src/components/auth-guard.tsx create mode 100644 apps/web/src/components/auth-states.tsx create mode 100644 apps/web/src/components/turnstile-box.tsx create mode 100644 apps/web/src/hooks/use-logout.ts create mode 100644 apps/web/src/lib/api-types.ts create mode 100644 apps/web/src/lib/validation.ts 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") || ""),