mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
2c2d008dc2
* fix(security): mint idempotency keys properly, and resolve the extractor by path Three findings out of a sweep of every open Sonar security issue. The other 49 turned out to be false positives and are documented in sonar-project.properties rather than changed — each entry scoped to a file, never a whole rule, so a future Math.random() on an auth path still fails. The idempotency key is the one that can lose data. Both producers fell back to Math.random when crypto.randomUUID was missing, and randomUUID needs a secure context — TREK's own quickstart is http://localhost:3000 and LAN installs run on http://192.168.x.x, so on the deployments the README documents the fallback is what actually runs. The axios one was `Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a single character and 0 yields the empty string, which the interceptor reads as "no key" and skips deduplication for entirely. Two writes sharing a key collapse into one — the second gets the first one's cached response and the edit is gone with no error anywhere. Both now come from utils/randomId, which has the getRandomValues rung that works in insecure contexts too. The extractor probe stored the bare binary name, so every extraction re-resolved it through whatever PATH held at that moment. The official image sets KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install without it ran an unqualified name from a PATH it does not control. It now walks the search path itself and keeps the absolute path of the file it verified; execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through app-config because process.env is walled off in src/. * fix: four tests and sorts that were not doing what they claimed Sonar reported 43 high-impact reliability issues. Reading all of them, four are real; the rest are cosmetic or would be made worse by the suggested change. budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid ? 1 : 0` and the handler stores the value verbatim — the test asserted a payload the server never sends. The line below it already used a number. admin.controller.test.ts had a `.resolves` assertion with no await. The promise was dropped, so that line could not fail the test. The two to-do category lists sorted with a bare .sort(), which is byte order: every accented name files behind the whole ASCII range, so in a German trip "Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data. One side effect worth knowing: katColor picks a category's dot colour by its index, so re-sorting can move which category shows which colour — it already moves whenever a category is added, and the colour carries no meaning. Deliberately not changed: the two `| 0` truncations (Math.trunc is not equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the two .map() callbacks whose second parameter is declared and used, and the sha1 in thumbnail.service (its own comment says a swap would orphan every existing thumbnail). * refactor: give every button a type, and prefer the Number statics Two mechanical sweeps, no behaviour change intended in either. A <button> with no type attribute is type="submit" per the HTML spec. Outside a form that does nothing today, but it turns into a surprise the moment the markup is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get an explicit type="button". Two things were left alone on purpose: buttons that take a {...spread}, because the spread may carry a type and the merge order decides which wins, and anything inside a <form> — there the answer is a judgement call, and checking all eleven form files found every real submit button already carrying type="submit". The Number sweep covers only the three globals that are exactly equivalent to their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false, and renaming it would change what the code does. It reaches server and plugin-sdk as well as the client; both copies of egress-policy.ts got the identical edit, so the mirror the SDK's parity test guards stays intact.
860 lines
47 KiB
TypeScript
860 lines
47 KiB
TypeScript
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
|
||
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 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 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 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 className="flex items-start gap-2.5 cursor-pointer">
|
||
<input 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>
|
||
<span className="text-sm font-medium text-content-secondary">{t('settings.oauth.modal.machineClient')}</span>
|
||
<p className="text-xs mt-0.5" 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 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 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 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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|