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.
248 lines
7.7 KiB
TypeScript
Vendored
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,
|
|
})
|