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 @@
+
+
+
+
+
+ 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 5b47139..4580f6c 100644
--- a/src/views/auth/ResetPasswordView.vue
+++ b/src/views/auth/ResetPasswordView.vue
@@ -1,27 +1,72 @@
@@ -74,21 +120,38 @@ async function handleResetPassword() {
- 返回登录
+
+
{{ countdown }} 秒后自动跳转地图页面...
+
进入地图
+
+
+
+
+
Password Recovery
设置新密码
-
请输入新的登录密码,提交后当前重置链接会失效。
+
+ {{
+ state === 'invalid'
+ ? '当前重置链接不可用,请重新发送密码重置邮件。'
+ : '请输入新的登录密码,提交后当前重置链接会失效。'
+ }}
+
@@ -118,6 +181,14 @@ async function handleResetPassword() {
{{ error }}
+
+ 返回重新发送重置邮件
+
+
()
+const authStore = useAuthStore()
const previewCloud = ref(null)
const satelliteOn = ref(false)
const statusText = ref('加载中...')
@@ -70,7 +72,6 @@ const archiveTitle = computed(() => {
if (mapMode.value === 'realtime') return '实时'
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
})
-
function getMinuteOfDay(date: Date) {
return date.getHours() * 60 + date.getMinutes()
}
@@ -344,6 +345,7 @@ function toggleSat() {
}
function openQuickUpload() {
+ if (authStore.loading || !authStore.isLoggedIn) return
quickUploadOpen.value = true
}
@@ -446,9 +448,11 @@ onUnmounted(() => {