feat(auth): 重构认证流程并补充前端登录态保护
- 拆分后端认证处理与会话创建逻辑,支持登录、注册、退出、个人资料和改密接口 - 增加二次验证挑战、Turnstile 人机校验与旧版邮箱迁移清理 - 新增前端认证守卫、管理员访问控制、退出 Hook 与验证工具 - 统一抽离接口类型,优化登录页、注册页和个人资料页的认证交互
This commit is contained in:
@@ -305,20 +305,74 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
|||||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
_, err := a.db.ExecContext(ctx, `
|
SELECT mb.id, mb.domain_id
|
||||||
UPDATE mailboxes
|
FROM mailboxes mb
|
||||||
SET status='disabled', updated_at=?
|
JOIN users u ON u.id=mb.user_id
|
||||||
WHERE address=?
|
WHERE mb.address=?
|
||||||
AND display_name='LanQin Admin'
|
AND mb.display_name='LanQin Admin'
|
||||||
AND EXISTS (
|
AND u.email=?
|
||||||
SELECT 1 FROM users
|
AND u.role='admin'`, adminEmail, adminEmail)
|
||||||
WHERE users.id=mailboxes.user_id
|
if err != nil {
|
||||||
AND users.email=?
|
|
||||||
AND users.role='admin'
|
|
||||||
)`, now, adminEmail, adminEmail)
|
|
||||||
return err
|
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 {
|
func (a *App) migrateMailRulesBuilder(ctx context.Context) error {
|
||||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`)
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(mail_rules)`)
|
||||||
|
|||||||
@@ -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) {
|
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
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 {
|
func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.authenticateRequest(r)
|
user, err := a.authenticateRequest(r)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -90,27 +90,6 @@ func generateTOTPForCounter(key []byte, counter int64) string {
|
|||||||
return fmt.Sprintf("%06d", value%1000000)
|
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) {
|
func (a *App) createLoginChallenge(ctx context.Context, userID string) (string, error) {
|
||||||
token := randomToken()
|
token := randomToken()
|
||||||
now := a.now().UTC()
|
now := a.now().UTC()
|
||||||
|
|||||||
@@ -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 <Navigate to="/login" replace />
|
||||||
|
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -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 <AuthLoading />
|
||||||
|
if (me.isError && isTimeoutError(me.error)) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
||||||
|
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function AuthLoading() {
|
||||||
|
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm space-y-4 text-center">
|
||||||
|
<div className="text-sm font-medium">无法连接后端服务</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{message}</div>
|
||||||
|
<Button type="button" variant="outline" onClick={onRetry}>重新加载</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as React from "react"
|
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 { 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 { useMe } from "@/hooks/use-me"
|
||||||
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
@@ -34,27 +34,24 @@ const adminSections = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function ProtectedLayout() {
|
export function ProtectedLayout() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<ProtectedContent />
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedContent() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const logout = useLogout()
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
if (me.isLoading) return <AuthLoading />
|
const user = me.data!.user
|
||||||
if (me.isError && me.error.message.includes("请求超时")) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
|
|
||||||
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
|
||||||
|
|
||||||
const user = me.data.user
|
|
||||||
const isMailRoute = location.pathname === "/" || location.pathname.startsWith("/mail")
|
const isMailRoute = location.pathname === "/" || location.pathname.startsWith("/mail")
|
||||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
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) {
|
if (isMailRoute || isProfileRoute) {
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
@@ -138,19 +135,3 @@ export function ProtectedLayout() {
|
|||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthLoading() {
|
|
||||||
return <div className="grid min-h-screen place-items-center text-muted-foreground">加载中...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
|
||||||
<div className="w-full max-w-sm space-y-4 text-center">
|
|
||||||
<div className="text-sm font-medium">无法连接后端服务</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{message}</div>
|
|
||||||
<Button type="button" variant="outline" onClick={onRetry}>重新加载</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<HTMLDivElement | null>(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 <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import type { User } from "@/lib/api"
|
||||||
|
|
||||||
export function useMe() {
|
type MeResponse = { user: User }
|
||||||
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: 1 })
|
|
||||||
|
export function useMe(
|
||||||
|
options?: Omit<UseQueryOptions<MeResponse, Error, MeResponse, ["me"]>, "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("请求超时")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, { ok: boolean; message: string; found?: string[] }> }
|
||||||
|
export type ListResponse<T> = { 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<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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 }
|
||||||
+2
-54
@@ -1,57 +1,5 @@
|
|||||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: 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 type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
export * from "./api-types"
|
||||||
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<string, { ok: boolean; message: string; found?: string[] }> }
|
|
||||||
export type ListResponse<T> = { 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<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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 }
|
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function validatePasswordConfirm(
|
||||||
|
password: string,
|
||||||
|
confirmPassword: string,
|
||||||
|
message?: string,
|
||||||
|
): void {
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
throw new Error(message || "两次输入的密码不一致")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||||
import { Toaster } from "@/components/ui/toaster"
|
import { Toaster } from "@/components/ui/toaster"
|
||||||
import { ProtectedLayout } from "@/components/protected-layout"
|
import { ProtectedLayout } from "@/components/protected-layout"
|
||||||
|
import { AdminOnly } from "@/components/admin-only"
|
||||||
import { LoginPage } from "@/pages/login"
|
import { LoginPage } from "@/pages/login"
|
||||||
import { RegisterPage } from "@/pages/register"
|
import { RegisterPage } from "@/pages/register"
|
||||||
import { MailPage } from "@/pages/mail"
|
import { MailPage } from "@/pages/mail"
|
||||||
import { AdminPage } from "@/pages/admin"
|
import { AdminPage } from "@/pages/admin"
|
||||||
import { ProfilePage } from "@/pages/profile"
|
import { ProfilePage } from "@/pages/profile"
|
||||||
import { useMe } from "@/hooks/use-me"
|
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
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 <Navigate to="/login" replace />
|
|
||||||
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, Navigate } from "react-router-dom"
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
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<HTMLDivElement | null>(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 <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { cn, formatBytes } from "@/lib/utils"
|
|||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
|
import { useLogout } from "@/hooks/use-logout"
|
||||||
|
import { validatePasswordConfirm } from "@/lib/validation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -75,7 +77,7 @@ export function ProfilePage() {
|
|||||||
const password = useMutation({
|
const password = useMutation({
|
||||||
mutationFn: (form: FormData) => {
|
mutationFn: (form: FormData) => {
|
||||||
const newPassword = String(form.get("newPassword") || "")
|
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 })
|
return api.changePassword({ currentPassword: String(form.get("currentPassword") || ""), newPassword })
|
||||||
},
|
},
|
||||||
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
|
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(() => { 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])
|
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: "已复制" }) }
|
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
|
||||||
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
|
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
|
||||||
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
|
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
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() {
|
export function RegisterPage() {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@@ -20,7 +21,7 @@ export function RegisterPage() {
|
|||||||
mutationFn: (form: FormData) => {
|
mutationFn: (form: FormData) => {
|
||||||
const password = String(form.get("password") || "")
|
const password = String(form.get("password") || "")
|
||||||
const confirmPassword = String(form.get("confirmPassword") || "")
|
const confirmPassword = String(form.get("confirmPassword") || "")
|
||||||
if (password !== confirmPassword) throw new Error("两次输入的密码不一致")
|
validatePasswordConfirm(password, confirmPassword)
|
||||||
return api.register({
|
return api.register({
|
||||||
email: String(form.get("email") || ""),
|
email: String(form.get("email") || ""),
|
||||||
displayName: String(form.get("displayName") || ""),
|
displayName: String(form.get("displayName") || ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user