Skip to content

Commit 643682d

Browse files
feat: oauth-consent-frontend-screen (#7136)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 0e985d9 commit 643682d

9 files changed

Lines changed: 595 additions & 232 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Res } from 'common/types/responses'
2+
import { Req } from 'common/types/requests'
3+
import { service } from 'common/service'
4+
5+
export const oauthAuthorizeService = service
6+
.enhanceEndpoints({ addTagTypes: ['OAuthAuthorize'] })
7+
.injectEndpoints({
8+
endpoints: (builder) => ({
9+
processOAuthConsent: builder.mutation<
10+
Res['processOAuthConsent'],
11+
Req['processOAuthConsent']
12+
>({
13+
query: (body) => ({
14+
body,
15+
method: 'POST',
16+
url: 'oauth/authorize/',
17+
}),
18+
}),
19+
validateOAuthAuthorize: builder.query<
20+
Res['validateOAuthAuthorize'],
21+
Req['validateOAuthAuthorize']
22+
>({
23+
keepUnusedDataFor: 600,
24+
query: (params) => ({
25+
url: `oauth/authorize/?${new URLSearchParams(params).toString()}`,
26+
}),
27+
}),
28+
// END OF ENDPOINTS
29+
}),
30+
})
31+
32+
export const {
33+
useProcessOAuthConsentMutation,
34+
useValidateOAuthAuthorizeQuery,
35+
// END OF EXPORTS
36+
} = oauthAuthorizeService

frontend/common/types/requests.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,5 +924,16 @@ export type Req = {
924924
enabled: boolean
925925
feature_state_value: FlagsmithValue | null
926926
}
927+
validateOAuthAuthorize: Record<string, string>
928+
processOAuthConsent: {
929+
allow: boolean
930+
client_id: string
931+
redirect_uri: string
932+
response_type: string
933+
scope: string
934+
code_challenge: string
935+
code_challenge_method: string
936+
state?: string
937+
}
927938
// END OF TYPES
928939
}

frontend/common/types/responses.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,5 +1258,14 @@ export type Res = {
12581258
feature_external_resource_id: number
12591259
html_url: string
12601260
}
1261+
validateOAuthAuthorize: {
1262+
application: { name: string; client_id: string }
1263+
scopes: Record<string, string>
1264+
redirect_uri: string
1265+
is_verified: boolean
1266+
}
1267+
processOAuthConsent: {
1268+
redirect_uri: string
1269+
}
12611270
// END OF TYPES
12621271
}

