mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat(mcp): introduce OAuth 2.1 auth and enforce addon gating
OAuth 2.1 authentication for MCP:
- Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts)
- Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts)
- Add typed scope definitions and enforcement helpers (mcp/scopes.ts)
- Add OAuth consent UI page (OAuthAuthorizePage.tsx)
- Add client-side scope labels and descriptions (api/oauthScopes.ts)
- Integrate OAuth token auth into MCP handler alongside existing static tokens
- All OAuth endpoints gated on `mcp` addon
Addon gating across MCP tools, resources, and prompts:
- Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals
- Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon
- Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon
- Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior)
- Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak)
- Expand collab gate to cover full tool body (collab notes no longer leak)
- Gate packing-list and budget-overview MCP prompts on their respective addons
- Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled
- Remove trip-files resource and files field from get_trip_summary
- Replace all isAddonEnabled('literal') calls with ADDON_IDS constants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,85 @@ import Section from './Section'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ShieldCheck, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import { getScopesByGroup, ALL_SCOPES } from '../../api/oauthScopes'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
uris: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
const OAUTH_PRESETS: OAuthPreset[] = [
|
||||
{
|
||||
id: 'claude-web',
|
||||
label: 'Claude.ai',
|
||||
name: 'Claude.ai',
|
||||
uris: 'https://claude.ai/api/mcp/auth_callback',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
label: 'Claude Desktop',
|
||||
name: 'Claude Desktop',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
name: 'Cursor',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'VS Code',
|
||||
name: 'VS Code / Copilot',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
name: 'Windsurf',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
label: 'Zed',
|
||||
name: 'Zed',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
interface OAuthClient {
|
||||
id: string
|
||||
name: string
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
|
||||
interface OAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
@@ -26,6 +100,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
loadAddons()
|
||||
}, [loadAddons])
|
||||
|
||||
// OAuth clients state
|
||||
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([])
|
||||
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
|
||||
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
|
||||
const [oauthNewName, setOauthNewName] = useState('')
|
||||
const [oauthNewUris, setOauthNewUris] = useState('')
|
||||
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
|
||||
const [oauthCreating, setOauthCreating] = useState(false)
|
||||
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
|
||||
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
|
||||
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
|
||||
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
|
||||
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
|
||||
const [oauthRotating, setOauthRotating] = useState(false)
|
||||
const [oauthScopesOpen, setOauthScopesOpen] = useState<Record<string, boolean>>({})
|
||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
@@ -89,6 +180,69 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
})
|
||||
}
|
||||
|
||||
// Load OAuth clients and sessions
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
|
||||
oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} finally {
|
||||
setOauthCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOAuthClient = async (id: string) => {
|
||||
try {
|
||||
await oauthApi.clients.delete(id)
|
||||
setOauthClients(prev => prev.filter(c => c.id !== id))
|
||||
setOauthDeleteId(null)
|
||||
toast.success(t('settings.oauth.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateSecret = async (id: string) => {
|
||||
setOauthRotating(true)
|
||||
try {
|
||||
const d = await oauthApi.clients.rotate(id)
|
||||
setOauthRotatedSecret(d.client_secret)
|
||||
setOauthRotateId(null)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.rotateError'))
|
||||
} finally {
|
||||
setOauthRotating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokeSession = async (id: number) => {
|
||||
try {
|
||||
await oauthApi.sessions.revoke(id)
|
||||
setOauthSessions(prev => prev.filter(s => s.id !== id))
|
||||
setOauthRevokeId(null)
|
||||
toast.success(t('settings.oauth.toast.revoked'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
const scopesByGroup = getScopesByGroup()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
@@ -126,46 +280,146 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
{/* OAuth Clients */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clients')}</label>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
{oauthClients.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
{t('settings.oauth.noClients')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
{oauthClients.map((client, i) => (
|
||||
<div key={client.id} className="px-4 py-3"
|
||||
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.clientId')}: {client.client_id}
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
|
||||
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
|
||||
))}
|
||||
{client.allowed_scopes.length > 5 && (
|
||||
<button
|
||||
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
|
||||
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
{oauthScopesExpanded[client.id] ? '−' : `+${client.allowed_scopes.length - 5}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setOauthRotateId(client.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setOauthDeleteId(client.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token list — deprecated */}
|
||||
<div className="rounded-lg border" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs font-medium" style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
|
||||
Deprecated
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
|
||||
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-start gap-2 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}>
|
||||
<span className="text-amber-500 mt-0.5 flex-shrink-0">⚠</span>
|
||||
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0 rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active OAuth Sessions */}
|
||||
{oauthSessions.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{oauthSessions.map((session, i) => (
|
||||
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
|
||||
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setOauthRevokeId(session.id)}
|
||||
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -248,6 +502,282 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create OAuth Client modal */}
|
||||
{oauthCreateOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}>
|
||||
{!oauthCreatedClient ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{OAUTH_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOauthNewName(preset.name)
|
||||
setOauthNewUris(preset.uris)
|
||||
setOauthNewScopes(preset.scopes)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
|
||||
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const allScopes = Object.values(scopesByGroup).flat().map(s => s.scope)
|
||||
const allSelected = allScopes.every(s => oauthNewScopes.includes(s))
|
||||
setOauthNewScopes(allSelected ? [] : allScopes)
|
||||
}}
|
||||
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)' }}>
|
||||
{Object.values(scopesByGroup).flat().every(s => oauthNewScopes.includes(s.scope))
|
||||
? t('settings.oauth.modal.deselectAll')
|
||||
: t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||
const allGroupSelected = groupScopeKeys.every(s => oauthNewScopes.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => oauthNewScopes.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={() => setOauthScopesOpen(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)' }}>
|
||||
{oauthScopesOpen[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 => oauthNewScopes.includes(s)).length}/{groupScopeKeys.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={e => setOauthNewScopes(prev =>
|
||||
e.target.checked
|
||||
? [...new Set([...prev, ...groupScopeKeys])]
|
||||
: prev.filter(s => !groupScopeKeys.includes(s))
|
||||
)}
|
||||
className="rounded" title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} />
|
||||
</div>
|
||||
{oauthScopesOpen[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={oauthNewScopes.includes(scope)}
|
||||
onChange={e => setOauthNewScopes(prev => e.target.checked ? [...prev, scope] : prev.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>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setOauthCreateOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateOAuthClient}
|
||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthNewScopes.length === 0 || oauthCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthCreatedClient.client_id}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthCreatedClient.client_secret}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete OAuth Client confirm */}
|
||||
{oauthDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.oauth.deleteClient')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotate OAuth Client Secret confirm */}
|
||||
{oauthRotateId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthRotateId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotated Secret display */}
|
||||
{oauthRotatedSecret !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthRotatedSecret}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => setOauthRotatedSecret(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke OAuth Session confirm */}
|
||||
{oauthRevokeId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthRevokeId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevokeSession(oauthRevokeId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.oauth.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user