[try] auth

This commit is contained in:
2025-11-05 11:21:13 +08:00
parent 41008cf13c
commit 6710fe1754
7 changed files with 808 additions and 1 deletions

View 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>
)
}

View 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
View 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
View 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
}
}