From 36e10a06b0f2af133e7c8ddb956b28f50e7b8d8c Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 22:38:33 +0800 Subject: [PATCH] feat(auth): add user profile page and display name update Add PATCH /api/auth/me endpoint to update user display name Create UserProfileView for managing account details and email status Update AppShell sidebar to link authenticated user to profile page --- DESIGN.md | 7 ++ backend/src/auth.ts | 23 ++++ backend/src/server.ts | 13 ++ frontend/src/components/AppShell.vue | 7 +- frontend/src/icons.ts | 1 + frontend/src/router/index.ts | 9 +- frontend/src/services/api.ts | 17 ++- frontend/src/styles/main.css | 130 ++++++++++++++++++++ frontend/src/views/UserProfileView.vue | 160 +++++++++++++++++++++++++ system-wordings.ts | 28 +++++ 10 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 frontend/src/views/UserProfileView.vue diff --git a/DESIGN.md b/DESIGN.md index dfd18ed..5b32aa3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,6 +115,11 @@ - 对外用户字段只包含必要信息: - 当前用户:`id`、`email`、`displayName`、`emailVerified` - 编辑署名:`id`、`displayName` +- User Profile: + - 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。 + - 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。 + - 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。 + - 显示名用于编辑署名、讨论和 Life 内容作者展示。 ## Community 编辑与审计 @@ -498,6 +503,7 @@ API 暴露边界: - UI 风格以 `DesignGuidelines.html` 为准。 - 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。 - 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。 +- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。 - 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。 - 导航和主要操作使用图标增强识别。 - 数据加载状态使用 Skeleton,避免裸文本 loading。 @@ -544,6 +550,7 @@ API 暴露边界: - `POST /api/auth/request-password-reset` - `POST /api/auth/reset-password` - `GET /api/auth/me` +- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。 - `POST /api/auth/logout` 已验证用户编辑 API: diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 945061e..c57da56 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -507,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise, + locale = defaultLocale +): Promise { + const displayName = await cleanDisplayName(payload.displayName, locale); + const user = await queryOne( + ` + UPDATE users + SET display_name = $1, updated_at = now() + WHERE id = $2 + RETURNING id, email, display_name, email_verified_at + `, + [displayName, userId] + ); + + if (!user) { + throw statusError(await systemMessage(locale || defaultLocale, 'server.errors.loginRequired'), 401); + } + + return toPublicUser(user); +} + export async function logoutSession(token: string): Promise { if (token.length < 32) { return; diff --git a/backend/src/server.ts b/backend/src/server.ts index b17a1f1..50efd6e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -8,6 +8,7 @@ import { registerUser, requestPasswordReset, resetPassword, + updateCurrentUser, verifyEmail, type AuthUser } from './auth.ts'; @@ -198,6 +199,18 @@ app.get('/api/auth/me', async (request, reply) => { return { user }; }); +app.patch('/api/auth/me', async (request, reply) => { + const token = getBearerToken(request.headers.authorization); + const user = token ? await getUserBySessionToken(token) : null; + + if (!user) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; + return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) }; +}); + app.post('/api/auth/logout', async (request, reply) => { const token = getBearerToken(request.headers.authorization); if (token) { diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 7adf79c..e572fe5 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; -import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons'; +import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons'; import type { AuthUser, Language } from '../services/api'; import PokeBallMark from './PokeBallMark.vue'; import StatusBadge from './StatusBadge.vue'; @@ -184,7 +184,10 @@ onBeforeUnmount(() => {