mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user