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'), avatar: text('avatar'),
email: text('email'), email: text('email'),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
pendingEmail: text('pending_email'),
emailVerifyCode: text('email_verify_code'), emailVerifyCode: text('email_verify_code'),
emailVerifyExpiry: integer('email_verify_expiry'), emailVerifyExpiry: integer('email_verify_expiry'),
resetCode: text('reset_code'), 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('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('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('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' import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({ function createTransporter() {
return nodemailer.createTransport({
host: 'smtp.126.com', host: 'smtp.126.com',
port: 465, port: 465,
secure: true, secure: true,
@ -9,9 +10,10 @@ const transporter = nodemailer.createTransport({
pass: process.env.EMAIL_PASS!, pass: process.env.EMAIL_PASS!,
}, },
}) })
}
export async function sendVerifyCode(to: string, code: string) { export async function sendVerifyCode(to: string, code: string) {
await transporter.sendMail({ await createTransporter().sendMail({
from: '"MikiVL 笔记" <mikivl@126.com>', from: '"MikiVL 笔记" <mikivl@126.com>',
to, to,
subject: '邮箱验证码', subject: '邮箱验证码',
@ -21,7 +23,7 @@ export async function sendVerifyCode(to: string, code: string) {
} }
export async function sendResetCode(to: string, code: string) { export async function sendResetCode(to: string, code: string) {
await transporter.sendMail({ await createTransporter().sendMail({
from: '"MikiVL 笔记" <mikivl@126.com>', from: '"MikiVL 笔记" <mikivl@126.com>',
to, to,
subject: '重置密码验证码', subject: '重置密码验证码',

View File

@ -96,17 +96,25 @@ authRouter.post('/email/send-verify', requireAuth, async (c) => {
const { email } = await c.req.json<{ email: string }>() const { email } = await c.req.json<{ email: string }>()
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400) if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
// 检查邮箱是否已被其他用户绑定 // 检查邮箱是否已被其他已验证用户绑定
const existing = db.select().from(users).where(eq(users.email, email)).all() const existing = db.select().from(users).where(eq(users.email, email)).all()
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409) 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 code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000 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 { try {
await sendVerifyCode(email, code) await sendVerifyCode(email, code)
} catch { } catch (err) {
console.error('[email]', err)
return c.json({ error: '邮件发送失败,请稍后再试' }, 500) return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
} }
return c.json({ success: true }) 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.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) 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 }) return c.json({ success: true })
}) })

View File

@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
import { seedIfEmpty, deduplicateDB } from './db' import { seedIfEmpty, deduplicateDB } from './db'
export default function App() { 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) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => { useEffect(() => {
@ -17,6 +17,7 @@ export default function App() {
seedIfEmpty() seedIfEmpty()
.then(() => deduplicateDB()) .then(() => deduplicateDB())
.then(() => loadAll()) .then(() => loadAll())
refreshCurrentUser()
}, []) }, [])
useEffect(() => { useEffect(() => {

View File

@ -1,17 +1,31 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { X, CheckCircle, Mail } from 'lucide-react' import { X, CheckCircle } from 'lucide-react'
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth' import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
type Step = 'status' | 'enter-email' | 'enter-code' type Step = 'enter-email' | 'enter-code'
export function AccountModal({ onClose }: { onClose: () => void }) { export function AccountModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser } = useAppStore() const { currentUser, setCurrentUser } = useAppStore()
const [step, setStep] = useState<Step>('status') const [step, setStep] = useState<Step>('enter-email')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) 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) { async function handleSendCode(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@ -20,6 +34,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
try { try {
await apiSendEmailVerify(email) await apiSendEmailVerify(email)
setStep('enter-code') setStep('enter-code')
startCountdown()
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -31,9 +46,8 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
await apiSendEmailVerify(currentUser?.email ?? '') await apiSendEmailVerify(email)
setStep('enter-code') startCountdown()
setEmail(currentUser?.email ?? '')
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -49,8 +63,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
await apiVerifyEmail(code) await apiVerifyEmail(code)
const updated = await apiGetMe() const updated = await apiGetMe()
setCurrentUser(updated) setCurrentUser(updated)
setStep('status') onClose()
setCode('')
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -81,63 +94,20 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="flex items-center justify-between"> <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)' }}> <button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} /> <X size={14} />
</button> </button>
</div> </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 ? ( {currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} /> <CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span> <span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
</div> </div>
) : currentUser?.email ? ( ) : step === 'enter-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' && (
<form onSubmit={handleSendCode} className="flex flex-col gap-3"> <form onSubmit={handleSendCode} className="flex flex-col gap-3">
<input <input
style={inputStyle} style={inputStyle}
@ -148,31 +118,19 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
autoFocus autoFocus
/> />
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>} {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 <button
type="submit" type="submit"
disabled={loading} 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 }} style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
> >
{loading ? '发送中…' : '发送验证码'} {loading ? '发送中…' : '发送验证码'}
</button> </button>
</div>
</form> </form>
)} ) : (
{step === 'enter-code' && (
<form onSubmit={handleVerify} className="flex flex-col gap-3"> <form onSubmit={handleVerify} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}> <p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{email || currentUser?.email}10 {email}10
</p> </p>
<input <input
style={inputStyle} style={inputStyle}
@ -183,23 +141,32 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
autoFocus autoFocus
/> />
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>} {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 <button
type="submit" type="submit"
disabled={loading} 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 }} style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
> >
{loading ? '验证中…' : '验证'} {loading ? '验证中…' : '验证'}
</button> </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> </div>
</form> </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 { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
import { import {
apiUpdateProfile, apiChangePassword, apiUpdateProfile, apiChangePassword,
@ -30,6 +30,8 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
const [emailCode, setEmailCode] = useState('') const [emailCode, setEmailCode] = useState('')
const [emailLoading, setEmailLoading] = useState(false) const [emailLoading, setEmailLoading] = useState(false)
const [emailError, setEmailError] = useState('') const [emailError, setEmailError] = useState('')
const [resendCountdown, setResendCountdown] = useState(0)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 修改密码 // 修改密码
const [oldPwd, setOldPwd] = useState('') const [oldPwd, setOldPwd] = useState('')
@ -38,6 +40,18 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
const [pwdError, setPwdError] = useState('') const [pwdError, setPwdError] = useState('')
const [pwdOk, setPwdOk] = useState(false) 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 = { const inputStyle: React.CSSProperties = {
background: 'var(--bg-muted)', background: 'var(--bg-muted)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
@ -102,6 +116,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
try { try {
await apiSendEmailVerify(email) await apiSendEmailVerify(email)
setSection('email-code') 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) { } catch (err: any) {
setEmailError(err.message) setEmailError(err.message)
} finally { } finally {
@ -369,7 +397,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
{section === 'email-code' && ( {section === 'email-code' && (
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3"> <form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}> <p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{email || currentUser?.email}10 {email}10
</p> </p>
<input <input
style={inputStyle} style={inputStyle}
@ -388,6 +416,15 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
> >
{emailLoading ? '验证中…' : '验证'} {emailLoading ? '验证中…' : '验证'}
</button> </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> </form>
)} )}

View File

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