From 6710fe1754e6d05aa9ae191b39e966d0727427df Mon Sep 17 00:00:00 2001 From: purp1e Date: Wed, 5 Nov 2025 11:21:13 +0800 Subject: [PATCH] [try] auth --- docs/auth-integration.md | 147 +++++++++++++++ docs/web-integration-example.md | 271 +++++++++++++++++++++++++++ src-tauri/src/main.rs | 6 +- src/components/auth/AuthButton.tsx | 142 ++++++++++++++ src/components/auth/AuthProvider.tsx | 75 ++++++++ src/store/auth.ts | 76 ++++++++ src/utils/auth.ts | 92 +++++++++ 7 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 docs/auth-integration.md create mode 100644 docs/web-integration-example.md create mode 100644 src/components/auth/AuthButton.tsx create mode 100644 src/components/auth/AuthProvider.tsx create mode 100644 src/store/auth.ts create mode 100644 src/utils/auth.ts diff --git a/docs/auth-integration.md b/docs/auth-integration.md new file mode 100644 index 0000000..c3c4f18 --- /dev/null +++ b/docs/auth-integration.md @@ -0,0 +1,147 @@ +# 认证集成说明 + +## 概述 + +本应用已实现与网页端(https://cstb.upup.cool/)的邮箱注册登录功能集成。用户可以通过网页端登录/注册,然后通过 deep-link 回调到本地应用。 + +## 实现方案 + +### 1. 用户流程 + +1. 用户在本地应用中点击"登录"或"注册"按钮 +2. 应用打开浏览器,跳转到网页端登录/注册页面 +3. 用户在网页端完成登录/注册 +4. 网页端重定向到 `cstb://auth` deep-link,携带认证信息 +5. 本地应用接收 deep-link 回调,解析并设置 session +6. 用户状态同步到本地应用 + +### 2. 技术实现 + +#### 前端(Next.js + Tauri) + +- **认证 Store** (`src/store/auth.ts`): 管理用户状态和会话 +- **认证工具** (`src/utils/auth.ts`): 处理登录/注册跳转和回调解析 +- **认证 Provider** (`src/components/auth/AuthProvider.tsx`): 监听 deep-link 和认证状态变化 +- **认证按钮** (`src/components/auth/AuthButton.tsx`): UI 组件,显示登录状态和用户信息 + +#### Deep-link 配置 + +- Scheme: `cstb` +- 回调 URL 格式: `cstb://auth?access_token=xxx&refresh_token=xxx` 或 `cstb://auth?code=xxx` + +### 3. 网页端集成要求 + +网页端需要在登录/注册成功后,重定向到 deep-link URL。以下是几种实现方式: + +#### 方式 1: 使用 access_token 和 refresh_token(推荐) + +```typescript +// 在登录成功后 +const { data } = await supabase.auth.signInWithPassword({ email, password }) + +if (data.session) { + const redirectUrl = new URL('cstb://auth', window.location.href) + redirectUrl.searchParams.set('access_token', data.session.access_token) + redirectUrl.searchParams.set('refresh_token', data.session.refresh_token) + window.location.href = redirectUrl.toString() +} +``` + +#### 方式 2: 使用 PKCE flow(更安全) + +```typescript +// 在登录页面初始化时 +const { data } = await supabase.auth.signInWithOAuth({ + provider: 'email', + options: { + redirectTo: 'cstb://auth' + } +}) + +// 处理回调 +const { data: { session } } = await supabase.auth.getSession() +if (session) { + window.location.href = 'cstb://auth?code=' + session.access_token +} +``` + +#### 方式 3: 使用完整 session JSON + +```typescript +// 在登录成功后 +const { data } = await supabase.auth.signInWithPassword({ email, password }) + +if (data.session) { + const redirectUrl = new URL('cstb://auth', window.location.href) + redirectUrl.searchParams.set('session', encodeURIComponent(JSON.stringify(data.session))) + window.location.href = redirectUrl.toString() +} +``` + +### 4. 网页端修改示例 + +在 `https://cstb.upup.cool/auth/login` 和 `https://cstb.upup.cool/auth/signup` 页面中: + +```typescript +// 检查是否有 redirect 参数 +const urlParams = new URLSearchParams(window.location.search) +const redirectTo = urlParams.get('redirect') // 应该是 'cstb://auth' + +// 登录成功后 +const handleLogin = async (email: string, password: string) => { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + if (error) { + // 处理错误 + return + } + + if (data.session && redirectTo) { + // 重定向到 deep-link + const redirectUrl = new URL(redirectTo) + redirectUrl.searchParams.set('access_token', data.session.access_token) + redirectUrl.searchParams.set('refresh_token', data.session.refresh_token) + window.location.href = redirectUrl.toString() + } else { + // 正常网页端跳转 + window.location.href = '/' + } +} +``` + +### 5. 环境变量 + +确保以下环境变量已配置: + +```env +NEXT_PUBLIC_SUPABASE_URL=your_supabase_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +### 6. 测试 + +1. 启动应用 +2. 点击导航栏的用户图标 +3. 选择"登录"或"注册" +4. 在浏览器中完成登录/注册 +5. 应用应该自动接收回调并显示登录成功提示 + +## 注意事项 + +1. **安全性**: 虽然 access_token 和 refresh_token 通过 URL 传递,但由于 deep-link 是本地协议,相对安全。但建议使用 PKCE flow 以获得更好的安全性。 + +2. **错误处理**: 网页端应该处理登录失败的情况,不要重定向到 deep-link。 + +3. **用户体验**: 可以在网页端显示"正在跳转到应用..."的提示,提升用户体验。 + +4. **兼容性**: 确保 deep-link 在所有目标平台上都已正确注册(Windows/macOS/Linux)。 + +## 故障排查 + +1. **Deep-link 未触发**: 检查 `tauri.conf.json` 中 deep-link 配置是否正确 +2. **Session 未保存**: 检查 Supabase client 配置,确保使用 localStorage +3. **回调参数错误**: 检查网页端传递的参数格式是否正确 + diff --git a/docs/web-integration-example.md b/docs/web-integration-example.md new file mode 100644 index 0000000..0d91d47 --- /dev/null +++ b/docs/web-integration-example.md @@ -0,0 +1,271 @@ +# 网页端集成示例代码 + +本文档提供网页端(https://cstb.upup.cool/)实现登录/注册回调的示例代码。 + +## 登录页面示例 + +在 `auth/login` 页面中: + +```typescript +'use client' + +import { useState } from 'react' +import { createClient } from '@/utils/supabase/client' +import { useSearchParams } from 'next/navigation' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const searchParams = useSearchParams() + const redirectTo = searchParams.get('redirect') // 应该是 'cstb://auth' + + const supabase = createClient() + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + if (error) { + alert(`登录失败: ${error.message}`) + setLoading(false) + return + } + + if (data.session) { + // 如果有 redirect 参数,说明是从应用跳转过来的 + if (redirectTo) { + // 构建 deep-link URL + const redirectUrl = new URL(redirectTo) + redirectUrl.searchParams.set('access_token', data.session.access_token) + redirectUrl.searchParams.set('refresh_token', data.session.refresh_token) + + // 重定向到应用 + window.location.href = redirectUrl.toString() + } else { + // 正常网页端跳转 + window.location.href = '/' + } + } + } catch (error) { + console.error('Login error:', error) + alert('登录时发生错误') + setLoading(false) + } + } + + return ( +
+ setEmail(e.target.value)} + placeholder="邮箱" + required + /> + setPassword(e.target.value)} + placeholder="密码" + required + /> + +
+ ) +} +``` + +## 注册页面示例 + +在 `auth/signup` 页面中: + +```typescript +'use client' + +import { useState } from 'react' +import { createClient } from '@/utils/supabase/client' +import { useSearchParams } from 'next/navigation' + +export default function SignupPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const searchParams = useSearchParams() + const redirectTo = searchParams.get('redirect') // 应该是 'cstb://auth' + + const supabase = createClient() + + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault() + + if (password !== confirmPassword) { + alert('两次输入的密码不一致') + return + } + + setLoading(true) + + try { + const { data, error } = await supabase.auth.signUp({ + email, + password, + }) + + if (error) { + alert(`注册失败: ${error.message}`) + setLoading(false) + return + } + + if (data.session) { + // 如果有 redirect 参数,说明是从应用跳转过来的 + if (redirectTo) { + // 构建 deep-link URL + const redirectUrl = new URL(redirectTo) + redirectUrl.searchParams.set('access_token', data.session.access_token) + redirectUrl.searchParams.set('refresh_token', data.session.refresh_token) + + // 重定向到应用 + window.location.href = redirectUrl.toString() + } else { + // 正常网页端跳转 + alert('注册成功!请检查邮箱验证链接。') + window.location.href = '/' + } + } else { + // 需要邮箱验证 + alert('注册成功!请检查邮箱中的验证链接。') + if (!redirectTo) { + window.location.href = '/' + } + } + } catch (error) { + console.error('Signup error:', error) + alert('注册时发生错误') + setLoading(false) + } + } + + return ( +
+ setEmail(e.target.value)} + placeholder="邮箱" + required + /> + setPassword(e.target.value)} + placeholder="密码" + required + /> + setConfirmPassword(e.target.value)} + placeholder="确认密码" + required + /> + +
+ ) +} +``` + +## 使用 OAuth 提供商的示例 + +如果使用第三方登录(如 Google、GitHub 等): + +```typescript +const handleOAuthLogin = async (provider: 'google' | 'github') => { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: redirectTo || `${window.location.origin}/auth/callback`, + }, + }) + + if (error) { + alert(`登录失败: ${error.message}`) + return + } + + // OAuth 会重定向到回调页面,在回调页面中处理 +} +``` + +## 回调页面处理 + +如果需要处理 OAuth 回调: + +```typescript +// auth/callback/page.tsx +'use client' + +import { useEffect } from 'react' +import { createClient } from '@/utils/supabase/client' +import { useSearchParams } from 'next/navigation' + +export default function CallbackPage() { + const searchParams = useSearchParams() + const redirectTo = searchParams.get('redirect') // 从应用跳转时的原始 redirect + + useEffect(() => { + const handleCallback = async () => { + const supabase = createClient() + const { data: { session }, error } = await supabase.auth.getSession() + + if (error) { + console.error('Error getting session:', error) + return + } + + if (session) { + // 如果是从应用跳转过来的,重定向回应用 + if (redirectTo) { + const redirectUrl = new URL(redirectTo) + redirectUrl.searchParams.set('access_token', session.access_token) + redirectUrl.searchParams.set('refresh_token', session.refresh_token) + window.location.href = redirectUrl.toString() + } else { + // 正常网页端跳转 + window.location.href = '/' + } + } + } + + void handleCallback() + }, [redirectTo]) + + return
正在处理登录...
+} +``` + +## 注意事项 + +1. **安全性**: 确保只在 HTTPS 环境下使用,避免 token 泄露 +2. **错误处理**: 始终处理登录/注册失败的情况 +3. **用户体验**: 在重定向前显示加载状态 +4. **验证**: 如果启用了邮箱验证,需要处理未验证的情况 + +## 测试步骤 + +1. 在应用中使用 `https://cstb.upup.cool/auth/login?redirect=cstb://auth` 打开登录页面 +2. 完成登录后,应该自动跳转回应用 +3. 检查应用是否成功接收并设置了 session + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 69fafa8..8ad1e17 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -88,7 +88,7 @@ fn main() { // .expect("Unsupported platform! 'apply_blur' is only supported on Windows"); // Deep Link - #[cfg(all(desktop, not(target_os = "macos")))] + #[cfg(desktop)] app.deep_link().register("cstb")?; // Tray @@ -120,6 +120,7 @@ fn main() { cmds::greet, cmds::launch_game, cmds::kill_game, + cmds::check_process_running, cmds::kill_steam, cmds::get_steam_path, cmds::get_cs_path, @@ -134,6 +135,9 @@ fn main() { cmds::analyze_replay, cmds::get_console_log_path, cmds::read_vprof_report, + cmds::check_app_update, + cmds::download_app_update, + cmds::install_app_update, on_button_clicked ]) .run(tauri::generate_context!()) diff --git a/src/components/auth/AuthButton.tsx b/src/components/auth/AuthButton.tsx new file mode 100644 index 0000000..87bd357 --- /dev/null +++ b/src/components/auth/AuthButton.tsx @@ -0,0 +1,142 @@ +"use client" + +import { Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Avatar, Spinner } from "@heroui/react" +import { User, Logout, Login, AddUser } from "@icon-park/react" +import { useAuthStore } from "@/store/auth" +import { openLoginPage, openSignupPage } from "@/utils/auth" +import { useDisclosure } from "@heroui/react" +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react" + +export function AuthButton() { + const { state, signOut } = useAuthStore() + const { isOpen, onOpen, onOpenChange } = useDisclosure() + + const handleLogin = async () => { + await openLoginPage() + } + + const handleSignup = async () => { + await openSignupPage() + } + + const handleSignOut = async () => { + await signOut() + onOpenChange() + } + + if (state.isLoading) { + return ( + + ) + } + + if (state.isAuthenticated && state.user) { + return ( + <> + + + + + + } + textValue="用户信息" + > +
+ {state.user.email} + {state.user.user_metadata?.name && ( + {state.user.user_metadata.name} + )} +
+
+ } + color="danger" + onPress={onOpen} + > + 退出登录 + +
+
+ + + {(onClose) => ( + <> + 退出登录 + +

确认要退出登录吗?

+
+ + + + + + )} +
+
+ + ) + } + + return ( + + + + + + } + onPress={handleLogin} + > + 登录 + + } + onPress={handleSignup} + > + 注册 + + + + ) +} + diff --git a/src/components/auth/AuthProvider.tsx b/src/components/auth/AuthProvider.tsx new file mode 100644 index 0000000..80c91db --- /dev/null +++ b/src/components/auth/AuthProvider.tsx @@ -0,0 +1,75 @@ +"use client" + +import { onOpenUrl } from "@tauri-apps/plugin-deep-link" +import { useEffect } from "react" +import { useAuthStore } from "@/store/auth" +import { handleAuthCallback } from "@/utils/auth" +import { createClient } from "@/utils/supabase/client" +import { addToast } from "@heroui/react" + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const { checkSession, setSession, setLoading } = useAuthStore() + + // 初始化时检查现有会话 + useEffect(() => { + void checkSession() + }, [checkSession]) + + // 监听 deep-link 认证回调 + useEffect(() => { + const unlisten = onOpenUrl(async (urls) => { + if (urls.length === 0) return + + const url = urls[0] + if (!url.startsWith("cstb://auth")) return + + setLoading(true) + try { + const session = await handleAuthCallback(url) + if (session) { + setSession(session) + addToast({ + title: "登录成功", + description: `欢迎回来,${session.user.email || session.user.id}`, + }) + } else { + addToast({ + title: "登录失败", + description: "无法验证登录信息,请重试", + variant: "danger", + }) + } + } catch (error) { + console.error("Auth callback error:", error) + addToast({ + title: "登录失败", + description: error instanceof Error ? error.message : "未知错误", + variant: "danger", + }) + } finally { + setLoading(false) + } + }) + + return () => { + void unlisten.then((fn) => fn()) + } + }, [setSession, setLoading]) + + // 监听 Supabase 认证状态变化 + useEffect(() => { + const supabase = createClient() + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session) + }) + + return () => { + subscription.unsubscribe() + } + }, [setSession]) + + return <>{children} +} + diff --git a/src/store/auth.ts b/src/store/auth.ts new file mode 100644 index 0000000..0814800 --- /dev/null +++ b/src/store/auth.ts @@ -0,0 +1,76 @@ +import { store } from "@tauri-store/valtio" +import { useSnapshot } from "valtio" +import { DEFAULT_STORE_CONFIG } from "./config" +import { createClient } from "@/utils/supabase/client" +import type { User, Session } from "@supabase/supabase-js" + +interface AuthState { + user: User | null + session: Session | null + isLoading: boolean + isAuthenticated: boolean +} + +const defaultValue: AuthState = { + user: null, + session: null, + isLoading: true, + isAuthenticated: false, +} + +export const authStore = store("auth", { ...defaultValue }, DEFAULT_STORE_CONFIG) + +export const useAuthStore = () => { + void authStore.start + const state = useSnapshot(authStore.state) + + return { + state, + store: authStore, + setUser, + setSession, + setLoading, + signOut, + checkSession, + } +} + +const setUser = (user: User | null) => { + authStore.state.user = user + authStore.state.isAuthenticated = !!user +} + +const setSession = (session: Session | null) => { + authStore.state.session = session + authStore.state.user = session?.user ?? null + authStore.state.isAuthenticated = !!session +} + +const setLoading = (isLoading: boolean) => { + authStore.state.isLoading = isLoading +} + +const signOut = async () => { + const supabase = createClient() + await supabase.auth.signOut() + authStore.state.user = null + authStore.state.session = null + authStore.state.isAuthenticated = false +} + +const checkSession = async () => { + setLoading(true) + try { + const supabase = createClient() + const { + data: { session }, + } = await supabase.auth.getSession() + setSession(session) + } catch (error) { + console.error("Error checking session:", error) + setSession(null) + } finally { + setLoading(false) + } +} + diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..6ad2093 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,92 @@ +import { open } from "@tauri-apps/plugin-shell" +import { createClient } from "@/utils/supabase/client" +import type { Session } from "@supabase/supabase-js" + +/** + * 打开网页端登录页面 + */ +export async function openLoginPage() { + const loginUrl = "https://cstb.upup.cool/auth/login?redirect=cstb://auth" + await open(loginUrl) +} + +/** + * 打开网页端注册页面 + */ +export async function openSignupPage() { + const signupUrl = "https://cstb.upup.cool/auth/signup?redirect=cstb://auth" + await open(signupUrl) +} + +/** + * 处理 deep-link 回调中的认证信息 + * @param url - deep-link URL,格式: cstb://auth?access_token=xxx&refresh_token=xxx 或 cstb://auth?session=xxx + */ +export async function handleAuthCallback(url: string): Promise { + try { + const urlObj = new URL(url) + const params = new URLSearchParams(urlObj.search) + + // 方式1: 从 URL 参数中获取 access_token 和 refresh_token + const accessToken = params.get("access_token") + const refreshToken = params.get("refresh_token") + + if (accessToken && refreshToken) { + const supabase = createClient() + const { data, error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + console.error("Error setting session:", error) + return null + } + + return data.session + } + + // 方式2: 从 session 参数中获取(如果网页端返回的是完整 session) + const sessionParam = params.get("session") + if (sessionParam) { + try { + const session = JSON.parse(decodeURIComponent(sessionParam)) as Session + const supabase = createClient() + const { data, error } = await supabase.auth.setSession({ + access_token: session.access_token, + refresh_token: session.refresh_token, + }) + + if (error) { + console.error("Error setting session:", error) + return null + } + + return data.session + } catch (e) { + console.error("Error parsing session:", e) + return null + } + } + + // 方式3: 如果网页端通过 code 参数返回(PKCE flow) + const code = params.get("code") + if (code) { + const supabase = createClient() + const { data, error } = await supabase.auth.exchangeCodeForSession(code) + + if (error) { + console.error("Error exchanging code for session:", error) + return null + } + + return data.session + } + + return null + } catch (error) { + console.error("Error handling auth callback:", error) + return null + } +} +