From b6c3653b64fee3ed47cb30ed9e2e2851a717e335 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 31 May 2026 15:51:11 +0800 Subject: [PATCH] fix: redesign password recovery flow --- src/lib/authEmail.ts | 152 ++++++++++++++++++++++ src/router/index.ts | 6 + src/stores/auth.ts | 7 +- src/views/auth/AuthConfirmView.vue | 58 ++------- src/views/auth/ForgotPasswordView.vue | 174 ++++++++++++++++++++++++++ src/views/auth/LoginView.vue | 55 ++------ src/views/auth/ResetPasswordView.vue | 71 ++++------- 7 files changed, 380 insertions(+), 143 deletions(-) create mode 100644 src/lib/authEmail.ts create mode 100644 src/views/auth/ForgotPasswordView.vue diff --git a/src/lib/authEmail.ts b/src/lib/authEmail.ts new file mode 100644 index 0000000..bbf3d6d --- /dev/null +++ b/src/lib/authEmail.ts @@ -0,0 +1,152 @@ +import { + createClient, + type EmailOtpType, + type Session, + type SupabaseClient, +} from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY + +const knownEmailOtpTypes = new Set([ + 'signup', + 'invite', + 'magiclink', + 'recovery', + 'email_change', + 'email', +]) + +type AuthCallbackParams = { + accessToken: string | null + code: string | null + error: string | null + errorCode: string | null + errorDescription: string | null + refreshToken: string | null + tokenHash: string | null + type: EmailOtpType | null +} + +type ResolveEmailAuthCallbackOptions = { + allowedTypes: EmailOtpType[] + client: SupabaseClient +} + +type ResolveEmailAuthCallbackResult = + | { + ok: true + session: Session | null + type: EmailOtpType | null + } + | { + ok: false + error: string + } + +function parseEmailOtpType(value: string | null): EmailOtpType | null { + if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) { + return null + } + + return value as EmailOtpType +} + +export function readAuthCallbackParams(): AuthCallbackParams { + const search = new URLSearchParams(window.location.search) + const hash = new URLSearchParams(window.location.hash.slice(1)) + + return { + code: search.get('code'), + tokenHash: search.get('token_hash') ?? hash.get('token_hash'), + accessToken: hash.get('access_token'), + refreshToken: hash.get('refresh_token'), + type: parseEmailOtpType(search.get('type') ?? hash.get('type')), + error: search.get('error') ?? hash.get('error'), + errorCode: search.get('error_code') ?? hash.get('error_code'), + errorDescription: search.get('error_description') ?? hash.get('error_description'), + } +} + +export function clearAuthCallbackUrl() { + window.history.replaceState(null, '', window.location.pathname) +} + +export function createDetachedAuthClient() { + return createClient(supabaseUrl, supabaseKey, { + auth: { + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false, + }, + }) +} + +export async function resolveEmailAuthCallback( + options: ResolveEmailAuthCallbackOptions, +): Promise { + const { client, allowedTypes } = options + const params = readAuthCallbackParams() + const allowedTypeSet = new Set(allowedTypes) + + if (params.error || params.errorCode || params.errorDescription) { + return { + ok: false, + error: params.errorDescription || '链接无效或已过期。', + } + } + + try { + let session: Session | null = null + + if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) { + const { data, error } = await client.auth.verifyOtp({ + token_hash: params.tokenHash, + type: params.type, + }) + if (error) throw error + session = data.session + } else if (params.code) { + const { data, error } = await client.auth.exchangeCodeForSession(params.code) + if (error) throw error + session = data.session + } else if ( + params.accessToken + && params.refreshToken + && params.type + && allowedTypeSet.has(params.type) + ) { + const { data, error } = await client.auth.setSession({ + access_token: params.accessToken, + refresh_token: params.refreshToken, + }) + if (error) throw error + session = data.session + } else { + return { + ok: false, + error: '链接无效或已过期。', + } + } + + if (!session) { + const { data, error } = await client.auth.getSession() + if (error) throw error + session = data.session + } + + clearAuthCallbackUrl() + + return { + ok: true, + session, + type: params.type, + } + } catch (error) { + const message = error instanceof Error ? error.message : '链接无效或已过期。' + return { + ok: false, + error: message, + } + } +} diff --git a/src/router/index.ts b/src/router/index.ts index c5d3601..1fc6435 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -27,6 +27,12 @@ const router = createRouter({ component: () => import('@/views/auth/RegisterView.vue'), meta: { title: '注册', noindex: true }, }, + { + path: '/forgot-password', + name: 'forgot-password', + component: () => import('@/views/auth/ForgotPasswordView.vue'), + meta: { title: '忘记密码', noindex: true }, + }, { path: '/auth/confirm', name: 'auth-confirm', diff --git a/src/stores/auth.ts b/src/stores/auth.ts index cc0fcab..fa6b96f 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -118,7 +118,12 @@ export const useAuthStore = defineStore('auth', () => { const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/auth/reset-password`, }) - if (error) throw error + if (error) { + if (error.message.includes('rate limit')) { + throw new Error('发送过于频繁,请稍后再试。') + } + throw error + } } async function initialize() { diff --git a/src/views/auth/AuthConfirmView.vue b/src/views/auth/AuthConfirmView.vue index f6fac75..911b892 100644 --- a/src/views/auth/AuthConfirmView.vue +++ b/src/views/auth/AuthConfirmView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { NButton, NCard, NResult, NSpin } from 'naive-ui' -import { createClient } from '@supabase/supabase-js' +import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail' const router = useRouter() @@ -22,58 +22,20 @@ function startCountdown() { onMounted(async () => { try { - const search = new URLSearchParams(window.location.search) - const hash = window.location.hash.slice(1) - const params = new URLSearchParams(hash) - const code = search.get('code') - const accessToken = params.get('access_token') - const refreshToken = params.get('refresh_token') - const type = params.get('type') - const error = search.get('error') ?? params.get('error') + const confirmClient = createDetachedAuthClient() + const result = await resolveEmailAuthCallback({ + client: confirmClient, + allowedTypes: ['signup', 'email'], + }) - if (error) { + if (!result.ok) { state.value = 'failed' return } - if (code || (type === 'signup' && accessToken && refreshToken)) { - const confirmClient = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY, - { - auth: { - detectSessionInUrl: false, - }, - }, - ) - - if (code) { - const { error: exchangeError } = await confirmClient.auth.exchangeCodeForSession(code) - if (exchangeError) { - state.value = 'failed' - return - } - } else { - const { error: setSessionError } = await confirmClient.auth.setSession({ - access_token: accessToken!, - refresh_token: refreshToken!, - }) - - if (setSessionError) { - state.value = 'failed' - return - } - } - - await confirmClient.auth.signOut() - - window.history.replaceState(null, '', window.location.pathname) - state.value = 'success' - startCountdown() - return - } - - state.value = 'failed' + await confirmClient.auth.signOut() + state.value = 'success' + startCountdown() } catch { state.value = 'failed' } diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue new file mode 100644 index 0000000..d4ad0ec --- /dev/null +++ b/src/views/auth/ForgotPasswordView.vue @@ -0,0 +1,174 @@ + + + diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index 9d0ca8c..5c34dca 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -8,13 +8,10 @@ const authStore = useAuthStore() const router = useRouter() const route = useRoute() -const email = ref('') +const email = ref(typeof route.query.email === 'string' ? route.query.email : '') const password = ref('') const error = ref('') -const resetMessage = ref('') -const resetMode = ref(false) const loading = ref(false) -const resetLoading = ref(false) async function handleLogin() { error.value = '' @@ -29,26 +26,6 @@ async function handleLogin() { loading.value = false } } - -async function handleSendResetEmail() { - error.value = '' - resetMessage.value = '' - - if (!email.value) { - error.value = '请输入需要重置密码的邮箱。' - return - } - - resetLoading.value = true - try { - await authStore.sendPasswordReset(email.value) - resetMessage.value = '重置密码邮件已发送,请查收邮箱。' - } catch (e: unknown) { - error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试' - } finally { - resetLoading.value = false - } -}