/** * "Sign in with Bambuddy" for connected apps. * * An app sends the browser here with its client_id, callback URL, PKCE * challenge and state. The page runs with the user's normal session: it asks * the backend whether the request is valid, shows a one-time consent screen, * and sends the browser back to the app with a single-use code. * * It never redirects anywhere until the backend has confirmed that the * callback URL is the one registered for that app, so it can't be used as an * open redirect. Every error before that point is shown here instead. */ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { AlertTriangle, Link2, Loader2 } from 'lucide-react'; import { api, ApiError, type ConnectAuthorizeInfo } from '../api/client'; import { useAuth } from '../contexts/AuthContext'; type Phase = | { kind: 'loading' } | { kind: 'consent'; info: ConnectAuthorizeInfo } | { kind: 'redirecting' } | { kind: 'error'; message: string }; function withParams(base: string, params: Record): string { const url = new URL(base); for (const [key, value] of Object.entries(params)) { if (value) url.searchParams.set(key, value); } return url.toString(); } export function ConnectAuthorizePage() { const { t } = useTranslation(); const { authEnabled, loading } = useAuth(); const [searchParams] = useSearchParams(); const [phase, setPhase] = useState({ kind: 'loading' }); const started = useRef(false); const clientId = searchParams.get('client_id') ?? ''; const redirectUri = searchParams.get('redirect_uri') ?? ''; const state = searchParams.get('state') ?? ''; const codeChallenge = searchParams.get('code_challenge') ?? ''; const codeChallengeMethod = searchParams.get('code_challenge_method') ?? ''; const approve = async () => { setPhase({ kind: 'redirecting' }); try { const result = await api.connectAuthorize({ client_id: clientId, redirect_uri: redirectUri, code_challenge: codeChallenge, code_challenge_method: 'S256', }); // Use the callback the backend returned, not the query parameter. window.location.replace(withParams(result.redirect_uri, { code: result.code, state })); } catch (err) { setPhase({ kind: 'error', message: err instanceof Error ? err.message : t('connectedApps.authorize.failed'), }); } }; const deny = () => { // Safe: we only reach consent after the backend matched redirectUri // against the app's registration. window.location.replace(withParams(redirectUri, { error: 'access_denied', state })); }; useEffect(() => { if (loading || started.current) return; started.current = true; if (!authEnabled) { setPhase({ kind: 'error', message: t('connectedApps.authorize.authDisabled') }); return; } if (!clientId || !redirectUri || !codeChallenge || codeChallengeMethod !== 'S256') { setPhase({ kind: 'error', message: t('connectedApps.authorize.invalidRequest') }); return; } api .getConnectAuthorizeInfo(clientId, redirectUri) .then((info) => { if (info.already_granted) { void approve(); } else { setPhase({ kind: 'consent', info }); } }) .catch((err) => { const disabled = err instanceof ApiError && err.status === 409; setPhase({ kind: 'error', message: disabled ? t('connectedApps.authorize.authDisabled') : t('connectedApps.authorize.invalidRequest'), }); }); // approve() reads the same query parameters; running this once is the point. // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, authEnabled]); return (
{(phase.kind === 'loading' || phase.kind === 'redirecting') && (

{phase.kind === 'loading' ? t('connectedApps.authorize.checking') : t('connectedApps.authorize.redirecting')}

)} {phase.kind === 'error' && (

{t('connectedApps.authorize.errorTitle')}

{phase.message}

)} {phase.kind === 'consent' && ( <>

{t('connectedApps.authorize.title', { app: phase.info.app_name })}

{t('connectedApps.authorize.signedInAs', { username: phase.info.username })}

{t('connectedApps.authorize.sharedData', { app: phase.info.app_name })}

)}
); }