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

@@ -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(() => {
</div>
</div>
<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">
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
{{ 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 iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
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 iconRegister: AppIcon = 'mdi:account-plus-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 ForgotPasswordView from '../views/ForgotPasswordView.vue';
import LoginView from '../views/LoginView.vue';
import UserProfileView from '../views/UserProfileView.vue';
import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
@@ -46,6 +47,7 @@ export const router = createRouter({
{ path: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
{ path: '/login', component: LoginView },
{ path: '/forgot-password', component: ForgotPasswordView },
{ path: '/reset-password', component: ResetPasswordView },
@@ -60,7 +62,10 @@ export const router = createRouter({
});
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;
}
@@ -70,7 +75,7 @@ router.beforeEach(async (to) => {
try {
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 {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };

View File

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

View File

@@ -364,11 +364,43 @@ svg {
}
.auth-user {
min-height: 44px;
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
overflow: hidden;
padding: 8px 10px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
font-size: 14px;
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;
white-space: nowrap;
}
@@ -3830,6 +3862,103 @@ button:disabled,
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 {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
@@ -4153,6 +4282,7 @@ button:disabled,
.pokemon-profile-grid,
.pokemon-profile-row,
.pokemon-related-grid,
.profile-layout,
.system-wording-layout,
.admin-layout {
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>