Files
donetick/src/views/Authorization/Authenticating.jsx
Mo Tarbin f92063dfe9 hotfix: OAuth login being aborted by a forced logout on Android
The app logged itself out in the middle of the OAuth code exchange,
tearing down the page before the callback request could finish.

On resume, the deep-link handler and the sync-on-resume listener fire in
the same tick. Sync ran first, hit a 401 (no session exists yet), and
ApiClient treated that as an expired session: it cleared tokens, wiped
the offline DB, and hard-navigated to /login via window.location.href.
That reload aborted the in-flight POST /auth/oauth2/callback, surfacing
as "Authentication request failed TypeError: Failed to fetch".
2026-07-28 20:45:29 -04:00

223 lines
6.7 KiB
JavaScript

import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import Logo from '../../Logo'
import { Capacitor } from '@capacitor/core'
import Cookies from 'js-cookie'
import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Authenticating')
const [subMessage, setSubMessage] = useState('Please wait')
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { provider } = useParams()
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
hasCalledHandleOAuth2.current = true
// Release the guard once the exchange settles, so a stuck flag can never
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown Authentication Provider')
setSubMessage('Please contact support')
}
return endOAuthExchange
}, [provider])
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
refetchUserProfile().then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
})
})
})
}
const handleMFASuccess = async data => {
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
getUserProfileAndNavigateToHome()
}
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Authentication failed')
setSubMessage('Two-factor authentication was cancelled')
setStatus('error')
}
const handleOAuth2 = async () => {
// get provider from params:
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const returnedState = urlParams.get('state')
const storedState = localStorage.getItem('authState')
if (returnedState !== storedState) {
setMessage('Authentication failed')
setSubMessage('State does not match')
setStatus('error')
return
}
if (code) {
await apiClient.init()
const baseURL = apiClient.getApiURL()
const redirectURI = Capacitor.isNativePlatform()
? 'donetick://auth/oauth2'
: `${window.location.origin}/auth/oauth2`
try {
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
})
if (!response.ok) {
console.error('Authentication failed')
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
return
}
const data = await response.json()
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Authentication failed')
setSubMessage('MFA session is missing. Please try again')
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-Factor Authentication Required')
setSubMessage('Please verify your login to continue')
return
}
if (!data.token && !data.access_token) {
setMessage('Authentication failed')
setSubMessage('No valid authentication token returned')
setStatus('error')
return
}
await saveTokens({
accessToken: data.token || data.access_token,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
}
}
}
return (
<Container className='flex h-full items-center justify-center'>
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
}}
>
<CircularProgress
determinate={status === 'error'}
color={status === 'pending' ? 'primary' : 'danger'}
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
}}
>
{message}
</Box>
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
{subMessage}
</Typography>
{status === 'error' && (
<Button
size='lg'
variant='outlined'
sx={{
mt: 4,
}}
>
<Link to='/login'>Go back Login</Link>
</Button>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Authentication failed')
setSubMessage('Two-factor authentication failed. Please try again')
}}
/>
</Box>
</Container>
)
}
export default AuthenticationLoading