refactor(encryption): strip client-side encryption from transactions UI

Transactions are stored unencrypted, so the client no longer decrypts
or masks them:
- delete the setup-encryption page, EncryptedText, and the decrypt-on-
  render description component (replaced by a plaintext + privacy-mode
  TransactionDescription)
- remove isKeySet-gated decryption from the transaction list (load,
  search, sort), categorize flow, and import dedup — all operate on
  plaintext now
- drop isKeySet masking/locking from amount display, app logo, import
  and add-transaction buttons, and the account page
- trim crypto.ts to the decrypt primitives the legacy migration needs

The decrypt-migration path (hooks, unlock dialog, encryption/message,
decrypt/importKey, salt, EncryptedMessage) is untouched.
This commit is contained in:
Víctor Falcón 2026-06-09 15:15:07 +02:00
parent 418f38fe92
commit 51c4f1fdc5
20 changed files with 171 additions and 1091 deletions

View File

@ -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 <BirdIcon className={iconClasses} />;

View File

@ -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<DisplayState>(() =>
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 (
<span
className={loadingClassName}
style={{
width: `${widthInCharacters}ch`,
height: '1em',
}}
/>
);
}
if (displayState === 'decrypted' && cachedDecryption) {
return <span className={className}>{cachedDecryption.value}</span>;
}
return <span className={className}>{maskedValue}</span>;
}

View File

@ -1,80 +0,0 @@
import { EncryptedText } from '@/components/encrypted-text';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useMemo } from 'react';
const FAKE_DESCRIPTIONS = [
'Coffee Shop Purchase',
'Grocery Store',
'Online Subscription',
'Restaurant Payment',
'Gas Station',
'Pharmacy Purchase',
'Utility Bill Payment',
'Mobile Phone Bill',
'Insurance Premium',
'Gym Membership',
'Streaming Service',
'Food Delivery',
'Public Transport',
'Parking Fee',
'Hardware Store',
'Clothing Store',
'Electronics Purchase',
'Medical Services',
'Dental Payment',
'Home Improvement',
];
function getFakeDescription(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
const index = Math.abs(hash) % FAKE_DESCRIPTIONS.length;
return FAKE_DESCRIPTIONS[index];
}
interface EncryptedTransactionDescriptionProps {
encryptedText: string;
iv: string | null;
className?: string;
length?: number | { min: number; max: number } | null;
}
export function EncryptedTransactionDescription({
encryptedText,
iv,
className = '',
length = null,
}: EncryptedTransactionDescriptionProps) {
const { isKeySet } = useEncryptionKey();
const { isPrivacyModeEnabled } = usePrivacyMode();
const fakeDescription = useMemo(
() => getFakeDescription(encryptedText),
[encryptedText],
);
if (!iv) {
if (isPrivacyModeEnabled) {
return <span className={className}>{fakeDescription}</span>;
}
return <span className={className}>{encryptedText}</span>;
}
if (isPrivacyModeEnabled && isKeySet) {
return <span className={className}>{fakeDescription}</span>;
}
return (
<EncryptedText
encryptedText={encryptedText}
iv={iv}
className={className}
length={length}
/>
);
}

View File

@ -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({
)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
<EncryptedTransactionDescription
encryptedText={
tx.description
}
iv={tx.description_iv}
length={{
min: 10,
max: 40,
}}
<TransactionDescription
text={tx.description}
/>
</TableCell>
<TableCell className="text-right font-mono">

View File

@ -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<ImportData | null>(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() {
<TooltipTrigger asChild>
<Button
variant="ghost"
className={`h-9 ${!isKeySet || loading ? 'cursor-not-allowed opacity-50' : ''}`}
className={`h-9 ${loading ? 'cursor-not-allowed opacity-50' : ''}`}
onClick={handleOpenDrawer}
disabled={loading}
aria-label={__('Import transactions')}
@ -73,9 +64,7 @@ export function ImportTransactionsButton() {
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? __('Unlock encryption to import transactions')
: __('Import transactions from CSV/Excel')}
{__('Import transactions from CSV/Excel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -10,10 +10,6 @@ vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
categorize: { url: () => '/transactions/categorize' },
}));
vi.mock('@/contexts/encryption-key-context', () => ({
useEncryptionKey: () => ({ isKeySet: true }),
}));
vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => false,
}));

