Files
TREK/client/src/components/Settings/IntegrationsTab.tsx
T
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00

861 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Section from './Section'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
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[]
allows_client_credentials: boolean
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
name: string
token_prefix: string
created_at: string
last_used_at: string | null
}
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = useAuthStore((s) => s.managed)
return (
<>
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
runs themselves. On a managed install there is none, and every one of
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{S.llmEnabled && !managed && <LlmConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
</>
)
}
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
useEffect(() => {
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)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
const [oauthIsMachine, setOauthIsMachine] = useState(false)
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
const [configOpenToken, setConfigOpenToken] = useState(false)
// MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [mcpNewName, setMcpNewName] = useState('')
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfigOAuth = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--static-oauth-client-info",
"{\\"client_id\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}
}`
useEffect(() => {
if (mcpEnabled) {
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
}
}, [mcpEnabled])
const handleCreateMcpToken = async () => {
if (!mcpNewName.trim()) return
setMcpCreating(true)
try {
const d = await authApi.mcpTokens.create(mcpNewName.trim())
setMcpCreatedToken(d.token.raw_token)
setMcpNewName('')
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
} catch {
toast.error(t('settings.mcp.toast.createError'))
} finally {
setMcpCreating(false)
}
}
const handleDeleteMcpToken = async (id: number) => {
try {
await authApi.mcpTokens.delete(id)
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
setMcpDeleteId(null)
toast.success(t('settings.mcp.toast.deleted'))
} catch {
toast.error(t('settings.mcp.toast.deleteError'))
}
}
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
})
}
// 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()) return
if (!oauthIsMachine && !oauthNewUris.trim()) return
setOauthCreating(true)
try {
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({
name: oauthNewName.trim(),
redirect_uris: uris,
allowed_scopes: oauthNewScopes,
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
})
setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('')
setOauthNewUris('')
setOauthNewScopes([])
setOauthIsMachine(false)
} 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'))
}
}
return {
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
function IntegrationsMcpSection(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<Section title={t('settings.mcp.title')} icon={Terminal}>
{/* Endpoint URL */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.mcp.endpoint')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border bg-surface-secondary border-edge text-content">
{mcpEndpoint}
</code>
<button type="button" onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
className="p-2 rounded-lg border border-edge transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
title={t('settings.mcp.copy')}>
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
</button>
</div>
</div>
{/* Sub-tab bar */}
<div className="flex gap-1 rounded-lg p-1 border bg-surface-secondary border-edge">
<button type="button"
onClick={() => setActiveMcpTab('oauth')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}>
{t('settings.oauth.clients')}
</button>
<button type="button"
onClick={() => setActiveMcpTab('apitokens')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}>
{t('settings.mcp.apiTokens')}
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgba(245,158,11,0.15)] text-[#b45309] border border-[rgba(245,158,11,0.4)]">
Deprecated
</span>
</button>
</div>
{/* OAuth 2.1 Clients tab */}
{activeMcpTab === 'oauth' && (
<>
{/* JSON config — OAuth (collapsible) */}
<div className="rounded-lg border overflow-hidden border-edge">
<button type="button"
onClick={() => setConfigOpenOAuth(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 bg-surface-secondary">
<span className="text-sm font-medium text-content-secondary">{t('settings.mcp.clientConfig')}</span>
{configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button>
{configOpenOAuth && (
<div className="p-3 border-t border-edge">
<div className="flex justify-end mb-1.5">
<button type="button" onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
{copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border bg-surface-secondary border-edge text-content">
{mcpJsonConfigOAuth}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
</div>
)}
</div>
<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 type="button" onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
</div>
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border border-edge" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden border-edge">
{oauthClients.map((client, i) => (
<div key={client.id} className={`px-4 py-3 ${i < oauthClients.length - 1 ? 'border-b border-edge' : ''}`}>
<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">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate text-content">{client.name}</p>
{client.allows_client_credentials && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0 bg-[rgba(99,102,241,0.12)] text-[#4f46e5] border border-[rgba(99,102,241,0.3)]">
{t('settings.oauth.badge.machine')}
</span>
)}
</div>
<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 border bg-surface-secondary border-edge" style={{ color: 'var(--text-tertiary)' }}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
type="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 border border-edge"
style={{ color: 'var(--text-tertiary)' }}>
{oauthScopesExpanded[client.id] ? '−' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<button type="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 type="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>
</div>
))}
</div>
)}
</div>
{/* Active OAuth Sessions */}
{oauthSessions.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2 text-content-secondary">{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden border-edge">
{oauthSessions.map((session, i) => (
<div key={session.id} className={`flex items-center gap-3 px-4 py-3 ${i < oauthSessions.length - 1 ? 'border-b border-edge' : ''}`}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-content">{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 type="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 border-edge"
style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')}
</button>
</div>
))}
</div>
</div>
)}
</>
)}
{/* API Tokens tab (deprecated) */}
{activeMcpTab === 'apitokens' && (
<>
<div className="flex items-baseline gap-2 px-3 py-2.5 rounded-lg bg-[rgba(245,158,11,0.06)] border border-[rgba(245,158,11,0.3)]">
<span className="text-amber-500 flex-shrink-0 leading-none">⚠</span>
<p className="text-xs text-[#92400e]">{t('settings.mcp.apiTokensDeprecated')}</p>
</div>
{/* JSON config — API Token (collapsible) */}
<div className="rounded-lg border overflow-hidden border-edge">
<button type="button"
onClick={() => setConfigOpenToken(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 bg-surface-secondary">
<span className="text-sm font-medium text-content-secondary">{t('settings.mcp.clientConfig')}</span>
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button>
{configOpenToken && (
<div className="p-3 border-t border-edge">
<div className="flex justify-end mb-1.5">
<button type="button" onClick={() => handleCopy(mcpJsonConfig, 'json-token')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
{copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border bg-surface-secondary border-edge text-content">
{mcpJsonConfig}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
)}
</div>
<div className="flex justify-end">
<button type="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 text-content-secondary bg-surface-tertiary">
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden border-edge">
{mcpTokens.map((token, i) => (
<div key={token.id} className={`flex items-center gap-3 px-4 py-3 ${i < mcpTokens.length - 1 ? 'border-b border-edge' : ''}`}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-content">{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 type="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>
)}
</>
)}
</Section>
)
}
function McpTokenModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create MCP Token modal */}
{mcpModalOpen && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
{!mcpCreatedToken ? (
<>
<h3 className="text-lg font-semibold text-content">{t('settings.mcp.modal.createTitle')}</h3>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.mcp.modal.tokenName')}</label>
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
autoFocus />
</div>
<div className="flex gap-2 justify-end pt-1">
<button type="button" onClick={() => setMcpModalOpen(false)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold text-content">{t('settings.mcp.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200 bg-[rgba(251,191,36,0.1)]">
<span className="text-amber-500 mt-0.5">⚠</span>
<p className="text-sm text-content-secondary">{t('settings.mcp.modal.createdWarning')}</p>
</div>
<div className="relative">
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap bg-surface-secondary border-edge text-content">
{mcpCreatedToken}
</pre>
<button type="button" onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600 text-content-secondary"
title={t('settings.mcp.copy')}>
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex justify-end">
<button type="button" onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete MCP Token confirm */}
{mcpDeleteId !== null && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('settings.mcp.deleteTokenTitle')}</h3>
<p className="text-sm text-content-secondary">{t('settings.mcp.deleteTokenMessage')}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setMcpDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={() => handleDeleteMcpToken(mcpDeleteId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.mcp.deleteTokenTitle')}
</button>
</div>
</div>
</div>
)}
</>
)
}
function OAuthClientModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[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] bg-surface-card">
{!oauthCreatedClient ? (
<>
<h3 className="text-lg font-semibold text-content">{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 border-edge text-content-secondary bg-surface-secondary">
{preset.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-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-slate-400 border-edge bg-surface-secondary text-content"
autoFocus />
</div>
<label htmlFor="oauth-machine-client" className="flex items-start gap-2.5 cursor-pointer">
<input id="oauth-machine-client" type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<div className="text-sm font-medium text-content-secondary">
{t('settings.oauth.modal.machineClient')}
<p className="text-xs mt-0.5 font-normal" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
</div>
</label>
{!oauthIsMachine && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-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-slate-400 border-edge bg-surface-secondary text-content" />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-content-secondary">{t('settings.oauth.modal.scopes')}</label>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
</div>
<div className="flex gap-2 justify-end pt-1">
<button type="button" onClick={() => setOauthCreateOpen(false)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold text-content">{t('settings.oauth.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200 bg-[rgba(251,191,36,0.1)]">
<span className="text-amber-500 mt-0.5">⚠</span>
<p className="text-sm text-content-secondary">{t('settings.oauth.modal.createdWarning')}</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium mb-1 text-content-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 bg-surface-secondary border-edge text-content">
{oauthCreatedClient.client_id}
</code>
<button type="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 border-edge">
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1 text-content-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 bg-surface-secondary border-edge text-content">
{oauthCreatedClient.client_secret}
</code>
<button type="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 border-edge">
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
</button>
</div>
</div>
</div>
{oauthCreatedClient?.allows_client_credentials && (
<div className="p-3 rounded-lg border text-xs font-mono bg-surface-secondary border-edge" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.modal.machineClientUsage')}
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete OAuth Client confirm */}
{oauthDeleteId !== null && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[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 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('settings.oauth.deleteClient')}</h3>
<p className="text-sm text-content-secondary">{t('settings.oauth.deleteClientMessage')}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setOauthDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="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 role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[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 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('settings.oauth.rotateSecret')}</h3>
<p className="text-sm text-content-secondary">{t('settings.oauth.rotateSecretMessage')}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setOauthRotateId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{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 bg-[rgba(0,0,0,0.5)]">
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
<h3 className="text-lg font-semibold text-content">{t('settings.oauth.rotateSecretDoneTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200 bg-[rgba(251,191,36,0.1)]">
<span className="text-amber-500 mt-0.5">⚠</span>
<p className="text-sm text-content-secondary">{t('settings.oauth.rotateSecretDoneWarning')}</p>
</div>
<div>
<label className="block text-xs font-medium mb-1 text-content-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 bg-surface-secondary border-edge text-content">
{oauthRotatedSecret}
</code>
<button type="button" onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
</button>
</div>
</div>
<div className="flex justify-end">
<button type="button" onClick={() => setOauthRotatedSecret(null)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
</button>
</div>
</div>
</div>
)}
{/* Revoke OAuth Session confirm */}
{oauthRevokeId !== null && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[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 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('settings.oauth.revokeSession')}</h3>
<p className="text-sm text-content-secondary">{t('settings.oauth.revokeSessionMessage')}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setOauthRevokeId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="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>
)}
</>
)
}