fix(auth): migrate frontend to Better Auth

This commit is contained in:
2026-09-28 13:14:01 +08:00
parent 015e0dd227
commit 0fdd94317e
13 changed files with 456 additions and 86 deletions
+10 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NIcon, NSpace } from 'naive-ui'
import { NIcon, NSpace, useMessage } from 'naive-ui'
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
import { useAuthStore } from '@/stores/auth'
import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -8,6 +8,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const isMapRoute = computed(() => route.name === 'map')
const headerHidden = ref(false)
@@ -119,10 +120,14 @@ function closeAccountCard() {
}
async function handleLogout() {
await authStore.logout()
closeAccountCard()
if (route.meta.requiresAuth) {
router.push({ name: 'map' })
try {
await authStore.logout()
closeAccountCard()
if (route.meta.requiresAuth) {
void router.push({ name: 'map' })
}
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
}
}
</script>
+1 -4
View File
@@ -27,9 +27,6 @@ export class ApiError extends Error {
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
// Kept for callers that explicitly disable retries. Cookie sessions do not
// use a refresh-and-retry flow, so this option is intentionally ignored.
retry?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
@@ -105,7 +102,7 @@ async function toApiError(response: Response) {
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry: _retry, ...requestOptions } = options
const { body, auth = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
+33
View File
@@ -0,0 +1,33 @@
import { createAuthClient } from 'better-auth/client'
import { API_URL } from '@/lib/api'
export const authClient = createAuthClient({
baseURL: `${API_URL}/auth`,
fetchOptions: { credentials: 'include' },
})
interface AuthClientFailure {
code?: string
message?: string
status?: number
}
export class AuthClientError extends Error {
code?: string
status?: number
constructor(failure: AuthClientFailure, fallback: string) {
super(failure.code === 'EMAIL_NOT_VERIFIED'
? '邮箱尚未验证,请先查收验证邮件。'
: failure.status === 429
? '操作过于频繁,请稍后重试。'
: fallback)
this.name = 'AuthClientError'
this.code = failure.code
this.status = failure.status
}
}
export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) {
if (result.error) throw new AuthClientError(result.error, fallback)
}
+29 -45
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api'
import { assertAuthResult, authClient } from '@/lib/authClient'
import type { BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => {
@@ -37,62 +38,45 @@ export const useAuthStore = defineStore('auth', () => {
}
async function login(email: string, password: string) {
await apiRequest<LoginResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
})
assertAuthResult(await authClient.signIn.email({ email, password }), '登录失败,请检查邮箱和密码。')
const session = await authClient.getSession()
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
if (!session.data) throw new Error('未能建立登录会话,请稍后重试。')
await fetchMe()
}
async function register(name: string, email: string, password: string) {
return await apiRequest<RegisterResponse>('/auth/register', {
method: 'POST',
auth: false,
body: { name, email, password },
})
assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。')
}
async function resendConfirmation(email: string) {
return await apiRequest<ActionResponse>('/auth/resend-confirmation', {
method: 'POST',
auth: false,
body: { email },
})
assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。')
}
async function confirmEmail(token: string) {
return await apiRequest<ActionResponse>('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
})
assertAuthResult(await authClient.$fetch('/verify-email', {
method: 'GET',
query: { token },
}), '确认链接无效或已过期。')
}
async function sendPasswordReset(email: string) {
return await apiRequest<ActionResponse>('/auth/forgot-password', {
method: 'POST',
auth: false,
body: { email },
})
assertAuthResult(await authClient.requestPasswordReset({ email }), '重置邮件发送失败,请稍后重试。')
}
async function resetPassword(email: string, token: string, newPassword: string) {
const response = await apiRequest<ActionResponse>('/auth/reset-password', {
method: 'POST',
auth: false,
body: { email, token, new_password: newPassword },
})
async function resetPassword(token: string, newPassword: string) {
assertAuthResult(await authClient.resetPassword({ token, newPassword }), '密码重置失败,请重新申请重置邮件。')
clearAuth()
return response
}
async function logout() {
try {
await apiRequest('/auth/logout', { method: 'POST', retry: false })
} finally {
const result = await authClient.signOut()
if (result.error?.status === 401) {
clearAuth()
return
}
assertAuthResult(result, '退出登录失败,请稍后重试。')
clearAuth()
}
async function updateUsername(username: string) {
@@ -104,14 +88,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updatePassword(currentPassword: string, newPassword: string) {
await apiRequest('/auth/password', {
method: 'PATCH',
body: {
current_password: currentPassword,
new_password: newPassword,
},
})
clearAuth()
assertAuthResult(await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
}), '密码更新失败,请检查当前密码。')
}
async function initialize() {
@@ -121,7 +102,10 @@ export const useAuthStore = defineStore('auth', () => {
}
try {
await fetchMe()
const session = await authClient.getSession()
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
if (session.data) await fetchMe()
else clearAuth()
} catch {
clearAuth()
} finally {
-8
View File
@@ -48,14 +48,6 @@ export interface ActionResponse {
message: string
}
export interface RegisterResponse extends ActionResponse {
email_sent: boolean
}
export interface LoginResponse {
access_token: string
}
export interface CloudCreateResponse extends ActionResponse {
cloud_id: string
}
+2 -1
View File
@@ -646,8 +646,9 @@ async function handleLogout() {
accountMenuOpen.value = false
try {
await authStore.logout()
} finally {
await router.push('/')
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</p>
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 1 小时的重置链接。</p>
</div>
<NForm @submit.prevent="submit">
<NFormItem label="邮箱">
+5 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -12,7 +13,7 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
const password = ref('')
const error = ref('')
const loading = ref(false)
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证'))
const needsEmailConfirmation = ref(false)
function getSafeRedirect(value: unknown) {
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
@@ -29,11 +30,13 @@ function getSafeRedirect(value: unknown) {
async function handleLogin() {
error.value = ''
needsEmailConfirmation.value = false
loading.value = true
try {
await authStore.login(email.value, password.value)
void router.push(getSafeRedirect(route.query.redirect))
} catch (e: unknown) {
needsEmailConfirmation.value = e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
} finally {
loading.value = false
+7 -12
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { ApiError } from '@/lib/api'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -15,9 +15,7 @@ const confirmPassword = ref('')
const error = ref('')
const loading = ref(false)
const registered = ref(false)
const emailDelivered = ref(true)
const registerConflict = ref(false)
const canResend = computed(() => registerConflict.value)
async function handleRegister() {
error.value = ''
@@ -48,12 +46,11 @@ async function handleRegister() {
loading.value = true
try {
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
await authStore.register(trimmedName, trimmedEmail, password.value)
email.value = trimmedEmail
emailDelivered.value = response.email_sent
registered.value = true
} catch (e: unknown) {
if (e instanceof ApiError && e.status === 409) {
if (e instanceof AuthClientError && (e.status === 409 || e.code === 'USER_ALREADY_EXISTS')) {
registerConflict.value = true
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
} else {
@@ -93,11 +90,9 @@ async function handleRegister() {
<NCard class="oc-panel-card-xl min-w-0 h-full">
<NResult
v-if="registered"
:status="emailDelivered ? 'success' : 'warning'"
status="success"
title="确认你的邮箱"
:description="emailDelivered
? '确认邮件已经发送,请查收并点击链接完成注册。'
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
>
<template #footer>
<div class="space-y-4">
@@ -137,7 +132,7 @@ async function handleRegister() {
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
<div v-if="canResend" class="mt-2">
<div v-if="registerConflict" class="mt-2">
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
</div>
</NAlert>
+5 -4
View File
@@ -2,6 +2,7 @@
import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -11,8 +12,8 @@ const email = typeof route.query.email === 'string' ? route.query.email : ''
const token = typeof route.query.token === 'string' ? route.query.token : ''
const password = ref('')
const confirmPassword = ref('')
const error = ref(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
const invalid = ref(!email || !token)
const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
const invalid = ref(!token)
const success = ref(false)
const loading = ref(false)
const countdown = ref(5)
@@ -46,12 +47,12 @@ async function submit() {
}
loading.value = true
try {
await authStore.resetPassword(email, token, password.value)
await authStore.resetPassword(token, password.value)
success.value = true
startCountdown()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
if (error.value.includes('无效') || error.value.includes('过期')) invalid.value = true
if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN')) invalid.value = true
} finally {
loading.value = false
}
+3 -4
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const message = useMessage()
const usernameDraft = ref('')
@@ -75,8 +74,8 @@ async function savePassword() {
currentPassword.value = ''
newPassword.value = ''
confirmNewPassword.value = ''
message.success('密码已更新,请重新登录')
await router.push('/login')
success.value = '密码已更新。其他设备上的会话已退出。'
message.success('密码已更新')
} catch (e) {
error.value = e instanceof Error ? e.message : '密码更新失败'
} finally {