-
diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx
index 558b276c..3e4d8724 100644
--- a/resources/js/components/dashboard/account-balance-card.tsx
+++ b/resources/js/components/dashboard/account-balance-card.tsx
@@ -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({
/>
)}
-
diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx
index 59ad0e64..8d934997 100644
--- a/resources/js/components/dashboard/net-worth-chart.tsx
+++ b/resources/js/components/dashboard/net-worth-chart.tsx
@@ -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 (
-
- );
+ return
;
}
interface CurrencyTotal {
diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx
index 5810ae0a..f890bd2c 100644
--- a/resources/js/components/onboarding/step-create-account.tsx
+++ b/resources/js/components/onboarding/step-create-account.tsx
@@ -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,
diff --git a/resources/js/components/onboarding/step-more-accounts.tsx b/resources/js/components/onboarding/step-more-accounts.tsx
index 4a49b974..f19f2757 100644
--- a/resources/js/components/onboarding/step-more-accounts.tsx
+++ b/resources/js/components/onboarding/step-more-accounts.tsx
@@ -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;
diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx
index f9d6c35d..c694197e 100644
--- a/resources/js/components/transactions/edit-transaction-dialog.tsx
+++ b/resources/js/components/transactions/edit-transaction-dialog.tsx
@@ -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
();
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,
diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx
index 4276f850..c1dc15b9 100644
--- a/resources/js/components/transactions/import-step-account.tsx
+++ b/resources/js/components/transactions/import-step-account.tsx
@@ -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({
)}
-
diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx
index 51c0d007..c74af874 100644
--- a/resources/js/components/transactions/transaction-columns.tsx
+++ b/resources/js/components/transactions/transaction-columns.tsx
@@ -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"
/>
)}
-
diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx
index e0b59f72..80c5d37e 100644
--- a/resources/js/components/transactions/transaction-filters.tsx
+++ b/resources/js/components/transactions/transaction-filters.tsx
@@ -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({
)
}
>
-
{__('Unlock Encrypted Data')}
{__(
- 'Enter your encryption password to decrypt transactions information and accounts name.',
+ 'Enter your encryption password to decrypt your transactions information.',
)}
diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts
index b1ec71e9..40464eba 100644
--- a/resources/js/hooks/use-dashboard-data.ts
+++ b/resources/js/hooks/use-dashboard-data.ts
@@ -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;
diff --git a/resources/js/hooks/use-decrypt-account-names.ts b/resources/js/hooks/use-decrypt-account-names.ts
new file mode 100644
index 00000000..fe837712
--- /dev/null
+++ b/resources/js/hooks/use-decrypt-account-names.ts
@@ -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().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('/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]);
+}
diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx
index 1457c51e..4c233e48 100644
--- a/resources/js/layouts/app-layout.tsx
+++ b/resources/js/layouts/app-layout.tsx
@@ -7,8 +7,14 @@ interface AppLayoutProps {
breadcrumbs?: BreadcrumbItem[];
}
-export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => (
-
- {children}
-
-);
+export default function AppLayout({
+ children,
+ breadcrumbs,
+ ...props
+}: AppLayoutProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx
index 1fb6bdf8..079e584b 100644
--- a/resources/js/layouts/app/app-sidebar-layout.tsx
+++ b/resources/js/layouts/app/app-sidebar-layout.tsx
@@ -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 (
diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts
index 048a7e46..a34a957c 100644
--- a/resources/js/lib/rule-engine.ts
+++ b/resources/js/lib/rule-engine.ts
@@ -70,8 +70,16 @@ function normalizeRuleJson(rulesJson: unknown): unknown {
async function decryptAccountName(
account: Account,
- key: CryptoKey,
+ key: CryptoKey | null,
): Promise {
+ if (!account.encrypted) {
+ return account.name.trim();
+ }
+
+ if (!key || !account.name_iv) {
+ return '';
+ }
+
try {
const decryptedAccountName = await decrypt(
account.name,
diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx
index 1bea101e..5a596236 100644
--- a/resources/js/pages/Accounts/Show.tsx
+++ b/resources/js/pages/Accounts/Show.tsx
@@ -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: (
-
+
),
href: show.url(account.id),
@@ -95,9 +91,8 @@ export default function AccountShow({
)}
}
diff --git a/resources/js/pages/onboarding/index.tsx b/resources/js/pages/onboarding/index.tsx
index 7c97572b..45c2066b 100644
--- a/resources/js/pages/onboarding/index.tsx
+++ b/resources/js/pages/onboarding/index.tsx
@@ -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;
diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx
index 51065706..44ce50d7 100644
--- a/resources/js/pages/settings/accounts.tsx
+++ b/resources/js/pages/settings/accounts.tsx
@@ -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 (
-
);
diff --git a/resources/js/pages/transactions/categorize.tsx b/resources/js/pages/transactions/categorize.tsx
index 6f305122..ddadf9c4 100644
--- a/resources/js/pages/transactions/categorize.tsx
+++ b/resources/js/pages/transactions/categorize.tsx
@@ -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"
/>
)}
- ;
[key: string]: unknown;
diff --git a/routes/api.php b/routes/api.php
index 7038b600..1fc93d81 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,6 +1,7 @@
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');
diff --git a/tests/Feature/Api/AccountApiTest.php b/tests/Feature/Api/AccountApiTest.php
new file mode 100644
index 00000000..5ddc9a2e
--- /dev/null
+++ b/tests/Feature/Api/AccountApiTest.php
@@ -0,0 +1,98 @@
+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();
+});
diff --git a/tests/Feature/Settings/AccountTest.php b/tests/Feature/Settings/AccountTest.php
index 2b16594c..605d6972 100644
--- a/tests/Feature/Settings/AccountTest.php
+++ b/tests/Feature/Settings/AccountTest.php
@@ -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,