refactor(js): extract shared getCsrfToken util (#475)

## What

Same XSRF-TOKEN cookie-parsing snippet was duplicated inline in **12
files** (some as local `getCsrfToken()` copies, some inline
`decodeURIComponent(...)` blocks).

Extracted to single util: `resources/js/lib/csrf.ts`, with vitest
coverage.

## Why

Part of duplication-removal series — PRs that mostly delete code.

## Stats

- App code: **-89 / +23 lines**
- 12 files now import one shared util
- New: `lib/csrf.ts` (8 lines) + `lib/csrf.test.ts`

## Checks

- `bun run test` — 154 pass (3 new)
- `bun run lint` — clean (1 pre-existing warning in chart.tsx)
- `bun run format` — applied
- `tsc --noEmit` — 59 pre-existing errors on main, same 59 on branch
(none introduced)
This commit is contained in:
Víctor Falcón 2026-06-03 17:26:09 +02:00 committed by GitHub
parent e178f1b1bd
commit aa3b811027
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 57 additions and 89 deletions

View File

@ -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',
},
},

View File

@ -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',
},
});

View File

@ -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',
},
});

View File

@ -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);

View File

@ -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({

View File

@ -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',
},
});

View File

@ -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,

View File

@ -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[];

View File

@ -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({

View File

@ -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

View File

@ -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());

View File

@ -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('');
});
});

8
resources/js/lib/csrf.ts Normal file
View File

@ -0,0 +1,8 @@
export function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}

View File

@ -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[];
}