b5da51ea0a
* feat(settings): API & MCP tab: correct connector URL namespace, plugin path, Swedish guide The in-product MCP URLs omitted `tool_namespace=accounted`, and resolveMcpToolNamespace() falls back to the legacy `gnubok_` prefix when the param is absent. Every connection made from Settings therefore got `gnubok_*` tool names while the docs, the accounted-api skill, and claude-plugin/.mcp.json all reference `accounted_*`. - Add `tool_namespace=accounted` to the Claude.ai, Claude Code, and Claude Desktop snippets. - Move the Claude Desktop bridge from `npx gnubok-mcp` / `GNUBOK_API_KEY` to `npx -y accounted-mcp` / `ACCOUNTED_API_KEY`, and emit `ACCOUNTED_URL` so self-hosted and white-label instances get a config pointing at their own host. The `gnubok_sk_` key prefix is unchanged: it is wire format. - Surface the Claude Code plugin, the only path that configures the connection and the seven workflow commands in one step. - Rename the settings tab "API" to "API & MCP" and rewrite its intro: the MCP connection is what most users come here for, not API keys. - Link the step-by-step guide from the panel, locale-aware. Docs: add a Swedish /docs/api/anslut-claude alongside the English page (the docs site has no locale routing, so each language is its own URL), give both the Claude Code plugin path, and teach the export and freshness scripts about the new page so cross-repo drift is caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * fix(settings,docs): Cursor is not Claude Code; use the accounted_ tool name Review follow-up on #2105. `claude mcp add` is a Claude Code command. Cursor does not read it, so the "Claude Code / Cursor" row and the docs sentence pointing Cursor users at that command were both wrong (the row predates this PR; the docs sentence did not). Cursor now gets its own row and its own `~/.cursor/mcp.json` snippet with the `url` field, in the panel and in both docs pages. Also `vat_close_check` -> `accounted_vat_close_check` in the reviewer test on both pages, matching the identifier used in the prompts section above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * feat(settings,docs): one-click Connect to Claude, and cut the panel to one action Anthropic documents an install link for custom connectors: https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=NAME&connectorUrl=ENCODED (claude.com/docs/connectors/building/directory-vs-custom). It opens claude.ai with the connector name and URL prefilled; the user still reviews and confirms, and it grants nothing on its own. We were telling people to copy a URL and go paste it somewhere else instead. Settings panel, rendered and reviewed: - "Connect to Claude" button is now the only thing above the fold. Everything that needs a config file or a terminal (claude.ai manual paste, Claude Code, the plugin, Cursor) moved into one "Other clients" disclosure, and the API-key methods keep theirs. Four code blocks -> one button, 1057px -> 719px. - The connect group renders above the API-keys group. Connecting is why users open this tab; the tab's own intro says so. - Each entry inside the disclosures shows its instruction as visible text. They were `?` HelpPopovers, so the panel read as opaque code blobs with no instructions on screen. - Prose interpolates the brand's real casing, not the lowercased config key. Docs, both languages: Path A leads with the install link and drops from five manual steps to a link plus three short paragraphs, with the manual paste kept under a subheading. No raw HTML: the docs renderer has no rehype-raw, so <details> would have been silently dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg * fix(settings,docs): correct claude mcp add syntax and the SSR install link Review follow-up. Both findings verified before acting on them. `claude mcp add --help` gives `claude mcp add [options] <name> <commandOrUrl>`: the URL is positional and there is no `--url` flag, so the API-key snippet would have failed on a missing argument. Both commands now put `--transport http` before the name and pass the URL positionally, in the panel and in both docs pages. The panel is server-rendered before it hydrates and window.location has no server equivalent, so the install link was built from a relative mcpBase in the first paint. A click in that window would hand claude.ai a connectorUrl it cannot resolve. The origin now resolves after mount and the anchor carries no href until it is known, which also makes it unclickable rather than wrong. Verified: the SSR HTML contains no claude.ai href and no relative connectorUrl, post-hydration the href is absolute, and there are no hydration warnings. DECISIONS.md: code-span the `gnubok_*`/`accounted_*` wildcards so they stop rendering as emphasis, and drop the "no one-click deeplink" claim from the earlier entry rather than leave a false statement standing two lines above its own correction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HfZgiZmNN6qGEfgvXqxAeg --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
723 lines
28 KiB
TypeScript
723 lines
28 KiB
TypeScript
'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_<domain>_<verb>`. */
|
|
const scopeLabelKey = (scope: Scope) => `scope_${scope.replace(':', '_')}`
|
|
/** i18n key for a group heading: `group_<domain>`. */
|
|
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<CopyState>('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 (
|
|
<div className="relative group">
|
|
<pre className="select-all rounded-lg bg-muted p-4 pr-12 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all">
|
|
{text}
|
|
</pre>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn(
|
|
'absolute right-1.5 top-1.5 h-7 w-7 p-0 transition-opacity focus-visible:opacity-100',
|
|
state === 'failed' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
)}
|
|
onClick={handleCopy}
|
|
aria-label={copyAriaLabel}
|
|
>
|
|
{state === 'copied' ? (
|
|
<Check className="h-3.5 w-3.5 text-success" />
|
|
) : state === 'failed' ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-attn" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
{/* Live region is always mounted so the message is announced when it
|
|
appears, not merely inserted. */}
|
|
<div role="status" aria-live="polite">
|
|
{state === 'failed' && <AttnLine className="mt-1.5">{t('copy_failed')}</AttnLine>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<label
|
|
className={cn(
|
|
'flex min-h-[68px] cursor-pointer flex-col gap-1 rounded-lg border p-2 transition-colors',
|
|
checked
|
|
? 'border-border bg-secondary'
|
|
: 'border-border hover:bg-secondary/60'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={onCheckedChange}
|
|
className="shrink-0"
|
|
/>
|
|
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
|
|
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
|
{tools > 0 ? t('tools_count', { count: tools }) : t('rest_badge')}
|
|
</span>
|
|
</div>
|
|
{description && (
|
|
<p className="ml-6 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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<ApiKey[]>([])
|
|
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<Set<Scope>>(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 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(mcpUrl('claude-connector'))}`
|
|
|
|
return (
|
|
<>
|
|
<SettingsGroup label={t('connect_mcp_title')}>
|
|
{/* The marketing site's halftone AI marks (Claude, OpenAI): a quiet
|
|
"works with" cue, not chrome. Text carries the meaning; the marks
|
|
are decorative. */}
|
|
<div className="flex items-center gap-3 px-1 pb-1 pt-3">
|
|
<div aria-hidden className="flex shrink-0 items-center gap-2">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={illustrationSrc('logo-claude')}
|
|
width={ILLUSTRATIONS['logo-claude'].w}
|
|
height={ILLUSTRATIONS['logo-claude'].h}
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="h-5 w-auto"
|
|
/>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={illustrationSrc('logo-openai')}
|
|
width={ILLUSTRATIONS['logo-openai'].w}
|
|
height={ILLUSTRATIONS['logo-openai'].h}
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="h-5 w-auto"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t('works_with_ai')}</p>
|
|
</div>
|
|
{/* 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. */}
|
|
<div className="px-1 pb-4 pt-2">
|
|
<Button asChild size="lg">
|
|
<a
|
|
href={origin ? claudeInstallUrl : undefined}
|
|
aria-disabled={!origin}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{t('connect_to_claude')}
|
|
<ArrowUpRight className="ml-1.5 h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
<p className="mt-2 max-w-prose text-xs text-muted-foreground">
|
|
{t('connect_to_claude_help')}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
aria-expanded={showOtherClients}
|
|
onClick={() => setShowOtherClients(!showOtherClients)}
|
|
className="flex w-full items-center gap-2 border-t border-border px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
|
>
|
|
<ChevronDown
|
|
className={cn(
|
|
'h-3.5 w-3.5 transition-transform duration-150',
|
|
!showOtherClients && '-rotate-90',
|
|
)}
|
|
/>
|
|
{t('other_clients')}
|
|
</button>
|
|
<SettingsReveal open={showOtherClients}>
|
|
<div className="space-y-6 pb-3 pt-1">
|
|
<div>
|
|
<p className="mb-1 text-sm">{t('claude_ai_manual')}</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
{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) => <strong>{chunks}</strong>,
|
|
})}
|
|
</p>
|
|
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-1 text-sm">{t('claude_plugin_label')}</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">{t('claude_plugin_instructions')}</p>
|
|
<CopyBlock
|
|
text={`/plugin marketplace add erp-mafia/accounted\n/plugin install accounted@accounted`}
|
|
copyAriaLabel={t('copy_aria')}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-1 text-sm">Claude Code</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">{t('terminal_runs_browser_login')}</p>
|
|
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
|
|
<CopyBlock text={`claude mcp add --transport http ${connectorName} "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-1 text-sm">Cursor</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
{t.rich('cursor_instructions', {
|
|
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
|
})}
|
|
</p>
|
|
<CopyBlock text={`{
|
|
"mcpServers": {
|
|
"${connectorName}": {
|
|
"url": "${mcpUrl('cursor')}"
|
|
}
|
|
}
|
|
}`} copyAriaLabel={t('copy_aria')} />
|
|
</div>
|
|
</div>
|
|
</SettingsReveal>
|
|
|
|
<button
|
|
type="button"
|
|
aria-expanded={showApiKeyMethods}
|
|
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
|
|
className="flex w-full items-center gap-2 border-t border-border px-1 py-3 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
|
>
|
|
<ChevronDown
|
|
className={cn(
|
|
'h-3.5 w-3.5 transition-transform duration-150',
|
|
!showApiKeyMethods && '-rotate-90',
|
|
)}
|
|
/>
|
|
{t('connect_with_api_key')}
|
|
</button>
|
|
<SettingsReveal open={showApiKeyMethods}>
|
|
<div className="space-y-6 pb-3 pt-1">
|
|
<div>
|
|
<p className="mb-1 text-sm">Claude Desktop</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
{t.rich('claude_desktop_instructions', {
|
|
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
|
})}
|
|
</p>
|
|
{/* 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. */}
|
|
<CopyBlock text={`{
|
|
"mcpServers": {
|
|
"${connectorName}": {
|
|
"command": "npx",
|
|
"args": ["-y", "accounted-mcp"],
|
|
"env": {
|
|
"ACCOUNTED_API_KEY": "gnubok_sk_...",
|
|
"ACCOUNTED_URL": "${mcpUrl('claude-desktop')}",
|
|
"ACCOUNTED_CLIENT": "claude-desktop"
|
|
}
|
|
}
|
|
}
|
|
}`} copyAriaLabel={t('copy_aria')} />
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mb-1 text-sm">Claude Code</p>
|
|
<p className="mb-2 text-xs text-muted-foreground">
|
|
{t('terminal_with_api_key')}
|
|
</p>
|
|
<CopyBlock text={`claude mcp add --transport http ${connectorName} \\
|
|
"${mcpUrl('claude-code')}" \\
|
|
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
|
|
</div>
|
|
</div>
|
|
</SettingsReveal>
|
|
|
|
{/* 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. */}
|
|
<a
|
|
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 px-1 py-3 text-xs text-muted-foreground underline-offset-4 transition-colors duration-150 hover:text-foreground hover:underline"
|
|
>
|
|
{t('full_guide_link')}
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
</a>
|
|
</SettingsGroup>
|
|
|
|
<SettingsGroup>
|
|
{/* Group eyebrow with the group's primary action on the right. Styling
|
|
mirrors SettingsGroup's label line; the "?" holds the old panel
|
|
description. */}
|
|
<div className="flex items-center justify-between gap-4 px-1">
|
|
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
<span>{t('title')}</span>
|
|
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setShowCreateDialog(true)}
|
|
disabled={keys.length >= 10}
|
|
>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('create_key')}
|
|
</Button>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : keys.length === 0 ? (
|
|
<EmptyState
|
|
icon={Key}
|
|
title={t('empty_title')}
|
|
description={t('empty_help')}
|
|
/>
|
|
) : (
|
|
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 (
|
|
<div
|
|
key={key.id}
|
|
className="flex items-center gap-3 border-b border-border px-1 py-3"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm">{key.name}</span>
|
|
{key.mode === 'test' && (
|
|
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
|
|
{t('badge_test')}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
|
{permissionSummary}
|
|
{' · '}
|
|
<span className="font-mono">{key.key_prefix}...</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{t('created')} {formatDateLong(key.created_at, locale)}
|
|
{' · '}
|
|
{key.last_used_at
|
|
? t('used_on', { date: formatDateLong(key.last_used_at, locale) })
|
|
: t('never_used')}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => handleRevoke(key.id, key.name)}
|
|
aria-label={t('revoke_aria', { name: key.name })}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</SettingsGroup>
|
|
|
|
{/* Create key dialog */}
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-xl p-4 sm:max-w-3xl sm:p-6">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('create_dialog_description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="key-name">{t('name_label')}</Label>
|
|
<Input
|
|
id="key-name"
|
|
placeholder={t('name_placeholder')}
|
|
value={newKeyName}
|
|
onChange={(e) => setNewKeyName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t('mode_label')}</Label>
|
|
<div className="inline-flex rounded-full border p-0.5" role="radiogroup" aria-label={t('mode_label')}>
|
|
{(['live', 'test'] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={newKeyMode === m}
|
|
onClick={() => setNewKeyMode(m)}
|
|
className={cn(
|
|
'rounded-full px-3 py-1.5 text-xs transition-colors',
|
|
newKeyMode === m
|
|
? 'bg-secondary text-foreground'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{t(m === 'live' ? 'mode_live' : 'mode_test')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{newKeyMode === 'test' ? t('mode_test_help') : t('mode_live_help')}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<Label>{t('permissions_label')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('permissions_help')}
|
|
</p>
|
|
</div>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{t('selected_count', { selected: newKeyScopes.size, total: ALL_SCOPES.length })}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{SCOPE_GROUPS.map((group) => (
|
|
<div key={group.domain} className="space-y-2">
|
|
<h4 className="text-sm font-medium">
|
|
{isRestOnlyGroup(group)
|
|
? t('group_rest_only', { name: t(groupLabelKey(group)) })
|
|
: t(groupLabelKey(group))}
|
|
</h4>
|
|
<div className="space-y-2 px-2">
|
|
{group.scopes.map((scope) => (
|
|
<ScopeCard
|
|
key={scope}
|
|
scope={scope}
|
|
checked={newKeyScopes.has(scope)}
|
|
onCheckedChange={(checked) => toggleScope(group, scope, checked)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{hasSodConflict && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-foreground"
|
|
>
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
|
<p className="leading-snug">{t('sod_warning')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
|
{t('cancel')}
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={isCreating || newKeyScopes.size === 0}>
|
|
{isCreating && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
{t('create')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DestructiveConfirmDialog {...revokeDialogProps} />
|
|
<DestructiveConfirmDialog {...sodDialogProps} />
|
|
|
|
{/* Show key once dialog */}
|
|
<Dialog open={showKeyDialog} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setNewKeyValue('')
|
|
}
|
|
setShowKeyDialog(open)
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('new_key_dialog_description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<CopyBlock text={newKeyValue} copyAriaLabel={t('copy_aria')} />
|
|
<DialogFooter>
|
|
<Button onClick={() => {
|
|
setShowKeyDialog(false)
|
|
setNewKeyValue('')
|
|
}}>
|
|
{t('done')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|