fix(oauth): gate scope selection UI to DCR clients only

Settings-created clients have fixed scopes chosen at creation time and
should show a read-only scope list on the consent screen. Only DCR-registered
clients expose the interactive checkbox UI for user-controlled scope selection.
This commit is contained in:
jubnl
2026-04-10 06:03:29 +02:00
parent ac9c5784ee
commit 4ad1ccf5dd
2 changed files with 72 additions and 40 deletions
+69 -40
View File
@@ -12,6 +12,7 @@ interface ValidateResult {
scopes?: string[] scopes?: string[]
consentRequired?: boolean consentRequired?: boolean
loginRequired?: boolean loginRequired?: boolean
scopeSelectable?: boolean
} }
type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done' type PageState = 'loading' | 'login_required' | 'consent' | 'auto_approving' | 'error' | 'done'
@@ -216,14 +217,16 @@ export default function OAuthAuthorizePage(): React.ReactElement {
</p> </p>
<button <button
onClick={() => submitConsent(true)} onClick={() => submitConsent(true)}
disabled={submitting || selectedScopes.length === 0} disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity" 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)' }}> style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting {submitting
? 'Authorizing…' ? 'Authorizing…'
: selectedScopes.length === 0 : validation?.scopeSelectable && selectedScopes.length === 0
? 'Select at least one scope' ? 'Select at least one scope'
: `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`} : validation?.scopeSelectable
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
: 'Approve Access'}
</button> </button>
<button <button
onClick={() => submitConsent(false)} onClick={() => submitConsent(false)}
@@ -241,43 +244,69 @@ export default function OAuthAuthorizePage(): React.ReactElement {
{Object.keys(scopesByGroup).length > 0 && ( {Object.keys(scopesByGroup).length > 0 && (
<div> <div>
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}> <p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
Choose which permissions to grant {validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
</p> </p>
<div className="space-y-3">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => { {validation?.scopeSelectable ? (
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s)) /* DCR client — user selects which scopes to grant */
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s)) <div className="space-y-3">
return ( {Object.entries(scopesByGroup).map(([group, groupScopes]) => {
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
{/* Group header with select-all toggle */} const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}> return (
<input <div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
type="checkbox" <label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
checked={allGroupSelected} <input
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }} type="checkbox"
onChange={() => toggleGroup(groupScopes, allGroupSelected)} checked={allGroupSelected}
className="rounded flex-shrink-0" ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
/> onChange={() => toggleGroup(groupScopes, allGroupSelected)}
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span> className="rounded flex-shrink-0"
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}> />
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length} <span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
</span> <span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
</label> {groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
{/* Individual scopes */} </span>
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}> </label>
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(s => {
const info = SCOPE_GROUPS[s]
return (
<label
key={s}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
<input
type="checkbox"
checked={selectedScopes.includes(s)}
onChange={() => toggleScope(s)}
className="mt-0.5 rounded flex-shrink-0"
/>
<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>
</label>
)
})}
</div>
</div>
)
})}
</div>
) : (
/* Settings-created client — scopes are fixed, show read-only */
<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 => { {groupScopes.map(s => {
const info = SCOPE_GROUPS[s] const info = SCOPE_GROUPS[s]
const checked = selectedScopes.includes(s)
return ( return (
<label <div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
key={s}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
<input
type="checkbox"
checked={checked}
onChange={() => toggleScope(s)}
className="mt-0.5 rounded flex-shrink-0"
/>
<span className="mt-0.5 text-base leading-none flex-shrink-0"> <span className="mt-0.5 text-base leading-none flex-shrink-0">
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'} {s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
</span> </span>
@@ -285,14 +314,14 @@ export default function OAuthAuthorizePage(): React.ReactElement {
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{info?.label || s}</p> <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> <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{info?.description || ''}</p>
</div> </div>
</label> </div>
) )
})} })}
</div> </div>
</div> </div>
) ))}
})} </div>
</div> )}
</div> </div>
)} )}
+3
View File
@@ -532,6 +532,8 @@ export interface ValidateAuthorizeResult {
consentRequired?: boolean; consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */ /** true when the request is valid but user is not authenticated */
loginRequired?: boolean; loginRequired?: boolean;
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
scopeSelectable?: boolean;
} }
export function validateAuthorizeRequest( export function validateAuthorizeRequest(
@@ -596,6 +598,7 @@ export function validateAuthorizeRequest(
client: { name: client.name, allowed_scopes: allowedScopes }, client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes, scopes: grantedScopes,
consentRequired, consentRequired,
scopeSelectable: client.created_via === 'dcr',
}; };
} }