feat(auth): implement user authentication and email verification

Add registration, login, and logout flows with session management
Integrate Resend for email verification tokens
Create frontend auth views and update topbar state
This commit is contained in:
2026-04-30 11:32:46 +08:00
parent 193b4e3fd5
commit 9af8c98401
13 changed files with 898 additions and 11 deletions

View File

@@ -1,21 +1,80 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser } from './services/api';
const navItems = [
{ label: 'Pokemon', to: '/pokemon' },
{ label: '栖息地', to: '/habitats' },
{ label: '物品 / 材料单', to: '/items' },
{ label: '管理', to: '/admin' }
];
const router = useRouter();
const currentUser = ref<AuthUser | null>(null);
let removeAuthListener: (() => void) | null = null;
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
}
}
async function logout() {
try {
await api.logout();
} catch {
// The local session is cleared even when the server session is already gone.
}
currentUser.value = null;
setAuthToken(null);
await router.push('/pokemon');
}
onMounted(() => {
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
removeAuthListener?.();
});
</script>
<template>
<div class="app-shell">
<header class="topbar">
<RouterLink class="brand" to="/pokemon">Pokopia Wiki</RouterLink>
<nav class="nav-tabs" aria-label="主导航">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
{{ item.label }}
</RouterLink>
</nav>
<div class="topbar-main">
<RouterLink class="brand" to="/pokemon">Pokopia Wiki</RouterLink>
<nav class="nav-tabs" aria-label="主导航">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
{{ item.label }}
</RouterLink>
</nav>
</div>
<div class="auth-actions">
<template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
<button class="plain-button" type="button" @click="logout">退出</button>
</template>
<template v-else>
<RouterLink to="/login">登录</RouterLink>
<RouterLink to="/register">注册</RouterLink>
</template>
</div>
</header>
<main class="page">

View File