View File

@ -13,7 +13,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useIsMobile } from '@/hooks/use-mobile';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { hasActiveFilters } from '@/lib/transaction-filter-serialization';
@ -36,7 +35,6 @@ import {
WandSparkles,
} from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
@ -63,7 +61,6 @@ export function TransactionActionsMenu({
onImportComplete,
filters,
}: TransactionActionsMenuProps) {
const { isKeySet } = useEncryptionKey();
const { features } = usePage<SharedData>().props;
const isMobile = useIsMobile();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
@ -85,22 +82,10 @@ export function TransactionActionsMenu({
};
const handleAddTransaction = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to add transactions',
);
return;
}
onAddTransaction();
};
const handleOpenImportDrawer = () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to import transactions',
);
return;
}
setImportDrawerOpen(true);
};
@ -187,22 +172,20 @@ export function TransactionActionsMenu({
<Button
variant="outline"
className={
!isKeySet || uncategorizedCount === 0
uncategorizedCount === 0
? 'cursor-not-allowed opacity-50'
: ''
}
disabled={!isKeySet || uncategorizedCount === 0}
asChild={isKeySet && uncategorizedCount > 0}
disabled={uncategorizedCount === 0}
asChild={uncategorizedCount > 0}
>
{isKeySet && uncategorizedCount > 0 ? (
{uncategorizedCount > 0 ? (
<Link href={categorize.url()}>
{__('Categorize')}
{uncategorizedCount > 0 && (
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{uncategorizedCount}
</span>
)}
<span className="ml-1 rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{uncategorizedCount}
</span>
</Link>
) : (
<>{__('Categorize')}</>
@ -210,11 +193,9 @@ export function TransactionActionsMenu({
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? 'Unlock encryption to categorize'
: uncategorizedCount === 0
? 'All transactions are categorized'
: `Categorize ${uncategorizedCount} transactions`}
{uncategorizedCount === 0
? 'All transactions are categorized'
: `Categorize ${uncategorizedCount} transactions`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -225,11 +206,6 @@ export function TransactionActionsMenu({
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!isKeySet
? 'cursor-not-allowed opacity-50'
: ''
}
onClick={handleAddTransaction}
aria-label={__('Add transaction')}
>
@ -240,11 +216,7 @@ export function TransactionActionsMenu({
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet
? __(
'Unlock encryption to add transactions',
)
: __('Create a new transaction')}
{__('Create a new transaction')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -271,18 +243,12 @@ export function TransactionActionsMenu({
</TooltipProvider>
<DropdownMenuContent align="end">
{isMobile && (
<DropdownMenuItem
onClick={handleAddTransaction}
disabled={!isKeySet}
>
<DropdownMenuItem onClick={handleAddTransaction}>
<Plus className="mr-2 h-4 w-4" />
{__('Add transaction')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleOpenImportDrawer}
disabled={!isKeySet}
>
<DropdownMenuItem onClick={handleOpenImportDrawer}>
<Upload className="mr-2 h-4 w-4" />
{__('Import Transactions')}
</DropdownMenuItem>

View File

@ -6,10 +6,9 @@ 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 { 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 +206,14 @@ 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 (
<div className="flex flex-col gap-0.5">
<div className="flex flex-row justify-between gap-1">
<div className="flex-grow truncate">
<EncryptedTransactionDescription
encryptedText={transaction.description}
iv={transaction.description_iv}
length={{ min: 20, max: 80 }}
<TransactionDescription
text={transaction.description}
/>
</div>
{showLabels && hasLabels && (
@ -231,19 +226,7 @@ export function createTransactionColumns({
{showNotes && hasNotes && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="truncate text-muted-foreground/80">
{transaction.decryptedNotes ? (
<span>
{transaction.decryptedNotes}
</span>
) : (
<EncryptedText
encryptedText={
transaction.notes || ''
}
iv={transaction.notes_iv || ''}
length={{ min: 10, max: 30 }}
/>
)}
<span>{transaction.notes}</span>
</div>
</div>
)}

View File

@ -0,0 +1,56 @@
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { useMemo } from 'react';
const FAKE_DESCRIPTIONS = [
'Coffee Shop Purchase',
'Grocery Store',
'Online Subscription',
'Restaurant Payment',
'Gas Station',
'Pharmacy Purchase',
'Utility Bill Payment',
'Mobile Phone Bill',
'Insurance Premium',
'Gym Membership',
'Streaming Service',
'Food Delivery',
'Public Transport',
'Parking Fee',
'Hardware Store',
'Clothing Store',
'Electronics Purchase',
'Medical Services',
'Dental Payment',
'Home Improvement',
];
function getFakeDescription(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
const index = Math.abs(hash) % FAKE_DESCRIPTIONS.length;
return FAKE_DESCRIPTIONS[index];
}
interface TransactionDescriptionProps {
text: string;
className?: string;
}
export function TransactionDescription({
text,
className = '',
}: TransactionDescriptionProps) {
const { isPrivacyModeEnabled } = usePrivacyMode();
const fakeDescription = useMemo(() => getFakeDescription(text), [text]);
return (
<span className={className}>
{isPrivacyModeEnabled ? fakeDescription : text}
</span>
);
}

View File

@ -45,7 +45,6 @@ interface TransactionFiltersProps {
categories: Category[];
labels: Label[];
accounts: Account[];
isKeySet: boolean;
actions?: ReactNode;
hideAccountFilter?: boolean;
enableSavedFilters?: boolean;

View File

@ -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<Label[]>(() => 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<Set<string>>(
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<string>();
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={
<div className="flex justify-end gap-2">

View File

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

View File

@ -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<AutomateCategorizationCandidate | null>(null);
const [, setEncryptionKey] = useState<CryptoKey | null>(null);
const [categorizedCount, setCategorizedCount] = useState(0);
const commandInputRef = useRef<HTMLInputElement>(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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HTMLFormElement>) {
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 (
<AuthLayout
title={__('Setup Encryption')}
description={__(
'Create a strong encryption password to secure your data',
)}
>
<Head title={__('Setup Encryption')} />
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="password">
{__('Encryption Password')}
</Label>
<Input
id="password"
type="password"
required
autoFocus
tabIndex={1}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={__(
'Enter a strong encryption password',
)}
disabled={processing}
/>
<p className="text-xs text-muted-foreground">
{__(
'Use a strong password (minimum 12 characters). This\n password will encrypt your data.',
)}
</p>
<InputError
message={errors.password}
className="mt-2"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">
{__('Confirm Password')}
</Label>
<Input
id="confirmPassword"
type="password"
required
tabIndex={2}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={__('Confirm your encryption password')}
disabled={processing}
/>
<InputError message={errors.confirmPassword} />
</div>
<div className="grid gap-2">
<Label htmlFor="storagePreference">
{__('Storage Preference')}
</Label>
<Select
value={storagePreference}
onValueChange={(value) =>
setStoragePreference(
value as 'session' | 'persistent',
)
}
disabled={processing}
>
<SelectTrigger id="storagePreference" tabIndex={3}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session">
{__('Session only (more secure)')}
</SelectItem>
<SelectItem value="persistent">
{__('Keep me logged in (less secure)')}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Session storage clears when you close your browser.
Persistent storage keeps your key until manually
cleared.
</p>
</div>
{errors.general && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{errors.general}
</div>
)}
<Button
type="submit"
className="mt-2 w-full"
tabIndex={4}
disabled={processing || !cryptoAvailable}
>
{processing && <Spinner />}
{__('Setup Encryption')}
</Button>
</div>
</form>
</AuthLayout>
);
}

View File

@ -1172,7 +1172,6 @@ export default function Transactions({
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={true}
enableSavedFilters={true}
actions={
<div className="flex w-full items-center justify-between gap-2">

View File

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