diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 5b373dad..518abeaa 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -42,16 +42,23 @@ export default function LoginPage() { return () => clearInterval(interval) }, [resetCooldownUntil]) + const [bankIdUnavailable, setBankIdUnavailable] = useState(false) + const handleBankIdComplete = async (result: BankIdResult) => { if (result.error === 'no_account') { setBankIdNoAccount({ givenName: result.givenName, surname: result.surname }) return } + if (result.error === 'service_unavailable') { + setBankIdUnavailable(true) + return + } + if (result.error) { toast({ title: 'Inloggning misslyckades', - description: 'Kunde inte slutfora BankID-inloggningen.', + description: 'Kunde inte slutföra BankID-inloggningen.', variant: 'destructive', }) return @@ -376,6 +383,24 @@ export default function LoginPage() { )} + {bankIdUnavailable && ( +
+

+ Har du inget lösenord? +

+

+ Om du skapade ditt konto med BankID kan du använda{' '} + {' '} + för att få en inloggningslänk via e-post. +

+
+ )}
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index b287d9ac..551e5762 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -60,7 +60,14 @@ function RegisterPageContent() { .catch(() => {}) }, [searchParams]) + const [bankIdUnavailable, setBankIdUnavailable] = useState(false) + const handleBankIdComplete = (result: BankIdResult) => { + if (result.error === 'service_unavailable') { + setBankIdUnavailable(true) + return + } + if (result.error) { toast({ title: 'BankID misslyckades', @@ -350,6 +357,14 @@ function RegisterPageContent() { )} + {bankIdUnavailable && !bankIdUser && ( +
+

+ Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare. +

+
+ )} + {bankIdUser ? (
diff --git a/app/(dashboard)/settings/banking/page.tsx b/app/(dashboard)/settings/banking/page.tsx index b6134cdb..e9b8b71c 100644 --- a/app/(dashboard)/settings/banking/page.tsx +++ b/app/(dashboard)/settings/banking/page.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' -import { AlertTriangle, CheckCircle2, CreditCard, ExternalLink, Loader2 } from 'lucide-react' +import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react' import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' @@ -17,9 +17,6 @@ export default function BankingSettingsPage() { const router = useRouter() const { toast } = useToast() const [bankConnectionError, setBankConnectionError] = useState(null) - const [isSyncing, setIsSyncing] = useState(false) - const [syncResult, setSyncResult] = useState<{ imported: number } | null>(null) - const successTimerRef = useRef>(null) const syncInitiatedRef = useRef(false) const abortControllerRef = useRef(null) const unmountedRef = useRef(false) @@ -28,7 +25,6 @@ export default function BankingSettingsPage() { useEffect(() => { return () => { unmountedRef.current = true - if (successTimerRef.current) clearTimeout(successTimerRef.current) if (abortControllerRef.current) abortControllerRef.current.abort() } }, []) @@ -43,7 +39,10 @@ export default function BankingSettingsPage() { router.replace('/settings/banking') if (connectionId) { - setIsSyncing(true) + toast({ + title: 'Synkroniserar transaktioner...', + description: 'Hämtar transaktioner från din bank i bakgrunden.', + }) const controller = new AbortController() abortControllerRef.current = controller const syncTimeout = setTimeout(() => controller.abort(), 120_000) @@ -59,11 +58,12 @@ export default function BankingSettingsPage() { clearTimeout(syncTimeout) const data = await res.json() if (res.ok) { - setSyncResult({ imported: data.imported ?? 0 }) - successTimerRef.current = setTimeout(() => { - setIsSyncing(false) - setSyncResult(null) - }, 3000) + if (!unmountedRef.current) { + toast({ + title: 'Bank ansluten!', + description: `${data.imported ?? 0} transaktioner importerade`, + }) + } } else { throw new Error(data.error || 'Sync failed') } @@ -82,7 +82,6 @@ export default function BankingSettingsPage() { variant: 'destructive', }) } - setIsSyncing(false) } })() } else { @@ -105,36 +104,6 @@ export default function BankingSettingsPage() { } }, [searchParams, router, toast]) - if (isSyncing) { - return ( -
- - - {syncResult ? ( - <> -
- -
-

Bank ansluten!

-

- {syncResult.imported} transaktioner importerade -

- - ) : ( - <> - -

Hämtar transaktioner från din bank...

-

- Detta kan ta upp till en minut -

- - )} -
-
-
- ) - } - return (
{bankConnectionError && ( diff --git a/app/api/transactions/[id]/__tests__/route.test.ts b/app/api/transactions/[id]/__tests__/route.test.ts index 59392042..9ac4e6ad 100644 --- a/app/api/transactions/[id]/__tests__/route.test.ts +++ b/app/api/transactions/[id]/__tests__/route.test.ts @@ -60,28 +60,30 @@ describe('DELETE /api/transactions/[id]', () => { expect(body.error).toContain('booked') }) - it('returns 409 when transaction is bank-synced', async () => { + it('allows deleting unbooked bank-synced transactions', async () => { const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null }) - enqueue({ data: tx, error: null }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: null }) // delete const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) - const { status, body } = await parseJsonResponse<{ error: string }>(response) + const { status, body } = await parseJsonResponse(response) - expect(status).toBe(409) - expect(body.error).toContain('bank-synced') + expect(status).toBe(200) + expect(body).toEqual({ success: true }) }) - it('returns 409 when transaction was imported', async () => { + it('allows deleting unbooked imported transactions', async () => { const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null }) - enqueue({ data: tx, error: null }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: null }) // delete const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' }) const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) - const { status, body } = await parseJsonResponse<{ error: string }>(response) + const { status, body } = await parseJsonResponse(response) - expect(status).toBe(409) - expect(body.error).toContain('imported') + expect(status).toBe(200) + expect(body).toEqual({ success: true }) }) it('deletes a manually added unbooked transaction', async () => { diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts index 5e694e8a..7ea3e32a 100644 --- a/app/api/transactions/[id]/route.ts +++ b/app/api/transactions/[id]/route.ts @@ -19,7 +19,7 @@ export async function DELETE( // Fetch the transaction with ownership check const { data: transaction, error: fetchError } = await supabase .from('transactions') - .select('id, journal_entry_id, bank_connection_id, import_source') + .select('id, journal_entry_id') .eq('id', id) .eq('company_id', companyId) .single() @@ -28,25 +28,13 @@ export async function DELETE( return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) } - // Guard: only manually added, unbooked transactions can be deleted + // Guard: only unbooked transactions can be deleted if (transaction.journal_entry_id) { return NextResponse.json( { error: 'Cannot delete a booked transaction. Use reversal (storno) instead.' }, { status: 409 } ) } - if (transaction.bank_connection_id) { - return NextResponse.json( - { error: 'Cannot delete a bank-synced transaction' }, - { status: 409 } - ) - } - if (transaction.import_source) { - return NextResponse.json( - { error: 'Cannot delete an imported transaction' }, - { status: 409 } - ) - } const { error: deleteError } = await supabase .from('transactions') diff --git a/components/auth/BankIdAuth.tsx b/components/auth/BankIdAuth.tsx index 11d8bef6..b4827cc4 100644 --- a/components/auth/BankIdAuth.tsx +++ b/components/auth/BankIdAuth.tsx @@ -4,9 +4,12 @@ import { useState, useEffect, useRef, useCallback } from 'react' import Image from 'next/image' import { BankIdQrCode } from './BankIdQrCode' import { Button } from '@/components/ui/button' -import { Smartphone, Monitor } from 'lucide-react' +import { Smartphone, Monitor, AlertTriangle } from 'lucide-react' -type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' +type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' | 'service_unavailable' + +/** Max consecutive poll failures before we declare service unavailable */ +const MAX_POLL_FAILURES = 3 interface BankIdSession { sessionId: string @@ -19,7 +22,7 @@ export interface BankIdResult { tokenHash?: string type?: string isNewUser?: boolean - error?: 'no_account' | 'already_linked' | 'session_invalid' + error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable' givenName?: string surname?: string sessionId?: string @@ -53,6 +56,8 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { const onCompleteRef = useRef(onComplete) onCompleteRef.current = onComplete + const pollFailureCount = useRef(0) + const cleanup = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current) @@ -62,6 +67,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { abortRef.current.abort() abortRef.current = null } + pollFailureCount.current = 0 }, []) useEffect(() => cleanup, [cleanup]) @@ -81,7 +87,14 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { const res = await fetch(`${API_BASE}/start`, { method: 'POST' }) if (!res.ok) { const err = await res.json().catch(() => ({})) - throw new Error(err.error || 'Failed to start BankID') + if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) { + cleanup() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) + return + } + throw new Error(err.message || err.error || 'Failed to start BankID') } const { data } = await res.json() @@ -105,7 +118,22 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { signal: abortRef.current?.signal, }) - if (!pollRes.ok) return + if (!pollRes.ok) { + const pollErr = await pollRes.json().catch(() => ({})) + if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) { + pollFailureCount.current++ + if (pollFailureCount.current >= MAX_POLL_FAILURES) { + cleanup() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) + } + } + return + } + + // Reset failure counter on successful poll + pollFailureCount.current = 0 const pollJson = await pollRes.json() const pollData = pollJson.data @@ -147,8 +175,15 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { const completeJson = await completeRes.json() if (!completeRes.ok) { + const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503 + ? 'service_unavailable' as const + : completeJson.error + if (errorCode === 'service_unavailable') { + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + } onCompleteRef.current({ - error: completeJson.error, + error: errorCode, givenName: completeJson.givenName, surname: completeJson.surname, }) @@ -197,7 +232,13 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { } } catch (error) { if (error instanceof Error && error.name === 'AbortError') return - // Polling error — will retry next tick + pollFailureCount.current++ + if (pollFailureCount.current >= MAX_POLL_FAILURES) { + cleanup() + setStatus('service_unavailable') + setErrorMessage('BankID-tjänsten är inte tillgänglig just nu') + onCompleteRef.current({ error: 'service_unavailable' }) + } } }, 2000) } catch (error) { @@ -234,13 +275,43 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) { ) } + if (status === 'service_unavailable') { + return ( +
+
+ +
+

+ BankID är inte tillgängligt just nu +

+

+ {mode === 'login' + ? 'Logga in med e-post och lösenord nedan, eller använd "Glömt lösenord?" för en inloggningslänk via e-post.' + : mode === 'signup' + ? 'Skapa konto med e-post och lösenord nedan istället.' + : 'Försök igen senare.'} +

+ +
+
+
+ ) + } + if (status === 'failed') { return (

{errorMessage}

) diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 5cc1c78f..38f95517 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -60,7 +60,7 @@ export default function TransactionInboxCard({ const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const showCheckbox = isBatchMode && isUncategorized const hasDocumentMatch = !!transaction.matched_inbox_item - const isManualTransaction = !transaction.bank_connection_id && !transaction.import_source && !transaction.journal_entry_id + const isDeletable = !transaction.journal_entry_id function handleSuggestionClick(suggestion: SuggestedCategory) { if (onOpenQuickReview) { @@ -251,8 +251,8 @@ export default function TransactionInboxCard({ Välj mall... - {/* Delete button — only for manually added, unbooked transactions */} - {isManualTransaction && onDelete && ( + {/* Delete button — available for all unbooked transactions */} + {isDeletable && onDelete && (