fix(transactions): mask still-encrypted values instead of showing ciphertext

Before a legacy encrypted user unlocks and the decrypt-migration runs, the
transaction list rendered raw base64 ciphertext as the description/notes
(the old EncryptedText component used to mask these while locked). Show a
neutral placeholder whenever a row is still encrypted (description_iv /
notes_iv set), so the list never leaks unreadable ciphertext. The placeholder
clears automatically once the row is decrypted and re-synced as plaintext.
This commit is contained in:
Víctor Falcón 2026-06-20 13:13:34 +02:00
parent b581206a35
commit bac28f332c
3 changed files with 55 additions and 2 deletions

View File

@ -8,7 +8,10 @@ import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
import { TransactionDescription } from '@/components/transactions/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';
@ -214,6 +217,7 @@ export function createTransactionColumns({
<div className="flex-grow truncate">
<TransactionDescription
text={transaction.description}
encrypted={!!transaction.description_iv}
/>
</div>
{showLabels && hasLabels && (
@ -226,7 +230,11 @@ 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">
<span>{transaction.notes}</span>
<span>
{transaction.notes_iv
? ENCRYPTED_PLACEHOLDER
: transaction.notes}
</span>
</div>
</div>
)}

View File

@ -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(<PrivacyModeProvider>{ui}</PrivacyModeProvider>);
}
describe('TransactionDescription', () => {
it('renders the plaintext description when not encrypted', () => {
renderWithPrivacy(
<TransactionDescription text="Coffee at Starbucks" />,
);
expect(screen.getByText('Coffee at Starbucks')).toBeInTheDocument();
});
it('masks a still-encrypted value instead of leaking raw ciphertext', () => {
const ciphertext = 'U2FsdGVkX1+abc123def456==';
renderWithPrivacy(
<TransactionDescription text={ciphertext} encrypted />,
);
expect(screen.queryByText(ciphertext)).not.toBeInTheDocument();
expect(screen.getByText(ENCRYPTED_PLACEHOLDER)).toBeInTheDocument();
});
});

View File

@ -35,19 +35,31 @@ function getFakeDescription(seed: string): string {
return FAKE_DESCRIPTIONS[index];
}
/**
* 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;
encrypted?: boolean;
}
export function TransactionDescription({
text,
className = '',
encrypted = false,
}: TransactionDescriptionProps) {
const { isPrivacyModeEnabled } = usePrivacyMode();
const fakeDescription = useMemo(() => getFakeDescription(text), [text]);
if (encrypted) {
return <span className={className}>{ENCRYPTED_PLACEHOLDER}</span>;
}
return (
<span className={className}>
{isPrivacyModeEnabled ? fakeDescription : text}