Remove encryption from bank account names (#104)

## Summary

- Store account names in plaintext instead of encrypting them
client-side. Encryption remains only for transaction descriptions and
notes.
- Existing encrypted account names are silently migrated when the user
unlocks their encryption key, via a new `useDecryptAccountNames` hook
that calls API endpoints to update each account.
- New `AccountName` component handles rendering both encrypted (legacy)
and plaintext accounts across the app.

## Test plan

- [x] Create a new account — name should be stored in plaintext
(`encrypted = false`, `name_iv = null`)
- [x] Unlock encryption key with existing encrypted accounts — they
should auto-migrate silently
- [x] After migration, verify accounts show `encrypted = false` in DB
and names are readable plaintext
- [x] Edit a migrated account — should work without encryption
- [x] Verify all account name displays work across dashboard, account
details, transactions, settings
This commit is contained in:
Víctor Falcón 2026-02-09 14:15:26 +01:00 committed by GitHub
parent caccac6166
commit 2dc8cb554c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 451 additions and 211 deletions

View File

@ -23,7 +23,7 @@ class AccountController extends Controller
->with('bank:id,name,logo')
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'loan', 'credit_card', 'others')")
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
return Inertia::render('Accounts/Index', [
'accounts' => $accounts,
@ -45,7 +45,7 @@ class AccountController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {
@ -58,7 +58,7 @@ class AccountController extends Controller
$account->load('bank:id,name,logo');
return Inertia::render('Accounts/Show', [
'account' => $account->only(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code', 'bank']),
'account' => $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'bank']),
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UpdateAccountNameRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AccountController extends Controller
{
use AuthorizesRequests;
/**
* Return all accounts for the authenticated user.
*/
public function index(Request $request): JsonResponse
{
$accounts = $request->user()
->accounts()
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
return response()->json($accounts);
}
/**
* Update an account's name (used for decryption migration).
*/
public function update(UpdateAccountNameRequest $request, Account $account): JsonResponse
{
$this->authorize('update', $account);
$validated = $request->validated();
$account->update([
'name' => $validated['name'],
'encrypted' => $validated['encrypted'],
'name_iv' => $validated['encrypted'] ? $account->name_iv : null,
]);
return response()->json($account);
}
}

View File

@ -103,6 +103,7 @@ class DashboardAnalyticsController extends Controller
'id' => $account->id,
'name' => $account->name,
'name_iv' => $account->name_iv,
'encrypted' => $account->encrypted,
'type' => $account->type,
'currency_code' => $account->currency_code,
'bank' => $account->bank,
@ -150,6 +151,7 @@ class DashboardAnalyticsController extends Controller
'id' => $account->id,
'name' => $account->name,
'name_iv' => $account->name_iv,
'encrypted' => $account->encrypted,
'type' => $account->type,
'currency_code' => $account->currency_code,
],

View File

@ -18,7 +18,7 @@ class ImportDataController extends Controller
'accounts' => $user->accounts()
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']),
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']),
'categories' => $user->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color']),

View File

@ -72,7 +72,7 @@ class BudgetController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = \App\Models\Bank::query()
->where(function ($q) use ($user) {

View File

@ -24,7 +24,7 @@ class CashflowController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {

View File

@ -24,7 +24,7 @@ class DashboardController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {

View File

@ -22,7 +22,7 @@ class OnboardingController extends Controller
$accounts = $user->accounts()
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'type', 'currency_code', 'bank_id']);
->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id']);
return Inertia::render('onboarding/index', [
'banks' => $banks,

View File

@ -25,7 +25,7 @@ class AccountController extends Controller
->accounts()
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
return Inertia::render('settings/accounts', [
'accounts' => $accounts,
@ -38,7 +38,11 @@ class AccountController extends Controller
public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse
{
$user = auth()->user();
$account = $user->accounts()->create($request->validated());
$account = $user->accounts()->create([
...$request->validated(),
'encrypted' => false,
'name_iv' => null,
]);
// Set user's currency_code from first account if not already set
if (is_null($user->currency_code)) {
@ -59,7 +63,11 @@ class AccountController extends Controller
{
$this->authorize('update', $account);
$account->update($request->validated());
$account->update([
...$request->validated(),
'encrypted' => false,
'name_iv' => null,
]);
return to_route('accounts.index');
}

View File

@ -34,7 +34,7 @@ class TransactionController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {
@ -77,7 +77,7 @@ class TransactionController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {

View File

@ -94,6 +94,7 @@ class HandleInertiaRequests extends Middleware
'labels' => fn () => $user ? $user->labels()
->orderBy('name')
->get(['id', 'name', 'color']) : [],
'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false,
'locale' => app()->getLocale(),
'translations' => $this->getTranslations(),
];

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAccountNameRequest 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 [
'name' => ['required', 'string'],
'encrypted' => ['required', 'boolean'],
];
}
}

View File

@ -26,7 +26,6 @@ class StoreAccountRequest extends FormRequest
{
return [
'name' => ['required', 'string'],
'name_iv' => ['required', 'string', 'size:16'],
'bank_id' => ['required', 'exists:banks,id'],
'currency_code' => [
'required',

View File

@ -26,7 +26,6 @@ class UpdateAccountRequest extends FormRequest
{
return [
'name' => ['required', 'string'],
'name_iv' => ['required', 'string', 'size:16'],
'bank_id' => ['required', 'exists:banks,id'],
'currency_code' => [
'required',

View File

@ -22,12 +22,14 @@ class Account extends Model
'bank_id',
'currency_code',
'type',
'encrypted',
];
protected function casts(): array
{
return [
'type' => AccountType::class,
'encrypted' => 'boolean',
];
}

View File

@ -22,7 +22,8 @@ class AccountFactory extends Factory
return [
'user_id' => User::factory(),
'name' => fake()->words(2, true).' Account',
'name_iv' => fake()->regexify('[A-Za-z0-9]{16}'),
'name_iv' => null,
'encrypted' => false,
'bank_id' => Bank::factory(),
'currency_code' => fake()->randomElement(['USD', 'EUR', 'GBP', 'JPY']),
'type' => fake()->randomElement(AccountType::cases()),

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->boolean('encrypted')->default(true)->after('name_iv');
$table->string('name_iv', 16)->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('encrypted');
$table->string('name_iv', 16)->nullable(false)->change();
});
}
};

View File

@ -1,10 +1,10 @@
import { AccountName } from '@/components/accounts/account-name';
import {
ChartViewToggle,
MoMChart,
MoMPercentChart,
} from '@/components/charts';
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
@ -176,11 +176,7 @@ export function AccountBalanceChart({
const chartConfig: ChartConfig = {
value: {
label: (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 20 }}
/>
<AccountName account={account} length={{ min: 5, max: 20 }} />
),
color: 'var(--color-chart-2)',

View File

@ -1,6 +1,6 @@
import { show } from '@/actions/App/Http/Controllers/AccountController';
import { AccountName } from '@/components/accounts/account-name';
import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
@ -78,9 +78,8 @@ export function AccountListCard({
</span>
</div>
)}
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
<AccountName
account={account}
length={{ min: 8, max: 25 }}
className="truncate"
/>

View File

@ -0,0 +1,32 @@
import { EncryptedText } from '@/components/encrypted-text';
type Length = number | { min: number; max: number } | null;
interface AccountNameProps {
account: {
name: string;
name_iv: string | null;
encrypted: boolean;
};
className?: string;
length?: Length;
}
export function AccountName({
account,
className = '',
length = null,
}: AccountNameProps) {
if (!account.encrypted) {
return <span className={className}>{account.name}</span>;
}
return (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv!}
className={className}
length={length}
/>
);
}

View File

@ -9,22 +9,13 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { AccountForm, AccountFormData } from './account-form';
export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false);
const [isKeyAvailable, setIsKeyAvailable] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const formDataRef = useRef<AccountFormData>({
displayName: '',
@ -34,18 +25,6 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
customBank: null,
});
useEffect(() => {
const checkKey = () => {
const key = getStoredKey();
setIsKeyAvailable(!!key);
};
checkKey();
const interval = setInterval(checkKey, 1000);
return () => clearInterval(interval);
}, []);
const handleFormChange = useCallback((data: AccountFormData) => {
formDataRef.current = data;
}, []);
@ -94,12 +73,6 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
const keyString = getStoredKey();
if (!keyString) {
alert('Encryption key not available. Please unlock first.');
return;
}
if (!type || !currencyCode) {
alert('Please fill in all required fields.');
return;
@ -130,14 +103,10 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
finalBankId = String(bankId);
}
const key = await importKey(keyString);
const { encrypted, iv } = await encrypt(displayName, key);
router.post(
store.url(),
{
name: encrypted,
name_iv: iv,
name: displayName,
bank_id: finalBankId,
type: type,
currency_code: currencyCode,
@ -163,30 +132,10 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
}
}
const createButton = (
<Button disabled={!isKeyAvailable}>{__('Create Account')}</Button>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{isKeyAvailable ? (
createButton
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{createButton}
</TooltipTrigger>
<TooltipContent>
<p>
Encryption key required. Please unlock your
data first.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button>{__('Create Account')}</Button>
</DialogTrigger>
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>

View File

@ -8,7 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import type { Account } from '@/types/account';
import { __ } from '@/utils/i18n';
@ -44,6 +44,11 @@ export function EditAccountDialog({
useEffect(() => {
if (!open) return;
if (!account.encrypted) {
setDecryptedName(account.name);
return;
}
async function decryptName() {
const keyString = getStoredKey();
if (!keyString) {
@ -53,7 +58,7 @@ export function EditAccountDialog({
try {
const key = await importKey(keyString);
const name = await decrypt(account.name, key, account.name_iv);
const name = await decrypt(account.name, key, account.name_iv!);
setDecryptedName(name);
} catch (err) {
console.error('Failed to decrypt account name:', err);
@ -62,7 +67,7 @@ export function EditAccountDialog({
}
decryptName();
}, [open, account.name, account.name_iv]);
}, [open, account.name, account.name_iv, account.encrypted]);
const initialValues = useMemo(
() =>
@ -125,12 +130,6 @@ export function EditAccountDialog({
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
const keyString = getStoredKey();
if (!keyString) {
alert('Encryption key not available. Please unlock first.');
return;
}
if (!type || !currencyCode) {
alert('Please fill in all required fields.');
return;
@ -161,14 +160,10 @@ export function EditAccountDialog({
finalBankId = String(bankId);
}
const key = await importKey(keyString);
const { encrypted, iv } = await encrypt(displayName, key);
router.patch(
update.url(account.id),
{
name: encrypted,
name_iv: iv,
name: displayName,
bank_id: finalBankId,
type: type,
currency_code: currencyCode,

View File

@ -1,4 +1,4 @@
import { EncryptedText } from '@/components/encrypted-text';
import { AccountName } from '@/components/accounts/account-name';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@ -61,9 +61,8 @@ export function ImportBalanceStepAccount({
)}
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium">
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
<AccountName
account={account}
length={19}
/>
</span>

View File

@ -1,6 +1,6 @@
import { show } from '@/actions/App/Http/Controllers/AccountController';
import { AccountName } from '@/components/accounts/account-name';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
@ -56,9 +56,8 @@ export function AccountBalanceCard({
/>
)}
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
<AccountName
account={account}
length={{ min: 5, max: 15 }}
/>
</Link>

View File

@ -1,9 +1,9 @@
import { AccountName } from '@/components/accounts/account-name';
import {
ChartViewToggle,
MoMChart,
MoMPercentChart,
} from '@/components/charts';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
@ -80,17 +80,11 @@ function calculateTrend(
}
interface EncryptedLabelProps {
account: { name: string; name_iv: string };
account: { name: string; name_iv: string | null; encrypted: boolean };
}
function EncryptedLabel({ account }: EncryptedLabelProps) {
return (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 20 }}
/>
);
return <AccountName account={account} length={{ min: 5, max: 20 }} />;
}
interface CurrencyTotal {

View File

@ -7,8 +7,6 @@ import {
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { type AccountType, formatAccountType } from '@/types/account';
import { __ } from '@/utils/i18n';
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
@ -18,7 +16,8 @@ import { StepButton } from './step-button';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
name_iv: string | null;
encrypted: boolean;
type: string;
currency_code: string;
bank_id: string;
@ -97,16 +96,6 @@ export function StepCreateAccount({
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
const keyString = getStoredKey();
if (!keyString) {
setError(
__(
'Encryption key not available. Please go back and set up encryption.',
),
);
return;
}
if (!displayName.trim()) {
setError(__('Please enter an account name.'));
return;
@ -147,14 +136,10 @@ export function StepCreateAccount({
finalBankId = String(bankId);
}
const key = await importKey(keyString);
const { encrypted, iv } = await encrypt(displayName, key);
const response = await fetch(store.url(), {
method: 'POST',
body: JSON.stringify({
name: encrypted,
name_iv: iv,
name: displayName,
bank_id: finalBankId,
type: type,
currency_code: currencyCode,

View File

@ -9,7 +9,8 @@ import { StepButton } from './step-button';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
name_iv: string | null;
encrypted: boolean;
type: string;
currency_code: string;
bank_id: string;

View File

@ -131,16 +131,27 @@ export function EditTransactionDialog({
async function decryptAccountNames() {
const keyString = getStoredKey();
if (!keyString) {
return;
}
try {
const key = await importKey(keyString);
let key: CryptoKey | null = null;
if (keyString) {
key = await importKey(keyString);
}
const decryptedNames = new Map<string, string>();
await Promise.all(
accounts.map(async (account) => {
if (!account.encrypted) {
decryptedNames.set(account.id, account.name);
return;
}
if (!key || !account.name_iv) {
decryptedNames.set(account.id, '[Encrypted]');
return;
}
try {
const decryptedName = await decrypt(
account.name,

View File

@ -1,4 +1,4 @@
import { EncryptedText } from '@/components/encrypted-text';
import { AccountName } from '@/components/accounts/account-name';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@ -72,9 +72,8 @@ export function ImportStepAccount({
)}
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium">
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
<AccountName
account={account}
length={19}
/>
</span>

View File

@ -4,7 +4,7 @@ import { ColumnDef } from '@tanstack/react-table';
import { getYear, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { EncryptedText } from '@/components/encrypted-text';
import { AccountName } from '@/components/accounts/account-name';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
import { EncryptedTransactionDescription } from '@/components/transactions/encrypted-transaction-description';
@ -161,9 +161,8 @@ export function createTransactionColumns({
className="h-5 w-5 rounded-full"
/>
)}
<EncryptedText
encryptedText={transaction.account.name}
iv={transaction.account.name_iv}
<AccountName
account={transaction.account}
length={{ min: 5, max: 15 }}
className="truncate"
/>

View File

@ -4,7 +4,7 @@ import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { EncryptedText } from '@/components/encrypted-text';
import { AccountName } from '@/components/accounts/account-name';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -532,11 +532,8 @@ export function TransactionFilters({
)
}
>
<EncryptedText
encryptedText={
account.name
}
iv={account.name_iv}
<AccountName
account={account}
length={{
min: 6,
max: 28,

View File

@ -114,7 +114,7 @@ export default function UnlockMessageDialog({
<DialogTitle>{__('Unlock Encrypted Data')}</DialogTitle>
<DialogDescription>
{__(
'Enter your encryption password to decrypt transactions information and accounts name.',
'Enter your encryption password to decrypt your transactions information.',
)}
</DialogDescription>
</DialogHeader>

View File

@ -6,7 +6,8 @@ import { useEffect, useState } from 'react';
export interface NetWorthEvolutionAccount {
id: string;
name: string;
name_iv: string;
name_iv: string | null;
encrypted: boolean;
type: AccountType;
currency_code: string;
bank: Bank;

View File

@ -0,0 +1,74 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
import axios from 'axios';
import { useEffect, useRef } from 'react';
interface EncryptedAccount {
id: string;
name: string;
name_iv: string | null;
encrypted: boolean;
}
export function useDecryptAccountNames() {
const { isKeySet } = useEncryptionKey();
const { hasEncryptedAccounts } = usePage<SharedData>().props;
const hasRun = useRef(false);
useEffect(() => {
if (!isKeySet || !hasEncryptedAccounts || hasRun.current) {
return;
}
hasRun.current = true;
async function migrateAccounts() {
try {
const keyString = getStoredKey();
if (!keyString) {
return;
}
const { data: accounts } =
await axios.get<EncryptedAccount[]>('/api/accounts');
const encryptedAccounts = accounts.filter(
(a) => a.encrypted && a.name_iv,
);
if (encryptedAccounts.length === 0) {
return;
}
const key = await importKey(keyString);
for (const account of encryptedAccounts) {
try {
const decryptedName = await decrypt(
account.name,
key,
account.name_iv!,
);
await axios.put(`/api/accounts/${account.id}`, {
name: decryptedName,
encrypted: false,
});
} catch {
// Skip accounts that fail to decrypt
}
}
window.location.reload();
} catch {
// Silent failure — migration will retry next session
hasRun.current = false;
}
}
migrateAccounts();
}, [isKeySet, hasEncryptedAccounts]);
}

View File

@ -7,8 +7,14 @@ interface AppLayoutProps {
breadcrumbs?: BreadcrumbItem[];
}
export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => (
<AppLayoutTemplate breadcrumbs={breadcrumbs} {...props}>
{children}
</AppLayoutTemplate>
);
export default function AppLayout({
children,
breadcrumbs,
...props
}: AppLayoutProps) {
return (
<AppLayoutTemplate breadcrumbs={breadcrumbs} {...props}>
{children}
</AppLayoutTemplate>
);
}

View File

@ -2,6 +2,7 @@ import { AppContent } from '@/components/app-content';
import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { useDecryptAccountNames } from '@/hooks/use-decrypt-account-names';
import { type BreadcrumbItem } from '@/types';
import { type PropsWithChildren } from 'react';
@ -9,6 +10,8 @@ export default function AppSidebarLayout({
children,
breadcrumbs = [],
}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
useDecryptAccountNames();
return (
<AppShell variant="sidebar">
<AppSidebar />

View File

@ -70,8 +70,16 @@ function normalizeRuleJson(rulesJson: unknown): unknown {
async function decryptAccountName(
account: Account,
key: CryptoKey,
key: CryptoKey | null,
): Promise<string> {
if (!account.encrypted) {
return account.name.trim();
}
if (!key || !account.name_iv) {
return '';
}
try {
const decryptedAccountName = await decrypt(
account.name,

View File

@ -1,11 +1,11 @@
import { index, show } from '@/actions/App/Http/Controllers/AccountController';
import { AccountBalanceChart } from '@/components/accounts/account-balance-chart';
import { AccountName } from '@/components/accounts/account-name';
import { BalancesModal } from '@/components/accounts/balances-modal';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { ImportBalancesDrawer } from '@/components/accounts/import-balances-drawer';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import HeadingSmall from '@/components/heading-small';
import { TransactionList } from '@/components/transactions/transaction-list';
import { Button } from '@/components/ui/button';
@ -62,11 +62,7 @@ export default function AccountShow({
},
{
title: (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 20 }}
/>
<AccountName account={account} length={{ min: 5, max: 20 }} />
),
href: show.url(account.id),
@ -95,9 +91,8 @@ export default function AccountShow({
)}
<HeadingSmall
title={
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
<AccountName
account={account}
length={{ min: 8, max: 30 }}
/>
}

View File

@ -25,7 +25,8 @@ import { useEffect, useRef } from 'react';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
name_iv: string | null;
encrypted: boolean;
type: string;
currency_code: string;
bank_id: string;

View File

@ -18,10 +18,10 @@ import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { useState } from 'react';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { AccountName } from '@/components/accounts/account-name';
import { CreateAccountDialog } from '@/components/accounts/create-account-dialog';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import HeadingSmall from '@/components/heading-small';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -214,10 +214,9 @@ export default function Accounts({ accounts }: AccountsPageProps) {
cell: ({ row }) => {
return (
<div className="pl-3 font-medium">
<EncryptedText
encryptedText={row.original.name}
<AccountName
account={row.original}
length={{ min: 10, max: 20 }}
iv={row.original.name_iv}
/>
</div>
);

View File

@ -1,6 +1,6 @@
import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController';
import { AccountName } from '@/components/accounts/account-name';
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import { CategoryIcon } from '@/components/shared/category-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
@ -716,16 +716,9 @@ export default function CategorizeTransactions({
className="h-5 w-5 rounded"
/>
)}
<EncryptedText
encryptedText={
currentTransaction
.account
.name
}
iv={
currentTransaction
.account
.name_iv
<AccountName
account={
currentTransaction.account
}
length={{
min: 5,

View File

@ -38,7 +38,8 @@ export interface Bank {
export interface Account {
id: UUID;
name: string;
name_iv: string;
name_iv: string | null;
encrypted: boolean;
bank: Bank;
type: AccountType;
currency_code: CurrencyCode;

View File

@ -53,6 +53,7 @@ export interface SharedData {
pricing: PricingConfig;
sidebarOpen: boolean;
features: Features;
hasEncryptedAccounts: boolean;
locale: string;
translations: Record<string, string>;
[key: string]: unknown;

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\AccountController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
@ -21,6 +22,10 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('transactions', [TransactionSyncController::class, 'index']);
});
// Accounts
Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index');
Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update');
// Account Balances
Route::put('accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('api.accounts.balance.update-current');
Route::get('accounts/{account}/balances', [AccountBalanceController::class, 'index'])->name('api.accounts.balances.index');

View File

@ -0,0 +1,98 @@
<?php
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
beforeEach(function () {
$this->user = User::factory()->create();
$this->bank = Bank::factory()->create();
});
it('returns all accounts for the authenticated user', function () {
actingAs($this->user);
Account::factory()->count(3)->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
]);
// Create account for another user (should not appear)
Account::factory()->create([
'user_id' => User::factory()->create()->id,
'bank_id' => $this->bank->id,
]);
$response = $this->getJson('/api/accounts');
$response->assertOk();
$response->assertJsonCount(3);
$response->assertJsonStructure([
'*' => ['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code'],
]);
});
it('can update account name and set encrypted to false', function () {
actingAs($this->user);
$account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'name' => 'encrypted_ciphertext',
'name_iv' => 'abcd1234efgh5678',
'encrypted' => true,
]);
$response = $this->putJson("/api/accounts/{$account->id}", [
'name' => 'My Checking Account',
'encrypted' => false,
]);
$response->assertOk();
assertDatabaseHas('accounts', [
'id' => $account->id,
'name' => 'My Checking Account',
'encrypted' => false,
'name_iv' => null,
]);
});
it('prevents updating another users account via api', function () {
actingAs($this->user);
$otherUser = User::factory()->create();
$account = Account::factory()->create([
'user_id' => $otherUser->id,
'bank_id' => $this->bank->id,
'encrypted' => true,
]);
$response = $this->putJson("/api/accounts/{$account->id}", [
'name' => 'Hacked Name',
'encrypted' => false,
]);
$response->assertForbidden();
});
it('validates required fields when updating via api', function () {
actingAs($this->user);
$account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
]);
$response = $this->putJson("/api/accounts/{$account->id}", []);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['name', 'encrypted']);
});
it('requires authentication for api endpoints', function () {
$response = $this->getJson('/api/accounts');
$response->assertUnauthorized();
});

View File

@ -30,12 +30,11 @@ it('displays user accounts on index page', function () {
->has('accounts', 1));
});
it('can create a new account', function () {
it('can create a new account with plaintext name', function () {
actingAs($this->user);
$data = [
'name' => 'encrypted_name_value',
'name_iv' => 'abcd1234efgh5678',
'name' => 'My Checking Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Checking->value,
@ -47,8 +46,9 @@ it('can create a new account', function () {
assertDatabaseHas('accounts', [
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'name' => 'encrypted_name_value',
'name_iv' => 'abcd1234efgh5678',
'name' => 'My Checking Account',
'name_iv' => null,
'encrypted' => false,
'currency_code' => 'USD',
'type' => AccountType::Checking->value,
]);
@ -59,29 +59,14 @@ it('validates required fields when creating account', function () {
$response = $this->post(route('accounts.store'), []);
$response->assertSessionHasErrors(['name', 'name_iv', 'bank_id', 'currency_code', 'type']);
});
it('validates name_iv must be exactly 16 characters', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'encrypted_name',
'name_iv' => 'short',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Checking->value,
]);
$response->assertSessionHasErrors(['name_iv']);
$response->assertSessionHasErrors(['name', 'bank_id', 'currency_code', 'type']);
});
it('validates currency_code must be in allowed list', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'encrypted_name',
'name_iv' => 'abcd1234efgh5678',
'name' => 'My Account',
'bank_id' => $this->bank->id,
'currency_code' => 'INVALID',
'type' => AccountType::Checking->value,
@ -94,8 +79,7 @@ it('validates type must be valid AccountType', function () {
actingAs($this->user);
$response = $this->post(route('accounts.store'), [
'name' => 'encrypted_name',
'name_iv' => 'abcd1234efgh5678',
'name' => 'My Account',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => 'invalid_type',
@ -115,8 +99,7 @@ it('can update an account', function () {
$newBank = Bank::factory()->create();
$data = [
'name' => 'updated_encrypted_name',
'name_iv' => 'newiv123456789ab',
'name' => 'Updated Account Name',
'bank_id' => $newBank->id,
'currency_code' => 'EUR',
'type' => AccountType::Savings->value,
@ -127,8 +110,9 @@ it('can update an account', function () {
$response->assertRedirect(route('accounts.index'));
assertDatabaseHas('accounts', [
'id' => $account->id,
'name' => 'updated_encrypted_name',
'name_iv' => 'newiv123456789ab',
'name' => 'Updated Account Name',
'encrypted' => false,
'name_iv' => null,
'bank_id' => $newBank->id,
'currency_code' => 'EUR',
'type' => AccountType::Savings->value,
@ -146,7 +130,6 @@ it('prevents updating another users account', function () {
$response = $this->patch(route('accounts.update', $account), [
'name' => 'hacked_name',
'name_iv' => 'abcd1234efgh5678',
'bank_id' => $this->bank->id,
'currency_code' => 'USD',
'type' => AccountType::Checking->value,