Skip to content

Commit ef9b0a9

Browse files
nl0claude
andauthored
catalog: Connect Auth UI (#4740)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ff25550 commit ef9b0a9

5 files changed

Lines changed: 234 additions & 0 deletions

File tree

catalog/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ where verb is one of
1818

1919
## Changes
2020

21+
- [Added] Connect OAuth authorize UI at `/connect/authorize` for MCP integration ([#4740](https://github.com/quiltdata/quilt/pull/4740))
2122
- [Fixed] Toolbar popover closing on click inside popup content, preventing text selection in code samples ([#4739](https://github.com/quiltdata/quilt/pull/4739))
2223
- [Changed] Package creation from S3 files now includes current bucket as default destination when no workflow config exists; cross-bucket push strictly respects workflow successors configuration ([#4734](https://github.com/quiltdata/quilt/pull/4734))
2324
- [Fixed] Add missing `deleteObject` property to GUI config editor to enable/disable delete buttons for files and directories ([#4692](https://github.com/quiltdata/quilt/pull/4692))

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: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
const ERROR_MESSAGES: Record<string, string> = {
11+
invalid_client: 'The application is not recognized.',
12+
invalid_redirect_uri: 'The application provided an invalid callback URL.',
13+
invalid_scope: 'The requested permissions are not valid.',
14+
invalid_resource: 'The requested resource is not valid.',
15+
invalid_request: 'The authorization request is invalid.',
16+
unsupported_response_type: 'The requested response type is not supported.',
17+
unexpected: 'Something went wrong. Please try again.',
18+
}
19+
20+
type State =
21+
| { status: 'loading' }
22+
| { status: 'error'; error: string; errorDescription?: string }
23+
| { status: 'ready'; clientName: string }
24+
| { status: 'authorizing'; clientName: string }
25+
26+
const useStyles = M.makeStyles((t) => ({
27+
container: {
28+
marginLeft: 'auto',
29+
marginRight: 'auto',
30+
maxWidth: 400,
31+
width: '100%',
32+
},
33+
heading: {
34+
marginBottom: t.spacing(3),
35+
},
36+
card: {
37+
padding: t.spacing(3),
38+
},
39+
clientName: {
40+
fontWeight: 500,
41+
},
42+
scope: {
43+
marginTop: t.spacing(2),
44+
color: t.palette.text.secondary,
45+
},
46+
actions: {
47+
display: 'flex',
48+
justifyContent: 'flex-end',
49+
marginTop: t.spacing(4),
50+
gap: t.spacing(2),
51+
},
52+
errorDetail: {
53+
marginTop: t.spacing(1),
54+
},
55+
loading: {
56+
display: 'flex',
57+
justifyContent: 'center',
58+
marginTop: t.spacing(4),
59+
},
60+
}))
61+
62+
export default function Authorize() {
63+
const classes = useStyles()
64+
const { search } = useLocation()
65+
const req = APIConnector.use()
66+
const [state, setState] = React.useState<State>({ status: 'loading' })
67+
68+
const params = parseSearch(search, true)
69+
const {
70+
client_id: clientId,
71+
redirect_uri: redirectUri,
72+
scope,
73+
state: oauthState,
74+
resource,
75+
} = params
76+
77+
const handleError = (e: unknown) => {
78+
if (e instanceof APIConnector.HTTPError) {
79+
const { error_code: error, message: errorDescription } = e.json || {}
80+
setState({ status: 'error', error: error || 'unexpected', errorDescription })
81+
} else {
82+
setState({ status: 'error', error: 'unexpected' })
83+
}
84+
}
85+
86+
React.useEffect(() => {
87+
let cancelled = false
88+
89+
const validate = async () => {
90+
try {
91+
const result = await req({
92+
endpoint: '/connect/validate',
93+
method: 'POST',
94+
body: {
95+
client_id: clientId,
96+
redirect_uri: redirectUri,
97+
scope,
98+
resource,
99+
},
100+
})
101+
if (!cancelled) {
102+
setState({ status: 'ready', clientName: result.client_name })
103+
}
104+
} catch (e) {
105+
if (!cancelled) handleError(e)
106+
}
107+
}
108+
109+
validate()
110+
return () => {
111+
cancelled = true
112+
}
113+
}, [req, clientId, redirectUri, scope, resource])
114+
115+
const handleContinue = async () => {
116+
if (state.status !== 'ready') return
117+
setState({ status: 'authorizing', clientName: state.clientName })
118+
119+
try {
120+
const result = await req({
121+
endpoint: '/connect/authorize',
122+
method: 'POST',
123+
body: params,
124+
})
125+
window.location.href = result.redirect_uri
126+
} catch (e) {
127+
handleError(e)
128+
}
129+
}
130+
131+
const handleCancel = () => {
132+
if (!redirectUri || !oauthState) return
133+
const url = new URL(redirectUri)
134+
url.searchParams.set('error', 'access_denied')
135+
url.searchParams.set('state', oauthState)
136+
window.location.href = url.toString()
137+
}
138+
139+
if (state.status === 'error') {
140+
const errorMessage = ERROR_MESSAGES[state.error] || ERROR_MESSAGES.unexpected
141+
return (
142+
<Layout>
143+
<M.Box pt={5} pb={2} className={classes.container}>
144+
<M.Typography variant="h4" align="center" className={classes.heading}>
145+
Authorization Failed
146+
</M.Typography>
147+
<M.Paper className={classes.card}>
148+
<M.Typography align="center" color="error">
149+
{errorMessage}
150+
</M.Typography>
151+
{state.errorDescription && (
152+
<M.Typography
153+
variant="body2"
154+
align="center"
155+
color="textSecondary"
156+
className={classes.errorDetail}
157+
>
158+
{state.errorDescription}
159+
</M.Typography>
160+
)}
161+
</M.Paper>
162+
</M.Box>
163+
</Layout>
164+
)
165+
}
166+
167+
if (state.status === 'loading') {
168+
return (
169+
<Layout>
170+
<M.Box pt={5} pb={2} className={classes.container}>
171+
<M.Typography variant="h4" align="center" className={classes.heading}>
172+
Authorize Application
173+
</M.Typography>
174+
<div className={classes.loading}>
175+
<Spinner />
176+
</div>
177+
</M.Box>
178+
</Layout>
179+
)
180+
}
181+
182+
const isAuthorizing = state.status === 'authorizing'
183+
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+
Authorize Application
189+
</M.Typography>
190+
<M.Paper className={classes.card}>
191+
<M.Typography variant="body1">
192+
<span className={classes.clientName}>{state.clientName}</span> wants to access
193+
your Quilt account.
194+
</M.Typography>
195+
<M.Typography variant="body2" className={classes.scope}>
196+
This will allow the application to access data on your behalf.
197+
</M.Typography>
198+
<div className={classes.actions}>
199+
<M.Button onClick={handleCancel} disabled={isAuthorizing} color="default">
200+
Cancel
201+
</M.Button>
202+
<M.Button
203+
onClick={handleContinue}
204+
disabled={isAuthorizing}
205+
color="primary"
206+
variant="contained"
207+
>
208+
{isAuthorizing ? (
209+
<>
210+
Authorizing...&nbsp;
211+
<Spinner style={{ fontSize: '1.5em', opacity: 0.5 }} />
212+
</>
213+
) : (
214+
'Continue'
215+
)}
216+
</M.Button>
217+
</div>
218+
</M.Paper>
219+
</M.Box>
220+
</Layout>
221+
)
222+
}
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)