fix(auth): migrate frontend to Better Auth
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 : '退出登录失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="邮箱">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user