import React, { useEffect, useState } from 'react' import { useAuthStore } from '../store/authStore' import { oauthApi } from '../api/client' import { SCOPE_GROUPS } from '../api/oauthScopes' import { Lock, ShieldCheck, AlertTriangle, Loader2, LogIn } from 'lucide-react' import { useTranslation } from '../i18n' interface ValidateResult { valid: boolean error?: string error_description?: string client?: { name: string; allowed_scopes: string[] } scopes?: string[] consentRequired?: boolean loginRequired?: boolean scopeSelectable?: boolean } type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done' export default function OAuthAuthorizePage(): React.ReactElement { const { t } = useTranslation() const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore() const [pageState, setPageState] = useState('loading') const [validation, setValidation] = useState(null) const [submitting, setSubmitting] = useState(false) const [errorMsg, setErrorMsg] = useState(null) const [selectedScopes, setSelectedScopes] = useState([]) const params = new URLSearchParams(window.location.search) const clientId = params.get('client_id') || '' const redirectUri = params.get('redirect_uri') || '' const scope = params.get('scope') || '' const state = params.get('state') || '' const codeChallenge = params.get('code_challenge') || '' const ccMethod = params.get('code_challenge_method') || '' const resource = params.get('resource') || undefined // Load auth state once, then validate useEffect(() => { loadUser({ silent: true }).catch(() => {}) }, [loadUser]) useEffect(() => { if (authLoading) return validateRequest() // eslint-disable-next-line react-hooks/exhaustive-deps }, [authLoading, isAuthenticated]) async function validateRequest() { setPageState('loading') try { const result = await oauthApi.validate({ client_id: clientId, redirect_uri: redirectUri, scope, state, code_challenge: codeChallenge, code_challenge_method: ccMethod, response_type: 'code', resource, }) setValidation(result) if (!result.valid) { setPageState('error') setErrorMsg(result.error_description || result.error || 'Invalid authorization request') return } if (result.loginRequired) { setPageState('login_required') return } if (!result.consentRequired) { // Consent already on record — auto-approve silently with the full validated scope setPageState('auto_approving') await submitConsent(true, result.scopes ?? []) return } // Pre-select all scopes the client is requesting — user can deselect setSelectedScopes(result.scopes ?? []) setPageState('consent') } catch (err: unknown) { setPageState('error') setErrorMsg('Failed to validate authorization request. Please try again.') } } async function submitConsent(approved: boolean, scopes: string[] = selectedScopes) { setSubmitting(true) try { const result = await oauthApi.authorize({ client_id: clientId, redirect_uri: redirectUri, // When approving, send only the scopes the user selected; deny uses original scope scope: approved ? scopes.join(' ') : scope, state, code_challenge: codeChallenge, code_challenge_method: ccMethod, approved, resource, }) setPageState('done') window.location.href = result.redirect } catch { setPageState('error') setErrorMsg('Authorization failed. Please try again.') setSubmitting(false) } } function toggleScope(s: string) { setSelectedScopes(prev => prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s] ) } function toggleGroup(groupScopes: string[], allSelected: boolean) { setSelectedScopes(prev => allSelected ? prev.filter(s => !groupScopes.includes(s)) : [...new Set([...prev, ...groupScopes])] ) } function handleLoginRedirect() { const next = '/oauth/consent?' + params.toString() + window.location.hash window.location.href = '/login?redirect=' + encodeURIComponent(next) } // Group requested scopes by their translated group name const scopesByGroup = React.useMemo(() => { const requested = validation?.scopes || [] const groups: Record = {} for (const s of requested) { const keys = SCOPE_GROUPS[s] const group = keys ? t(keys.groupKey) : 'Other' if (!groups[group]) groups[group] = [] groups[group].push(s) } return groups }, [validation, t]) // ---- Render states ---- if (pageState === 'loading' || pageState === 'auto_approving') { return (

{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}

) } if (pageState === 'error') { return (

Authorization Error

{errorMsg}

) } if (pageState === 'login_required') { return (

Sign in to continue

{validation?.client?.name || clientId} wants access to your TREK account. Please sign in first.

) } // pageState === 'consent' return (
{/* Left panel — app identity + actions */}

Authorization Request

{validation?.client?.name || clientId}

This application is requesting access to your TREK account.

Only grant access to applications you trust. Your data stays on your server.

{/* Right panel — selectable scopes */}
{Object.keys(scopesByGroup).length > 0 && (

{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}

{validation?.scopeSelectable ? ( /* DCR client — user selects which scopes to grant */
{Object.entries(scopesByGroup).map(([group, groupScopes]) => { const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s)) const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s)) return (
{groupScopes.map(s => { const keys = SCOPE_GROUPS[s] return ( ) })}
) })}
) : ( /* Settings-created client — scopes are fixed, show read-only */
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (

{group}

{groupScopes.map(s => { const keys = SCOPE_GROUPS[s] return (
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}

{keys ? t(keys.labelKey) : s}

{keys ? t(keys.descriptionKey) : ''}

) })}
))}
)}
)} {/* Always-available tools — granted regardless of scopes */}

Always included

{[ { name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' }, { name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' }, ].map(({ name, desc }) => (
👁️

{name}

{desc}

))}
) }