frontend/web/components/App.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ const App = class extends Component {
150150
this.props.location.pathname === '/' ||
151151
this.props.location.pathname === '/widget' ||
152152
this.props.location.pathname === '/saml' ||
153-
this.props.location.pathname.includes('/oauth') ||
153+
(this.props.location.pathname.includes('/oauth') &&
154+
!this.props.location.pathname.startsWith('/oauth/authorize')) ||
154155
this.props.location.pathname === '/login' ||
155156
this.props.location.pathname === '/signup'
156157
) {
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import React, { useMemo, useState } from 'react'
2+
import { useLocation } from 'react-router-dom'
3+
import {
4+
useProcessOAuthConsentMutation,
5+
useValidateOAuthAuthorizeQuery,
6+
} from 'common/services/useOAuthAuthorize'
7+
import Utils from 'common/utils/utils'
8+
import Icon from 'components/Icon'
9+
import Logo from 'components/Logo'
10+
11+
// Frontend-maintained scope descriptions. The backend returns `mcp` as an
12+
// umbrella scope; we expand it into granular descriptions for the consent UI.
13+
// If a scope is not found here, the backend description is used as fallback.
14+
const SCOPE_DESCRIPTIONS: Record<string, string[]> = {
15+
mcp: [
16+
'Manage feature flags, toggle states, and update values',
17+
'Create and manage audience targeting segments',
18+
'View and configure environments',
19+
'View and update project settings',
20+
'Create and review change requests',
21+
'View organisation details, roles, and groups',
22+
],
23+
}
24+
25+
const OAuthAuthorizePage = () => {
26+
const location = useLocation()
27+
const [isRedirecting, setIsRedirecting] = useState(false)
28+
const [consentError, setConsentError] = useState<string | null>(null)
29+
30+
const oauthParams = useMemo(() => {
31+
const params = Utils.fromParam(location.search)
32+
return {
33+
client_id: params.client_id || '',
34+
code_challenge: params.code_challenge || '',
35+
code_challenge_method: params.code_challenge_method || '',
36+
redirect_uri: params.redirect_uri || '',
37+
response_type: params.response_type || '',
38+
scope: params.scope || 'mcp',
39+
state: params.state || '',
40+
}
41+
}, [location.search])
42+
43+
const hasRequiredParams = !!(
44+
oauthParams.client_id &&
45+
oauthParams.redirect_uri &&
46+
oauthParams.response_type &&
47+
oauthParams.code_challenge &&
48+
oauthParams.code_challenge_method
49+
)
50+
51+
const { data, error, isLoading } = useValidateOAuthAuthorizeQuery(
52+
oauthParams,
53+
{
54+
refetchOnFocus: false,
55+
refetchOnReconnect: false,
56+
skip: !hasRequiredParams,
57+
},
58+
)
59+
60+
const [processConsent, { isLoading: isProcessing }] =
61+
useProcessOAuthConsentMutation()
62+
63+
const handleConsent = async (allow: boolean) => {
64+
try {
65+
setConsentError(null)
66+
setIsRedirecting(true)
67+
const result = await processConsent({
68+
...oauthParams,
69+
allow,
70+
}).unwrap()
71+
window.location.href = result.redirect_uri
72+
} catch (e) {
73+
console.error('OAuth consent error:', e)
74+
setIsRedirecting(false)
75+
setConsentError('Something went wrong. Please try again.')
76+
}
77+
}
78+
79+
const renderContent = () => {
80+
if (!hasRequiredParams) {
81+
return (
82+
<div className='oauth-authorize__card card shadow p-4'>
83+
<h3>Invalid authorisation request</h3>
84+
<p className='text-muted'>
85+
Required OAuth parameters are missing. Please return to the
86+
application and try again.
87+
</p>
88+
</div>
89+
)
90+
}
91+
92+
if (isLoading) {
93+
return (
94+
<div className='oauth-authorize__card card shadow p-4'>
95+
<div className='centered-container'>
96+
<Loader />
97+
</div>
98+
</div>
99+
)
100+
}
101+
102+
if (error || !data) {
103+
return (
104+
<div className='oauth-authorize__card card shadow p-4'>
105+
<h3>Authorisation error</h3>
106+
<p className='text-muted'>
107+
The authorisation request is invalid. The application may have
108+
provided incorrect parameters.
109+
</p>
110+
</div>
111+
)
112+
}
113+
114+
return (
115+
<div className='oauth-authorize__card card shadow p-4'>
116+
<div className='text-center mb-4'>
117+
<Logo size={48} />
118+
<h3 className='oauth-authorize__title mb-0'>
119+
<strong>{data.application.name}</strong> would like to connect to
120+
your Flagsmith account
121+
</h3>
122+
</div>
123+
124+
{!data.is_verified && (
125+
<div className='oauth-authorize__warning'>
126+
<Icon name='warning' width={16} fill='#e5a00d' />
127+
<span>
128+
This application is unverified. Only authorise if you trust it.
129+
</span>
130+
</div>
131+
)}
132+
133+
<div className='oauth-authorize__scope-box'>
134+
<p className='oauth-authorize__scope-header mb-3 mt-0'>
135+
YOUR ACCOUNT WILL BE USED TO:
136+
</p>
137+
<div className='d-flex flex-column gap-3'>
138+
{Object.entries(data.scopes).flatMap(([scope, description]) => {
139+
const descriptions = SCOPE_DESCRIPTIONS[scope]
140+
if (descriptions) {
141+
return descriptions.map((desc, i) => (
142+
<div
143+
key={`${scope}-${i}`}
144+
className='oauth-authorize__scope-item'
145+
>
146+
<Icon name='checkmark' width={20} fill='#6837fc' />
147+
<span>{desc}</span>
148+
</div>
149+
))
150+
}
151+
return [
152+
<div key={scope} className='oauth-authorize__scope-item'>
153+
<Icon name='checkmark' width={20} fill='#6837fc' />
154+
<span>{description}</span>
155+
</div>,
156+
]
157+
})}
158+
</div>
159+
</div>
160+
161+
<p className='oauth-authorize__redirect-text text-muted text-center mb-4'>
162+
You will be redirected to:
163+
<br />
164+
<code className='oauth-authorize__redirect-uri'>
165+
{data.redirect_uri}
166+
</code>
167+
</p>
168+
169+
{consentError && (
170+
<p className='oauth-authorize__error text-center text-danger mb-2'>
171+
{consentError}
172+
</p>
173+
)}
174+
175+
{isRedirecting || isProcessing ? (
176+
<div className='centered-container'>
177+
<Loader />
178+
</div>
179+
) : (
180+
<div className='d-flex flex-column gap-2'>
181+
<Button className='w-100' onClick={() => handleConsent(true)}>
182+
Authorise
183+
</Button>
184+
<Button
185+
theme='secondary'
186+
className='w-100'
187+
onClick={() => handleConsent(false)}
188+
>
189+
Decline
190+
</Button>
191+
</div>
192+
)}
193+
</div>
194+
)
195+
}
196+
197+
return <div className='oauth-authorize'>{renderContent()}</div>
198+
}
199+
200+
export default OAuthAuthorizePage

frontend/web/main.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,21 @@ if (event) {
4242
}
4343

4444
const isInvite = document.location.href.includes('invite')
45-
const isOauth = document.location.href.includes('/oauth')
45+
const isOauth =
46+
document.location.href.includes('/oauth') &&
47+
!document.location.pathname.startsWith('/oauth/authorize')
4648
if (res && !isInvite && !isOauth) {
4749
AppActions.setToken(res)
4850
}
4951

5052
function isPublicURL() {
5153
const pathname = document.location.pathname
5254

55+
// /oauth/authorize requires auth (consent screen), but /oauth/:type (callbacks) is public.
56+
if (pathname.startsWith('/oauth/authorize')) {
57+
return false
58+
}
59+
5360
const publicPaths = [
5461
'/',
5562
'/404',

0 commit comments

Comments
 (0)