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
This commit is contained in:
2026-05-02 22:38:33 +08:00
parent 4a42756e2e
commit 36e10a06b0
10 changed files with 387 additions and 8 deletions

View File

@@ -115,6 +115,11 @@
- 对外用户字段只包含必要信息: - 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified` - 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName` - 编辑署名:`id``displayName`
- User Profile
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
- 更新显示名后API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
## Community 编辑与审计 ## Community 编辑与审计
@@ -498,6 +503,7 @@ API 暴露边界:
- UI 风格以 `DesignGuidelines.html` 为准。 - UI 风格以 `DesignGuidelines.html` 为准。
- 页面结构以 `AppShell``PageHeader`、列表、详情区和管理区为核心。 - 页面结构以 `AppShell``PageHeader`、列表、详情区和管理区为核心。
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。 - 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。 - 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。 - 导航和主要操作使用图标增强识别。
- 数据加载状态使用 Skeleton避免裸文本 loading。 - 数据加载状态使用 Skeleton避免裸文本 loading。
@@ -544,6 +550,7 @@ API 暴露边界:
- `POST /api/auth/request-password-reset` - `POST /api/auth/request-password-reset`
- `POST /api/auth/reset-password` - `POST /api/auth/reset-password`
- `GET /api/auth/me` - `GET /api/auth/me`
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
- `POST /api/auth/logout` - `POST /api/auth/logout`
已验证用户编辑 API 已验证用户编辑 API

View File

