- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog - i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo) - Settings pages: convert to Server Components (general, appearance) + loading skeleton - AI menu: add Translate option (10 languages) + Undo AI button in toolbar - Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode - Fix: NotesTabsView notes sync effect preserves selected note on content changes - Fix: auto-tag suggestions now filter already-assigned labels - Fix: color change in card view uses local state (no refresh) - Fix: nav links use <Link> for prefetching (Settings, Admin) - Fix: suppress duplicate label suggestions already on note - Route: add /api/ai/translate endpoint
479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { toast } from 'sonner'
|
|
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'
|
|
|
|
interface McpSettingsPanelProps {
|
|
initialKeys: McpKeyInfo[]
|
|
serverStatus: McpServerStatus
|
|
}
|
|
|
|
export function McpSettingsPanel({ initialKeys, serverStatus }: 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)
|
|
// Show the raw key in a new dialog
|
|
setShowRawKey(result.rawKey)
|
|
setRawKeyName(result.info.name)
|
|
// Refresh keys
|
|
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 : 'Failed to generate key')
|
|
}
|
|
})
|
|
}
|
|
|
|
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 : 'Failed to revoke key')
|
|
}
|
|
})
|
|
}
|
|
|
|
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 : 'Failed to delete key')
|
|
}
|
|
})
|
|
}
|
|
|
|
// Raw key display state
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Section 1: What is MCP */}
|
|
<Card className="p-6">
|
|
<div className="flex items-start gap-3">
|
|
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
{t('mcpSettings.whatIsMcp.description')}
|
|
</p>
|
|
<a
|
|
href="https://modelcontextprotocol.io"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
|
|
>
|
|
{t('mcpSettings.whatIsMcp.learnMore')}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Section 2: Server Status */}
|
|
<Card className="p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Server className="h-5 w-5 shrink-0" />
|
|
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
|
|
</div>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
|
|
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
|
|
</div>
|
|
{serverStatus.mode === 'sse' && serverStatus.url && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
|
|
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
|
{serverStatus.url}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Section 3: API Keys */}
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<Key className="h-5 w-5 shrink-0" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
|
|
<p className="text-sm text-gray-500">
|
|
{t('mcpSettings.apiKeys.description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" className="gap-1.5">
|
|
<Plus className="h-4 w-4" />
|
|
{t('mcpSettings.apiKeys.generate')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<CreateKeyDialog
|
|
onGenerate={handleGenerate}
|
|
isPending={isPending}
|
|
/>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{keys.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">
|
|
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
<p>{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>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Section 4: Configuration Instructions */}
|
|
<ConfigInstructions serverStatus={serverStatus} />
|
|
|
|
{/* Raw Key Display Dialog */}
|
|
<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-xs text-gray-500">{rawKeyName}</Label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
|
|
{showRawKey}
|
|
</code>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleCopy(showRawKey!)}
|
|
className="shrink-0"
|
|
>
|
|
{copied ? (
|
|
<Check className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => setShowRawKey(null)}>
|
|
{t('mcpSettings.createDialog.done')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Sub-components ──────────────────────────────────────────────────────────────
|
|
|
|
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">{t('mcpSettings.createDialog.nameLabel')}</Label>
|
|
<Input
|
|
id="key-name"
|
|
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => onGenerate(name)}
|
|
disabled={isPending}
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
|
{t('mcpSettings.createDialog.generating')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Key className="h-4 w-4 mr-1" />
|
|
{t('mcpSettings.createDialog.generate')}
|
|
</>
|
|
)}
|
|
</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-lg border bg-gray-50 dark:bg-gray-900">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm">{keyInfo.name}</span>
|
|
<Badge variant={keyInfo.active ? 'default' : 'secondary'} className="text-xs">
|
|
{keyInfo.active
|
|
? t('mcpSettings.apiKeys.active')
|
|
: t('mcpSettings.apiKeys.revoked')}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-gray-500">
|
|
<span>
|
|
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
|
|
</span>
|
|
<span>
|
|
{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{keyInfo.active ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => onRevoke(keyInfo.shortId)}
|
|
disabled={isPending}
|
|
className="gap-1"
|
|
>
|
|
<Ban className="h-3.5 w-3.5" />
|
|
{t('mcpSettings.apiKeys.revoke')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => onDelete(keyInfo.shortId)}
|
|
disabled={isPending}
|
|
className="gap-1"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
{t('mcpSettings.apiKeys.delete')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
|
|
const { t } = useLanguage()
|
|
const [expanded, setExpanded] = useState<string | null>(null)
|
|
|
|
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: {
|
|
'keep-notes': {
|
|
command: 'node',
|
|
args: ['path/to/mcp-server/index.js'],
|
|
env: {
|
|
DATABASE_URL: 'file:path/to/keep-notes/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: {
|
|
'keep-notes': {
|
|
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
|
|
Header: x-api-key: YOUR_API_KEY
|
|
Transport: Streamable HTTP`,
|
|
},
|
|
]
|
|
|
|
return (
|
|
<Card className="p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<ExternalLink className="h-5 w-5 shrink-0" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold">
|
|
{t('mcpSettings.configInstructions.title')}
|
|
</h2>
|
|
<p className="text-sm text-gray-500">
|
|
{t('mcpSettings.configInstructions.description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{configs.map(cfg => (
|
|
<div key={cfg.id} className="border rounded-lg overflow-hidden">
|
|
<button
|
|
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
|
|
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
|
>
|
|
<span className="font-medium text-sm">{cfg.title}</span>
|
|
{expanded === cfg.id ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
{expanded === cfg.id && (
|
|
<div className="px-4 pb-4">
|
|
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
|
|
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
|
|
<code>{cfg.snippet}</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|