Files
Momento/memento-note/components/mcp/mcp-settings-panel.tsx
Antigravity 5f9b8a9a09
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m7s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): thème des réglages et derniers textes de la revue
Les boutons des pages Agents, MCP, Données, Intégrations et Généraux suivent la couleur choisie. Les libellés encore techniques (recherche par le sens, notes proches, connexion unique) sont en langage courant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:42:28 +00:00

471 lines
17 KiB
TypeScript

'use client'
import { useState, useTransition } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Info,
Key,
Server,
Plus,
Copy,
Check,
Trash2,
Ban,
Loader2,
ExternalLink,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
generateMcpKey,
revokeMcpKey,
deleteMcpKey,
type McpKeyInfo,
type McpServerStatus,
} from '@/app/actions/mcp-keys'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
mcpAllowed?: boolean
tier?: string
}
export function McpSettingsPanel({
initialKeys,
serverStatus,
mcpAllowed = true,
tier = 'PRO',
}: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const { t } = useLanguage()
const handleGenerate = async (name: string) => {
startTransition(async () => {
try {
const result = await generateMcpKey(name)
setCreateOpen(false)
setShowRawKey(result.rawKey)
setRawKeyName(result.info.name)
setKeys(prev => [
{
shortId: result.info.shortId,
name: result.info.name,
userId: '',
userName: '',
active: true,
createdAt: new Date().toISOString(),
lastUsedAt: null,
},
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.generate'))
}
})
}
const handleRevoke = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmRevoke'))) return
startTransition(async () => {
try {
await revokeMcpKey(shortId)
setKeys(prev =>
prev.map(k => (k.shortId === shortId ? { ...k, active: false } : k))
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.revoke'))
}
})
}
const handleDelete = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmDelete'))) return
startTransition(async () => {
try {
await deleteMcpKey(shortId)
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.delete'))
}
})
}
const [showRawKey, setShowRawKey] = useState<string | null>(null)
const [rawKeyName, setRawKeyName] = useState('')
const [copied, setCopied] = useState(false)
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<div className="flex items-center gap-5 p-6 border-b border-border/40">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
<Info size={18} />
</div>
<div>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4>
</div>
</div>
<div className="p-6">
<p className="text-sm text-concrete leading-relaxed">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-accent hover:underline mt-4"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<div className="flex items-center gap-5 p-6 border-b border-border/40">
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20">
<Server size={18} />
</div>
<div>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.serverStatus.title')}</h4>
</div>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-concrete">{t('mcpSettings.serverStatus.mode')}</span>
<span className="text-xs font-medium text-ink bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border">
{serverStatus.mode.toUpperCase()}
</span>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="space-y-2">
<span className="text-sm text-concrete">{t('mcpSettings.serverStatus.url')}</span>
<code className="text-xs bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink">
{serverStatus.url}
</code>
</div>
)}
</div>
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div className="flex items-center gap-5">
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20">
<Key size={18} />
</div>
<div>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div>
</div>
{!mcpAllowed ? (
<p className="text-sm text-concrete max-w-[200px] text-end leading-relaxed">
{t('mcpSettings.tierRequired', { tier })}
</p>
) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-brand-accent text-white text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all">
<Plus className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.generate')}
</button>
</DialogTrigger>
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
</Dialog>
)}
</div>
{!mcpAllowed && (
<div className="px-6 pb-4">
<p className="text-sm text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
{t('mcpSettings.upgradeHint')}
</p>
</div>
)}
<div className="p-6">
{keys.length === 0 ? (
<div className="text-center py-8">
<Key className="h-8 w-8 mx-auto mb-2 text-concrete opacity-30" />
<p className="text-sm text-concrete">{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard key={k.shortId} keyInfo={k} onRevoke={handleRevoke} onDelete={handleDelete} isPending={isPending} />
))}
</div>
)}
</div>
</div>
<div>
<ConfigInstructions serverStatus={serverStatus} />
</div>
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.successTitle')}</DialogTitle>
<DialogDescription>{t('mcpSettings.createDialog.successDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-sm text-concrete">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-2">
<code className="flex-1 text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl break-all font-mono border border-border text-ink">
{showRawKey}
</code>
<button
onClick={() => handleCopy(showRawKey!)}
className="shrink-0 p-2.5 rounded-xl border border-border hover:bg-paper dark:hover:bg-white/10 transition-colors"
>
{copied ? <Check className="h-4 w-4 text-primary" /> : <Copy className="h-4 w-4 text-concrete" />}
</button>
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setShowRawKey(null)}
className="px-6 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium"
>
{t('mcpSettings.createDialog.done')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
)
}
function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string) => void; isPending: boolean }) {
const [name, setName] = useState('')
const { t } = useLanguage()
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.title')}</DialogTitle>
<DialogDescription>{t('mcpSettings.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="key-name" className="text-sm text-concrete">{t('mcpSettings.createDialog.nameLabel')}</Label>
<Input
id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
className="mt-2"
/>
</div>
</div>
<DialogFooter>
<button
onClick={() => onGenerate(name)}
disabled={isPending}
className="px-6 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium disabled:opacity-60"
>
<div className="flex items-center gap-2">
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />}
{isPending ? t('mcpSettings.createDialog.generating') : t('mcpSettings.createDialog.generate')}
</div>
</button>
</DialogFooter>
</DialogContent>
)
}
function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyInfo; onRevoke: (shortId: string) => void; onDelete: (shortId: string) => void; isPending: boolean }) {
const { t } = useLanguage()
const formatDate = (iso: string | null) => {
if (!iso) return t('mcpSettings.apiKeys.never')
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
return (
<div className="flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-paper/30 dark:bg-black/10">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{keyInfo.name}</span>
<span className={cn(
'text-xs font-medium px-2 py-0.5 rounded-lg',
keyInfo.active
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300'
: 'bg-concrete/10 text-concrete'
)}>
{keyInfo.active ? t('mcpSettings.apiKeys.active') : t('mcpSettings.apiKeys.revoked')}
</span>
</div>
<div className="flex gap-4 text-sm text-concrete">
<span>{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}</span>
<span>{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span>
</div>
</div>
<div>
{keyInfo.active ? (
<button
onClick={() => onRevoke(keyInfo.shortId)}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-sm font-medium text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60"
>
<Ban className="h-3 w-3" />
{t('mcpSettings.apiKeys.revoke')}
</button>
) : (
<button
onClick={() => onDelete(keyInfo.shortId)}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-sm font-medium hover:bg-rose-500/20 transition-colors disabled:opacity-60"
>
<Trash2 className="h-3 w-3" />
{t('mcpSettings.apiKeys.delete')}
</button>
)}
</div>
</div>
)
}
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
const { t } = useLanguage()
const [expanded, setExpanded] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const baseUrl = serverStatus.url || 'http://localhost:3001'
const configs = [
{
id: 'claude-code',
title: t('mcpSettings.configInstructions.claudeCode.title'),
description: t('mcpSettings.configInstructions.claudeCode.description'),
snippet: JSON.stringify(
{
mcpServers: {
'memento-note': {
command: 'node',
args: ['path/to/mcp-server/index.js'],
env: {
DATABASE_URL: 'file:path/to/memento-note/prisma/dev.db',
APP_BASE_URL: 'http://localhost:3000',
},
},
},
},
null,
2
),
},
{
id: 'cursor',
title: t('mcpSettings.configInstructions.cursor.title'),
description: t('mcpSettings.configInstructions.cursor.description'),
snippet: JSON.stringify(
{
mcpServers: {
'memento-note': {
url: baseUrl + '/mcp',
headers: { 'x-api-key': 'YOUR_API_KEY' },
},
},
},
null,
2
),
},
{
id: 'n8n',
title: t('mcpSettings.configInstructions.n8n.title'),
description: t('mcpSettings.configInstructions.n8n.description'),
snippet: `MCP Server URL: ${baseUrl}/mcp\nHeader: x-api-key: YOUR_API_KEY\nTransport: Streamable HTTP`,
},
]
const handleCopySnippet = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<div className="flex items-center gap-5 p-6 border-b border-border/40">
<div className="p-3 bg-primary/10 rounded-2xl text-primary/80 dark:text-primary border border-primary/20">
<ExternalLink size={18} />
</div>
<div>
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
</div>
</div>
<div className="p-6 space-y-3">
{configs.map(cfg => (
<div key={cfg.id} className="border border-border/60 rounded-2xl overflow-hidden">
<button
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-paper/50 dark:hover:bg-white/5 transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
>
<span className="text-sm font-medium text-ink">{cfg.title}</span>
{expanded === cfg.id ? (
<ChevronDown className="h-4 w-4 text-concrete" />
) : (
<ChevronRight className="h-4 w-4 text-concrete" />
)}
</button>
{expanded === cfg.id && (
<div className="px-5 pb-5">
<p className="text-sm text-concrete mb-3">{cfg.description}</p>
<div className="relative">
<pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border">
<code>{cfg.snippet}</code>
</pre>
<button
onClick={() => handleCopySnippet(cfg.snippet)}
className="absolute top-3 right-3 p-1.5 rounded-lg border border-border bg-paper dark:bg-black/30 hover:bg-white dark:hover:bg-black/50 transition-colors"
>
{copied ? <Check className="h-3 w-3 text-primary" /> : <Copy className="h-3 w-3 text-concrete" />}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)
}