'use client' import { useTranslations } from 'next-intl' import { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Checkbox } from '@/components/ui/checkbox' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react' import { cn } from '@/lib/utils' import { getBranding } from '@/lib/branding/service' import { STAGING_SCOPES } from '@/lib/auth/api-keys' import type { ApiKeyScope } from '@/lib/auth/api-keys' const branding = getBranding() const connectorName = branding.appName.toLowerCase() type ScopeEntry = { scope: ApiKeyScope labelKey: string /** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */ tools: number } type ScopeGroup = { domain: string labelKey: string read: ScopeEntry | null write: ScopeEntry | null } const SCOPE_GROUPS: ScopeGroup[] = [ { domain: 'transactions', labelKey: 'group_transactions', read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 }, write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 }, }, { domain: 'customers', labelKey: 'group_customers', read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 }, write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 }, }, { domain: 'invoices', labelKey: 'group_invoices', read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 }, write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 }, }, { domain: 'suppliers', labelKey: 'group_suppliers', read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 }, write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 }, }, { domain: 'reports', labelKey: 'group_reports', read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 }, write: null, }, { domain: 'bookkeeping', labelKey: 'group_bookkeeping', read: null, write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 }, }, { domain: 'payroll', labelKey: 'group_payroll', read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 }, write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 }, }, { domain: 'pending_operations', labelKey: 'group_pending_operations', read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 }, write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 }, }, { domain: 'agent', labelKey: 'group_agent', read: { scope: 'agent:read', labelKey: 'scope_agent_read', tools: 1 }, write: { scope: 'agent:write', labelKey: 'scope_agent_write', tools: 2 }, }, { domain: 'documents', labelKey: 'group_documents', read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 }, write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 }, }, { domain: 'companies', labelKey: 'group_companies', read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 0 }, write: null, }, { domain: 'events', labelKey: 'group_events', read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 }, write: null, }, { domain: 'webhooks', labelKey: 'group_webhooks', read: null, write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 }, }, { domain: 'operations', labelKey: 'group_operations', read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 }, write: null, }, { domain: 'compliance', labelKey: 'group_compliance', read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 3 }, write: null, }, { domain: 'skatteverket', labelKey: 'group_skatteverket', read: null, write: { scope: 'skatteverket:write', labelKey: 'scope_skatteverket_write', tools: 2 }, }, ] type Scope = ApiKeyScope const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => { const out: Scope[] = [] if (g.read) out.push(g.read.scope) if (g.write) out.push(g.write.scope) return out }) interface ApiKey { id: string key_prefix: string name: string scopes: string[] | null rate_limit_rpm: number mode?: 'live' | 'test' last_used_at: string | null revoked_at: string | null created_at: string } function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) { const [copied, setCopied] = useState(false) async function handleCopy() { try { await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { // clipboard unavailable (insecure context) — silently ignore } } return (
        {text}
      
) } function ScopeCard({ entry, checked, onCheckedChange, }: { entry: ScopeEntry checked: boolean onCheckedChange: (checked: boolean) => void }) { const t = useTranslations('settings_api_keys') const label = t(entry.labelKey) const dashIdx = label.indexOf(' — ') const verb = dashIdx > 0 ? label.slice(0, dashIdx) : label const description = dashIdx > 0 ? label.slice(dashIdx + 3) : '' return ( ) } export function ApiKeysPanel() { const t = useTranslations('settings_api_keys') const { toast } = useToast() const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm() const { dialogProps: sodDialogProps, confirm: confirmSod } = useDestructiveConfirm() const [keys, setKeys] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isCreating, setIsCreating] = useState(false) const [showCreateDialog, setShowCreateDialog] = useState(false) const [showKeyDialog, setShowKeyDialog] = useState(false) const [showApiKeyMethods, setShowApiKeyMethods] = useState(false) const [newKeyName, setNewKeyName] = useState('') // 'live' by default: this is the general MCP-key surface and the dominant case // is a key for the user's real company. 'test' is an explicit opt-in — a // simulation-only key that forces dry-run on every write (nothing is saved). const [newKeyMode, setNewKeyMode] = useState<'live' | 'test'>('live') const [newKeyScopes, setNewKeyScopes] = useState>(new Set(ALL_SCOPES)) const [newKeyValue, setNewKeyValue] = useState('') const [copied, setCopied] = useState(false) // Segregation-of-duties: a single key that both stages bookkeeping (any // STAGING_SCOPES member) AND can approve it (pending_operations:approve) // lets an automated agent commit financial postings with no human in the // loop. We warn inline and require an explicit confirm before submitting // with acknowledge_sod — the route returns 409 API_KEY_SOD_CONFLICT // otherwise (default create ticks all scopes, so this path is the norm). const sodConflictScope = STAGING_SCOPES.find((s) => newKeyScopes.has(s)) ?? null const hasSodConflict = newKeyScopes.has('pending_operations:approve') && sodConflictScope !== null const fetchKeys = useCallback(async () => { try { const res = await fetch('/api/settings/api-keys') const json = await res.json() if (json.data) { setKeys(json.data.filter((k: ApiKey) => !k.revoked_at)) } } catch { toast({ title: t('toast_fetch_failed'), variant: 'destructive' }) } finally { setIsLoading(false) } }, [toast, t]) useEffect(() => { fetchKeys() }, [fetchKeys]) async function handleCreate() { // SoD: require an explicit, auditable acknowledgement before minting a key // that can both stage and approve postings. if (hasSodConflict) { const ok = await confirmSod({ title: t('sod_dialog_title'), description: t('sod_dialog_description'), confirmLabel: t('sod_confirm'), variant: 'warning', }) if (!ok) return } setIsCreating(true) try { const res = await fetch('/api/settings/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newKeyName || t('default_key_name'), scopes: Array.from(newKeyScopes), mode: newKeyMode, ...(hasSodConflict ? { acknowledge_sod: true } : {}), }), }) const json = await res.json() if (!res.ok) { // The route returns the canonical { error: { code, message, message_en } } // envelope — render the message string, never the object (a React child // must be a string, not { code, message, ... }). const message = typeof json.error === 'string' ? json.error : json.error?.message ?? t('toast_create_failed') toast({ title: message, variant: 'destructive' }) return } setNewKeyValue(json.data.key) setShowCreateDialog(false) setShowKeyDialog(true) setNewKeyName('') setNewKeyMode('live') setNewKeyScopes(new Set(ALL_SCOPES)) fetchKeys() } catch { toast({ title: t('toast_create_failed'), variant: 'destructive' }) } finally { setIsCreating(false) } } async function handleRevoke(id: string, name: string) { const ok = await confirmRevoke({ title: t('revoke_dialog_title'), description: t('revoke_dialog_description', { name }), confirmLabel: t('revoke_confirm'), }) if (!ok) return try { await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' }) setKeys((prev) => prev.filter((k) => k.id !== id)) toast({ title: t('toast_revoked') }) } catch { toast({ title: t('toast_revoke_failed'), variant: 'destructive' }) } } function handleCopy() { navigator.clipboard.writeText(newKeyValue) setCopied(true) setTimeout(() => setCopied(false), 2000) } function formatDate(iso: string | null) { if (!iso) return '—' return new Date(iso).toLocaleDateString('sv-SE', { year: 'numeric', month: 'short', day: 'numeric', }) } const mcpBase = typeof window !== 'undefined' ? `${window.location.origin}/api/extensions/ext/mcp-server/mcp` : '/api/extensions/ext/mcp-server/mcp' // Telemetry-only distribution-channel marker (server reads the `client` query // param; never used for auth). Lets us measure which Claude surface connected. const mcpUrl = (client: string) => `${mcpBase}?client=${client}` return (
{t('title')} {t('description')}
{isLoading ? (
) : keys.length === 0 ? (

{t('empty_title')}

{t('empty_help')}

) : (
{keys.map((key) => { const scopeCount = key.scopes?.length ?? 0 return (

{key.name}

{key.mode === 'test' && ( {t('badge_test')} )} {scopeCount === ALL_SCOPES.length ? t('all_permissions') : scopeCount === 0 ? t('no_permissions') : t('permissions_count', { count: scopeCount })}
{key.key_prefix}... {t('created')} {formatDate(key.created_at)} {key.last_used_at ? t('used_on', { date: formatDate(key.last_used_at) }) : t('never_used')}
) })}
)}
{t('connect_mcp_title')}

