'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 */}
{/* Show key once dialog */}
)
}