diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index 718cf4a6..f28a304a 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -34,6 +34,7 @@ import { TableRow, } from '@/components/ui/table'; import { useLocale } from '@/hooks/use-locale'; +import { getCsrfToken } from '@/lib/csrf'; import type { SharedData } from '@/types'; import type { Account, AccountBalance } from '@/types/account'; import { @@ -158,12 +159,7 @@ export function BalancesModal({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, body: JSON.stringify({ @@ -202,12 +198,7 @@ export function BalancesModal({ { method: 'DELETE', headers: { - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, }, diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index 93ddf09d..b5460aad 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -12,6 +12,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { getCsrfToken } from '@/lib/csrf'; import { SharedData } from '@/types'; import { Account } from '@/types/account'; import { __ } from '@/utils/i18n'; @@ -87,12 +88,7 @@ export function CreateAccountDialog({ method: 'POST', body: formData, headers: { - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, }); diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx index 1ca5dfa3..686e84a1 100644 --- a/resources/js/components/accounts/edit-account-dialog.tsx +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { decrypt, importKey } from '@/lib/crypto'; +import { getCsrfToken } from '@/lib/csrf'; import { getStoredKey } from '@/lib/key-storage'; import type { Account, LoanDetail, RealEstateDetail } from '@/types/account'; import { __ } from '@/utils/i18n'; @@ -165,12 +166,7 @@ export function EditAccountDialog({ method: 'POST', body: formData, headers: { - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, }); diff --git a/resources/js/components/accounts/import-balances-drawer.tsx b/resources/js/components/accounts/import-balances-drawer.tsx index 93d086c6..adee1566 100644 --- a/resources/js/components/accounts/import-balances-drawer.tsx +++ b/resources/js/components/accounts/import-balances-drawer.tsx @@ -12,6 +12,7 @@ import { loadBalanceImportConfig, saveBalanceImportConfig, } from '@/lib/balance-import-config-storage'; +import { getCsrfToken } from '@/lib/csrf'; import { autoDetectDateFormat, parseAmount, @@ -425,12 +426,7 @@ export function ImportBalancesDrawer({ const BATCH_SIZE = 50; let processedCount = 0; - const xsrfToken = decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ); + const xsrfToken = getCsrfToken(); for (let i = 0; i < state.balances.length; i += BATCH_SIZE) { const batch = state.balances.slice(i, i + BATCH_SIZE); diff --git a/resources/js/components/accounts/update-balance-dialog.tsx b/resources/js/components/accounts/update-balance-dialog.tsx index efa8b70c..d2e6b5f5 100644 --- a/resources/js/components/accounts/update-balance-dialog.tsx +++ b/resources/js/components/accounts/update-balance-dialog.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { getCsrfToken } from '@/lib/csrf'; import type { SharedData } from '@/types'; import { type Account, @@ -131,12 +132,7 @@ export function UpdateBalanceDialog({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, body: JSON.stringify({ diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index 71df4544..f537e26e 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -9,6 +9,7 @@ import { StepHeader } from '@/components/onboarding/step-header'; import { ConnectAccountInline } from '@/components/open-banking/connect-account-inline'; import { Button } from '@/components/ui/button'; import { CreatedAccount } from '@/hooks/use-onboarding-state'; +import { getCsrfToken } from '@/lib/csrf'; import { cn } from '@/lib/utils'; import { type SharedData } from '@/types'; import { formatCurrency } from '@/utils/currency'; @@ -128,12 +129,7 @@ export function StepCreateAccount({ method: 'POST', body: formData, headers: { - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, }); @@ -262,12 +258,7 @@ export function StepCreateAccount({ }), headers: { 'Content-Type': 'application/json', - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, }); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 7aaf5a87..604996d3 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -17,6 +17,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; +import { getCsrfToken } from '@/lib/csrf'; import type { BankingConnection, EnableBankingInstitution, @@ -81,15 +82,6 @@ interface ConnectAccountDialogProps { type Step = 'country' | 'bank' | 'confirm'; -function getCsrfToken(): string { - return decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ); -} - export function ConnectAccountDialog({ open, onOpenChange, diff --git a/resources/js/components/open-banking/connect-account-inline.tsx b/resources/js/components/open-banking/connect-account-inline.tsx index 7aa0c2cd..ea585db6 100644 --- a/resources/js/components/open-banking/connect-account-inline.tsx +++ b/resources/js/components/open-banking/connect-account-inline.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useWebHaptics } from '@/hooks/use-web-haptics'; +import { getCsrfToken } from '@/lib/csrf'; import type { BankingConnection, EnableBankingInstitution, @@ -70,15 +71,6 @@ const COINBASE_INSTITUTION: EnableBankingInstitution = { type Step = 'country' | 'bank' | 'confirm'; -function getCsrfToken(): string { - return decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ); -} - interface ConnectAccountInlineProps { onBack: () => void; connections?: BankingConnection[]; diff --git a/resources/js/components/shared/label-combobox.tsx b/resources/js/components/shared/label-combobox.tsx index 0bd53974..e1efdabc 100644 --- a/resources/js/components/shared/label-combobox.tsx +++ b/resources/js/components/shared/label-combobox.tsx @@ -14,6 +14,7 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { getCsrfToken } from '@/lib/csrf'; import { cn } from '@/lib/utils'; import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label'; import { __ } from '@/utils/i18n'; @@ -101,12 +102,7 @@ export function LabelCombobox({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), Accept: 'application/json', }, body: JSON.stringify({ diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 25b2bbe2..77053273 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -28,6 +28,7 @@ import { Textarea } from '@/components/ui/textarea'; import { useSyncContext } from '@/contexts/sync-context'; import { useLocale } from '@/hooks/use-locale'; import { decrypt, importKey } from '@/lib/crypto'; +import { getCsrfToken } from '@/lib/csrf'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; @@ -281,12 +282,7 @@ export function EditTransactionDialog({ transactionDateStr: string, transactionAmount: number, ) { - const xsrfToken = decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ); + const xsrfToken = getCsrfToken(); try { // Fetch balances from backend diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 0014568f..92cb937e 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/drawer'; import { Progress } from '@/components/ui/progress'; import { importKey } from '@/lib/crypto'; +import { getCsrfToken } from '@/lib/csrf'; import { autoDetectColumns, calculateBalancesFromTransactions, @@ -633,12 +634,7 @@ export function ImportTransactionsDrawer({ if (balancesToImport.size > 0) { try { - const xsrfToken = decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ); + const xsrfToken = getCsrfToken(); const balanceRecords = Array.from(balancesToImport.entries()); diff --git a/resources/js/lib/csrf.test.ts b/resources/js/lib/csrf.test.ts new file mode 100644 index 00000000..cf660c73 --- /dev/null +++ b/resources/js/lib/csrf.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getCsrfToken } from './csrf'; + +describe('getCsrfToken', () => { + afterEach(() => { + document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + document.cookie = 'other=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; + }); + + it('returns the decoded XSRF-TOKEN cookie value', () => { + document.cookie = 'XSRF-TOKEN=abc%3D%3D123'; + + expect(getCsrfToken()).toBe('abc==123'); + }); + + it('finds the token among other cookies', () => { + document.cookie = 'other=value'; + document.cookie = 'XSRF-TOKEN=token'; + + expect(getCsrfToken()).toBe('token'); + }); + + it('returns an empty string when the cookie is missing', () => { + expect(getCsrfToken()).toBe(''); + }); +}); diff --git a/resources/js/lib/csrf.ts b/resources/js/lib/csrf.ts new file mode 100644 index 00000000..10f860f0 --- /dev/null +++ b/resources/js/lib/csrf.ts @@ -0,0 +1,8 @@ +export function getCsrfToken(): string { + return decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ); +} diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx index de039bd8..17c3f833 100644 --- a/resources/js/pages/settings/connections.tsx +++ b/resources/js/pages/settings/connections.tsx @@ -21,6 +21,7 @@ import { import { Spinner } from '@/components/ui/spinner'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { getCsrfToken } from '@/lib/csrf'; import type { SharedData } from '@/types'; import type { BankingConnection } from '@/types/banking'; import { __ } from '@/utils/i18n'; @@ -37,11 +38,6 @@ import { import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -function getCsrfToken(): string { - const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); - return match ? decodeURIComponent(match[1]) : ''; -} - interface Props { connections: BankingConnection[]; }