Skip to content

Commit 5f17068

Browse files
committed
feat: let users deselect scopes on the OAuth2 consent screen
The consent screen showed the requested scopes as a read-only list (all-or- nothing Allow). Render them as checkboxes (default checked) and submit only the selected subset; disable Allow when nothing is selected. Spring Authorization Server already honors a partial-scope consent POST, so the granted token carries exactly the approved scopes.
1 parent e55516c commit 5f17068

1 file changed

Lines changed: 37 additions & 13 deletions

File tree

webapp/src/component/security/oauth2/OAuth2ConsentView.tsx

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import React, { useEffect, useState } from 'react';
22
import { T, useTranslate } from '@tolgee/react';
3-
import { Alert, Box, styled, Typography } from '@mui/material';
3+
import {
4+
Alert,
5+
Box,
6+
Checkbox,
7+
FormControlLabel,
8+
styled,
9+
Typography,
10+
} from '@mui/material';
411

512
import { DashboardPage } from 'tg.component/layout/DashboardPage';
613
import { CompactView } from 'tg.component/layout/CompactView';
@@ -23,11 +30,9 @@ type ConsentInfo = {
2330
allProjects: boolean;
2431
};
2532

26-
const StyledCapabilities = styled('ul')`
27-
margin: 0;
28-
padding-left: 20px;
33+
const StyledCapabilities = styled('div')`
2934
display: grid;
30-
gap: 8px;
35+
gap: 4px;
3136
`;
3237

3338
const StyledButtons = styled(Box)`
@@ -45,19 +50,27 @@ const OAuth2ConsentView: React.FC<React.PropsWithChildren<unknown>> = () => {
4550
const scope = asString(search.scope);
4651
const state = asString(search.state);
4752
const [info, setInfo] = useState<ConsentInfo>();
53+
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
4854
const [failed, setFailed] = useState(false);
4955
const [submitting, setSubmitting] = useState(false);
5056

5157
useEffect(() => {
5258
apiV2HttpService
5359
.get<ConsentInfo>('oauth2/consent-info', { clientId, scope, state })
54-
.then(setInfo)
60+
.then((data) => {
61+
setInfo(data);
62+
setSelectedScopes(data.scopes);
63+
})
5564
.catch(() => setFailed(true));
5665
}, [clientId, scope, state]);
5766

58-
// Consent is submitted as a real form POST to the authorization endpoint so the browser sends the
59-
// session cookie and follows the redirect back to the client. Approving = posting the scopes; denying
60-
// = posting none, which the server turns into access_denied.
67+
const toggleScope = (s: string) => {
68+
setSelectedScopes((prev) =>
69+
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
70+
);
71+
};
72+
73+
// Real form POST (not fetch) so the browser sends the session cookie and follows the redirect back to the client.
6174
const submitConsent = (approvedScopes: string[]) => {
6275
setSubmitting(true);
6376
const form = document.createElement('form');
@@ -117,9 +130,19 @@ const OAuth2ConsentView: React.FC<React.PropsWithChildren<unknown>> = () => {
117130
<Box data-cy="oauth2-consent">
118131
<StyledCapabilities>
119132
{info.scopes.map((s) => (
120-
<li key={s} data-cy="oauth2-consent-scope">
121-
{getScopeTranslation(s as PermissionModelScope)}
122-
</li>
133+
<FormControlLabel
134+
key={s}
135+
data-cy="oauth2-consent-scope"
136+
data-cy-scope={s}
137+
control={
138+
<Checkbox
139+
size="small"
140+
checked={selectedScopes.includes(s)}
141+
onChange={() => toggleScope(s)}
142+
/>
143+
}
144+
label={getScopeTranslation(s as PermissionModelScope)}
145+
/>
123146
))}
124147
</StyledCapabilities>
125148
{info.project && (
@@ -165,7 +188,8 @@ const OAuth2ConsentView: React.FC<React.PropsWithChildren<unknown>> = () => {
165188
color="primary"
166189
data-cy="oauth2-consent-allow"
167190
loading={submitting}
168-
onClick={() => submitConsent(info.scopes)}
191+
disabled={selectedScopes.length === 0}
192+
onClick={() => submitConsent(selectedScopes)}
169193
>
170194
<T keyName="oauth2_consent_allow" defaultValue="Allow" />
171195
</LoadingButton>

0 commit comments

Comments
 (0)