mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
535c06bb3f
- Split `media:read` into `geo:read` and `weather:read` scopes - Add dedicated `atlas:read/write` scopes (previously under `places`) - Add dedicated `todos:read/write` scopes (previously under `collab`) - Rate limiting now keyed by userId+clientId instead of userId alone - Bind MCP sessions to the OAuth client that created them - Log MCP tool calls to audit log with clientId - Invalidate all MCP sessions on addon state change - Reduce session sweep interval from 10min to 1min - Update all translations with new scope labels
97 lines
4.5 KiB
TypeScript
97 lines
4.5 KiB
TypeScript
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"
|
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-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" style={{ borderColor: 'var(--border-primary)' }}>
|
|
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-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"
|
|
style={{ color: 'var(--text-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" style={{ borderColor: 'var(--border-primary)' }}>
|
|
{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" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|