diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php index 2b3ec7fb..0b3d0640 100644 --- a/app/Http/Controllers/Api/TransactionController.php +++ b/app/Http/Controllers/Api/TransactionController.php @@ -21,8 +21,6 @@ class TransactionController extends Controller if ($request->query('encrypted') === 'true') { $query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')); - } elseif ($request->query('encrypted') === 'false') { - $query->whereNull('description_iv')->whereNull('notes_iv'); } $transactions = $query->simplePaginate(100); diff --git a/app/Http/Controllers/EncryptionController.php b/app/Http/Controllers/EncryptionController.php index 311aedb7..0da92b28 100644 --- a/app/Http/Controllers/EncryptionController.php +++ b/app/Http/Controllers/EncryptionController.php @@ -2,34 +2,11 @@ namespace App\Http\Controllers; -use App\Http\Requests\SetupEncryptionRequest; -use App\Models\EncryptedMessage; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class EncryptionController extends Controller { - public function setup(SetupEncryptionRequest $request): JsonResponse - { - $user = $request->user(); - - $user->update([ - 'encryption_salt' => $request->validated('salt'), - ]); - - EncryptedMessage::query()->updateOrCreate( - ['user_id' => $user->id], - [ - 'encrypted_content' => $request->validated('encrypted_content'), - 'iv' => $request->validated('iv'), - ] - ); - - return response()->json([ - 'message' => 'Encryption setup completed successfully', - ]); - } - public function getMessage(Request $request): JsonResponse { $user = $request->user(); diff --git a/app/Http/Requests/SetupEncryptionRequest.php b/app/Http/Requests/SetupEncryptionRequest.php deleted file mode 100644 index ca985f08..00000000 --- a/app/Http/Requests/SetupEncryptionRequest.php +++ /dev/null @@ -1,31 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'salt' => ['required', 'string', 'size:24'], - 'encrypted_content' => ['required', 'string'], - 'iv' => ['required', 'string', 'size:16'], - ]; - } -} diff --git a/database/migrations/2026_06_20_105609_align_accounts_encrypted_flag_with_plaintext_names.php b/database/migrations/2026_06_20_105609_align_accounts_encrypted_flag_with_plaintext_names.php new file mode 100644 index 00000000..6021920b --- /dev/null +++ b/database/migrations/2026_06_20_105609_align_accounts_encrypted_flag_with_plaintext_names.php @@ -0,0 +1,35 @@ +where('encrypted', true) + ->whereNull('name_iv') + ->update(['encrypted' => false]); + } + + /** + * Irreversible: once flipped, a migration-corrected account is + * indistinguishable from an account that was always plaintext, so blanket + * re-flagging would wrongly encrypt legitimately unencrypted accounts. + */ + public function down(): void + { + // + } +}; diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx index 91120404..97b4f3f3 100644 --- a/resources/js/components/app-logo-icon.tsx +++ b/resources/js/components/app-logo-icon.tsx @@ -1,4 +1,3 @@ -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { usePrivacyMode } from '@/contexts/privacy-mode-context'; import { cn } from '@/lib/utils'; import { BirdIcon, Birdhouse, SVGAttributes } from 'lucide-react'; @@ -10,7 +9,6 @@ export default function AppLogoIcon({ className?: SVGAttributes['className']; animated?: boolean; }) { - const { isKeySet } = useEncryptionKey(); const { isPrivacyModeEnabled } = usePrivacyMode(); const iconClasses = cn( @@ -19,7 +17,7 @@ export default function AppLogoIcon({ 'fill-transparent', ); - const showBirdhouse = !isKeySet || isPrivacyModeEnabled; + const showBirdhouse = isPrivacyModeEnabled; if (!animated) { return ; diff --git a/resources/js/components/encrypted-text.tsx b/resources/js/components/encrypted-text.tsx deleted file mode 100644 index 053af8b0..00000000 --- a/resources/js/components/encrypted-text.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { decrypt, importKey } from '@/lib/crypto'; -import { getStoredKey } from '@/lib/key-storage'; -import { useEffect, useMemo, useRef, useState } from 'react'; - -type Length = number | { min: number; max: number } | null; - -interface EncryptedTextProps { - encryptedText: string; - iv: string; - className?: string; - length: Length; -} - -type DisplayState = 'encrypted' | 'decrypted' | 'loading'; - -const ENCRYPTED_CHARSET = '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - -function resolveTargetLength(length: Length, fallback: number): number { - if (typeof length === 'number') { - return Math.max(1, length); - } - - if (length && typeof length === 'object') { - const min = Math.max(1, length.min); - const max = Math.max(min, length.max); - const clampedFallback = Math.min(Math.max(fallback, min), max); - - return clampedFallback; - } - - return Math.max(1, fallback); -} - -function generateMaskedText(targetLength: number): string { - if (targetLength <= 0) { - return ''; - } - - let result = ''; - for (let index = 0; index < targetLength; index += 1) { - const randomIndex = Math.floor( - Math.random() * ENCRYPTED_CHARSET.length, - ); - result += ENCRYPTED_CHARSET.charAt(randomIndex); - } - - return result; -} - -function getInitialDisplayState(isKeySet: boolean): DisplayState { - if (!isKeySet) { - return 'encrypted'; - } - if (typeof window === 'undefined') { - return 'encrypted'; - } - const keyString = getStoredKey(); - return keyString ? 'loading' : 'encrypted'; -} - -export function EncryptedText(props: EncryptedTextProps) { - const { encryptedText, iv, className = '', length = null } = props; - const { isKeySet } = useEncryptionKey(); - const targetLength = useMemo( - () => resolveTargetLength(length, encryptedText.length), - [length, encryptedText.length], - ); - const maskedValue = useMemo( - () => generateMaskedText(targetLength), - [targetLength], - ); - const [cachedDecryption, setCachedDecryption] = useState<{ - encryptedText: string; - iv: string; - value: string; - } | null>(null); - const [displayState, setDisplayState] = useState(() => - getInitialDisplayState(isKeySet), - ); - const prevIsKeySetRef = useRef(isKeySet); - - useEffect(() => { - const wasKeySet = prevIsKeySetRef.current; - prevIsKeySetRef.current = isKeySet; - - if (!isKeySet) { - if (wasKeySet) { - setCachedDecryption(null); - setDisplayState('encrypted'); - } - return; - } - - const keyString = getStoredKey(); - if (!keyString) { - if (wasKeySet !== isKeySet) { - setCachedDecryption(null); - setDisplayState('encrypted'); - } - return; - } - - if (!wasKeySet && isKeySet) { - setDisplayState('loading'); - } - - let cancelled = false; - - importKey(keyString) - .then((key) => decrypt(encryptedText, key, iv)) - .then((value) => { - if (cancelled) { - return; - } - setCachedDecryption({ encryptedText, iv, value }); - setDisplayState('decrypted'); - }) - .catch(() => { - if (cancelled) { - return; - } - setCachedDecryption(null); - setDisplayState('encrypted'); - }); - - return () => { - cancelled = true; - }; - }, [encryptedText, iv, isKeySet]); - - if (displayState === 'loading') { - const widthInCharacters = Math.max(targetLength, 3) / 2; - const loadingClassName = [ - 'inline-block animate-pulse rounded bg-muted/60', - className, - ] - .filter(Boolean) - .join(' '); - - return ( - - ); - } - - if (displayState === 'decrypted' && cachedDecryption) { - return {cachedDecryption.value}; - } - - return {maskedValue}; -} diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx index 2607c896..6f987758 100644 --- a/resources/js/components/transactions/import-step-preview.tsx +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -1,4 +1,4 @@ -import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description'; +import { TransactionDescription } from '@/components/transactions/transaction-description'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -16,7 +16,6 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useLocale } from '@/hooks/use-locale'; import { transactionSyncService } from '@/services/transaction-sync'; import { type ParsedTransaction } from '@/types/import'; @@ -47,7 +46,6 @@ export function ImportStepPreview({ onSelectAll, isImporting, }: ImportStepPreviewProps) { - const { isKeySet } = useEncryptionKey(); const locale = useLocale(); const [existingTransactions, setExistingTransactions] = useState< Transaction[] @@ -55,7 +53,7 @@ export function ImportStepPreview({ const [isExistingOpen, setIsExistingOpen] = useState(false); useEffect(() => { - if (accountId && isKeySet) { + if (accountId) { transactionSyncService.getByAccountId(accountId).then((txns) => { const sorted = txns.sort( (a, b) => @@ -65,7 +63,7 @@ export function ImportStepPreview({ setExistingTransactions(sorted.slice(0, 10)); }); } - }, [accountId, isKeySet]); + }, [accountId]); const stats = useMemo(() => { const selectableTransactions = transactions.filter( @@ -304,15 +302,8 @@ export function ImportStepPreview({ )} - diff --git a/resources/js/components/transactions/import-transactions-button.tsx b/resources/js/components/transactions/import-transactions-button.tsx index 787b6357..baa399bd 100644 --- a/resources/js/components/transactions/import-transactions-button.tsx +++ b/resources/js/components/transactions/import-transactions-button.tsx @@ -5,7 +5,6 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; @@ -23,19 +22,11 @@ interface ImportData { } export function ImportTransactionsButton() { - const { isKeySet } = useEncryptionKey(); const [drawerOpen, setDrawerOpen] = useState(false); const [importData, setImportData] = useState(null); const [loading, setLoading] = useState(false); const handleOpenDrawer = async () => { - if (!isKeySet) { - toast.error( - __('Please unlock your encryption key to import transactions'), - ); - return; - } - // Fetch data on-demand when drawer opens setLoading(true); try { @@ -61,7 +52,7 @@ export function ImportTransactionsButton() { - {!isKeySet - ? 'Unlock encryption to categorize' - : uncategorizedCount === 0 - ? 'All transactions are categorized' - : `Categorize ${uncategorizedCount} transactions`} + {uncategorizedCount === 0 + ? 'All transactions are categorized' + : `Categorize ${uncategorizedCount} transactions`} @@ -225,11 +206,6 @@ export function TransactionActionsMenu({ - {!isKeySet - ? __( - 'Unlock encryption to add transactions', - ) - : __('Create a new transaction')} + {__('Create a new transaction')} @@ -271,18 +243,12 @@ export function TransactionActionsMenu({ {isMobile && ( - + {__('Add transaction')} )} - + {__('Import Transactions')} diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index e96fb5cd..6b9eda84 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -6,10 +6,12 @@ import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react'; import { AccountName } from '@/components/accounts/account-name'; import { BankLogo } from '@/components/bank-logo'; -import { EncryptedText } from '@/components/encrypted-text'; import { LabelBadges } from '@/components/shared/label-combobox'; import { CategoryCell } from '@/components/transactions/category-cell'; -import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description'; +import { + ENCRYPTED_PLACEHOLDER, + TransactionDescription, +} from '@/components/transactions/transaction-description'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -207,18 +209,15 @@ export function createTransactionColumns({ .filter(Boolean) as Label[]; const hasLabels = transactionLabels.length > 0; - const hasNotes = - transaction.decryptedNotes || - (transaction.notes && transaction.notes_iv); + const hasNotes = !!transaction.notes; return (
-
{showLabels && hasLabels && ( @@ -231,19 +230,11 @@ export function createTransactionColumns({ {showNotes && hasNotes && (
- {transaction.decryptedNotes ? ( - - {transaction.decryptedNotes} - - ) : ( - - )} + + {transaction.notes_iv + ? ENCRYPTED_PLACEHOLDER + : transaction.notes} +
)} diff --git a/resources/js/components/transactions/transaction-description.test.tsx b/resources/js/components/transactions/transaction-description.test.tsx new file mode 100644 index 00000000..de11420b --- /dev/null +++ b/resources/js/components/transactions/transaction-description.test.tsx @@ -0,0 +1,33 @@ +import { PrivacyModeProvider } from '@/contexts/privacy-mode-context'; +import { render, screen } from '@testing-library/react'; +import type React from 'react'; +import { describe, expect, it } from 'vitest'; +import { + ENCRYPTED_PLACEHOLDER, + TransactionDescription, +} from './transaction-description'; + +function renderWithPrivacy(ui: React.ReactElement) { + return render({ui}); +} + +describe('TransactionDescription', () => { + it('renders the plaintext description when not encrypted', () => { + renderWithPrivacy( + , + ); + + expect(screen.getByText('Coffee at Starbucks')).toBeInTheDocument(); + }); + + it('masks a still-encrypted value instead of leaking raw ciphertext', () => { + const ciphertext = 'U2FsdGVkX1+abc123def456=='; + + renderWithPrivacy( + , + ); + + expect(screen.queryByText(ciphertext)).not.toBeInTheDocument(); + expect(screen.getByText(ENCRYPTED_PLACEHOLDER)).toBeInTheDocument(); + }); +}); diff --git a/resources/js/components/transactions/encrypted-transaction-description.tsx b/resources/js/components/transactions/transaction-description.tsx similarity index 50% rename from resources/js/components/transactions/encrypted-transaction-description.tsx rename to resources/js/components/transactions/transaction-description.tsx index f8727f1b..e32718ef 100644 --- a/resources/js/components/transactions/encrypted-transaction-description.tsx +++ b/resources/js/components/transactions/transaction-description.tsx @@ -1,5 +1,3 @@ -import { EncryptedText } from '@/components/encrypted-text'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { usePrivacyMode } from '@/contexts/privacy-mode-context'; import { useMemo } from 'react'; @@ -37,44 +35,34 @@ function getFakeDescription(seed: string): string { return FAKE_DESCRIPTIONS[index]; } -interface EncryptedTransactionDescriptionProps { - encryptedText: string; - iv: string | null; +/** + * Shown in place of a still-encrypted legacy value before the user unlocks and + * the decrypt-migration runs, so the list never renders raw ciphertext. + */ +export const ENCRYPTED_PLACEHOLDER = '••••••••••'; + +interface TransactionDescriptionProps { + text: string; className?: string; - length?: number | { min: number; max: number } | null; + encrypted?: boolean; } -export function EncryptedTransactionDescription({ - encryptedText, - iv, +export function TransactionDescription({ + text, className = '', - length = null, -}: EncryptedTransactionDescriptionProps) { - const { isKeySet } = useEncryptionKey(); + encrypted = false, +}: TransactionDescriptionProps) { const { isPrivacyModeEnabled } = usePrivacyMode(); - const fakeDescription = useMemo( - () => getFakeDescription(encryptedText), - [encryptedText], - ); + const fakeDescription = useMemo(() => getFakeDescription(text), [text]); - if (!iv) { - if (isPrivacyModeEnabled) { - return {fakeDescription}; - } - return {encryptedText}; - } - - if (isPrivacyModeEnabled && isKeySet) { - return {fakeDescription}; + if (encrypted) { + return {ENCRYPTED_PLACEHOLDER}; } return ( - + + {isPrivacyModeEnabled ? fakeDescription : text} + ); } diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index 093631d5..50cb3bba 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -45,7 +45,6 @@ interface TransactionFiltersProps { categories: Category[]; labels: Label[]; accounts: Account[]; - isKeySet: boolean; actions?: ReactNode; hideAccountFilter?: boolean; enableSavedFilters?: boolean; diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index 4924a5e0..32946b05 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -64,11 +64,8 @@ import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { TableCell, TableRow } from '@/components/ui/table'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { decrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; import { db } from '@/lib/dexie-db'; -import { getStoredKey } from '@/lib/key-storage'; import { captureEvent } from '@/lib/posthog'; import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; import { transactionSyncService } from '@/services/transaction-sync'; @@ -260,7 +257,6 @@ export function TransactionList({ hideColumns = [], onBalanceUpdated, }: TransactionListProps) { - const { isKeySet } = useEncryptionKey(); const locale = useLocale(); const [labels, setLabels] = useState(() => initialLabels ?? []); @@ -354,85 +350,26 @@ export function TransactionList({ useEffect(() => { async function processTransactions() { - // If transactions are provided directly, use them as-is (already decrypted from backend) + // If transactions are provided directly, use them as-is. if (providedTransactions) { setIsLoading(true); try { - const keyString = getStoredKey(); - let key: CryptoKey | null = null; - - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - } catch (error) { - console.error( - 'Failed to import encryption key:', - error, - ); - } - } - - const decrypted = await Promise.all( - providedTransactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; - - if (!transaction.description_iv) { - decryptedDescription = - transaction.description; - decryptedNotes = transaction.notes || null; - } else if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); - if ( - transaction.notes && - transaction.notes_iv - ) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - error, - ); - } - } - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - label_ids: - transaction.label_ids ?? - transaction.labels?.map( - (label) => label.id, - ) ?? - [], - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Error processing transaction:', - error, - ); - return null; - } - }), + const processed = providedTransactions.map( + (transaction) => + ({ + ...transaction, + decryptedDescription: transaction.description, + decryptedNotes: transaction.notes || null, + label_ids: + transaction.label_ids ?? + transaction.labels?.map( + (label) => label.id, + ) ?? + [], + }) as DecryptedTransaction, ); - const validTransactions = decrypted.filter( - (t): t is DecryptedTransaction => t !== null, - ); - - setTransactions(validTransactions); + setTransactions(processed); } catch (error) { console.error('Error processing transactions:', error); } finally { @@ -465,20 +402,6 @@ export function TransactionList({ ); const banksMap = new Map(banks.map((bank) => [bank.id, bank])); - const keyString = getStoredKey(); - let key: CryptoKey | null = null; - - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - } catch (error) { - console.error( - 'Failed to import encryption key:', - error, - ); - } - } - const transformedTransactions = filteredServerData.map( (serverRecord: Transaction) => { const label_ids = serverRecord.labels?.map( @@ -498,75 +421,26 @@ export function TransactionList({ }, ); - const decrypted = await Promise.all( - transformedTransactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; + const processed = transformedTransactions.map((transaction) => { + const account = accountsMap.get(transaction.account_id); + const category = transaction.category_id + ? categoriesMap.get(transaction.category_id) + : null; + const bank = account?.bank?.id + ? banksMap.get(account.bank!.id) + : undefined; - if (!transaction.description_iv) { - decryptedDescription = transaction.description; - decryptedNotes = transaction.notes || null; - } else if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); + return { + ...transaction, + decryptedDescription: transaction.description, + decryptedNotes: transaction.notes || null, + account, + category: category || null, + bank, + } as DecryptedTransaction; + }); - if ( - transaction.notes && - transaction.notes_iv - ) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - transaction.id, - error, - ); - } - } - - const account = accountsMap.get( - transaction.account_id, - ); - const category = transaction.category_id - ? categoriesMap.get(transaction.category_id) - : null; - const bank = account?.bank?.id - ? banksMap.get(account.bank!.id) - : undefined; - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - account, - category: category || null, - bank, - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Failed to process transaction:', - transaction.id, - error, - ); - return null; - } - }), - ); - - const validTransactions = decrypted.filter( - (transaction): transaction is DecryptedTransaction => - transaction !== null, - ); + const validTransactions = processed; validTransactions.sort((a, b) => { const dateA = parseISO(a.transaction_date).getTime(); @@ -588,7 +462,6 @@ export function TransactionList({ accounts, banks, categories, - isKeySet, accountId, providedTransactions, ]); @@ -607,79 +480,6 @@ export function TransactionList({ } }, [columnVisibility]); - useEffect(() => { - async function reDecryptTransactions() { - if (transactions.length === 0) { - return; - } - - const keyString = getStoredKey(); - let key: CryptoKey | null = null; - - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - } catch (error) { - console.error('Failed to import encryption key:', error); - } - } - - const reDecrypted = await Promise.all( - transactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; - - if (!transaction.description_iv) { - decryptedDescription = transaction.description; - decryptedNotes = transaction.notes || null; - } else if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); - - if (transaction.notes && transaction.notes_iv) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - transaction.id, - error, - ); - } - } - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Failed to process transaction:', - transaction.id, - error, - ); - return transaction; - } - }), - ); - - setTransactions(reDecrypted); - } - - reDecryptTransactions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isKeySet]); - const [searchMatchedIds, setSearchMatchedIds] = useState>( new Set(), ); @@ -687,7 +487,7 @@ export function TransactionList({ useEffect(() => { async function searchInIndexedDB() { - if (!filters.searchText || !isKeySet) { + if (!filters.searchText) { setSearchMatchedIds(new Set()); setIsSearching(false); return; @@ -695,13 +495,6 @@ export function TransactionList({ setIsSearching(true); try { - const keyString = getStoredKey(); - if (!keyString) { - setSearchMatchedIds(new Set()); - return; - } - - const key = await importKey(keyString); const searchLower = filters.searchText.toLowerCase(); let allIndexedTransactions = await db.transactions.toArray(); @@ -715,46 +508,14 @@ export function TransactionList({ const matchedIds = new Set(); for (const tx of allIndexedTransactions) { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; + const matchesDescription = tx.description + .toLowerCase() + .includes(searchLower); + const matchesNotes = + tx.notes?.toLowerCase().includes(searchLower) || false; - try { - if (!tx.description_iv) { - decryptedDescription = tx.description; - decryptedNotes = tx.notes || null; - } else { - decryptedDescription = await decrypt( - tx.description, - key, - tx.description_iv, - ); - - if (tx.notes && tx.notes_iv) { - decryptedNotes = await decrypt( - tx.notes, - key, - tx.notes_iv, - ); - } - } - } catch { - continue; - } - - const matchesDescription = decryptedDescription - .toLowerCase() - .includes(searchLower); - const matchesNotes = - decryptedNotes - ?.toLowerCase() - .includes(searchLower) || false; - - if (matchesDescription || matchesNotes) { - matchedIds.add(tx.id); - } - } catch { - continue; + if (matchesDescription || matchesNotes) { + matchedIds.add(tx.id); } } @@ -768,7 +529,7 @@ export function TransactionList({ } searchInIndexedDB(); - }, [filters.searchText, isKeySet, accountId]); + }, [filters.searchText, accountId]); const manualAccountIds = useMemo( () => @@ -782,11 +543,7 @@ export function TransactionList({ const filteredTransactions = useMemo(() => { return transactions.filter((transaction) => { - if (filters.searchText && isKeySet) { - if (!searchMatchedIds.has(transaction.id)) { - return false; - } - } else if (filters.searchText && !isKeySet) { + if (filters.searchText && !searchMatchedIds.has(transaction.id)) { return false; } @@ -858,7 +615,7 @@ export function TransactionList({ return true; }); - }, [transactions, filters, isKeySet, accountId, searchMatchedIds]); + }, [transactions, filters, accountId, searchMatchedIds]); const sortedTransactions = useMemo(() => { if (sorting.length === 0) { @@ -1157,13 +914,6 @@ export function TransactionList({ } async function handleBulkCategoryChange(categoryId: number | null) { - if (!isKeySet) { - toast.error( - 'Please unlock your encryption key to update transactions', - ); - return; - } - const selectedIds = Object.keys(rowSelection); if (selectedIds.length === 0) { return; @@ -1331,7 +1081,6 @@ export function TransactionList({ categories={categories} labels={labels} accounts={accounts} - isKeySet={isKeySet} hideAccountFilter={hideAccountFilter} actions={
diff --git a/resources/js/components/ui/amount-display.tsx b/resources/js/components/ui/amount-display.tsx index 44f98979..8f88867e 100644 --- a/resources/js/components/ui/amount-display.tsx +++ b/resources/js/components/ui/amount-display.tsx @@ -1,4 +1,3 @@ -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { usePrivacyMode } from '@/contexts/privacy-mode-context'; import { useLocale } from '@/hooks/use-locale'; import { cn } from '@/lib/utils'; @@ -57,38 +56,16 @@ export function AmountDisplay({ monospace = false, highlightPositive = false, }: AmountDisplayProps) { - const { isKeySet } = useEncryptionKey(); const { isPrivacyModeEnabled } = usePrivacyMode(); const locale = useLocale(); const isPositive = amountInCents > 0; - const shouldHideAmount = !isKeySet; - - const displayAmountInCents = useMemo(() => { - if (shouldHideAmount) { - const length = Math.max(3, amountInCents.toString().length); - return parseInt('8'.repeat(length - 2) + '00'); - } - - return amountInCents; - }, [amountInCents, shouldHideAmount]); - const formatted = useMemo(() => { - return formatCurrency(displayAmountInCents, currencyCode, locale, minimumFractionDigits, maximumFractionDigits); - }, [locale, displayAmountInCents, currencyCode, minimumFractionDigits, maximumFractionDigits]); + return formatCurrency(amountInCents, currencyCode, locale, minimumFractionDigits, maximumFractionDigits); + }, [locale, amountInCents, currencyCode, minimumFractionDigits, maximumFractionDigits]); - const getBackgroundClass = (shouldHideAmount: boolean) => { - if (!highlightPositive && !shouldHideAmount) return ''; - - if (shouldHideAmount) { - if (variant === 'positive-highlight' && isPositive) { - return 'rounded-xs bg-green-400 dark:bg-green-900 text-green-400 dark:text-green-900 opacity-20 dark:opacity-100'; - } - - return 'rounded-xs bg-zinc-950 dark:bg-zinc-700 dark:text-zinc-700'; - } - - if (variant === 'positive-highlight') { + const getBackgroundClass = () => { + if (highlightPositive && variant === 'positive-highlight') { return 'bg-green-100/70 dark:bg-green-900'; } @@ -103,7 +80,7 @@ export function AmountDisplay({ variantStyles[variant], size && sizeStyles[size], weight && weightStyles[weight], - getBackgroundClass(shouldHideAmount), + getBackgroundClass(), { 'font-mono tabular-nums': monospace }, className, )} diff --git a/resources/js/hooks/use-categorize-transactions.ts b/resources/js/hooks/use-categorize-transactions.ts index 0ccfb4b9..b23a37e8 100644 --- a/resources/js/hooks/use-categorize-transactions.ts +++ b/resources/js/hooks/use-categorize-transactions.ts @@ -1,7 +1,4 @@ import type { AutomateCategorizationCandidate } from '@/components/automation-rules/automate-categorization-dialog'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { decrypt, importKey } from '@/lib/crypto'; -import { getStoredKey } from '@/lib/key-storage'; import { captureEvent } from '@/lib/posthog'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; @@ -80,8 +77,6 @@ export function useCategorizeTransactions({ banks, transactions: initialTransactions, }: UseCategorizeTransactionsOptions) { - const { isKeySet } = useEncryptionKey(); - const [uncategorizedTransactions, setUncategorizedTransactions] = useState< DecryptedTransaction[] >([]); @@ -97,7 +92,6 @@ export function useCategorizeTransactions({ const [automateDialogOpen, setAutomateDialogOpen] = useState(false); const [automateCandidate, setAutomateCandidate] = useState(null); - const [, setEncryptionKey] = useState(null); const [categorizedCount, setCategorizedCount] = useState(0); const commandInputRef = useRef(null); @@ -112,115 +106,42 @@ export function useCategorizeTransactions({ }, [isLoading, animationState, currentIndex]); useEffect(() => { - async function decryptTransactions() { - setIsLoading(true); - try { - const accountsMap = new Map( - accounts.map((account) => [account.id, account]), - ); - const banksMap = new Map(banks.map((bank) => [bank.id, bank])); + setIsLoading(true); + try { + const accountsMap = new Map( + accounts.map((account) => [account.id, account]), + ); + const banksMap = new Map(banks.map((bank) => [bank.id, bank])); - const keyString = getStoredKey(); - let key: CryptoKey | null = null; + const processed = initialTransactions.map((transaction) => { + const account = accountsMap.get(transaction.account_id); + const bank = account?.bank?.id + ? banksMap.get(account.bank.id) + : undefined; - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - setEncryptionKey(key); - } catch (error) { - console.error( - 'Failed to import encryption key:', - error, - ); - } - } + return { + ...transaction, + decryptedDescription: transaction.description, + decryptedNotes: transaction.notes || null, + account, + category: null, + bank, + } as DecryptedTransaction; + }); - const decrypted = await Promise.all( - initialTransactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; + processed.sort((a, b) => { + const dateA = parseISO(a.transaction_date).getTime(); + const dateB = parseISO(b.transaction_date).getTime(); + return dateB - dateA; + }); - if (!transaction.description_iv) { - decryptedDescription = transaction.description; - decryptedNotes = transaction.notes || null; - } else if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); - - if ( - transaction.notes && - transaction.notes_iv - ) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - transaction.id, - error, - ); - } - } - - const account = accountsMap.get( - transaction.account_id, - ); - const bank = account?.bank?.id - ? banksMap.get(account.bank.id) - : undefined; - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - account, - category: null, - bank, - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Failed to process transaction:', - transaction.id, - error, - ); - return null; - } - }), - ); - - const validTransactions = decrypted.filter( - (transaction): transaction is DecryptedTransaction => - transaction !== null, - ); - - validTransactions.sort((a, b) => { - const dateA = parseISO(a.transaction_date).getTime(); - const dateB = parseISO(b.transaction_date).getTime(); - return dateB - dateA; - }); - - setUncategorizedTransactions(validTransactions); - } catch (error) { - console.error( - 'Failed to load uncategorized transactions:', - error, - ); - } finally { - setIsLoading(false); - } + setUncategorizedTransactions(processed); + } catch (error) { + console.error('Failed to load uncategorized transactions:', error); + } finally { + setIsLoading(false); } - - decryptTransactions(); - }, [initialTransactions, accounts, banks, isKeySet]); + }, [initialTransactions, accounts, banks]); const currentTransaction = uncategorizedTransactions[currentIndex]; const remainingCount = uncategorizedTransactions.length - currentIndex; diff --git a/resources/js/hooks/use-decrypt-transactions.test.tsx b/resources/js/hooks/use-decrypt-transactions.test.tsx new file mode 100644 index 00000000..73b72ba8 --- /dev/null +++ b/resources/js/hooks/use-decrypt-transactions.test.tsx @@ -0,0 +1,90 @@ +import { decrypt } from '@/lib/crypto'; +import { renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDecryptTransactions } from './use-decrypt-transactions'; + +vi.mock('axios'); +vi.mock('@/contexts/encryption-key-context', () => ({ + useEncryptionKey: () => ({ isKeySet: true }), +})); +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { hasEncryptedTransactions: true } }), +})); +vi.mock('@/lib/key-storage', () => ({ + getStoredKey: () => 'stored-key', +})); +vi.mock('@/lib/crypto', () => ({ + importKey: vi.fn(async () => 'crypto-key'), + decrypt: vi.fn(async (text: string) => `plain:${text}`), +})); + +interface EncryptedRow { + id: string; + description: string; + description_iv: string | null; + notes: string | null; + notes_iv: string | null; +} + +interface BulkBody { + transactions: { id: string }[]; +} + +const reloadMock = vi.fn(); + +function makeRow(id: string): EncryptedRow { + return { + id, + description: `cipher-${id}`, + description_iv: `iv-${id}`, + notes: null, + notes_iv: null, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(decrypt).mockImplementation( + async (text: string) => `plain:${text}`, + ); + Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: reloadMock }, + }); +}); + +describe('useDecryptTransactions', () => { + it('migrates every encrypted row even as the set shrinks under it', async () => { + // The server returns only the first still-encrypted rows on each + // fetch; each bulk update removes the patched rows. A cursor that + // advanced its offset would skip the rows shifting into freed slots. + let remaining = ['1', '2', '3', '4', '5'].map(makeRow); + + vi.mocked(axios.get).mockImplementation(async () => ({ + data: { data: remaining.slice(0, 2), next_page_url: null }, + })); + vi.mocked(axios.patch).mockImplementation(async (_url, body) => { + const ids = (body as BulkBody).transactions.map((t) => t.id); + remaining = remaining.filter((row) => !ids.includes(row.id)); + return { data: {} }; + }); + + renderHook(() => useDecryptTransactions()); + + await waitFor(() => expect(reloadMock).toHaveBeenCalledTimes(1)); + expect(remaining).toHaveLength(0); + }); + + it('stops instead of looping forever when rows never decrypt', async () => { + vi.mocked(decrypt).mockRejectedValue(new Error('wrong key')); + vi.mocked(axios.get).mockResolvedValue({ + data: { data: [makeRow('1'), makeRow('2')], next_page_url: null }, + }); + + renderHook(() => useDecryptTransactions()); + + await waitFor(() => expect(reloadMock).toHaveBeenCalledTimes(1)); + expect(axios.patch).not.toHaveBeenCalled(); + }); +}); diff --git a/resources/js/hooks/use-decrypt-transactions.ts b/resources/js/hooks/use-decrypt-transactions.ts index db8f1b27..05fe3b79 100644 --- a/resources/js/hooks/use-decrypt-transactions.ts +++ b/resources/js/hooks/use-decrypt-transactions.ts @@ -48,11 +48,20 @@ export function useDecryptTransactions() { const key = await importKey(keyString); - let url: string | null = '/api/transactions?encrypted=true'; + // Always re-fetch the first page: each bulk update clears the + // rows' IVs, removing them from the encrypted set, so an + // offset-based cursor (next_page_url) would skip the rows that + // shift into the freed slots. Stop when nothing is left, or + // when a page yields nothing we can decrypt — otherwise rows + // that always fail to decrypt would loop forever. + while (true) { + const { data: page } = await axios.get( + '/api/transactions?encrypted=true', + ); - while (url) { - const { data: page } = - await axios.get(url); + if (page.data.length === 0) { + break; + } const batch: BulkUpdateItem[] = []; @@ -86,17 +95,17 @@ export function useDecryptTransactions() { } } - if (batch.length > 0) { - // Send in chunks of 50 - for (let i = 0; i < batch.length; i += 50) { - const chunk = batch.slice(i, i + 50); - await axios.patch('/api/transactions/bulk', { - transactions: chunk, - }); - } + if (batch.length === 0) { + break; } - url = page.next_page_url; + // Send in chunks of 50 + for (let i = 0; i < batch.length; i += 50) { + const chunk = batch.slice(i, i + 50); + await axios.patch('/api/transactions/bulk', { + transactions: chunk, + }); + } } window.location.reload(); diff --git a/resources/js/lib/crypto.ts b/resources/js/lib/crypto.ts index bf49f618..6a120015 100644 --- a/resources/js/lib/crypto.ts +++ b/resources/js/lib/crypto.ts @@ -37,31 +37,6 @@ export async function getAESKeyFromPBKDF( ); } -export async function encrypt( - plaintext: string, - key: CryptoKey, -): Promise<{ encrypted: string; iv: string }> { - ensureCryptoAvailable(); - const encoder = new TextEncoder(); - const data = encoder.encode(plaintext); - - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - - const encryptedBuffer = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv, - }, - key, - data, - ); - - return { - encrypted: bufferToBase64(encryptedBuffer), - iv: bufferToBase64(iv), - }; -} - export async function decrypt( encrypted: string, key: CryptoKey, @@ -84,11 +59,6 @@ export async function decrypt( return decoder.decode(decryptedBuffer); } -export function generateSalt(): Uint8Array { - ensureCryptoAvailable(); - return window.crypto.getRandomValues(new Uint8Array(16)); -} - export function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); diff --git a/resources/js/lib/key-storage.ts b/resources/js/lib/key-storage.ts index f0f442d8..6686ae17 100644 --- a/resources/js/lib/key-storage.ts +++ b/resources/js/lib/key-storage.ts @@ -29,9 +29,3 @@ export function clearKey(): void { sessionStorage.removeItem(ENCRYPTION_KEY_NAME); localStorage.removeItem(ENCRYPTION_KEY_NAME); } - -export function isKeyPersistent(): boolean { - if (!isBrowser()) return false; - - return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null; -} diff --git a/resources/js/pages/Accounts/Show.test.tsx b/resources/js/pages/Accounts/Show.test.tsx index 98d4ba33..e1de798e 100644 --- a/resources/js/pages/Accounts/Show.test.tsx +++ b/resources/js/pages/Accounts/Show.test.tsx @@ -24,10 +24,6 @@ vi.mock('@/actions/App/Http/Controllers/RealEstateDetailController', () => ({ }, })); -vi.mock('@/contexts/encryption-key-context', () => ({ - useEncryptionKey: () => ({ isKeySet: true }), -})); - vi.mock('@/layouts/app/app-sidebar-layout', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, })); diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 92646f91..5abebba9 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -40,7 +40,6 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useChartColors } from '@/hooks/use-chart-color-scheme'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { BreadcrumbItem } from '@/types'; @@ -67,7 +66,6 @@ import { Head, router } from '@inertiajs/react'; import { ChevronDown, Pencil, Plus } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; -import { toast } from 'sonner'; interface AccountWithDetails extends Account { real_estate_detail?: RealEstateDetail; @@ -106,7 +104,6 @@ export default function AccountShow({ const [transactionRefreshKey, setTransactionRefreshKey] = useState(0); const [chartComputedData, setChartComputedData] = useState(null); - const { isKeySet } = useEncryptionKey(); const handleChartDataLoaded = useCallback((data: ChartComputedData) => { setChartComputedData(data); @@ -117,13 +114,6 @@ export default function AccountShow({ } function handleAddTransaction() { - if (!isKeySet) { - toast.error( - __('Please unlock your encryption key to add transactions'), - ); - return; - } - setCreateTransactionOpen(true); } diff --git a/resources/js/pages/auth/setup-encryption.tsx b/resources/js/pages/auth/setup-encryption.tsx deleted file mode 100644 index be742ec5..00000000 --- a/resources/js/pages/auth/setup-encryption.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { __ } from '@/utils/i18n'; -import { Head, router } from '@inertiajs/react'; -import axios from 'axios'; -import { useState } from 'react'; - -import InputError from '@/components/input-error'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Spinner } from '@/components/ui/spinner'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import AuthLayout from '@/layouts/auth-layout'; -import { - bufferToBase64, - encrypt, - exportKey, - generateSalt, - getAESKeyFromPBKDF, - getKeyFromPassword, -} from '@/lib/crypto'; -import { storeKey } from '@/lib/key-storage'; -import { dashboard } from '@/routes'; - -function isCryptoAvailable(): boolean { - if (typeof window === 'undefined') return true; - return !!(window.crypto && window.crypto.subtle); -} - -function getInitialErrors(): { - password?: string; - confirmPassword?: string; - general?: string; -} { - if (typeof window === 'undefined') return {}; - if (!window.crypto || !window.crypto.subtle) { - return { - general: - 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.', - }; - } - return {}; -} - -export default function SetupEncryption() { - const { refreshKeyState } = useEncryptionKey(); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [storagePreference, setStoragePreference] = useState< - 'session' | 'persistent' - >('session'); - const [processing, setProcessing] = useState(false); - const [cryptoAvailable] = useState(isCryptoAvailable); - const [errors, setErrors] = useState<{ - password?: string; - confirmPassword?: string; - general?: string; - }>(getInitialErrors); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setErrors({}); - - if (password.length < 12) { - setErrors({ - password: 'Encryption password must be at least 12 characters', - }); - return; - } - - if (password !== confirmPassword) { - setErrors({ - confirmPassword: 'Passwords do not match', - }); - return; - } - - setProcessing(true); - - try { - const salt = generateSalt(); - - const pbkdfKey = await getKeyFromPassword(password); - - const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt); - - const { encrypted, iv } = await encrypt('Hello, world', aesKey); - - const exportedKey = await exportKey(aesKey); - - await axios.post('/api/encryption/setup', { - salt: bufferToBase64(salt), - encrypted_content: encrypted, - iv: iv, - }); - - storeKey(exportedKey, storagePreference === 'persistent'); - refreshKeyState(); - - router.visit(dashboard().url); - } catch (error) { - console.error('Encryption setup error:', error); - setErrors({ - general: - 'Failed to setup encryption. Please try again or contact support.', - }); - setProcessing(false); - } - } - - return ( - - -
-
-
- - setPassword(e.target.value)} - placeholder={__( - 'Enter a strong encryption password', - )} - disabled={processing} - /> - -

- {__( - 'Use a strong password (minimum 12 characters). This\n password will encrypt your data.', - )} -

- -
- -
- - setConfirmPassword(e.target.value)} - placeholder={__('Confirm your encryption password')} - disabled={processing} - /> - - -
- -
- - -

- Session storage clears when you close your browser. - Persistent storage keeps your key until manually - cleared. -

-
- - {errors.general && ( -
- {errors.general} -
- )} - - -
-
-
- ); -} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index ddfed952..db04b364 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1172,7 +1172,6 @@ export default function Transactions({ categories={categories} labels={labels} accounts={accounts} - isKeySet={true} enableSavedFilters={true} actions={
diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 81c30865..3e395f29 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -1,6 +1,4 @@ -import { importKey } from '@/lib/crypto'; import { db } from '@/lib/dexie-db'; -import { getStoredKey } from '@/lib/key-storage'; import { TransactionSyncManager } from '@/lib/sync-manager'; import type { Transaction } from '@/types/transaction'; import type { UUID } from '@/types/uuid'; @@ -233,42 +231,14 @@ class TransactionSyncService { return txDate >= minDate && txDate <= maxDate; }); - const keyString = getStoredKey(); - const key = keyString ? await importKey(keyString) : null; - - const decryptedTransactions = await Promise.all( - transactionsInRange.map(async (t) => { - try { - let decryptedDescription: string; - if (t.description_iv && key) { - const { decrypt } = await import('@/lib/crypto'); - decryptedDescription = await decrypt( - t.description, - key, - t.description_iv, - ); - } else if (t.description_iv && !key) { - return null; - } else { - decryptedDescription = t.description; - } - return { - transaction_date: normalizeDate(t.transaction_date), - amount: parseFloat(t.amount), - description: decryptedDescription - .toLowerCase() - .trim() - .replace(/\s+/g, ' '), - }; - } catch { - return null; - } - }), - ); - - const validDecryptedTransactions = decryptedTransactions.filter( - (t) => t !== null, - ); + const existingTransactions = transactionsInRange.map((t) => ({ + transaction_date: normalizeDate(t.transaction_date), + amount: parseFloat(t.amount), + description: t.description + .toLowerCase() + .trim() + .replace(/\s+/g, ' '), + })); return transactions.map((importingTx) => { const normalizedDescription = importingTx.description @@ -276,7 +246,7 @@ class TransactionSyncService { .trim() .replace(/\s+/g, ' '); - return validDecryptedTransactions.some( + return existingTransactions.some( (existing) => existing.transaction_date === importingTx.transaction_date && diff --git a/routes/api.php b/routes/api.php index 9e100920..5f9bca04 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,8 +14,7 @@ use App\Http\Controllers\Sync\TransactionSyncController; use Illuminate\Support\Facades\Route; Route::middleware(['web', 'auth'])->group(function () { - // Encryption - Route::post('encryption/setup', [EncryptionController::class, 'setup']); + // Encryption (legacy decrypt-migration support only) Route::get('encryption/message', [EncryptionController::class, 'getMessage']); // Import Data (for import drawers) diff --git a/tests/Feature/AlignAccountsEncryptedFlagMigrationTest.php b/tests/Feature/AlignAccountsEncryptedFlagMigrationTest.php new file mode 100644 index 00000000..70bd3566 --- /dev/null +++ b/tests/Feature/AlignAccountsEncryptedFlagMigrationTest.php @@ -0,0 +1,21 @@ +up(); +} + +it('clears the encrypted flag only for accounts whose name is plaintext', function () { + $staleFlag = Account::factory()->create(['encrypted' => true, 'name_iv' => null]); + $encryptedName = Account::factory()->create(['encrypted' => true, 'name_iv' => str_repeat('a', 16)]); + $alreadyPlaintext = Account::factory()->create(['encrypted' => false, 'name_iv' => null]); + + runAlignEncryptedFlagMigration(); + + expect($staleFlag->fresh()->encrypted)->toBeFalse() + ->and($encryptedName->fresh()->encrypted)->toBeTrue() + ->and($alreadyPlaintext->fresh()->encrypted)->toBeFalse(); +}); diff --git a/tests/Feature/DecryptTransactionsTest.php b/tests/Feature/DecryptTransactionsTest.php index a4045f8f..875ab477 100644 --- a/tests/Feature/DecryptTransactionsTest.php +++ b/tests/Feature/DecryptTransactionsTest.php @@ -27,23 +27,6 @@ test('encrypted transactions endpoint returns only encrypted transactions', func expect($data[0]['id'])->toBe($encrypted->id); }); -test('plaintext filter returns only plaintext transactions', function () { - Transaction::factory()->create([ - 'user_id' => $this->user->id, - 'description_iv' => 'some-iv', - ]); - $plaintext = Transaction::factory()->plaintext()->create([ - 'user_id' => $this->user->id, - ]); - - $response = $this->getJson('/api/transactions?encrypted=false'); - - $response->assertOk(); - $data = $response->json('data'); - expect($data)->toHaveCount(1); - expect($data[0]['id'])->toBe($plaintext->id); -}); - test('encrypted transactions endpoint paginates correctly', function () { $account = Account::factory()->create(['user_id' => $this->user->id]); $category = Category::factory()->create(['user_id' => $this->user->id]); diff --git a/tests/Feature/EncryptionTest.php b/tests/Feature/EncryptionTest.php index e17d77a3..ab1818fc 100644 --- a/tests/Feature/EncryptionTest.php +++ b/tests/Feature/EncryptionTest.php @@ -4,7 +4,6 @@ use App\Models\EncryptedMessage; use App\Models\User; use function Pest\Laravel\actingAs; -use function Pest\Laravel\assertDatabaseHas; test('authenticated user without encryption salt can access setup page', function () { $user = User::factory()->create(['encryption_salt' => null]); @@ -14,54 +13,6 @@ test('authenticated user without encryption salt can access setup page', functio $response->assertSuccessful(); }); -test('user can setup encryption', function () { - $user = User::factory()->create(['encryption_salt' => null]); - - $response = actingAs($user)->postJson('/api/encryption/setup', [ - 'salt' => str_repeat('a', 24), - 'encrypted_content' => 'encrypted_test_content', - 'iv' => str_repeat('b', 16), - ]); - - $response->assertSuccessful(); - - $user->refresh(); - - expect($user->encryption_salt)->toBe(str_repeat('a', 24)); - - assertDatabaseHas('encrypted_messages', [ - 'user_id' => $user->id, - 'encrypted_content' => 'encrypted_test_content', - 'iv' => str_repeat('b', 16), - ]); -}); - -test('encryption setup requires valid salt', function () { - $user = User::factory()->create(['encryption_salt' => null]); - - $response = actingAs($user)->postJson('/api/encryption/setup', [ - 'salt' => 'invalid', - 'encrypted_content' => 'encrypted_test_content', - 'iv' => str_repeat('b', 16), - ]); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['salt']); -}); - -test('encryption setup requires valid iv', function () { - $user = User::factory()->create(['encryption_salt' => null]); - - $response = actingAs($user)->postJson('/api/encryption/setup', [ - 'salt' => str_repeat('a', 24), - 'encrypted_content' => 'encrypted_test_content', - 'iv' => 'invalid', - ]); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['iv']); -}); - test('user can retrieve encrypted message', function () { $user = User::factory()->create([ 'encryption_salt' => str_repeat('a', 24),