@@ -507,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
return user ? toPublicUser(user) : null; return user ? toPublicUser(user) : null;
} }
export async function updateCurrentUser(
userId: number,
payload: Record<string, unknown>,
locale = defaultLocale
): Promise<AuthUser> {
const displayName = await cleanDisplayName(payload.displayName, locale);
const user = await queryOne<UserRow>(
`
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<void> { export async function logoutSession(token: string): Promise<void> {
if (token.length < 32) { if (token.length < 32) {
return; return;

View File

@@ -8,6 +8,7 @@ import {
registerUser, registerUser,
requestPasswordReset, requestPasswordReset,
resetPassword, resetPassword,
updateCurrentUser,
verifyEmail, verifyEmail,
type AuthUser type AuthUser
} from './auth.ts'; } from './auth.ts';
@@ -198,6 +199,18 @@ app.get('/api/auth/me', async (request, reply) => {
return { user }; 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<string, unknown>) : {};
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
});
app.post('/api/auth/logout', async (request, reply) => { app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
if (token) { if (token) {

View File

@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; 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 type { AuthUser, Language } from '../services/api';
import PokeBallMark from './PokeBallMark.vue'; import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
@@ -184,7 +184,10 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<template v-if="currentUser"> <template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span> <RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
</RouterLink>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout"> <button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
{{ t('nav.logout') }} {{ t('nav.logout') }}

View File

@@ -29,6 +29,7 @@ export const iconMail: AppIcon = 'mdi:email-fast-outline';
export const iconMenu: AppIcon = 'mdi:menu'; export const iconMenu: AppIcon = 'mdi:menu';
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline'; export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
export const iconPokemon: AppIcon = 'mdi:pokeball'; export const iconPokemon: AppIcon = 'mdi:pokeball';
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline'; export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
export const iconRegister: AppIcon = 'mdi:account-plus-outline'; export const iconRegister: AppIcon = 'mdi:account-plus-outline';
export const iconReply: AppIcon = 'mdi:reply-outline'; export const iconReply: AppIcon = 'mdi:reply-outline';

View File

@@ -13,6 +13,7 @@ import ComingSoonView from '../views/ComingSoonView.vue';
import AdminView from '../views/AdminView.vue'; import AdminView from '../views/AdminView.vue';
import ForgotPasswordView from '../views/ForgotPasswordView.vue'; import ForgotPasswordView from '../views/ForgotPasswordView.vue';
import LoginView from '../views/LoginView.vue'; import LoginView from '../views/LoginView.vue';
import UserProfileView from '../views/UserProfileView.vue';
import RegisterView from '../views/RegisterView.vue'; import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue'; import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue'; import VerifyEmailView from '../views/VerifyEmailView.vue';
@@ -46,6 +47,7 @@ export const router = createRouter({
{ path: '/checklist', component: DailyChecklistView }, { path: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView }, { path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
{ path: '/login', component: LoginView }, { path: '/login', component: LoginView },
{ path: '/forgot-password', component: ForgotPasswordView }, { path: '/forgot-password', component: ForgotPasswordView },
{ path: '/reset-password', component: ResetPasswordView }, { path: '/reset-password', component: ResetPasswordView },
@@ -60,7 +62,10 @@ export const router = createRouter({
}); });
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (!to.matched.some((record) => record.meta.requiresVerified === true)) { const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true);
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
if (!requiresAuth) {
return true; return true;
} }
@@ -70,7 +75,7 @@ router.beforeEach(async (to) => {
try { try {
const response = await api.me(); const response = await api.me();
return response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } }; return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
} catch { } catch {
setAuthToken(null); setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } }; return { path: '/login', query: { redirect: to.fullPath } };

View File

@@ -274,6 +274,10 @@ export interface AuthUser {
emailVerified: boolean; emailVerified: boolean;
} }
export interface UserProfilePayload {
displayName: string;
}
export interface LoginPayload { export interface LoginPayload {
email: string; email: string;
password: string; password: string;
@@ -449,9 +453,7 @@ export function setAuthToken(token: string | null, options: { persistent?: boole
session?.removeItem(authTokenKey); session?.removeItem(authTokenKey);
} }
if (typeof window !== 'undefined') { notifyAuthChange();
window.dispatchEvent(new Event(authChangeEvent));
}
} }
export function onAuthTokenChange(callback: () => void): () => void { export function onAuthTokenChange(callback: () => void): () => void {
@@ -459,6 +461,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
return () => window.removeEventListener(authChangeEvent, callback); return () => window.removeEventListener(authChangeEvent, callback);
} }
export function notifyAuthChange(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(authChangeEvent));
}
}
function requestHeaders(): HeadersInit { function requestHeaders(): HeadersInit {
const token = getAuthToken(); const token = getAuthToken();
return { return {
@@ -493,7 +501,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<T> { async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
method, method,
headers: { headers: {
@@ -567,6 +575,7 @@ export const api = {
resetPassword: (payload: { token: string; password: string }) => resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload), sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'), me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
logout: () => postEmpty('/api/auth/logout'), logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'), options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'), dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),

View File

@@ -364,11 +364,43 @@ svg {
} }
.auth-user { .auth-user {
min-height: 44px;
max-width: 100%; max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden; overflow: hidden;
padding: 8px 10px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft); color: var(--ink-soft);
font-size: 14px; font-size: 14px;
font-weight: 850; font-weight: 850;
line-height: 1.1;
text-overflow: ellipsis;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease;
}
.auth-user:hover,
.auth-user.router-link-active {
border-color: var(--pokemon-blue);
background: rgba(255, 203, 5, 0.22);
color: var(--pokemon-blue-deep);
}
.auth-user__icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.auth-user__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -3830,6 +3862,103 @@ button:disabled,
background: color-mix(in srgb, var(--danger) 10%, var(--surface)); background: color-mix(in srgb, var(--danger) 10%, var(--surface));
} }
.profile-page {
display: grid;
gap: 18px;
}
.profile-layout {
display: grid;
grid-template-columns: minmax(260px, 0.62fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.profile-card {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-control);
}
.profile-card--identity {
align-content: start;
}
.profile-identity {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: center;
}
.profile-avatar {
width: 58px;
height: 58px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 3px solid var(--line-strong);
border-radius: 50%;
background:
linear-gradient(to bottom, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--pokeball-white) 55% 100%);
color: var(--line-strong);
box-shadow: inset 0 3px 0 rgba(255, 255, 255, 0.38), 0 3px 0 rgba(0, 0, 0, 0.16);
font-family: var(--font-display);
font-size: 23px;
font-weight: 950;
text-transform: uppercase;
}
.profile-identity__copy {
min-width: 0;
}
.profile-identity h2,
.profile-card__header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 28px;
font-weight: 950;
line-height: 1.1;
overflow-wrap: anywhere;
}
.profile-identity p {
margin: 6px 0 0;
color: var(--muted);
font-weight: 800;
overflow-wrap: anywhere;
}
.profile-card__header {
display: flex;
align-items: center;
gap: 10px;
}
.profile-card__icon {
width: 26px;
height: 26px;
flex: 0 0 auto;
color: var(--pokemon-blue);
}
.profile-field-note {
color: var(--muted);
font-size: 13px;
font-weight: 750;
}
.profile-readonly-input {
color: var(--muted);
cursor: default;
}
.admin-layout { .admin-layout {
display: grid; display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
@@ -4153,6 +4282,7 @@ button:disabled,
.pokemon-profile-grid, .pokemon-profile-grid,
.pokemon-profile-row, .pokemon-profile-row,
.pokemon-related-grid, .pokemon-related-grid,
.profile-layout,
.system-wording-layout, .system-wording-layout,
.admin-layout { .admin-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconProfile, iconSave } from '../icons';
import { api, notifyAuthChange, type AuthUser } from '../services/api';
const { t } = useI18n();
const user = ref<AuthUser | null>(null);
const displayName = ref('');
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
const trimmedDisplayName = computed(() => displayName.value.trim());
const hasChanges = computed(() => {
const currentUser = user.value;
if (!currentUser) return false;
return trimmedDisplayName.value !== currentUser.displayName;
});
const profileInitial = computed(() => {
const name = user.value?.displayName.trim() || user.value?.email.trim() || '';
return name.charAt(0).toUpperCase();
});
async function loadProfile() {
loading.value = true;
message.value = '';
errorMessage.value = '';
try {
const response = await api.me();
user.value = response.user;
displayName.value = response.user.displayName;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
}
}
async function saveProfile() {
message.value = '';
errorMessage.value = '';
if (!trimmedDisplayName.value) {
errorMessage.value = t('pages.profile.displayNameRequired');
return;
}
busy.value = true;
try {
const response = await api.updateMe({ displayName: trimmedDisplayName.value });
user.value = response.user;
displayName.value = response.user.displayName;
message.value = t('pages.profile.saved');
notifyAuthChange();
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.saveFailed');
} finally {
busy.value = false;
}
}
onMounted(() => {
void loadProfile();
});
</script>
<template>
<section class="profile-page">
<PageHeader :title="t('pages.profile.title')" :subtitle="t('pages.profile.subtitle')">
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<div v-if="loading" class="profile-layout" aria-busy="true" :aria-label="t('pages.profile.loading')">
<section class="profile-card profile-card--identity" aria-hidden="true">
<div class="profile-identity">
<Skeleton variant="box" width="58px" height="58px" />
<div class="profile-identity__copy">
<Skeleton width="160px" height="28px" />
<Skeleton width="220px" />
</div>
</div>
</section>
<section class="profile-card" aria-hidden="true">
<Skeleton width="180px" height="28px" />
<div class="auth-form">
<div class="field">
<Skeleton width="110px" />
<Skeleton variant="box" height="44px" />
</div>
<div class="field">
<Skeleton width="70px" />
<Skeleton variant="box" height="44px" />
</div>
<Skeleton variant="box" width="120px" height="42px" />
</div>
</section>
</div>
<div v-else-if="user" class="profile-layout">
<section class="profile-card profile-card--identity" :aria-label="t('pages.profile.accountSummary')">
<div class="profile-identity">
<div class="profile-avatar" aria-hidden="true">{{ profileInitial }}</div>
<div class="profile-identity__copy">
<h2>{{ user.displayName }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
<StatusBadge
:label="user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
:tone="user.emailVerified ? 'success' : 'warning'"
/>
</section>
<section class="profile-card" :aria-label="t('pages.profile.profileDetails')">
<div class="profile-card__header">
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
<h2>{{ t('pages.profile.profileDetails') }}</h2>
</div>
<form class="auth-form" @submit.prevent="saveProfile">
<div class="field">
<label for="profile-display-name">{{ t('auth.displayName') }}</label>
<input
id="profile-display-name"
v-model="displayName"
autocomplete="nickname"
maxlength="40"
required
:disabled="busy"
/>
<small class="profile-field-note">{{ t('pages.profile.displayNameHint') }}</small>
</div>
<div class="field">
<label for="profile-email">{{ t('auth.email') }}</label>
<input id="profile-email" class="profile-readonly-input" :value="user.email" readonly type="email" />
</div>
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy || !hasChanges" type="submit">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</form>
</section>
</div>
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
</section>
</template>

View File

@@ -57,6 +57,7 @@ export const systemWordingMessages = {
openMenu: 'Open navigation', openMenu: 'Open navigation',
closeMenu: 'Close navigation', closeMenu: 'Close navigation',
language: 'Language', language: 'Language',
profile: 'Profile',
login: 'Log in', login: 'Log in',
logout: 'Log out', logout: 'Log out',
register: 'Register' register: 'Register'
@@ -108,6 +109,19 @@ export const systemWordingMessages = {
completeEmailVerification: 'Please complete email verification first.' completeEmailVerification: 'Please complete email verification first.'
}, },
pages: { pages: {
profile: {
title: 'User profile',
subtitle: 'Manage your account display name and email status.',
loading: 'Loading profile',
accountSummary: 'Account summary',
profileDetails: 'Profile details',
displayNameHint: 'Display name is shown on edits, discussions, and Life posts.',
displayNameRequired: 'Display name is required.',
emailVerified: 'Email verified',
emailUnverified: 'Email unverified',
saved: 'Profile saved',
saveFailed: 'Profile save failed'
},
pokemon: { pokemon: {
title: 'Pokemon', title: 'Pokemon',
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
@@ -669,6 +683,7 @@ export const systemWordingMessages = {
openMenu: '打开导航', openMenu: '打开导航',
closeMenu: '关闭导航', closeMenu: '关闭导航',
language: '语言', language: '语言',
profile: '个人资料',
login: '登录', login: '登录',
logout: '退出', logout: '退出',
register: '注册' register: '注册'
@@ -720,6 +735,19 @@ export const systemWordingMessages = {
completeEmailVerification: '请先完成邮箱验证' completeEmailVerification: '请先完成邮箱验证'
}, },
pages: { pages: {
profile: {
title: '个人资料',
subtitle: '管理账号显示名和邮箱状态。',
loading: '正在加载个人资料',
accountSummary: '账号概览',
profileDetails: '资料详情',
displayNameHint: '显示名会用于编辑署名、讨论和 Life 动态。',
displayNameRequired: '请输入显示名。',
emailVerified: '邮箱已验证',
emailUnverified: '邮箱未验证',
saved: '个人资料已保存',
saveFailed: '个人资料保存失败'
},
pokemon: { pokemon: {
title: 'Pokemon', title: 'Pokemon',
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',