diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index cac129de..11d53d25 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -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, diff --git a/app/Http/Controllers/Api/AccountController.php b/app/Http/Controllers/Api/AccountController.php new file mode 100644 index 00000000..5f61074a --- /dev/null +++ b/app/Http/Controllers/Api/AccountController.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 1e87a615..893f2714 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -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, ], diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php index c0ed71fa..3ec3599f 100644 --- a/app/Http/Controllers/Api/ImportDataController.php +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -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']), diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 9089de32..2c63ed47 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -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) { diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index c9183aa1..1dbf80fd 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -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) { diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 5c131af1..6265e635 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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) { diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 6e080482..433cc141 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -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, diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index a09b1365..6e500235 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -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'); } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 44599670..448aee80 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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) { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index ad850346..2c933472 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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(), ]; diff --git a/app/Http/Requests/Api/UpdateAccountNameRequest.php b/app/Http/Requests/Api/UpdateAccountNameRequest.php new file mode 100644 index 00000000..5758bc69 --- /dev/null +++ b/app/Http/Requests/Api/UpdateAccountNameRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string'], + 'encrypted' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Settings/StoreAccountRequest.php b/app/Http/Requests/Settings/StoreAccountRequest.php index ce1f43d1..44fa8cd3 100644 --- a/app/Http/Requests/Settings/StoreAccountRequest.php +++ b/app/Http/Requests/Settings/StoreAccountRequest.php @@ -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', diff --git a/app/Http/Requests/Settings/UpdateAccountRequest.php b/app/Http/Requests/Settings/UpdateAccountRequest.php index c4ce48b2..9ff0b3c7 100644 --- a/app/Http/Requests/Settings/UpdateAccountRequest.php +++ b/app/Http/Requests/Settings/UpdateAccountRequest.php @@ -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', diff --git a/app/Models/Account.php b/app/Models/Account.php index 2aea1829..4b340b29 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -22,12 +22,14 @@ class Account extends Model 'bank_id', 'currency_code', 'type', + 'encrypted', ]; protected function casts(): array { return [ 'type' => AccountType::class, + 'encrypted' => 'boolean', ]; } diff --git a/database/factories/AccountFactory.php b/database/factories/AccountFactory.php index 5a50a7c3..3a479caa 100644 --- a/database/factories/AccountFactory.php +++ b/database/factories/AccountFactory.php @@ -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()), diff --git a/database/migrations/2026_02_09_110624_add_encrypted_to_accounts_table.php b/database/migrations/2026_02_09_110624_add_encrypted_to_accounts_table.php new file mode 100644 index 00000000..279de6ce --- /dev/null +++ b/database/migrations/2026_02_09_110624_add_encrypted_to_accounts_table.php @@ -0,0 +1,30 @@ +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(); + }); + } +}; diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx index 6e802cf9..2eff4f8d 100644 --- a/resources/js/components/accounts/account-balance-chart.tsx +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -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: ( - + ), color: 'var(--color-chart-2)', diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx index c948ea27..78e33c44 100644 --- a/resources/js/components/accounts/account-list-card.tsx +++ b/resources/js/components/accounts/account-list-card.tsx @@ -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({ )} - diff --git a/resources/js/components/accounts/account-name.tsx b/resources/js/components/accounts/account-name.tsx new file mode 100644 index 00000000..807f7c06 --- /dev/null +++ b/resources/js/components/accounts/account-name.tsx @@ -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 {account.name}; + } + + return ( + + ); +} diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index bdad62e8..b0bf10a1 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -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({ 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 = ( - - ); - return ( - {isKeyAvailable ? ( - createButton - ) : ( - - - - {createButton} - - -

- Encryption key required. Please unlock your - data first. -

-
-
-
- )} +
diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx index d02b32c3..0e39a5c8 100644 --- a/resources/js/components/accounts/edit-account-dialog.tsx +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -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, diff --git a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx index 4f813a10..83b7c306 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-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'; @@ -61,9 +61,8 @@ export function ImportBalanceStepAccount({ )}
- 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,