[try] auth
This commit is contained in:
147
docs/auth-integration.md
Normal file
147
docs/auth-integration.md
Normal file
@@ -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. **回调参数错误**: 检查网页端传递的参数格式是否正确
|
||||
|
||||
271
docs/web-integration-example.md
Normal file
271
docs/web-integration-example.md
Normal file
@@ -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 (
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="邮箱"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 注册页面示例
|
||||
|
||||
在 `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 (
|
||||
<form onSubmit={handleSignup}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="邮箱"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="确认密码"
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 使用 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 <div>正在处理登录...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**: 确保只在 HTTPS 环境下使用,避免 token 泄露
|
||||
2. **错误处理**: 始终处理登录/注册失败的情况
|
||||
3. **用户体验**: 在重定向前显示加载状态
|
||||
4. **验证**: 如果启用了邮箱验证,需要处理未验证的情况
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. 在应用中使用 `https://cstb.upup.cool/auth/login?redirect=cstb://auth` 打开登录页面
|
||||
2. 完成登录后,应该自动跳转回应用
|
||||
3. 检查应用是否成功接收并设置了 session
|
||||
|
||||
@@ -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!())
|
||||
|
||||
142
src/components/auth/AuthButton.tsx
Normal file
142
src/components/auth/AuthButton.tsx
Normal file
@@ -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 (
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isAuthenticated && state.user) {
|
||||
return (
|
||||
<>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
>
|
||||
<Avatar
|
||||
src={state.user.user_metadata?.avatar_url}
|
||||
name={state.user.email || state.user.id}
|
||||
size="sm"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="用户菜单">
|
||||
<DropdownItem
|
||||
key="profile"
|
||||
startContent={<User size={16} />}
|
||||
textValue="用户信息"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{state.user.email}</span>
|
||||
{state.user.user_metadata?.name && (
|
||||
<span className="text-xs text-zinc-500">{state.user.user_metadata.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="logout"
|
||||
startContent={<Logout size={16} />}
|
||||
color="danger"
|
||||
onPress={onOpen}
|
||||
>
|
||||
退出登录
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">退出登录</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>确认要退出登录吗?</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="default" variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
handleSignOut()
|
||||
}}
|
||||
>
|
||||
确认退出
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="px-2 py-0 rounded transition duration-150 hover:bg-black/10 dark:hover:bg-zinc-100/10 active:scale-95"
|
||||
>
|
||||
<User size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="登录菜单">
|
||||
<DropdownItem
|
||||
key="login"
|
||||
startContent={<Login size={16} />}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
登录
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="signup"
|
||||
startContent={<AddUser size={16} />}
|
||||
onPress={handleSignup}
|
||||
>
|
||||
注册
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
75
src/components/auth/AuthProvider.tsx
Normal file
75
src/components/auth/AuthProvider.tsx
Normal file
@@ -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}</>
|
||||
}
|
||||
|
||||
76
src/store/auth.ts
Normal file
76
src/store/auth.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
92
src/utils/auth.ts
Normal file
92
src/utils/auth.ts
Normal file
@@ -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<Session | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user