@@ -7,6 +7,9 @@ import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
export const router = createRouter({
history: createWebHistory(),
@@ -19,7 +22,10 @@ export const router = createRouter({
{ path: '/items', component: ItemsList },
{ path: '/items/:id', component: ItemDetail },
{ path: '/recipes/:id', component: RecipeDetail },
{ path: '/admin', component: AdminView }
{ path: '/admin', component: AdminView },
{ path: '/login', component: LoginView },
{ path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView }
],
scrollBehavior: () => ({ top: 0 })
});

View File

@@ -1,4 +1,6 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export interface NamedEntity {
id: number;
@@ -85,6 +87,27 @@ export interface Options {
maps: NamedEntity[];
}
export interface AuthUser {
id: number;
email: string;
displayName: string;
emailVerified: boolean;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload extends LoginPayload {
displayName: string;
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
export type ConfigType =
| 'skills'
| 'environments'
@@ -144,6 +167,38 @@ export function buildQuery(params: Record<string, string | number | undefined>):
return query ? `?${query}` : '';
}
export function getAuthToken(): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(authTokenKey);
}
export function setAuthToken(token: string | null): void {
if (typeof localStorage === 'undefined') {
return;
}
if (token) {
localStorage.setItem(authTokenKey, token);
} else {
localStorage.removeItem(authTokenKey);
}
window.dispatchEvent(new Event(authChangeEvent));
}
export function onAuthTokenChange(callback: () => void): () => void {
window.addEventListener(authChangeEvent, callback);
return () => window.removeEventListener(authChangeEvent, callback);
}
function authHeaders(): HeadersInit {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function getErrorMessage(response: Response): Promise<string> {
try {
const data = (await response.json()) as { message?: unknown };
@@ -158,7 +213,9 @@ async function getErrorMessage(response: Response): Promise<string> {
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`);
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
@@ -171,7 +228,8 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
const response = await fetch(`${apiBaseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(body)
});
@@ -183,9 +241,21 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
return response.json() as Promise<T>;
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: authHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
}
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
@@ -194,6 +264,12 @@ async function deleteJson(path: string): Promise<void> {
}
export const api = {
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'),
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; subcategory?: string | null }) =>

View File

@@ -45,7 +45,15 @@ select {
backdrop-filter: blur(12px);
}
.topbar-main {
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
}
.brand {
flex: 0 0 auto;
font-size: 20px;
font-weight: 800;
color: #1d3b2b;
@@ -70,6 +78,38 @@ select {
color: #ffffff;
}
.auth-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.auth-actions a,
.auth-actions .plain-button {
min-height: 36px;
padding: 7px 12px;
border: 1px solid #c7c0b2;
border-radius: 8px;
background: #fffdfa;
color: #4e5c52;
font-weight: 800;
}
.auth-actions a.router-link-active {
border-color: #1f6f50;
color: #1f5c40;
}
.auth-user {
max-width: 180px;
overflow: hidden;
color: #566156;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.page {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
@@ -455,6 +495,21 @@ select {
font-weight: 800;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 40px;
padding: 8px 14px;
border: 0;
border-radius: 8px;
background: #1f6f50;
color: #ffffff;
font-weight: 800;
cursor: pointer;
}
.status {
padding: 18px;
border: 1px solid #d7d2c4;
@@ -486,6 +541,57 @@ select {
font-weight: 800;
}
.auth-page {
display: grid;
justify-items: center;
padding: 24px 0;
}
.auth-panel {
display: grid;
gap: 18px;
width: min(460px, 100%);
padding: 22px;
border: 1px solid #d7d2c4;
border-radius: 8px;
background: #ffffff;
}
.auth-panel .page-header {
margin-bottom: 0;
}
.auth-form {
display: grid;
gap: 14px;
}
.auth-message {
margin: 0;
padding: 10px 12px;
border: 1px solid #b9d8bd;
border-radius: 8px;
background: #edf7ef;
color: #1f5c40;
font-weight: 800;
}
.auth-message.error {
border-color: #e0b0ac;
background: #fff0ee;
color: #a83f39;
}
.auth-switch {
margin: 0;
color: #657067;
}
.auth-switch a {
color: #1f6f50;
font-weight: 800;
}
.admin-layout {
display: grid;
grid-template-columns: minmax(320px, 420px) 1fr;
@@ -569,6 +675,18 @@ button:disabled {
flex-direction: column;
}
.topbar-main {
align-items: start;
flex-direction: column;
gap: 12px;
width: 100%;
}
.auth-actions {
flex-wrap: wrap;
width: 100%;
}
.page-header {
align-items: start;
flex-direction: column;

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, setAuthToken } from '../services/api';
const route = useRoute();
const router = useRouter();
const email = ref('');
const password = ref('');
const busy = ref(false);
const errorMessage = ref('');
async function submitLogin() {
busy.value = true;
errorMessage.value = '';
try {
const response = await api.login({
email: email.value,
password: password.value
});
setAuthToken(response.token);
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/pokemon';
await router.push(redirect);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '登录失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">登录</h1>
<p class="page-subtitle">使用已验证邮箱进入 Pokopia Wiki</p>
</div>
</div>
<form class="auth-form" @submit.prevent="submitLogin">
<div class="field">
<label for="login-email">邮箱</label>
<input id="login-email" v-model="email" autocomplete="email" required type="email" />
</div>
<div class="field">
<label for="login-password">密码</label>
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
</div>
<p v-if="errorMessage" class="auth-message error">{{ errorMessage }}</p>
<button class="primary-button" :disabled="busy" type="submit">
{{ busy ? '登录中' : '登录' }}
</button>
</form>
<p class="auth-switch">
还没有账号
<RouterLink to="/register">注册</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref } from 'vue';
import { api } from '../services/api';
const email = ref('');
const displayName = ref('');
const password = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
async function submitRegister() {
busy.value = true;
message.value = '';
errorMessage.value = '';
try {
const response = await api.register({
email: email.value,
displayName: displayName.value,
password: password.value
});
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '注册失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">注册</h1>
<p class="page-subtitle">创建账号后需要完成邮箱验证</p>
</div>
</div>
<form class="auth-form" @submit.prevent="submitRegister">
<div class="field">
<label for="register-email">邮箱</label>
<input id="register-email" v-model="email" autocomplete="email" required type="email" />
</div>
<div class="field">
<label for="register-display-name">显示名</label>
<input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required />
</div>
<div class="field">
<label for="register-password">密码</label>
<input
id="register-password"
v-model="password"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<p v-if="message" class="auth-message">{{ message }}</p>
<p v-if="errorMessage" class="auth-message error">{{ errorMessage }}</p>
<button class="primary-button" :disabled="busy" type="submit">
{{ busy ? '发送中' : '发送验证邮件' }}
</button>
</form>
<p class="auth-switch">
已有账号
<RouterLink to="/login">登录</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../services/api';
const route = useRoute();
const busy = ref(true);
const message = ref('');
const errorMessage = ref('');
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) {
busy.value = false;
errorMessage.value = '验证链接无效或已过期';
return;
}
try {
const response = await api.verifyEmail(token);
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '邮箱验证失败';
} finally {
busy.value = false;
}
});
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<div class="page-header">
<div>
<h1 class="page-title">邮箱验证</h1>
<p class="page-subtitle">完成验证后即可登录</p>
</div>
</div>
<p v-if="busy" class="auth-message">正在验证邮箱</p>
<p v-else-if="message" class="auth-message">{{ message }}</p>
<p v-else class="auth-message error">{{ errorMessage }}</p>
<RouterLink class="primary-button" to="/login">去登录</RouterLink>
</div>
</section>
</template>