Files
TREK/client/src/pages/OAuthAuthorizePage.tsx
T
jubnl 8212f3c023 feat(oauth): add trips:share scope and redesign consent screen
Introduce trips:share as a dedicated OAuth scope for managing public
share links, decoupled from trips:read and trips:write. Share link
tools (get/create/delete_share_link) now gate on canShareTrips()
instead of the generic read/write scopes. Scope added to both client
and server definitions with full test coverage.

Redesign the consent screen from a narrow single-column card
(max-w-sm) to a two-panel layout (max-w-2xl): app identity and
action buttons on the left, scrollable scope list on the right.
Responsive — stacks vertically on mobile.
2026-04-10 00:55:12 +02:00

250 lines
10 KiB
TypeScript

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'
interface ValidateResult {
valid: boolean
error?: string
error_description?: string
client?: { name: string; allowed_scopes: string[] }
scopes?: string[]
consentRequired?: boolean
loginRequired?: boolean
}
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
export default function OAuthAuthorizePage(): React.ReactElement {
const { isAuthenticated, isLoading: authLoading, loadUser } = useAuthStore()
const [pageState, setPageState] = useState<PageState>('loading')
const [validation, setValidation] = useState<ValidateResult | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
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') || ''
// 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',
})
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
setPageState('auto_approving')
await submitConsent(true)
return
}
setPageState('consent')
} catch (err: unknown) {
setPageState('error')
setErrorMsg('Failed to validate authorization request. Please try again.')
}
}
async function submitConsent(approved: boolean) {
setSubmitting(true)
try {
const result = await oauthApi.authorize({
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
approved,
})
setPageState('done')
window.location.href = result.redirect
} catch {
setPageState('error')
setErrorMsg('Authorization failed. Please try again.')
setSubmitting(false)
}
}
function handleLoginRedirect() {
const next = '/oauth/authorize?' + params.toString()
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
// Group requested scopes by their human-readable group
const scopesByGroup = React.useMemo(() => {
const requested = validation?.scopes || []
const groups: Record<string, string[]> = {}
for (const s of requested) {
const info = SCOPE_GROUPS[s]
const group = info?.group || 'Other'
if (!groups[group]) groups[group] = []
groups[group].push(s)
}
return groups
}, [validation])
// ---- Render states ----
if (pageState === 'loading' || pageState === 'auto_approving') {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
</p>
</div>
</div>
)
}
if (pageState === 'error') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
</div>
</div>
)
}
if (pageState === 'login_required') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
<div className="text-center space-y-2">
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
</p>
</div>
<button
onClick={handleLoginRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
<LogIn className="w-4 h-4" />
Sign in to TREK
</button>
</div>
</div>
)
}
// pageState === 'consent'
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
{/* Left panel — app identity + actions */}
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex-1 space-y-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
{validation?.client?.name || clientId}
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
This application is requesting access to your TREK account.
</p>
</div>
</div>
<div className="mt-8 space-y-2">
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
Only grant access to applications you trust. Your data stays on your server.
</p>
<button
onClick={() => submitConsent(true)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting ? 'Authorizing…' : 'Approve Access'}
</button>
<button
onClick={() => submitConsent(false)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
Deny
</button>
</div>
</div>
{/* Right panel — scopes */}
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
Permissions requested
</p>
<div className="space-y-5">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
<div key={group}>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
<div className="space-y-1.5">
{groupScopes.map(s => {
const info = SCOPE_GROUPS[s]
return (
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<span className="mt-0.5 text-base leading-none flex-shrink-0">
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
</span>
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{info?.label || s}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{info?.description || ''}</p>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}