Files
chaos-api/web/default/src/routes/oauth/$provider.tsx
T
t0ng7u d146e45e2f ⚖️ chore(web/default): add reusable copyright header tooling
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files.

The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
2026-05-09 11:35:07 +08:00

248 lines
7.7 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import type { AxiosRequestConfig } from 'axios'
import {
createFileRoute,
useNavigate,
useParams,
useSearch,
} from '@tanstack/react-router'
import i18next from 'i18next'
import { toast } from 'sonner'
import { useAuthStore, type AuthUser } from '@/stores/auth-store'
import { api, getSelf } from '@/lib/api'
import { OAuthCallbackScreen } from '@/features/auth/components/oauth-callback-screen'
import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants'
type OAuthRequestConfig = AxiosRequestConfig & {
skipBusinessError?: boolean
}
function OAuthCallback() {
const navigate = useNavigate()
const { provider } = useParams({ from: '/oauth/$provider' }) as {
provider: string
}
const search = useSearch({ from: '/oauth/$provider' }) as {
code?: string
state?: string
redirect?: string
}
const [mode, setMode] = useState<'login' | 'bind'>(() => {
if (typeof window === 'undefined') return 'login'
return window.opener ? 'bind' : 'login'
})
useEffect(() => {
if (typeof window === 'undefined') return
// eslint-disable-next-line react-hooks/set-state-in-effect
setMode(window.opener ? 'bind' : 'login')
}, [])
useEffect(() => {
;(async () => {
const safeNavigate = (target: string) => {
navigate({ to: target as never, replace: true })
if (typeof window !== 'undefined') {
setTimeout(() => {
const normalizedTarget = target.startsWith('/')
? target
: `/${target}`
const currentPath =
window.location.pathname + window.location.search
if (
currentPath !== normalizedTarget &&
currentPath !== `${normalizedTarget}/`
) {
window.location.replace(target)
}
}, 100)
}
}
if (!search?.code) {
toast.error(i18next.t('Missing code'))
safeNavigate('/sign-in')
return
}
const isBindingFlow =
typeof window !== 'undefined' ? Boolean(window.opener) : mode === 'bind'
if (isBindingFlow && mode !== 'bind') {
setMode('bind')
} else if (!isBindingFlow && mode !== 'login') {
setMode('login')
}
const notifyBindingResult = (status: 'success' | 'error') => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(
OAUTH_BIND_STORAGE_KEY,
JSON.stringify({
provider,
status,
timestamp: Date.now(),
})
)
} catch (_error) {
// ignore storage write failures
void _error
}
}
const closeBindingWindow = () => {
if (typeof window === 'undefined') return
window.close()
setTimeout(() => {
if (!window.closed) {
window.location.replace('/_authenticated/profile/')
}
}, 200)
}
const finalizeLogin = async (): Promise<boolean> => {
try {
const selfResponse = (await getSelf()) as {
success?: boolean
data?: AuthUser | null
}
if (selfResponse?.success && selfResponse.data) {
useAuthStore.getState().auth.setUser(selfResponse.data)
try {
if (
typeof window !== 'undefined' &&
selfResponse.data?.id != null
) {
window.localStorage.setItem('uid', String(selfResponse.data.id))
}
} catch (_error) {
void _error
}
return true
}
} catch (_error) {
void _error
}
return false
}
const redirectAfterLogin = (target?: string) => {
const to = target || search?.redirect || '/dashboard'
safeNavigate(to)
toast.success(i18next.t('Signed in successfully!'))
}
const handleBindingFailure = (message: string) => {
notifyBindingResult('error')
toast.error(message)
}
const handleLoginFailure = async (message: string) => {
if (await finalizeLogin()) {
redirectAfterLogin()
return
}
toast.error(message)
safeNavigate('/sign-in')
}
try {
const config: OAuthRequestConfig = {
params: { code: search.code, state: search.state },
skipBusinessError: true,
}
const res = await api.get(`/api/oauth/${provider}`, config)
if (res?.data?.success) {
const { message } = res.data
const loginUser = (res.data?.data ?? null) as AuthUser | null
// Check if this is a bind operation
if (message === 'bind') {
toast.success(i18next.t('Binding successful!'))
notifyBindingResult('success')
if (isBindingFlow) {
// Close the callback window if we opened a new tab for binding
closeBindingWindow()
} else {
safeNavigate('/_authenticated/profile/')
}
return
}
// Otherwise it's a login, use payload user if available
if (loginUser) {
useAuthStore.getState().auth.setUser(loginUser)
try {
if (typeof window !== 'undefined' && loginUser.id != null) {
window.localStorage.setItem('uid', String(loginUser.id))
}
} catch (_error) {
void _error
}
redirectAfterLogin()
return
}
if (await finalizeLogin()) {
redirectAfterLogin()
return
}
toast.error(res?.data?.message || i18next.t('OAuth failed'))
safeNavigate('/sign-in')
return
}
const message = res?.data?.message || 'OAuth failed'
if (!res?.data?.success && !isBindingFlow) {
// When logging in with an already bound GitHub account, backend may return this message
if (message === '该 GitHub 账户已被绑定') {
if (await finalizeLogin()) {
redirectAfterLogin()
return
}
}
}
if (isBindingFlow) {
handleBindingFailure(message)
} else {
await handleLoginFailure(message)
}
return
} catch (error) {
const message = ((error &&
typeof error === 'object' &&
'response' in error &&
(error as { response?: { data?: { message?: string } } }).response
?.data?.message) ??
(error instanceof Error ? error.message : undefined) ??
'OAuth failed') as string
if (isBindingFlow) {
handleBindingFailure(message)
return
}
await handleLoginFailure(message)
return
}
})()
}, [mode, navigate, provider, search])
return <OAuthCallbackScreen provider={provider} mode={mode} />
}
export const Route = createFileRoute('/oauth/$provider')({
component: OAuthCallback,
})