fix: 邮件懒加载、cloudEnabled 刷新、邮箱写入时机、验证码重发冷却

- server/lib/email.ts: transporter 改为懒加载,避免 ESM import 提升导致 EMAIL_PASS 未加载
- server/routes/auth.ts: send-verify 改为写 pendingEmail,验证成功后才写 email;新增 60s 冷却;catch 块打印错误
- server/db.ts: 新增 pendingEmail 字段及迁移
- src/stores/appStore.ts: 新增 refreshCurrentUser,启动时调用 apiGetMe 拉取真实 cloudEnabled
- src/App.tsx: 启动时调用 refreshCurrentUser
- ProfileModal/AccountModal: 加入 60s 倒计时和重新发送按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-05 14:37:05 +08:00
parent b864b2903a
commit 689c8b2d86
7 changed files with 143 additions and 110 deletions

View File

@ -22,6 +22,7 @@ export const users = sqliteTable('users', {
avatar: text('avatar'),
email: text('email'),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
pendingEmail: text('pending_email'),
emailVerifyCode: text('email_verify_code'),
emailVerifyExpiry: integer('email_verify_expiry'),
resetCode: text('reset_code'),
@ -134,4 +135,5 @@ export function initDb() {
if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`)
if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`)
if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
if (!colNames.includes('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`)
}

View File

@ -1,6 +1,7 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
function createTransporter() {
return nodemailer.createTransport({
host: 'smtp.126.com',
port: 465,
secure: true,
@ -9,9 +10,10 @@ const transporter = nodemailer.createTransport({
pass: process.env.EMAIL_PASS!,
},
})
}
export async function sendVerifyCode(to: string, code: string) {
await transporter.sendMail({
await createTransporter().sendMail({
from: '"MikiVL 笔记" <mikivl@126.com>',
to,
subject: '邮箱验证码',
@ -21,7 +23,7 @@ export async function sendVerifyCode(to: string, code: string) {
}
export async function sendResetCode(to: string, code: string) {
await transporter.sendMail({
await createTransporter().sendMail({
from: '"MikiVL 笔记" <mikivl@126.com>',
to,
subject: '重置密码验证码',

View File

@ -96,17 +96,25 @@ authRouter.post('/email/send-verify', requireAuth, async (c) => {
const { email } = await c.req.json<{ email: string }>()
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
// 检查邮箱是否已被其他用户绑定
// 检查邮箱是否已被其他已验证用户绑定
const existing = db.select().from(users).where(eq(users.email, email)).all()
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
// 60 秒冷却:上次发送时间在 60 秒内则拒绝
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) {
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
}
const code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000
db.update(users).set({ email, emailVerifyCode: code, emailVerifyExpiry: expiry, emailVerified: false }).where(eq(users.id, userId)).run()
// 只写入 pendingEmail不修改 email 字段
db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run()
try {
await sendVerifyCode(email, code)
} catch {
} catch (err) {
console.error('[email]', err)
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
@ -123,7 +131,8 @@ authRouter.post('/email/verify', requireAuth, async (c) => {
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
db.update(users).set({ emailVerified: true, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
// 验证成功:将 pendingEmail 写入正式 email 字段
db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})

View File

@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId, refreshCurrentUser } = useAppStore()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => {
@ -17,6 +17,7 @@ export default function App() {
seedIfEmpty()
.then(() => deduplicateDB())
.then(() => loadAll())
refreshCurrentUser()
}, [])
useEffect(() => {

View File

@ -1,17 +1,31 @@
import { useState } from 'react'
import { X, CheckCircle, Mail } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { X, CheckCircle } from 'lucide-react'
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Step = 'status' | 'enter-email' | 'enter-code'
type Step = 'enter-email' | 'enter-code'
export function AccountModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser } = useAppStore()
const [step, setStep] = useState<Step>('status')
const [step, setStep] = useState<Step>('enter-email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
function startCountdown() {
setCountdown(60)
timerRef.current = setInterval(() => {
setCountdown(n => {
if (n <= 1) { clearInterval(timerRef.current!); return 0 }
return n - 1
})
}, 1000)
}
async function handleSendCode(e: React.FormEvent) {
e.preventDefault()
@ -20,6 +34,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
try {
await apiSendEmailVerify(email)
setStep('enter-code')
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
@ -31,9 +46,8 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
setError('')
setLoading(true)
try {
await apiSendEmailVerify(currentUser?.email ?? '')
setStep('enter-code')
setEmail(currentUser?.email ?? '')
await apiSendEmailVerify(email)
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
@ -49,8 +63,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
await apiVerifyEmail(code)
const updated = await apiGetMe()
setCurrentUser(updated)
setStep('status')
setCode('')
onClose()
} catch (err: any) {
setError(err.message)
} finally {
@ -81,63 +94,20 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}></h2>
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{currentUser?.emailVerified ? '邮箱已绑定' : '绑定邮箱'}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{step === 'status' && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}></span>
{currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
</div>
) : currentUser?.email ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<Mail size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
{currentUser.email}
</span>
</div>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
onClick={handleResend}
disabled={loading}
className="text-xs"
style={{ color: 'var(--accent)', opacity: loading ? 0.7 : 1 }}
>
{loading ? '发送中…' : '重新发送验证码'}
</button>
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>·</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setStep('enter-code') }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
</button>
</div>
</div>
) : (
<button
onClick={() => setStep('enter-email')}
className="text-xs text-left"
style={{ color: 'var(--accent)' }}
>
+
</button>
)}
</div>
</div>
)}
{step === 'enter-email' && (
) : step === 'enter-email' ? (
<form onSubmit={handleSendCode} className="flex flex-col gap-3">
<input
style={inputStyle}
@ -148,31 +118,19 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStep('status'); setError('') }}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '发送中…' : '发送验证码'}
</button>
</div>
</form>
)}
{step === 'enter-code' && (
) : (
<form onSubmit={handleVerify} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{email || currentUser?.email}10
{email}10
</p>
<input
style={inputStyle}
@ -183,23 +141,32 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '验证中…' : '验证'}
</button>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
</button>
<button
type="button"
onClick={handleResend}
disabled={countdown > 0 || loading}
className="text-xs"
style={{ color: countdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: countdown > 0 ? 'default' : 'pointer' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '重新发送'}
</button>
</div>
</form>
)}

View File

@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
import {
apiUpdateProfile, apiChangePassword,
@ -30,6 +30,8 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
const [emailCode, setEmailCode] = useState('')
const [emailLoading, setEmailLoading] = useState(false)
const [emailError, setEmailError] = useState('')
const [resendCountdown, setResendCountdown] = useState(0)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 修改密码
const [oldPwd, setOldPwd] = useState('')
@ -38,6 +40,18 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
const [pwdError, setPwdError] = useState('')
const [pwdOk, setPwdOk] = useState(false)
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
function startCountdown() {
setResendCountdown(60)
countdownRef.current = setInterval(() => {
setResendCountdown(n => {
if (n <= 1) { clearInterval(countdownRef.current!); return 0 }
return n - 1
})
}, 1000)
}
const inputStyle: React.CSSProperties = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
@ -102,6 +116,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
try {
await apiSendEmailVerify(email)
setSection('email-code')
startCountdown()
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleResendEmailCode() {
setEmailError('')
setEmailLoading(true)
try {
await apiSendEmailVerify(email)
startCountdown()
} catch (err: any) {
setEmailError(err.message)
} finally {
@ -369,7 +397,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{section === 'email-code' && (
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{email || currentUser?.email}10
{email}10
</p>
<input
style={inputStyle}
@ -388,6 +416,15 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
>
{emailLoading ? '验证中…' : '验证'}
</button>
<button
type="button"
onClick={handleResendEmailCode}
disabled={resendCountdown > 0 || emailLoading}
className="text-xs text-center"
style={{ color: resendCountdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: resendCountdown > 0 ? 'default' : 'pointer' }}
>
{resendCountdown > 0 ? `${resendCountdown} 秒后可重新发送` : '重新发送验证码'}
</button>
</form>
)}

View File

@ -1,7 +1,7 @@
import { create } from 'zustand'
import { db, type Note, type Folder } from '../db'
import { generateId, extractTextFromJSON } from '../lib/utils'
import { getToken, clearToken, parseToken, type CurrentUser } from '../lib/auth'
import { getToken, clearToken, parseToken, apiGetMe, type CurrentUser } from '../lib/auth'
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync'
interface AppState {
@ -49,6 +49,7 @@ interface AppState {
syncStatus: 'idle' | 'syncing' | 'error'
setCurrentUser: (user: CurrentUser | null) => void
logout: () => void
refreshCurrentUser: () => Promise<void>
syncFromCloud: () => Promise<void>
}
@ -264,6 +265,20 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ currentUser: null, syncStatus: 'idle' })
},
refreshCurrentUser: async () => {
const token = getToken()
if (!token) return
try {
const user = await apiGetMe()
set({ currentUser: user })
if (user.cloudEnabled) get().syncFromCloud()
} catch {
// token 失效则清除
clearToken()
set({ currentUser: null })
}
},
syncFromCloud: async () => {
const { notes, folders, currentUser } = get()
if (!currentUser?.cloudEnabled) return