From 217b81c50602bf6d1d2f16fb1061f2a25305b807 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 31 May 2026 17:00:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AF=86=E7=A0=81=E6=89=BE?= =?UTF-8?q?=E5=9B=9E=E6=B5=81=E7=A8=8B=E3=80=81=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C=E5=92=8C=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BD=93=E9=AA=8C=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.catpl.top/Mplan/opencloud/pulls/2 --- package-lock.json | 39 ++++++ package.json | 1 + src/App.vue | 2 + src/lib/authEmail.ts | 152 +++++++++++++++++++++ src/lib/supabase.ts | 7 +- src/router/index.ts | 19 ++- src/stores/auth.ts | 7 +- src/views/admin/AdminView.vue | 183 ++++++++++++++++++++++---- src/views/auth/AuthConfirmView.vue | 43 ++---- src/views/auth/ForgotPasswordView.vue | 174 ++++++++++++++++++++++++ src/views/auth/LoginView.vue | 55 ++------ src/views/auth/ResetPasswordView.vue | 139 ++++++++++++++----- src/views/map/MapView.vue | 10 +- src/views/system/AuthRequiredView.vue | 50 +++++++ src/views/upload/UploadView.vue | 13 +- 15 files changed, 736 insertions(+), 158 deletions(-) create mode 100644 src/lib/authEmail.ts create mode 100644 src/views/auth/ForgotPasswordView.vue create mode 100644 src/views/system/AuthRequiredView.vue diff --git a/package-lock.json b/package-lock.json index 0cfe534..e40a8ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@supabase/supabase-js": "^2.106.1", + "@vercel/speed-insights": "^2.0.0", "naive-ui": "^2.44.1", "pinia": "^3.0.4", "vue": "^3.5.34", @@ -952,6 +953,44 @@ } } }, + "node_modules/@vercel/speed-insights": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@vercel/speed-insights/-/speed-insights-2.0.0.tgz", + "integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==", + "license": "Apache-2.0", + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/@vicons/tabler": { "version": "0.13.0", "resolved": "https://registry.npmmirror.com/@vicons/tabler/-/tabler-0.13.0.tgz", diff --git a/package.json b/package.json index a40bcc4..65b0d22 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@supabase/supabase-js": "^2.106.1", + "@vercel/speed-insights": "^2.0.0", "naive-ui": "^2.44.1", "pinia": "^3.0.4", "vue": "^3.5.34", diff --git a/src/App.vue b/src/App.vue index 4029c61..2503693 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider, type GlobalThemeOverrides } from 'naive-ui' import AppHeader from '@/components/layout/AppHeader.vue' import { Analytics } from "@vercel/analytics/vue" +import { SpeedInsights } from "@vercel/speed-insights/vue" const themeOverrides: GlobalThemeOverrides = { common: { @@ -89,4 +90,5 @@ const themeOverrides: GlobalThemeOverrides = { + 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/lib/supabase.ts b/src/lib/supabase.ts index 11bd985..95820f9 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -7,4 +7,9 @@ if (!supabaseUrl || !supabaseKey) { throw new Error('Missing Supabase environment variables') } -export const supabase = createClient(supabaseUrl, supabaseKey) +export const supabase = createClient(supabaseUrl, supabaseKey, { + auth: { + // Email confirmation and password recovery are handled by route components. + detectSessionInUrl: false, + }, +}) diff --git a/src/router/index.ts b/src/router/index.ts index c5d3601..da44c1c 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', @@ -114,6 +120,12 @@ const router = createRouter({ component: () => import('@/views/system/ForbiddenView.vue'), meta: { title: '无权访问', noindex: true }, }, + { + path: '/401', + name: 'auth-required', + component: () => import('@/views/system/AuthRequiredView.vue'), + meta: { title: '需要登录', noindex: true }, + }, { path: '/:pathMatch(.*)*', name: 'not-found', @@ -130,7 +142,12 @@ router.beforeEach((to, _from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isLoggedIn) { - next({ name: 'login', query: { redirect: to.fullPath } }) + next({ + name: 'auth-required', + query: { + redirect: to.fullPath, + }, + }) } else if (to.meta.requiresAdmin && !authStore.isAdmin) { next({ name: 'forbidden' }) } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { 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/admin/AdminView.vue b/src/views/admin/AdminView.vue index 63d2426..0e4c1df 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -1,5 +1,5 @@ + + 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 - } -}