后端迁移到fastapi (#3)
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
import type { AuthResponse } from '@/types/api'
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
|
||||
|
||||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||||
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
|
||||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshPromise: Promise<AuthResponse> | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
detail: unknown
|
||||
|
||||
constructor(message: string, status: number, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
auth?: boolean
|
||||
retry?: boolean
|
||||
}
|
||||
|
||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||
return !!body
|
||||
&& typeof body === 'object'
|
||||
&& !(body instanceof FormData)
|
||||
&& !(body instanceof Blob)
|
||||
&& !(body instanceof URLSearchParams)
|
||||
&& !(body instanceof ArrayBuffer)
|
||||
}
|
||||
|
||||
function detailToMessage(detail: unknown, fallback: string) {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function toApiError(response: Response) {
|
||||
let detail: unknown
|
||||
try {
|
||||
const payload = await response.json() as { detail?: unknown }
|
||||
detail = payload.detail
|
||||
} catch {
|
||||
detail = undefined
|
||||
}
|
||||
|
||||
return new ApiError(
|
||||
detailToMessage(detail, `请求失败(${response.status})`),
|
||||
response.status,
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, retry = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
if (isJsonBody(body)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
if (auth && accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && accessToken && retry) {
|
||||
try {
|
||||
const authResponse = await refreshSession()
|
||||
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
||||
return request<T>(path, { ...options, retry: false })
|
||||
} catch {
|
||||
clearAccessToken()
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
return request<T>(path, options)
|
||||
}
|
||||
|
||||
export function refreshSession() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
|
||||
const authResponse = await response.json() as AuthResponse
|
||||
setAccessToken(authResponse.access_token)
|
||||
return authResponse
|
||||
})().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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<EmailOtpType>([
|
||||
'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<ResolveEmailAuthCallbackResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Missing Supabase environment variables')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
// Email confirmation and password recovery are handled by route components.
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user