refactor(encryption): strip client-side transaction encryption (#514)
## Why Transactions and account names used to be stored encrypted client-side. They now live **unencrypted in the backend**, so the browser no longer needs to encrypt, decrypt, or mask them. A handful of legacy accounts may still have encrypted transactions, but the auto-run decrypt-migration handles those — so this PR strips the now-dead encryption machinery while keeping the migration path fully intact. Net: **−1213 / +176** lines across 25 files. ## Removed (dead machinery) - `setup-encryption` page, `POST /api/encryption/setup`, `SetupEncryptionRequest`, and `encrypt()` / `generateSalt()` — new data is never encrypted again - `EncryptedText` and the decrypt-on-render description component → replaced by a plaintext `TransactionDescription` that keeps the privacy-mode fake labels - All `isKeySet`-gated client decryption: transaction list (load / search / sort), categorize flow, import dedup - `isKeySet` masking / locking on amount display, app logo, import button, add-transaction button, account page, and filters - The unused `?encrypted=false` API filter and `isKeyPersistent()` ## Kept (legacy decrypt-migration) - The two migration hooks, the unlock dialog, and the dashboard/login unlock prompt - `decrypt` / `importKey` + key derivation, key-storage, `EncryptedMessage`, salt, `GET /api/encryption/message`, `?encrypted=true`, and the middleware salt-cleanup - `rule-engine` / edit / import-drawer account-name decryption — plaintext-first (`if (!account.encrypted) return name`) legacy fallback on the kept primitives; intentionally untouched ## Testing - Pint ✅ · ESLint ✅ · Prettier ✅ - 184 vitest tests ✅ - 1425 PHP Feature/Unit tests ✅ (Browser suite not run locally — needs `bun run build`; no Browser test references the encryption UI) ## Follow-up (out of scope) `DecryptedTransaction.decryptedDescription` / `decryptedNotes` are now just plaintext aliases. Collapsing that type touches 13+ files (rule engine, categorizer, edit dialog) and was left as a separate cleanup.
This commit is contained in:
parent
14c4598cda
commit
7fb190683c
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SetupEncryptionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'salt' => ['required', 'string', 'size:24'],
|
||||
'encrypted_content' => ['required', 'string'],
|
||||
'iv' => ['required', 'string', 'size:16'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Some legacy accounts are still flagged `encrypted = true` even though
|
||||
* their name is stored in plaintext (`name_iv` is null). The client-side
|
||||
* decrypt-migration only clears the flag for accounts whose name is
|
||||
* actually encrypted, so these stale flags would never resolve on their
|
||||
* own and keep the user perpetually counted as "has encrypted accounts".
|
||||
* Align the flag with reality: an account with no `name_iv` is not
|
||||
* encrypted. Transaction decryption is unaffected — it is driven by each
|
||||
* transaction's `description_iv` / `notes_iv`, not by this flag.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('accounts')
|
||||
->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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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}
|
||||
encrypted={!!transaction.description_iv}
|
||||
/>
|
||||
</div>
|
||||
{showLabels && hasLabels && (
|
||||
|
|
@ -231,19 +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">
|
||||
{transaction.decryptedNotes ? (
|
||||
<span>
|
||||
{transaction.decryptedNotes}
|
||||
</span>
|
||||
) : (
|
||||
<EncryptedText
|
||||
encryptedText={
|
||||
transaction.notes || ''
|
||||
}
|
||||
iv={transaction.notes_iv || ''}
|
||||
length={{ min: 10, max: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{transaction.notes_iv
|
||||
? ENCRYPTED_PLACEHOLDER
|
||||
: transaction.notes}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 <span className={className}>{fakeDescription}</span>;
|
||||
}
|
||||
return <span className={className}>{encryptedText}</span>;
|
||||
}
|
||||
|
||||
if (isPrivacyModeEnabled && isKeySet) {
|
||||
return <span className={className}>{fakeDescription}</span>;
|
||||
if (encrypted) {
|
||||
return <span className={className}>{ENCRYPTED_PLACEHOLDER}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EncryptedText
|
||||
encryptedText={encryptedText}
|
||||
iv={iv}
|
||||
className={className}
|
||||
length={length}
|
||||
/>
|
||||
<span className={className}>
|
||||
{isPrivacyModeEnabled ? fakeDescription : text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,7 +45,6 @@ interface TransactionFiltersProps {
|
|||
categories: Category[];
|
||||
labels: Label[];
|
||||
accounts: Account[];
|
||||
isKeySet: boolean;
|
||||
actions?: ReactNode;
|
||||
hideAccountFilter?: boolean;
|
||||
enableSavedFilters?: boolean;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<PaginatedResponse>(
|
||||
'/api/transactions?encrypted=true',
|
||||
);
|
||||
|
||||
while (url) {
|
||||
const { data: page } =
|
||||
await axios.get<PaginatedResponse>(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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}</>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
|
||||
function runAlignEncryptedFlagMigration(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_06_20_105609_align_accounts_encrypted_flag_with_plaintext_names.php');
|
||||
$migration->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();
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue