From b864b2903af1669b5b316ac75f96890aa67da8e8 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Tue, 5 May 2026 14:01:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E7=AE=B1=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E3=80=81=E6=89=BE=E5=9B=9E=E5=AF=86=E7=A0=81=E3=80=81=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增邮箱绑定/验证、忘记密码/重置密码、修改密码接口 - users 表新增 nickname、avatar 字段,含迁移脚本 - 新增 PUT /api/auth/me 更新头像和昵称 - 新增 POST /api/auth/change-password 修改密码(需旧密码) - 前端新增 ProfileModal 个人中心(头像上传、昵称、邀请码、邮箱绑定、修改密码、退出) - LoginModal 新增忘记密码流程 - UserMenu 点击头像直接打开个人中心 - server/lib/email.ts:nodemailer 邮件发送封装 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 21 ++ package.json | 2 + server/db.ts | 36 ++- server/index.ts | 5 +- server/lib/email.ts | 31 ++ server/routes/auth.ts | 129 +++++++- src/components/auth/AccountModal.tsx | 209 +++++++++++++ src/components/auth/LoginModal.tsx | 237 ++++++++++++--- src/components/auth/ProfileModal.tsx | 427 +++++++++++++++++++++++++++ src/components/auth/UserMenu.tsx | 57 ++-- src/lib/auth.ts | 73 ++++- 11 files changed, 1138 insertions(+), 89 deletions(-) create mode 100644 server/lib/email.ts create mode 100644 src/components/auth/AccountModal.tsx create mode 100644 src/components/auth/ProfileModal.tsx diff --git a/package-lock.json b/package-lock.json index 03a98ca..6d69d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "lowlight": "^3.3.0", "lucide-react": "^1.14.0", "mammoth": "^1.12.0", + "nodemailer": "^8.0.7", "pdfjs-dist": "^5.7.284", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -70,6 +71,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.12.2", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -4115,6 +4117,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8132,6 +8144,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", diff --git a/package.json b/package.json index 18935b5..3d88270 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "lowlight": "^3.3.0", "lucide-react": "^1.14.0", "mammoth": "^1.12.0", + "nodemailer": "^8.0.7", "pdfjs-dist": "^5.7.284", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -76,6 +77,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.12.2", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/server/db.ts b/server/db.ts index 948dcf0..859f69e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -14,11 +14,19 @@ sqlite.pragma('foreign_keys = ON') export const db = drizzle(sqlite) export const users = sqliteTable('users', { - id: text('id').primaryKey(), - username: text('username').notNull().unique(), - passwordHash: text('password_hash').notNull(), - cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false), - createdAt: integer('created_at').notNull(), + id: text('id').primaryKey(), + username: text('username').notNull().unique(), + passwordHash: text('password_hash').notNull(), + cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false), + nickname: text('nickname'), + avatar: text('avatar'), + email: text('email'), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + emailVerifyCode: text('email_verify_code'), + emailVerifyExpiry: integer('email_verify_expiry'), + resetCode: text('reset_code'), + resetCodeExpiry: integer('reset_code_expiry'), + createdAt: integer('created_at').notNull(), }) export const notes = sqliteTable('notes', { @@ -64,6 +72,12 @@ export function initDb() { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, cloud_enabled INTEGER NOT NULL DEFAULT 0, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + email_verify_code TEXT, + email_verify_expiry INTEGER, + reset_code TEXT, + reset_code_expiry INTEGER, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS notes ( @@ -108,4 +122,16 @@ export function initDb() { ] const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`) for (const code of INVITE_CODES) insert.run(code) + + // 迁移:为已有数据库添加邮箱相关字段 + const cols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[] + const colNames = cols.map(c => c.name) + if (!colNames.includes('email')) sqlite.exec(`ALTER TABLE users ADD COLUMN email TEXT`) + if (!colNames.includes('email_verified')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`) + if (!colNames.includes('email_verify_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_code TEXT`) + if (!colNames.includes('email_verify_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_expiry INTEGER`) + if (!colNames.includes('reset_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code TEXT`) + 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`) } diff --git a/server/index.ts b/server/index.ts index cdcf118..3a7ba38 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,10 +1,11 @@ -import 'dotenv/config' +import dotenv from 'dotenv' +import path from 'node:path' +dotenv.config({ path: path.resolve(process.cwd(), '.env') }) import { Hono } from 'hono' import { serve } from '@hono/node-server' import { cors } from 'hono/cors' import Anthropic from '@anthropic-ai/sdk' import fs from 'node:fs' -import path from 'node:path' import { initDb } from './db' import { authRouter } from './routes/auth' import { notesRouter } from './routes/notes' diff --git a/server/lib/email.ts b/server/lib/email.ts new file mode 100644 index 0000000..4c5ed0a --- /dev/null +++ b/server/lib/email.ts @@ -0,0 +1,31 @@ +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!, + }, +}) + +export async function sendVerifyCode(to: string, code: string) { + await transporter.sendMail({ + from: '"MikiVL 笔记" ', + to, + subject: '邮箱验证码', + text: `你的验证码是:${code},10 分钟内有效。`, + html: `

你的验证码是:${code},10 分钟内有效。

`, + }) +} + +export async function sendResetCode(to: string, code: string) { + await transporter.sendMail({ + from: '"MikiVL 笔记" ', + to, + subject: '重置密码验证码', + text: `你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。`, + html: `

你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。

`, + }) +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index e40e7a1..e4fd113 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken' import { eq } from 'drizzle-orm' import { db, users, inviteCodes } from '../db' import { requireAuth } from '../middleware/auth' +import { sendVerifyCode, sendResetCode } from '../lib/email' export const authRouter = new Hono() @@ -11,6 +12,10 @@ function nanoid() { return Math.random().toString(36).slice(2, 11) + Date.now().toString(36) } +function randomCode() { + return String(Math.floor(100000 + Math.random() * 900000)) +} + authRouter.post('/register', async (c) => { const { username, password } = await c.req.json<{ username: string; password: string }>() if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400) @@ -25,7 +30,7 @@ authRouter.post('/register', async (c) => { db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run() const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' }) - return c.json({ token, user: { id, username, cloudEnabled: false } }) + return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } }) }) authRouter.post('/login', async (c) => { @@ -39,7 +44,7 @@ authRouter.post('/login', async (c) => { if (!ok) return c.json({ error: '用户名或密码错误' }, 401) const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' }) - return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled } }) + return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } }) }) authRouter.post('/activate', requireAuth, async (c) => { @@ -56,3 +61,123 @@ authRouter.post('/activate', requireAuth, async (c) => { return c.json({ success: true }) }) + +// 获取当前用户信息 +authRouter.get('/me', requireAuth, async (c) => { + const userId = c.get('userId') + const [user] = db.select().from(users).where(eq(users.id, userId)).all() + if (!user) return c.json({ error: '用户不存在' }, 404) + return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null }) +}) + +// 更新个人资料(昵称、头像) +authRouter.put('/me', requireAuth, async (c) => { + const userId = c.get('userId') + const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>() + const updates: Record = {} + if (nickname !== undefined) { + if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400) + updates.nickname = nickname.trim() || null + } + if (avatar !== undefined) { + // 限制头像大小(base64 约 100KB) + if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400) + updates.avatar = avatar || null + } + if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400) + db.update(users).set(updates).where(eq(users.id, userId)).run() + const [user] = db.select().from(users).where(eq(users.id, userId)).all() + return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null }) +}) + +// 发送邮箱绑定验证码 +authRouter.post('/email/send-verify', requireAuth, async (c) => { + const userId = c.get('userId') + 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) + + 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() + + try { + await sendVerifyCode(email, code) + } catch { + return c.json({ error: '邮件发送失败,请稍后再试' }, 500) + } + return c.json({ success: true }) +}) + +// 验证邮箱绑定验证码 +authRouter.post('/email/verify', requireAuth, async (c) => { + const userId = c.get('userId') + const { code } = await c.req.json<{ code: string }>() + if (!code) return c.json({ error: '请输入验证码' }, 400) + + const [user] = db.select().from(users).where(eq(users.id, userId)).all() + if (!user) return c.json({ error: '用户不存在' }, 404) + 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() + return c.json({ success: true }) +}) + +// 忘记密码:发送重置验证码 +authRouter.post('/forgot-password', async (c) => { + const { email } = await c.req.json<{ email: string }>() + if (!email) return c.json({ error: '请输入邮箱' }, 400) + + const [user] = db.select().from(users).where(eq(users.email, email)).all() + // 无论是否找到用户都返回成功,避免枚举攻击 + if (!user || !user.emailVerified) return c.json({ success: true }) + + const code = randomCode() + const expiry = Date.now() + 10 * 60 * 1000 + db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run() + + try { + await sendResetCode(email, code) + } catch { + return c.json({ error: '邮件发送失败,请稍后再试' }, 500) + } + return c.json({ success: true }) +}) + +// 重置密码:验证码 + 新密码 +authRouter.post('/reset-password', async (c) => { + const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>() + if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400) + if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400) + + const [user] = db.select().from(users).where(eq(users.email, email)).all() + if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404) + if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400) + if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400) + + const passwordHash = await bcrypt.hash(newPassword, 10) + db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run() + return c.json({ success: true }) +}) + +// 修改密码(需要旧密码) +authRouter.post('/change-password', requireAuth, async (c) => { + const userId = c.get('userId') + const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>() + if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400) + if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400) + + const [user] = db.select().from(users).where(eq(users.id, userId)).all() + if (!user) return c.json({ error: '用户不存在' }, 404) + const ok = await bcrypt.compare(oldPassword, user.passwordHash) + if (!ok) return c.json({ error: '当前密码错误' }, 400) + + const passwordHash = await bcrypt.hash(newPassword, 10) + db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run() + return c.json({ success: true }) +}) + diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx new file mode 100644 index 0000000..ab330d7 --- /dev/null +++ b/src/components/auth/AccountModal.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react' +import { X, CheckCircle, Mail } from 'lucide-react' +import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth' +import { useAppStore } from '../../stores/appStore' + +type Step = 'status' | 'enter-email' | 'enter-code' + +export function AccountModal({ onClose }: { onClose: () => void }) { + const { currentUser, setCurrentUser } = useAppStore() + const [step, setStep] = useState('status') + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSendCode(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await apiSendEmailVerify(email) + setStep('enter-code') + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + async function handleResend() { + setError('') + setLoading(true) + try { + await apiSendEmailVerify(currentUser?.email ?? '') + setStep('enter-code') + setEmail(currentUser?.email ?? '') + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + async function handleVerify(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await apiVerifyEmail(code) + const updated = await apiGetMe() + setCurrentUser(updated) + setStep('status') + setCode('') + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const inputStyle = { + background: 'var(--bg-muted)', + border: '1px solid var(--border)', + color: 'var(--text)', + width: '100%', + padding: '0.5rem 0.75rem', + borderRadius: '0.5rem', + fontSize: '0.875rem', + outline: 'none', + } + + return ( +
+
e.stopPropagation()} + > +
+

账号管理

+ +
+ + {step === 'status' && ( +
+
+ 绑定邮箱 + {currentUser?.emailVerified ? ( +
+ + {currentUser.email} +
+ ) : currentUser?.email ? ( +
+
+ + + {currentUser.email}(未验证) + +
+ {error &&

{error}

} +
+ + · + +
+
+ ) : ( + + )} +
+
+ )} + + {step === 'enter-email' && ( +
+ setEmail(e.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+ )} + + {step === 'enter-code' && ( +
+

+ 验证码已发送至 {email || currentUser?.email},10 分钟内有效 +

+ setCode(e.target.value)} + maxLength={6} + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx index 7061a72..f1c813d 100644 --- a/src/components/auth/LoginModal.tsx +++ b/src/components/auth/LoginModal.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { X } from 'lucide-react' -import { apiLogin, apiRegister, apiActivate, setToken } from '../../lib/auth' +import { apiLogin, apiRegister, apiActivate, setToken, apiForgotPassword, apiResetPassword } from '../../lib/auth' import { useAppStore } from '../../stores/appStore' type Tab = 'login' | 'register' | 'activate' +// 忘记密码分两步:输入邮箱 → 输入验证码+新密码 +type ForgotStep = 'email' | 'reset' export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) { const [tab, setTab] = useState(initialTab ?? 'login') @@ -14,6 +16,50 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi const [loading, setLoading] = useState(false) const { setCurrentUser, syncFromCloud, currentUser } = useAppStore() + // 忘记密码状态 + const [forgotMode, setForgotMode] = useState(false) + const [forgotStep, setForgotStep] = useState('email') + const [forgotEmail, setForgotEmail] = useState('') + const [forgotCode, setForgotCode] = useState('') + const [forgotPassword, setForgotPassword] = useState('') + + function resetForgot() { + setForgotMode(false) + setForgotStep('email') + setForgotEmail('') + setForgotCode('') + setForgotPassword('') + setError('') + } + + async function handleForgotSend(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await apiForgotPassword(forgotEmail) + setForgotStep('reset') + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + async function handleForgotReset(e: React.FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await apiResetPassword(forgotEmail, forgotCode, forgotPassword) + resetForgot() + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError('') @@ -67,70 +113,163 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi >

- {tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储'} + {forgotMode + ? (forgotStep === 'email' ? '找回密码' : '重置密码') + : (tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储')}

- {tab !== 'activate' && ( -
- {(['login', 'register'] as Tab[]).map(t => ( - - ))} -
- )} - -
- {tab !== 'activate' ? ( - <> + {/* 忘记密码流程 */} + {forgotMode ? ( + forgotStep === 'email' ? ( + +

+ 输入绑定的邮箱,我们将发送验证码 +

setUsername(e.target.value)} + type="email" + placeholder="邮箱地址" + value={forgotEmail} + onChange={e => setForgotEmail(e.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+ ) : ( +
+

+ 验证码已发送至 {forgotEmail},10 分钟内有效 +

+ setForgotCode(e.target.value)} + maxLength={6} autoFocus /> setPassword(e.target.value)} + placeholder="新密码(至少 6 位)" + value={forgotPassword} + onChange={e => setForgotPassword(e.target.value)} /> - - ) : ( - setCode(e.target.value)} - autoFocus - /> - )} + {error &&

{error}

} +
+ + +
+
+ ) + ) : ( + <> + {tab !== 'activate' && ( +
+ {(['login', 'register'] as Tab[]).map(t => ( + + ))} +
+ )} - {error &&

{error}

} +
+ {tab !== 'activate' ? ( + <> + setUsername(e.target.value)} + autoFocus + /> + setPassword(e.target.value)} + /> + + ) : ( + setCode(e.target.value)} + autoFocus + /> + )} - -
+ {error &&

{error}

} + + + + {tab === 'login' && ( + + )} + + + )} ) diff --git a/src/components/auth/ProfileModal.tsx b/src/components/auth/ProfileModal.tsx new file mode 100644 index 0000000..a206d7e --- /dev/null +++ b/src/components/auth/ProfileModal.tsx @@ -0,0 +1,427 @@ +import { useState, useRef } from 'react' +import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react' +import { + apiUpdateProfile, apiChangePassword, + apiSendEmailVerify, apiVerifyEmail, apiActivate, apiGetMe, +} from '../../lib/auth' +import { useAppStore } from '../../stores/appStore' + +type Section = 'main' | 'email' | 'email-code' | 'password' | 'change-password' + +export function ProfileModal({ onClose }: { onClose: () => void }) { + const { currentUser, setCurrentUser, logout, syncStatus, syncFromCloud } = useAppStore() + const [section, setSection] = useState
('main') + + // 头像/昵称 + const [nickname, setNickname] = useState(currentUser?.nickname ?? '') + const [avatarPreview, setAvatarPreview] = useState(currentUser?.avatar ?? null) + const [avatarData, setAvatarData] = useState(undefined) + const [profileLoading, setProfileLoading] = useState(false) + const [profileMsg, setProfileMsg] = useState('') + const fileRef = useRef(null) + + // 邀请码 + const [inviteCode, setInviteCode] = useState('') + const [inviteLoading, setInviteLoading] = useState(false) + const [inviteError, setInviteError] = useState('') + + // 邮箱 + const [email, setEmail] = useState('') + const [emailCode, setEmailCode] = useState('') + const [emailLoading, setEmailLoading] = useState(false) + const [emailError, setEmailError] = useState('') + + // 修改密码 + const [oldPwd, setOldPwd] = useState('') + const [newPwd, setNewPwd] = useState('') + const [pwdLoading, setPwdLoading] = useState(false) + const [pwdError, setPwdError] = useState('') + const [pwdOk, setPwdOk] = useState(false) + + const inputStyle: React.CSSProperties = { + background: 'var(--bg-muted)', + border: '1px solid var(--border)', + color: 'var(--text)', + width: '100%', + padding: '0.5rem 0.75rem', + borderRadius: '0.5rem', + fontSize: '0.875rem', + outline: 'none', + } + + function handleAvatarChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = ev => { + const result = ev.target?.result as string + setAvatarPreview(result) + setAvatarData(result) + } + reader.readAsDataURL(file) + } + + async function handleSaveProfile(e: React.FormEvent) { + e.preventDefault() + setProfileLoading(true) + setProfileMsg('') + try { + const updated = await apiUpdateProfile(nickname, avatarData) + setCurrentUser(updated) + setAvatarData(undefined) + setProfileMsg('保存成功') + setTimeout(() => setProfileMsg(''), 2000) + } catch (err: any) { + setProfileMsg(err.message) + } finally { + setProfileLoading(false) + } + } + + async function handleActivate(e: React.FormEvent) { + e.preventDefault() + setInviteError('') + setInviteLoading(true) + try { + await apiActivate(inviteCode) + const updated = await apiGetMe() + setCurrentUser(updated) + await syncFromCloud() + setInviteCode('') + } catch (err: any) { + setInviteError(err.message) + } finally { + setInviteLoading(false) + } + } + + async function handleSendEmailCode(e: React.FormEvent) { + e.preventDefault() + setEmailError('') + setEmailLoading(true) + try { + await apiSendEmailVerify(email) + setSection('email-code') + } catch (err: any) { + setEmailError(err.message) + } finally { + setEmailLoading(false) + } + } + + async function handleVerifyEmail(e: React.FormEvent) { + e.preventDefault() + setEmailError('') + setEmailLoading(true) + try { + await apiVerifyEmail(emailCode) + const updated = await apiGetMe() + setCurrentUser(updated) + setSection('main') + setEmailCode('') + setEmail('') + } catch (err: any) { + setEmailError(err.message) + } finally { + setEmailLoading(false) + } + } + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault() + setPwdError('') + setPwdOk(false) + setPwdLoading(true) + try { + await apiChangePassword(oldPwd, newPwd) + setOldPwd('') + setNewPwd('') + setPwdOk(true) + setTimeout(() => { setPwdOk(false); setSection('main') }, 1500) + } catch (err: any) { + setPwdError(err.message) + } finally { + setPwdLoading(false) + } + } + + const displayName = currentUser?.nickname || currentUser?.username || '' + const avatarSrc = avatarPreview || currentUser?.avatar + + return ( +
+
e.stopPropagation()} + > + {/* 标题栏 */} +
+
+ {section !== 'main' && ( + + )} +

+ {section === 'main' && '个人中心'} + {section === 'email' && '绑定邮箱'} + {section === 'email-code' && '验证邮箱'} + {section === 'password' && '修改密码'} + {section === 'change-password' && '修改密码'} +

+
+ +
+ + {/* 主界面 */} + {section === 'main' && ( + <> + {/* 头像 + 昵称 */} +
+
+
+
fileRef.current?.click()} + > + {avatarSrc ? ( + 头像 + ) : ( + displayName[0]?.toUpperCase() + )} +
+ + +
+
+ + setNickname(e.target.value)} + maxLength={20} + /> +
+
+ {profileMsg && ( +

+ {profileMsg} +

+ )} + +
+ +
+ + {/* 云存储 / 邀请码 */} +
+ 云存储 + {currentUser?.cloudEnabled ? ( + + ) : ( +
+ setInviteCode(e.target.value)} + /> + {inviteError &&

{inviteError}

} + +
+ )} +
+ +
+ + {/* 邮箱 */} +
+ 绑定邮箱 + {currentUser?.emailVerified ? ( +
+ + {currentUser.email} + +
+ ) : currentUser?.email ? ( +
+ + {currentUser.email}(未验证) + +
+ ) : ( + + )} +
+ +
+ + {/* 修改密码 */} + + + {/* 退出登录 */} + + + )} + + {/* 绑定邮箱 */} + {section === 'email' && ( +
+ setEmail(e.target.value)} + autoFocus + /> + {emailError &&

{emailError}

} + +
+ )} + + {/* 验证邮箱验证码 */} + {section === 'email-code' && ( +
+

+ 验证码已发送至 {email || currentUser?.email},10 分钟内有效 +

+ setEmailCode(e.target.value)} + maxLength={6} + autoFocus + /> + {emailError &&

{emailError}

} + +
+ )} + + {/* 修改密码 */} + {section === 'change-password' && ( +
+ setOldPwd(e.target.value)} + autoFocus + /> + setNewPwd(e.target.value)} + /> + {pwdError &&

{pwdError}

} + {pwdOk &&

密码修改成功

} + +
+ )} +
+
+ ) +} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index a6ad75e..09805ab 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -1,12 +1,13 @@ import { useState } from 'react' -import { User, LogOut, Cloud, Key } from 'lucide-react' +import { User, Cloud, Key } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { LoginModal } from './LoginModal' +import { ProfileModal } from './ProfileModal' export function UserMenu() { - const { currentUser, logout, syncStatus, syncFromCloud } = useAppStore() + const { currentUser, syncStatus, syncFromCloud } = useAppStore() const [showLogin, setShowLogin] = useState(false) - const [showActivate, setShowActivate] = useState(false) + const [showProfile, setShowProfile] = useState(false) if (!currentUser) { return ( @@ -26,32 +27,32 @@ export function UserMenu() { ) } + const displayName = currentUser.nickname || currentUser.username + return ( <>
-
-
-
- {currentUser.username[0].toUpperCase()} -
- - {currentUser.username} - -
- -
+ {currentUser.avatar ? ( + 头像 + ) : ( + displayName[0]?.toUpperCase() + )} +
+ + {displayName} + + {currentUser.cloudEnabled ? (
- {showActivate && ( - setShowActivate(false)} /> - )} + {showProfile && setShowProfile(false)} />} ) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 05df057..51c51a3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,7 @@ const TOKEN_KEY = 'mikivl_token' const API = '/api' -export type CurrentUser = { id: string; username: string; cloudEnabled: boolean } +export type CurrentUser = { id: string; username: string; cloudEnabled: boolean; email: string | null; emailVerified: boolean; nickname: string | null; avatar: string | null } export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) @@ -52,11 +52,80 @@ export async function apiActivate(code: string): Promise { if (!res.ok) throw new Error(data.error ?? '激活失败') } +export async function apiUpdateProfile(nickname?: string, avatar?: string): Promise { + const res = await fetch(`${API}/auth/me`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ nickname, avatar }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '更新失败') + return data +} + +export async function apiChangePassword(oldPassword: string, newPassword: string): Promise { + const res = await fetch(`${API}/auth/change-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ oldPassword, newPassword }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '修改失败') +} + +export async function apiGetMe(): Promise { + const res = await fetch(`${API}/auth/me`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '获取用户信息失败') + return data +} + +export async function apiSendEmailVerify(email: string): Promise { + const res = await fetch(`${API}/auth/email/send-verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ email }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '发送失败') +} + +export async function apiVerifyEmail(code: string): Promise { + const res = await fetch(`${API}/auth/email/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ code }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '验证失败') +} + +export async function apiForgotPassword(email: string): Promise { + const res = await fetch(`${API}/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '发送失败') +} + +export async function apiResetPassword(email: string, code: string, newPassword: string): Promise { + const res = await fetch(`${API}/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code, newPassword }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? '重置失败') +} + export function parseToken(token: string): CurrentUser | null { try { const payload = JSON.parse(atob(token.split('.')[1])) - return { id: payload.userId, username: payload.username, cloudEnabled: false } + return { id: payload.userId, username: payload.username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } } catch { return null } } +