Skip to content

Commit 9cb8603

Browse files
nl0claude
andcommitted
feat(catalog): add Connect OAuth authorize UI
Add /connect/authorize route for OAuth 2.1 authorization flow. The Authorize component handles the OAuth consent screen: - Validates OAuth params via /api/connect/validate - Shows client name and requested permissions - Issues auth code via /api/connect/authorize on approval - Redirects with error=access_denied on cancel Uses local React state (no Redux) following new code patterns. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 77c1452 commit 9cb8603

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

catalog/app/constants/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const code = route('/code')
5050

5151
export const activationError = route('/activation_error')
5252

53+
// Connect OAuth
54+
export const connectAuthorize = route('/connect/authorize')
55+
5356
// Profile
5457
export const profile = route('/profile')
5558

catalog/app/containers/App/App.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const AuthPassReset = RT.mkLazy(() => import('containers/Auth/PassReset'), Place
5555
const AuthSignIn = RT.mkLazy(() => import('containers/Auth/SignIn'), Placeholder)
5656
const AuthSignOut = RT.mkLazy(() => import('containers/Auth/SignOut'), Placeholder)
5757
const AuthSignUp = RT.mkLazy(() => import('containers/Auth/SignUp'), Placeholder)
58+
const ConnectAuthorize = requireAuth()(
59+
RT.mkLazy(() => import('containers/Connect'), Placeholder),
60+
)
5861
const Bucket = protect(RT.mkLazy(() => import('containers/Bucket'), Placeholder))
5962
const Redir = protect(RT.mkLazy(() => import('containers/Redir'), Placeholder))
6063
const Search = protect(RT.mkLazy(() => import('containers/Search'), Placeholder))
@@ -131,6 +134,10 @@ export default function App() {
131134
<AuthActivationError />
132135
</Route>
133136

137+
<Route path={paths.connectAuthorize} exact>
138+
<ConnectAuthorize />
139+
</Route>
140+
134141
{cfg.mode === 'OPEN' && (
135142
// XXX: show profile in all modes?
136143
<Route path={paths.profile} exact>
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import * as React from 'react'
2+
import { useLocation } from 'react-router-dom'
3+
import * as M from '@material-ui/core'
4+
5+
import Layout from 'components/Layout'
6+
import Spinner from 'components/Spinner'
7+
import * as APIConnector from 'utils/APIConnector'
8+
import parseSearch from 'utils/parseSearch'
9+
10+
type State =
11+
| { status: 'loading' }
12+
| { status: 'error'; error: string; errorDescription?: string }
13+
| { status: 'ready'; clientName: string }
14+
| { status: 'authorizing'; clientName: string }
15+
16+
const useStyles = M.makeStyles((t) => ({
17+
container: {
18+
marginLeft: 'auto',
19+
marginRight: 'auto',
20+
maxWidth: 400,
21+
width: '100%',
22+
},
23+
heading: {
24+
marginBottom: t.spacing(3),
25+
},
26+
card: {
27+
padding: t.spacing(3),
28+
},
29+
clientName: {
30+
fontWeight: 500,
31+
},
32+
scope: {
33+
marginTop: t.spacing(2),
34+
color: t.palette.text.secondary,
35+
},
36+
actions: {
37+
display: 'flex',
38+
justifyContent: 'flex-end',
39+
marginTop: t.spacing(4),
40+
gap: t.spacing(2),
41+
},
42+
error: {
43+
color: t.palette.error.main,
44+
textAlign: 'center',
45+
marginTop: t.spacing(4),
46+
},
47+
loading: {
48+
display: 'flex',
49+
justifyContent: 'center',
50+
marginTop: t.spacing(4),
51+
},
52+
}))
53+
54+
export default function Authorize() {
55+
const classes = useStyles()
56+
const { search } = useLocation()
57+
const req = APIConnector.use()
58+
const [state, setState] = React.useState<State>({ status: 'loading' })
59+
60+
// Parse OAuth params from URL
61+
const params = React.useMemo(() => parseSearch(search, true), [search])
62+
const {
63+
client_id: clientId,
64+
redirect_uri: redirectUri,
65+
response_type: responseType,
66+
scope,
67+
state: oauthState,
68+
code_challenge: codeChallenge,
69+
code_challenge_method: codeChallengeMethod,
70+
resource,
71+
} = params
72+
73+
// Validate on mount
74+
React.useEffect(() => {
75+
let cancelled = false
76+
77+
const validate = async () => {
78+
try {
79+
const result = await req({
80+
endpoint: '/connect/validate',
81+
method: 'POST',
82+
body: {
83+
client_id: clientId,
84+
redirect_uri: redirectUri,
85+
scope,
86+
resource,
87+
},
88+
})
89+
if (!cancelled) {
90+
setState({ status: 'ready', clientName: result.client_name })
91+
}
92+
} catch (e) {
93+
if (cancelled) return
94+
if (e instanceof APIConnector.HTTPError) {
95+
const { error, error_description: errorDescription } = e.json || {}
96+
setState({ status: 'error', error: error || 'unexpected', errorDescription })
97+
} else {
98+
setState({ status: 'error', error: 'unexpected' })
99+
}
100+
}
101+
}
102+
103+
validate()
104+
return () => {
105+
cancelled = true
106+
}
107+
}, [req, clientId, redirectUri, scope, resource])
108+
109+
// Continue handler - authorize and redirect
110+
const handleContinue = React.useCallback(async () => {
111+
if (state.status !== 'ready') return
112+
setState({ status: 'authorizing', clientName: state.clientName })
113+
114+
try {
115+
const result = await req({
116+
endpoint: '/connect/authorize',
117+
method: 'POST',
118+
body: {
119+
client_id: clientId,
120+
redirect_uri: redirectUri,
121+
response_type: responseType,
122+
scope,
123+
state: oauthState,
124+
code_challenge: codeChallenge,
125+
code_challenge_method: codeChallengeMethod,
126+
resource,
127+
},
128+
})
129+
// Redirect to the client with the authorization code
130+
window.location.href = result.redirect_uri
131+
} catch (e) {
132+
if (e instanceof APIConnector.HTTPError) {
133+
const { error, error_description: errorDescription } = e.json || {}
134+
setState({ status: 'error', error: error || 'unexpected', errorDescription })
135+
} else {
136+
setState({ status: 'error', error: 'unexpected' })
137+
}
138+
}
139+
}, [
140+
state,
141+
req,
142+
clientId,
143+
redirectUri,
144+
responseType,
145+
scope,
146+
oauthState,
147+
codeChallenge,
148+
codeChallengeMethod,
149+
resource,
150+
])
151+
152+
// Cancel handler - redirect with error
153+
const handleCancel = React.useCallback(() => {
154+
if (!redirectUri || !oauthState) {
155+
// Can't redirect without valid redirect_uri and state
156+
setState({ status: 'error', error: 'invalid_request' })
157+
return
158+
}
159+
try {
160+
const url = new URL(redirectUri)
161+
url.searchParams.set('error', 'access_denied')
162+
url.searchParams.set('state', oauthState)
163+
window.location.href = url.toString()
164+
} catch {
165+
// Invalid redirect_uri
166+
setState({ status: 'error', error: 'invalid_redirect_uri' })
167+
}
168+
}, [redirectUri, oauthState])
169+
170+
// Error messages
171+
const errorMessages: Record<string, string> = {
172+
invalid_client: 'The application is not recognized.',
173+
invalid_redirect_uri: 'The application provided an invalid callback URL.',
174+
invalid_scope: 'The requested permissions are not valid.',
175+
invalid_resource: 'The requested resource is not valid.',
176+
invalid_request: 'The authorization request is invalid.',
177+
unsupported_response_type: 'The requested response type is not supported.',
178+
unexpected: 'Something went wrong. Please try again.',
179+
}
180+
181+
// Render error state
182+
if (state.status === 'error') {
183+
const errorMessage = errorMessages[state.error] || errorMessages.unexpected
184+
return (
185+
<Layout>
186+
<M.Box pt={5} pb={2} className={classes.container}>
187+
<M.Typography variant="h4" align="center" className={classes.heading}>
188+
Authorization Failed
189+
</M.Typography>
190+
<M.Paper className={classes.card}>
191+
<M.Typography align="center" color="error">
192+
{errorMessage}
193+
</M.Typography>
194+
{state.errorDescription && (
195+
<M.Typography
196+
variant="body2"
197+
align="center"
198+
color="textSecondary"
199+
style={{ marginTop: 8 }}
200+
>
201+
{state.errorDescription}
202+
</M.Typography>
203+
)}
204+
</M.Paper>
205+
</M.Box>
206+
</Layout>
207+
)
208+
}
209+
210+
// Render loading state
211+
if (state.status === 'loading') {
212+
return (
213+
<Layout>
214+
<M.Box pt={5} pb={2} className={classes.container}>
215+
<M.Typography variant="h4" align="center" className={classes.heading}>
216+
Authorize Application
217+
</M.Typography>
218+
<div className={classes.loading}>
219+
<Spinner />
220+
</div>
221+
</M.Box>
222+
</Layout>
223+
)
224+
}
225+
226+
// Render ready/authorizing state
227+
const isAuthorizing = state.status === 'authorizing'
228+
229+
return (
230+
<Layout>
231+
<M.Box pt={5} pb={2} className={classes.container}>
232+
<M.Typography variant="h4" align="center" className={classes.heading}>
233+
Authorize Application
234+
</M.Typography>
235+
<M.Paper className={classes.card}>
236+
<M.Typography variant="body1">
237+
<span className={classes.clientName}>{state.clientName}</span> wants to access
238+
your Quilt account.
239+
</M.Typography>
240+
<M.Typography variant="body2" className={classes.scope}>
241+
This will allow the application to access data on your behalf.
242+
</M.Typography>
243+
<div className={classes.actions}>
244+
<M.Button onClick={handleCancel} disabled={isAuthorizing} color="default">
245+
Cancel
246+
</M.Button>
247+
<M.Button
248+
onClick={handleContinue}
249+
disabled={isAuthorizing}
250+
color="primary"
251+
variant="contained"
252+
>
253+
{isAuthorizing ? (
254+
<>
255+
Authorizing...&nbsp;
256+
<Spinner style={{ fontSize: '1.5em', opacity: 0.5 }} />
257+
</>
258+
) : (
259+
'Continue'
260+
)}
261+
</M.Button>
262+
</div>
263+
</M.Paper>
264+
</M.Box>
265+
</Layout>
266+
)
267+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './Authorize'

0 commit comments

Comments
 (0)