/*
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 .
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useRef, useEffect } from 'react'
import type { AxiosRequestConfig } from 'axios'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { getOAuthState } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '../lib/oauth'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
/**
* Generate a random code verifier for PKCE
*/
function generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* Generate code challenge from code verifier using SHA-256
*/
async function generateCodeChallenge(verifier: string): Promise {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
type LogoutRequestConfig = AxiosRequestConfig & {
skipErrorHandler?: boolean
}
/**
* Hook for managing OAuth login
*/
export function useOAuthLogin(status: SystemStatus | null) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef(null)
const { auth } = useAuthStore()
useEffect(() => {
setGithubButtonText(t('Continue with GitHub'))
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
}
}, [t])
const resetSession = async () => {
try {
auth.reset()
} catch (_error) {
// ignore store reset errors
}
try {
await api.get('/api/user/logout', {
skipErrorHandler: true,
} as LogoutRequestConfig)
} catch (_error) {
// ignore logout errors
}
}
const handleGitHubLogin = async () => {
if (!status?.github_client_id) return
if (githubButtonDisabled) return
setIsLoading(true)
setGithubButtonDisabled(true)
setGithubButtonText(t('Redirecting to GitHub...'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
githubTimeoutRef.current = setTimeout(() => {
setIsLoading(false)
setGithubButtonText(
t('Request timed out, please refresh and restart GitHub login')
)
setGithubButtonDisabled(true)
}, 20000)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
return
}
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start GitHub login'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
}
}
const handleDiscordLogin = async () => {
if (!status?.discord_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start Discord login'))
} finally {
setIsLoading(false)
}
}
const handleOIDCLogin = async () => {
if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildOIDCOAuthUrl(
status.oidc_authorization_endpoint,
status.oidc_client_id,
state
)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start OIDC login'))
} finally {
setIsLoading(false)
}
}
const handleLinuxDOLogin = async () => {
if (!status?.linuxdo_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start LinuxDO login'))
} finally {
setIsLoading(false)
}
}
const handleTelegramLogin = () => {
toast.info(t('Telegram login requires widget integration; coming soon'))
}
const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => {
if (!provider.authorization_endpoint || !provider.client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
const url = new URL(provider.authorization_endpoint)
url.searchParams.set('client_id', provider.client_id)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('state', state)
if (provider.scopes) {
url.searchParams.set('scope', provider.scopes)
}
// Add PKCE support if enabled
if (provider.pkce_enabled) {
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// Store code_verifier in sessionStorage keyed by state
sessionStorage.setItem(`pkce_verifier_${state}`, codeVerifier)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
}
window.open(url.toString(), '_self')
} catch (_error) {
toast.error(
t('Failed to start {{provider}} login', { provider: provider.name })
)
} finally {
setIsLoading(false)
}
}
return {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
}
}