Claude.ai

{t('recommended_badge')}

{t.rich('claude_ai_instructions', { connectorName, path: (chunks) => {chunks}, })}

{t('claude_code_cursor')}

{t('terminal_runs_browser_login')}

{/* URL is quoted — unquoted `?` in the query string trips zsh globbing. */}
{showApiKeyMethods && (

Claude Desktop

{t.rich('claude_desktop_instructions', { code: (chunks) => {chunks}, })}

{t('claude_code_cursor')}

{t('terminal_with_api_key')}

)}
{/* Create key dialog */} {t('create_dialog_title')} {t('create_dialog_description')}
setNewKeyName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreate()} />
{(['live', 'test'] as const).map((m) => ( ))}

{newKeyMode === 'test' ? t('mode_test_help') : t('mode_live_help')}

{t('permissions_help')}

{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
{SCOPE_GROUPS.map((group) => (

{t(group.labelKey)}

{group.read && ( { setNewKeyScopes((prev) => { const next = new Set(prev) if (checked) { next.add(group.read!.scope) } else { next.delete(group.read!.scope) if (group.write) next.delete(group.write.scope) } return next }) }} /> )} {group.write && ( { setNewKeyScopes((prev) => { const next = new Set(prev) if (checked) { next.add(group.write!.scope) if (group.read) next.add(group.read.scope) } else { next.delete(group.write!.scope) } return next }) }} /> )}
))}
{hasSodConflict && (

{t('sod_warning')}

)}
{/* Show key once dialog */} { if (!open) { setNewKeyValue('') setCopied(false) } setShowKeyDialog(open) }}> {t('new_key_dialog_title')} {t('new_key_dialog_description')}
{newKeyValue}
) }