From a25e75be25a166c271cdc9fd6905f26e7cacc4d6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:11:47 +0200 Subject: [PATCH] fix: Nordea Business CSV variants, API keys UI polish, transaction categorization (#183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: MCP OAuth 303 redirect, send dialog auto-close, bank details null payload - OAuth authorize: use 303 See Other instead of default 307, which preserved POST method and caused Claude's callback to return 405 - SendInvoiceDialog: close dialog and show toast after email send instead of leaving a success message that requires manual close - BankDetailsSetupDialog: omit empty fields from payload instead of sending null, which fails Zod validation on non-nullable schema fields Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove dead sentMessage state and fix stale comment Remove sentMessage state, its success banner JSX, and the CheckCircle2 import — all unreachable after the dialog now auto-closes on email send. Fix stale "to null" comment in BankDetailsSetupDialog. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: support Nordea Business CSV variants, polish API keys UI, fix transaction categorization - Extend Nordea Business bank file parser to handle three CSV export formats (classic, Betalare/Mottagare variant, Bokföringsdatum variant) with proper detection guards against SEB/LF misidentification - Rework ApiKeysPanel: add CopyBlock component, destructive confirm on revoke, collapsible API-key-based connection methods, Claude.ai OAuth instructions as recommended path, simplified scope badges - Stop deriving is_business from category on manual transaction creation; set null so categorization flow handles it correctly - Show categorize button when journal_entry_id is missing regardless of is_business value Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review — await clipboard, fix zero-scope label, simplify condition - Await navigator.clipboard.writeText and catch failures - Change zero-scope label from "Enbart läs" to "Inga behörigheter" - Simplify redundant ternary condition in TransactionHistoryList Co-Authored-By: Claude Opus 4.6 (1M context) * fix: repair broken ternary in TransactionHistoryList JSX Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/transactions/page.tsx | 2 +- components/settings/ApiKeysPanel.tsx | 209 ++++++++++++------ components/transactions/TransactionForm.tsx | 4 +- .../transactions/TransactionHistoryList.tsx | 6 +- lib/import/bank-file/__tests__/parser.test.ts | 98 ++++++++ .../bank-file/formats/nordea-business.ts | 106 +++++++-- 6 files changed, 325 insertions(+), 100 deletions(-) diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 1b68176d..1745bd14 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -488,7 +488,7 @@ export default function TransactionsPage() { amount: data.amount, currency: data.currency, category: data.category || 'uncategorized', - is_business: data.is_business, + is_business: null, notes: data.notes, }) .select() diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index e95e74ce..f8b98c03 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com 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, @@ -14,8 +15,9 @@ import { 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 } from 'lucide-react' +import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown } from 'lucide-react' const SCOPE_GROUPS = [ { @@ -104,19 +106,55 @@ interface ApiKey { created_at: string } +function CopyBlock({ text }: { text: 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}
+      
+ +
+ ) +} + export function ApiKeysPanel() { const { toast } = useToast() + const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = 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('') const [newKeyScopes, setNewKeyScopes] = useState>(new Set(ALL_SCOPES)) const [newKeyValue, setNewKeyValue] = useState('') const [copied, setCopied] = useState(false) - const [revokingId, setRevokingId] = useState(null) const fetchKeys = useCallback(async () => { try { @@ -164,16 +202,20 @@ export function ApiKeysPanel() { } } - async function handleRevoke(id: string) { - setRevokingId(id) + async function handleRevoke(id: string, name: string) { + const ok = await confirmRevoke({ + title: 'Återkalla API-nyckel', + description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`, + confirmLabel: 'Återkalla', + }) + if (!ok) return + try { await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' }) setKeys((prev) => prev.filter((k) => k.id !== id)) toast({ title: 'Nyckel återkallad' }) } catch { toast({ title: 'Fel', description: 'Kunde inte återkalla nyckel', variant: 'destructive' }) - } finally { - setRevokingId(null) } } @@ -232,59 +274,50 @@ export function ApiKeysPanel() { ) : (
- {keys.map((key) => ( -
-
-
-

{key.name}

-
- {(key.scopes ?? []).map((s) => ( - - {SCOPE_LABELS[s as Scope] ?? s} - - ))} - {(!key.scopes || key.scopes.length === 0) && ( - - Enbart läs - - )} + {keys.map((key) => { + const scopeCount = key.scopes?.length ?? 0 + return ( +
+
+
+

{key.name}

+ + {scopeCount === ALL_SCOPES.length + ? 'Alla behörigheter' + : scopeCount === 0 + ? 'Inga behörigheter' + : `${scopeCount} behörigheter`} + +
+
+ + {key.key_prefix}... + + + Skapad {formatDate(key.created_at)} + + + {key.last_used_at + ? `Använd ${formatDate(key.last_used_at)}` + : 'Aldrig använd'} +
-
- - {key.key_prefix}... - - - Skapad {formatDate(key.created_at)} - - - {key.last_used_at - ? `Använd ${formatDate(key.last_used_at)}` - : 'Aldrig använd'} - -
-
- -
- ))} + +
+ ) + })}
)} @@ -296,12 +329,42 @@ export function ApiKeysPanel() {
-

Claude Desktop

+
+

Claude.ai

+ Rekommenderat +

- Lägg till i claude_desktop_config.json (Inställningar → Developer): + Gå till Settings → Integrations → Add Integration och klistra in MCP-serverns URL. + Du loggas in via ditt gnubok-konto — ingen API-nyckel behövs.

-
-{`{
+            
+          
+ +
+

Claude Code / Cursor

+

+ Kör i terminalen — loggar in via webbläsaren: +

+ +
+ +
+ + {showApiKeyMethods && ( +
+
+

Claude Desktop

+

+ Lägg till i claude_desktop_config.json (Inställningar → Developer): +

+ -
+}`} /> +
-
-

Claude Code / Cursor

-

- Kör i terminalen med en API-nyckel: -

-
-{`claude mcp add gnubok --transport http \\
+                
+

Claude Code / Cursor

+

+ Kör i terminalen med en API-nyckel: +

+ + --header "Authorization: Bearer gnubok_sk_..."`} /> +
+
+ )}
@@ -419,6 +482,8 @@ export function ApiKeysPanel() { + + {/* Show key once dialog */} { if (!open) { diff --git a/components/transactions/TransactionForm.tsx b/components/transactions/TransactionForm.tsx index 7dfd065b..e4912330 100644 --- a/components/transactions/TransactionForm.tsx +++ b/components/transactions/TransactionForm.tsx @@ -78,15 +78,13 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm const isIncome = categories.find((c) => c.value === watchCategory)?.isIncome const onFormSubmit = (data: FormData) => { - const isBusiness = data.category ? data.category !== 'private' : undefined - onSubmit({ date: data.date, description: data.description, amount: data.amount, currency: data.currency, category: data.category as TransactionCategory, - is_business: isBusiness, + is_business: undefined, notes: data.notes, }) } diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index ea52f7d0..121dd5ce 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -138,7 +138,7 @@ export default function TransactionHistoryList({ Bokförd - ) : transaction.is_business === null ? ( + ) : ( <> ·
- {transaction.is_business === null && !transaction.journal_entry_id && ( + {!transaction.journal_entry_id && (