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 @@
+
+
+
+
+
+
+ Password Recovery
+
+ 重新拿回你的
+ OpenCloud 账号
+
+
+ 输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
+
+
+
+
Step 1
+
发送邮件
+
使用注册邮箱请求重置链接
+
+
+
Step 2
+
设置新密码
+
在专用页面完成新密码更新
+
+
+
+
+
+
+
+
+
+ 目标邮箱:
+ {{ email }}
+
+
+
+ {{ resendButtonLabel }}
+
+
+ 返回登录
+
+
+
+
+
+
+
+
+
Reset Request
+
忘记密码
+
输入你的注册邮箱,我们会发送一封密码重置邮件。
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+ {{ loading ? '发送中...' : '发送重置邮件' }}
+
+
+
+
+
如果你已经记起密码,可以直接返回登录。
+
+ 返回登录
+
+
+
+
+
+
+
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
- }
-}
@@ -84,7 +61,7 @@ async function handleSendResetEmail() {
输入邮箱和密码,继续你的天空档案。
-
+
-
+
-
- 输入注册邮箱,我们会发送一封密码重置邮件。点击邮件链接后即可设置新密码。
-
-
{{ error }}
-
- {{ resetMessage }}
-
-
-
- {{ resetLoading ? '发送中...' : '发送重置邮件' }}
-
-
- {{ loading ? '登录中...' : '登录' }}
-
+ {{ loading ? '登录中...' : '登录' }}
@@ -139,13 +103,12 @@ async function handleSendResetEmail() {
没有账号?
去注册
-
+ 忘记密码?
+
diff --git a/src/views/auth/ResetPasswordView.vue b/src/views/auth/ResetPasswordView.vue
index ba2669e..82c6303 100644
--- a/src/views/auth/ResetPasswordView.vue
+++ b/src/views/auth/ResetPasswordView.vue
@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
+import { resolveEmailAuthCallback } from '@/lib/authEmail'
import { supabase } from '@/lib/supabase'
const router = useRouter()
@@ -23,20 +24,6 @@ function showInvalidRecoveryLink() {
state.value = 'invalid'
}
-function getRecoveryParams() {
- const search = new URLSearchParams(window.location.search)
- const hash = new URLSearchParams(window.location.hash.slice(1))
-
- return {
- code: search.get('code'),
- accessToken: hash.get('access_token'),
- refreshToken: hash.get('refresh_token'),
- type: hash.get('type'),
- error: search.get('error') ?? hash.get('error'),
- errorDescription: search.get('error_description') ?? hash.get('error_description'),
- }
-}
-
function getResetErrorMessage(value: unknown) {
const message = value instanceof Error ? value.message : ''
if (
@@ -44,10 +31,16 @@ function getResetErrorMessage(value: unknown) {
|| message.includes('session_not_found')
|| message.includes('refresh_token_not_found')
|| message.includes('Invalid Refresh Token')
+ || message.includes('otp_expired')
+ || message.includes('expired')
) {
return '密码重置链接无效或已过期,请重新发送邮件。'
}
+ if (message.includes('Password should be at least')) {
+ return '新密码至少需要 6 位。'
+ }
+
return message || '密码重置失败,请稍后重试。'
}
@@ -62,44 +55,18 @@ function startCountdown() {
}
async function initializeRecoverySession() {
- const {
- code,
- accessToken,
- refreshToken,
- type,
- error: recoveryError,
- errorDescription,
- } = getRecoveryParams()
+ const result = await resolveEmailAuthCallback({
+ client: supabase,
+ allowedTypes: ['recovery'],
+ })
- if (recoveryError || errorDescription) {
- showInvalidRecoveryLink()
+ if (!result.ok || !result.session?.user) {
+ error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
+ state.value = 'invalid'
return
}
- try {
- if (code) {
- const { error } = await supabase.auth.exchangeCodeForSession(code)
- if (error) throw error
- } else if (type === 'recovery' && accessToken && refreshToken) {
- const { error } = await supabase.auth.setSession({
- access_token: accessToken,
- refresh_token: refreshToken,
- })
- if (error) throw error
- }
-
- const { data: { session }, error } = await supabase.auth.getSession()
- if (error || !session?.user) {
- showInvalidRecoveryLink()
- return
- }
-
- window.history.replaceState(null, '', window.location.pathname)
- state.value = 'ready'
- } catch (e) {
- error.value = getResetErrorMessage(e)
- state.value = 'invalid'
- }
+ state.value = 'ready'
}
async function handleResetPassword() {
@@ -215,6 +182,14 @@ onUnmounted(() => {
{{ error }}
+
+ 返回重新发送重置邮件
+
+