Files
accounted/components/settings/ApiKeysPanel.tsx
T
MattssonandClaude Opus 5 d54b43f80f Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:56:17 +02:00

715 lines
25 KiB
TypeScript

'use client'
import { 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,
SettingsRow,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn, formatDateLong } 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: 1 },
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 (
<div className="relative group">
<pre className="rounded-md bg-muted p-4 pr-12 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all">
{text}
</pre>
<Button
variant="ghost"
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
aria-label={copyAriaLabel}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-success" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
}
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 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-md 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">
{entry.tools > 0 ? t('tools_count', { count: entry.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 { 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 [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('')
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)
}
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 (
<>
<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)}
{' · '}
{key.last_used_at
? t('used_on', { date: formatDateLong(key.last_used_at) })
: 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>
<SettingsGroup label={t('connect_mcp_title')}>
<SettingsRow
label="Claude.ai"
align="baseline"
help={t.rich('claude_ai_instructions', {
connectorName,
path: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRowNote>{t('recommended_badge')}</SettingsRowNote>
<div className="w-full min-w-0">
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<SettingsRow
label={t('claude_code_cursor')}
align="baseline"
help={t('terminal_runs_browser_login')}
>
{/* URL is quoted: unquoted `?` in the query string trips zsh globbing. */}
<div className="w-full min-w-0">
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
</SettingsRow>
<button
type="button"
aria-expanded={showApiKeyMethods}
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
className="flex items-center gap-2 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>
<CopyBlock text={`{
"mcpServers": {
"${connectorName}": {
"command": "npx",
"args": ["gnubok-mcp"],
"env": {
"GNUBOK_API_KEY": "gnubok_sk_...",
"GNUBOK_CLIENT": "claude-desktop"
}
}
}
}`} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">{t('claude_code_cursor')}</p>
<p className="mb-2 text-xs text-muted-foreground">
{t('terminal_with_api_key')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
</SettingsReveal>
</SettingsGroup>
{/* Create key dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-[calc(100vw-2rem)] rounded-2xl 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-md 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-[5px] 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">{t(group.labelKey)}</h4>
<div className="space-y-2 px-2">
{group.read && (
<ScopeCard
entry={group.read}
checked={newKeyScopes.has(group.read.scope)}
onCheckedChange={(checked) => {
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 && (
<ScopeCard
entry={group.write}
checked={newKeyScopes.has(group.write.scope)}
onCheckedChange={(checked) => {
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
})
}}
/>
)}
</div>
</div>
))}
</div>
{hasSodConflict && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 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('')
setCopied(false)
}
setShowKeyDialog(open)
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('new_key_dialog_title')}</DialogTitle>
<DialogDescription>
{t('new_key_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="relative">
<code className="block rounded-md bg-muted p-4 pr-12 text-sm font-mono break-all">
{newKeyValue}
</code>
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-2"
onClick={handleCopy}
aria-label={t('copy_aria')}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<DialogFooter>
<Button onClick={() => {
setShowKeyDialog(false)
setNewKeyValue('')
setCopied(false)
}}>
{t('done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}