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:
parent
caccac6166
commit
2dc8cb554c
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ class Account extends Model
|
|||
'bank_id',
|
||||
'currency_code',
|
||||
'type',
|
||||
'encrypted',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => AccountType::class,
|
||||
'encrypted' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface SharedData {
|
|||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
features: Features;
|
||||
hasEncryptedAccounts: boolean;
|
||||
locale: string;
|
||||
translations: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue