Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react' | |
| import { ChevronDown, ChevronRight } from 'lucide-react' | |
| import { getScopesByGroup } from '../../api/oauthScopes' | |
| import { useTranslation } from '../../i18n' | |
| interface Props { | |
| selected: string[] | |
| onChange: (scopes: string[]) => void | |
| } | |
| export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement { | |
| const { t } = useTranslation() | |
| const [open, setOpen] = useState<Record<string, boolean>>({}) | |
| const scopesByGroup = getScopesByGroup(t) | |
| const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope) | |
| const allSelected = allScopeKeys.every(s => selected.includes(s)) | |
| return ( | |
| <div className="space-y-1"> | |
| <div className="flex justify-end mb-2"> | |
| <button | |
| type="button" | |
| onClick={() => onChange(allSelected ? [] : allScopeKeys)} | |
| className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary"> | |
| {allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')} | |
| </button> | |
| </div> | |
| <div className="space-y-1 max-h-96 overflow-y-auto pr-1"> | |
| {Object.entries(scopesByGroup).map(([group, groupScopes]) => { | |
| const groupScopeKeys = groupScopes.map(s => s.scope) | |
| const allGroupSelected = groupScopeKeys.every(s => selected.includes(s)) | |
| const someGroupSelected = groupScopeKeys.some(s => selected.includes(s)) | |
| return ( | |
| <div key={group} className="rounded-lg border overflow-hidden border-edge"> | |
| <div className="flex items-center gap-1 px-3 py-2 bg-surface-secondary"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))} | |
| className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left text-content-secondary"> | |
| {open[group] | |
| ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> | |
| : <ChevronRight className="w-3 h-3 flex-shrink-0" />} | |
| {group} | |
| {someGroupSelected && ( | |
| <span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}> | |
| ({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length}) | |
| </span> | |
| )} | |
| </button> | |
| <input | |
| type="checkbox" | |
| checked={allGroupSelected} | |
| ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }} | |
| onChange={e => onChange( | |
| e.target.checked | |
| ? [...new Set([...selected, ...groupScopeKeys])] | |
| : selected.filter(s => !groupScopeKeys.includes(s)) | |
| )} | |
| className="rounded" | |
| title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} | |
| /> | |
| </div> | |
| {open[group] && ( | |
| <div className="divide-y border-edge"> | |
| {groupScopes.map(({ scope, label, description }) => ( | |
| <label | |
| key={scope} | |
| className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> | |
| <input | |
| type="checkbox" | |
| checked={selected.includes(scope)} | |
| onChange={e => onChange( | |
| e.target.checked | |
| ? [...selected, scope] | |
| : selected.filter(s => s !== scope) | |
| )} | |
| className="mt-0.5 rounded flex-shrink-0" | |
| /> | |
| <div> | |
| <p className="text-xs font-medium text-content">{label}</p> | |
| <p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p> | |
| </div> | |
| </label> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| } | |