'use client' import { useLocale, useTranslations } from 'next-intl' import { useState, useEffect, useCallback } from 'react' 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 { EmptyState } from '@/components/ui/empty-state' import { HelpPopover } from '@/components/ui/help-popover' import { useToast } from '@/components/ui/use-toast' import { SettingsGroup, SettingsReveal, } from '@/components/settings/SettingsRows' import { AttnLine } from '@/components/ui/attn-line' import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle, ArrowUpRight } from 'lucide-react' import { cn, formatDateLong } from '@/lib/utils' import { copyToClipboard } from '@/lib/browser/copy-to-clipboard' import { getBranding } from '@/lib/branding/service' import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations' import { ALL_SCOPES, SCOPE_GROUPS, STAGING_SCOPES, TOOL_COUNT_BY_SCOPE, scopeKind, type ApiKeyScope, type ScopeGroup, } from '@/lib/auth/scope-catalog' const branding = getBranding() const connectorName = branding.appName.toLowerCase() type Scope = ApiKeyScope /** i18n key for a scope card: `scope__`. */ const scopeLabelKey = (scope: Scope) => `scope_${scope.replace(':', '_')}` /** i18n key for a group heading: `group_`. */ const groupLabelKey = (group: ScopeGroup) => `group_${group.domain}` /** A group with no MCP tool behind any of its scopes only gates REST endpoints. */ const isRestOnlyGroup = (group: ScopeGroup) => group.scopes.every((scope) => TOOL_COUNT_BY_SCOPE[scope] === 0) 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 } type CopyState = 'idle' | 'copied' | 'failed' function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) { const t = useTranslations('settings_api_keys') const [state, setState] = useState('idle') async function handleCopy() { // The write is the first await, so the click's user activation still holds. const result = await copyToClipboard(text) if (result !== 'copied') { // Never imply success. The block stays on screen and is select-all, so // the user can copy it by hand: with no clipboard there is no other way. setState('failed') return } setState('copied') setTimeout(() => setState('idle'), 2000) } return (
        {text}
      
{/* Live region is always mounted so the message is announced when it appears, not merely inserted. */}
{state === 'failed' && {t('copy_failed')}}
) } function ScopeCard({ scope, checked, onCheckedChange, }: { scope: Scope checked: boolean onCheckedChange: (checked: boolean) => void }) { const t = useTranslations('settings_api_keys') const label = t(scopeLabelKey(scope)) const tools = TOOL_COUNT_BY_SCOPE[scope] const sepIdx = label.indexOf(': ') const verb = sepIdx > 0 ? label.slice(0, sepIdx) : label const description = sepIdx > 0 ? label.slice(sepIdx + 2) : '' return ( ) } export function ApiKeysPanel() { const t = useTranslations('settings_api_keys') const locale = useLocale() 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 [showOtherClients, setShowOtherClients] = 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('') // 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 // Elevated scopes (write/approve/signoff) imply the group's read scope: // ticking one ticks read, and unticking read clears the whole group. function toggleScope(group: ScopeGroup, scope: Scope, checked: boolean) { setNewKeyScopes((prev) => { const next = new Set(prev) const readScope = group.scopes.find((s) => scopeKind(s) === 'read') if (checked) { next.add(scope) if (readScope) next.add(readScope) } else if (scope === readScope) { for (const s of group.scopes) next.delete(s) } else { next.delete(scope) } return next }) } 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' }) } } // This panel is server-rendered before it hydrates, and window.location has // no server equivalent. Reading the origin at render time therefore yields a // relative URL in the first paint, and a click on the install link in that // window would hand claude.ai a connectorUrl it cannot resolve. Resolve the // origin after mount and withhold the link's href until it is known. const [origin, setOrigin] = useState('') useEffect(() => setOrigin(window.location.origin), []) const mcpBase = `${origin}/api/extensions/ext/mcp-server/mcp` // `tool_namespace=accounted` is load-bearing: resolveMcpToolNamespace() // falls back to the legacy `gnubok_` tool prefix when the param is absent, // so a URL without it hands the client tool names that none of our docs, // skills, or the Claude Code plugin reference. `client` is a telemetry-only // distribution marker (server reads it; never used for auth). const mcpUrl = (client: string) => `${mcpBase}?tool_namespace=accounted&client=${client}` // claude.ai's Add-custom-connector dialog probes the URL without credentials // and pre-fills Authentication "None" when the lazy handshake answers 200, // which blocks the sign-in later. `auth=required` makes every tokenless // request answer the 401 challenge so the dialog detects OAuth instead // (extensions/general/mcp-server/auth-mode.ts). Claude Code, Cursor and the // stdio bridge keep the lazy URL. const claudeConnectorUrl = `${mcpUrl('claude-connector')}&auth=required` // Grok's custom-connector dialog does the same probe: on the lazy URL it // lists every tool and never opens the sign-in (observed 2026-09-02). const grokConnectorUrl = `${mcpUrl('grok')}&auth=required` // claude.ai install link: opens Add-custom-connector with name and URL // prefilled. It only prefills the dialog, so the user still reviews and // confirms, and Anthropic's cloud must be able to reach the URL: on // localhost or a firewalled self-host the manual paste below is the path. // https://claude.com/docs/connectors/building/directory-vs-custom const claudeInstallUrl = 'https://claude.ai/customize/connectors?modal=add-custom-connector' + `&connectorName=${encodeURIComponent(branding.appName)}` + `&connectorUrl=${encodeURIComponent(claudeConnectorUrl)}` return ( <> {/* The marketing site's halftone AI marks (Claude, OpenAI): a quiet "works with" cue, not chrome. Text carries the meaning; the marks are decorative. */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}

{t('works_with_ai')}

{/* claude.ai is the path for nearly everyone, and the install link makes it one click, so it is the only thing above the fold. Every other client needs a config file or a terminal, which is a different job: it lives behind one disclosure instead of four code blocks competing with the button. */}

{t('connect_to_claude_help')}

{/* The step-by-step guide is canonical on the docs site, in one language per URL (the docs site has no locale routing). Root-relative so the /docs/api/* 308 in next.config.ts forwards to docs.gnubok.se. It sits right under the button: the steps on Claude's side after the click (consent, first-call sign-in) live there, and a reader who has just clicked should not have to open two disclosures to find them (issue #2133). */} {t('full_guide_link')}

{t('claude_ai_manual')}

{t.rich('claude_ai_instructions', { // Prose gets the brand's real casing; `connectorName` is the // lowercased config key and reads wrong in a sentence. connectorName: branding.appName, path: (chunks) => {chunks}, })}

Grok

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

{t('claude_plugin_label')}

{t('claude_plugin_instructions')}

Claude Code

{t('terminal_runs_browser_login')}

{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}

Cursor

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

Claude Desktop

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

{/* ACCOUNTED_URL is emitted so self-hosted and white-label instances get a config that points at their own host: the bridge otherwise defaults to the hosted endpoint. The key value keeps the `gnubok_sk_` wire prefix on purpose. */}

Claude Code

{t('terminal_with_api_key')}

{/* Group eyebrow with the group's primary action on the right. Styling mirrors SettingsGroup's label line; the "?" holds the old panel description. */}

{t('title')} {t('description')}

{isLoading ? (
) : keys.length === 0 ? ( ) : ( keys.map((key) => { const scopeCount = key.scopes?.length ?? 0 const permissionSummary = scopeCount === ALL_SCOPES.length ? t('all_permissions') : scopeCount === 0 ? t('no_permissions') : t('permissions_count', { count: scopeCount }) return (
{key.name} {key.mode === 'test' && ( {t('badge_test')} )} {permissionSummary} {' · '} {key.key_prefix}... {t('created')} {formatDateLong(key.created_at, locale)} {' · '} {key.last_used_at ? t('used_on', { date: formatDateLong(key.last_used_at, locale) }) : t('never_used')}
) }) )}
{/* 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) => (

{isRestOnlyGroup(group) ? t('group_rest_only', { name: t(groupLabelKey(group)) }) : t(groupLabelKey(group))}

{group.scopes.map((scope) => ( toggleScope(group, scope, checked)} /> ))}
))}
{hasSodConflict && (

{t('sod_warning')}

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