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:
parent
b864b2903a
commit
689c8b2d86
@ -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`)
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'smtp.126.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: 'mikivl@126.com',
|
||||
pass: process.env.EMAIL_PASS!,
|
||||
},
|
||||
})
|
||||
function createTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
host: 'smtp.126.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: 'mikivl@126.com',
|
||||
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: '重置密码验证码',
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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"
|
||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? '发送中…' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? '发送中…' : '发送验证码'}
|
||||
</button>
|
||||
</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,22 +141,31 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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="flex-1 py-2 rounded-lg text-sm"
|
||||
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
>
|
||||
返回
|
||||
重新输入邮箱
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||
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' }}
|
||||
>
|
||||
{loading ? '验证中…' : '验证'}
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '重新发送'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user