Settings: add account view

This commit is contained in:
Víctor Falcón 2025-11-07 21:38:23 +00:00
parent 08baf3b19a
commit b9679b9328
16 changed files with 10257 additions and 9831 deletions

View File

@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreAccountRequest;
use App\Http\Requests\Settings\UpdateAccountRequest;
use App\Models\Account;
use App\Models\Bank;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
@ -27,17 +26,8 @@ class AccountController extends Controller
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($query) {
$query->where('user_id', auth()->id())
->orWhereNull('user_id');
})
->orderBy('name')
->get(['id', 'name', 'logo']);
return Inertia::render('settings/accounts', [
'accounts' => $accounts,
'banks' => $banks,
]);
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BankSearchController extends Controller
{
/**
* Search banks by query.
*/
public function __invoke(Request $request): JsonResponse
{
$query = $request->input('query', '');
if (strlen($query) < 3) {
return response()->json([
'banks' => [],
'message' => 'Type at least 3 characters to search',
]);
}
$banks = Bank::query()
->where(function ($q) use ($request) {
$q->where('user_id', $request->user()->id)
->orWhereNull('user_id');
})
->where('name', 'like', '%'.$query.'%')
->orderBy('name')
->limit(100)
->get(['id', 'name', 'logo']);
return response()->json([
'banks' => $banks,
]);
}
}

View File

@ -9,10 +9,11 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
@ -26,6 +27,7 @@
"@vitejs/plugin-react": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
"input-otp": "^1.4.2",
@ -256,6 +258,8 @@
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@ -506,6 +510,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],

File diff suppressed because one or more lines are too long

View File

@ -32,10 +32,11 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
@ -49,6 +50,7 @@
"@vitejs/plugin-react": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
"input-otp": "^1.4.2",

View File

@ -0,0 +1,168 @@
import { useState, useEffect, useCallback } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import axios from 'axios';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { type Bank } from '@/types/account';
interface BankComboboxProps {
value: number | null;
onValueChange: (value: number | null) => void;
defaultBank?: Bank;
}
const bankCache = new Map<string, Bank[]>();
export function BankCombobox({
value,
onValueChange,
defaultBank,
}: BankComboboxProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [banks, setBanks] = useState<Bank[]>(defaultBank ? [defaultBank] : []);
const [isLoading, setIsLoading] = useState(false);
const [selectedBank, setSelectedBank] = useState<Bank | null>(
defaultBank || null,
);
const searchBanks = useCallback(async (query: string) => {
if (query.length < 3) {
setBanks(defaultBank ? [defaultBank] : []);
return;
}
if (bankCache.has(query)) {
setBanks(bankCache.get(query)!);
return;
}
setIsLoading(true);
try {
const response = await axios.get<{ banks: Bank[] }>(
'/settings/banks/search',
{
params: { query },
},
);
const results = response.data.banks;
bankCache.set(query, results);
setBanks(results);
} catch (error) {
console.error('Failed to search banks:', error);
setBanks([]);
} finally {
setIsLoading(false);
}
}, [defaultBank]);
useEffect(() => {
const debounceTimer = setTimeout(() => {
searchBanks(searchQuery);
}, 300);
return () => clearTimeout(debounceTimer);
}, [searchQuery, searchBanks]);
const handleSelect = (bank: Bank) => {
setSelectedBank(bank);
onValueChange(bank.id);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedBank ? (
<div className="flex items-center gap-2">
{selectedBank.logo ? (
<img
src={selectedBank.logo}
alt={selectedBank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{selectedBank.name}</span>
</div>
) : (
'Select bank...'
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command filter={() => 1}>
<CommandInput
placeholder="Search bank..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>
{isLoading
? 'Searching...'
: searchQuery.length < 3
? 'Type at least 3 characters to search'
: 'No bank found.'}
</CommandEmpty>
<CommandGroup>
{banks.map((bank) => (
<CommandItem
key={bank.id}
value={`${bank.id}-${bank.name}`}
onSelect={() => handleSelect(bank)}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
value === bank.id
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Form } from '@inertiajs/react';
import { router } from '@inertiajs/react';
import {
Dialog,
DialogContent,
@ -28,19 +28,17 @@ import {
ACCOUNT_TYPES,
CURRENCY_OPTIONS,
formatAccountType,
type Bank,
} from '@/types/account';
import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, encrypt, bufferToBase64 } from '@/lib/crypto';
import { importKey, encrypt } from '@/lib/crypto';
import { BankCombobox } from './bank-combobox';
interface CreateAccountDialogProps {
banks: Bank[];
}
export function CreateAccountDialog({ banks }: CreateAccountDialogProps) {
export function CreateAccountDialog() {
const [open, setOpen] = useState(false);
const [isKeyAvailable, setIsKeyAvailable] = useState(false);
const [selectedBankId, setSelectedBankId] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const checkKey = () => {
@ -58,7 +56,7 @@ export function CreateAccountDialog({ banks }: CreateAccountDialogProps) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('name') as string;
const displayName = formData.get('display_name') as string;
const bankId = formData.get('bank_id') as string;
const type = formData.get('type') as string;
const currencyCode = formData.get('currency_code') as string;
@ -69,28 +67,30 @@ export function CreateAccountDialog({ banks }: CreateAccountDialogProps) {
return;
}
setIsSubmitting(true);
try {
const key = await importKey(keyString);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ivString = bufferToBase64(iv);
const { encrypted } = await encrypt(name, key);
const { encrypted, iv } = await encrypt(displayName, key);
const form = event.currentTarget;
const hiddenNameInput = form.querySelector(
'input[name="encrypted_name"]',
) as HTMLInputElement;
const hiddenIvInput = form.querySelector(
'input[name="name_iv"]',
) as HTMLInputElement;
if (hiddenNameInput && hiddenIvInput) {
hiddenNameInput.value = encrypted;
hiddenIvInput.value = ivString;
form.requestSubmit();
}
router.post(store.url(), {
name: encrypted,
name_iv: iv,
bank_id: bankId,
type: type,
currency_code: currencyCode,
}, {
onSuccess: () => {
setOpen(false);
},
onFinish: () => {
setIsSubmitting(false);
},
});
} catch (err) {
console.error('Encryption failed:', err);
alert('Failed to encrypt account name. Please try again.');
setIsSubmitting(false);
}
}
@ -126,136 +126,86 @@ export function CreateAccountDialog({ banks }: CreateAccountDialogProps) {
Add a new bank account to track your transactions.
</DialogDescription>
</DialogHeader>
<Form
{...store.form()}
<form
onSubmit={handleSubmit}
onSuccess={() => setOpen(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
placeholder="Account name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<input
type="hidden"
name="name"
id="encrypted_name"
name="bank_id"
value={selectedBankId || ''}
required
/>
<input
type="hidden"
name="name_iv"
id="name_iv"
<BankCombobox
value={selectedBankId}
onValueChange={setSelectedBankId}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
placeholder="Account name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select name="type" required>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<Select name="bank_id" required>
<SelectTrigger>
<SelectValue placeholder="Select a bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem
key={bank.id}
value={bank.id.toString()}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.bank_id && (
<p className="text-sm text-red-500">
{errors.bank_id}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select name="currency_code" required>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select name="type" required>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select name="currency_code" required>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency_code && (
<p className="text-sm text-red-500">
{errors.currency_code}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create'}
</Button>
</div>
</>
)}
</Form>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>
</>
</form>
</DialogContent>
</Dialog>
);

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Form, usePage } from '@inertiajs/react';
import { router } from '@inertiajs/react';
import {
Dialog,
DialogContent,
@ -22,11 +22,11 @@ import {
CURRENCY_OPTIONS,
formatAccountType,
type Account,
type Bank,
} from '@/types/account';
import { update } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, encrypt, decrypt, bufferToBase64 } from '@/lib/crypto';
import { importKey, encrypt, decrypt } from '@/lib/crypto';
import { BankCombobox } from './bank-combobox';
interface EditAccountDialogProps {
account: Account;
@ -39,10 +39,15 @@ export function EditAccountDialog({
open,
onOpenChange,
}: EditAccountDialogProps) {
const { banks } = usePage<{ banks: Bank[] }>().props;
const [decryptedName, setDecryptedName] = useState('');
const [selectedBankId, setSelectedBankId] = useState<number | null>(
account.bank.id,
);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
async function decryptName() {
const keyString = getStoredKey();
if (!keyString) {
@ -64,16 +69,25 @@ export function EditAccountDialog({
}
}
if (open) {
decryptName();
decryptName();
}, [open, account.name, account.name_iv]);
// Update selected bank when dialog opens with a new account
useEffect(() => {
if (open && selectedBankId !== account.bank.id) {
// Schedule state update to avoid cascading renders
const timer = setTimeout(() => {
setSelectedBankId(account.bank.id);
}, 0);
return () => clearTimeout(timer);
}
}, [open, account]);
}, [open, account.bank.id, selectedBankId]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('display_name') as string;
const displayName = formData.get('display_name') as string;
const bankId = formData.get('bank_id') as string;
const type = formData.get('type') as string;
const currencyCode = formData.get('currency_code') as string;
@ -84,28 +98,30 @@ export function EditAccountDialog({
return;
}
setIsSubmitting(true);
try {
const key = await importKey(keyString);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ivString = bufferToBase64(iv);
const { encrypted } = await encrypt(name, key);
const { encrypted, iv } = await encrypt(displayName, key);
const form = event.currentTarget;
const hiddenNameInput = form.querySelector(
'input[name="name"]',
) as HTMLInputElement;
const hiddenIvInput = form.querySelector(
'input[name="name_iv"]',
) as HTMLInputElement;
if (hiddenNameInput && hiddenIvInput) {
hiddenNameInput.value = encrypted;
hiddenIvInput.value = ivString;
form.requestSubmit();
}
router.patch(update.url(account.id), {
name: encrypted,
name_iv: iv,
bank_id: bankId,
type: type,
currency_code: currencyCode,
}, {
onSuccess: () => {
onOpenChange(false);
},
onFinish: () => {
setIsSubmitting(false);
},
});
} catch (err) {
console.error('Encryption failed:', err);
alert('Failed to encrypt account name. Please try again.');
setIsSubmitting(false);
}
}
@ -118,145 +134,95 @@ export function EditAccountDialog({
Update the account information.
</DialogDescription>
</DialogHeader>
<Form
{...update.form.patch(account.id)}
<form
onSubmit={handleSubmit}
onSuccess={() => onOpenChange(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<input type="hidden" name="name" id="name" />
<>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
defaultValue={decryptedName}
placeholder="Account name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<input
type="hidden"
name="name_iv"
id="name_iv"
name="bank_id"
value={selectedBankId || ''}
required
/>
<BankCombobox
value={selectedBankId}
onValueChange={setSelectedBankId}
defaultBank={account.bank}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Name</Label>
<Input
id="display_name"
name="display_name"
defaultValue={decryptedName}
placeholder="Account name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select
name="type"
defaultValue={account.type}
required
>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bank_id">Bank</Label>
<Select
name="bank_id"
defaultValue={account.bank.id.toString()}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem
key={bank.id}
value={bank.id.toString()}
>
<div className="flex items-center gap-2">
{bank.logo ? (
<img
src={bank.logo}
alt={bank.name}
className="h-4 w-4 rounded object-contain"
/>
) : (
<div className="h-4 w-4 rounded bg-muted" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.bank_id && (
<p className="text-sm text-red-500">
{errors.bank_id}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select
name="currency_code"
defaultValue={account.currency_code}
required
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="type">Account Type</Label>
<Select
name="type"
defaultValue={account.type}
required
>
<SelectTrigger>
<SelectValue placeholder="Select account type" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatAccountType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency_code">Currency</Label>
<Select
name="currency_code"
defaultValue={account.currency_code}
required
>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
{CURRENCY_OPTIONS.map((currency) => (
<SelectItem
key={currency}
value={currency}
>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency_code && (
<p className="text-sm text-red-500">
{errors.currency_code}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Updating...' : 'Update'}
</Button>
</div>
</>
)}
</Form>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Updating...' : 'Update'}
</Button>
</div>
</>
</form>
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,107 @@
import { useState, useEffect } from 'react';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, decrypt } from '@/lib/crypto';
interface EncryptedTextProps {
encryptedText: string;
iv: string;
interval?: number;
className?: string;
fallback?: string;
}
const chars = "-_~`!@#$%^&*()+=[]{}|;:,.<>?";
export function EncryptedText({
encryptedText,
iv,
interval = 30,
className = '',
fallback = '[Encrypted]',
}: EncryptedTextProps) {
const { isKeySet } = useEncryptionKey();
const [decryptedText, setDecryptedText] = useState<string | null>(null);
const [outputText, setOutputText] = useState('');
const [isMounted, setIsMounted] = useState(false);
const [targetText, setTargetText] = useState('');
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
async function decryptData() {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
setDecryptedText(null);
return;
}
try {
const key = await importKey(keyString);
const text = await decrypt(encryptedText, key, iv);
setDecryptedText(text);
} catch (err) {
console.error('Failed to decrypt text:', err);
setDecryptedText(null);
}
}
decryptData();
}, [encryptedText, iv, isKeySet]);
useEffect(() => {
if (decryptedText !== null && isKeySet) {
setTargetText(decryptedText);
} else {
setTargetText(generateRandomChars(fallback.length));
}
}, [decryptedText, isKeySet, fallback.length]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (outputText !== targetText) {
let currentIndex = 0;
timer = setInterval(() => {
if (currentIndex < targetText.length) {
setOutputText(targetText.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(timer);
}
}, interval);
}
return () => clearInterval(timer);
}, [targetText, interval]);
function generateRandomChars(length: number): string {
return Array.from({ length }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
}
const remainder =
outputText.length < targetText.length
? targetText
.slice(outputText.length)
.split('')
.map(() => chars[Math.floor(Math.random() * chars.length)])
.join('')
: '';
if (!isMounted) {
return <span className={className}> </span>;
}
return (
<span className={className}>
{outputText}
{remainder}
</span>
);
}

View File

@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
@ -36,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
@ -47,8 +49,11 @@ function DialogOverlay({
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@ -61,10 +66,15 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Head } from '@inertiajs/react';
import {
ColumnDef,
@ -38,25 +38,22 @@ import HeadingSmall from '@/components/heading-small';
import { CreateAccountDialog } from '@/components/accounts/create-account-dialog';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
import { EncryptedText } from '@/components/encrypted-text';
import {
type Account,
type Bank,
formatAccountType,
} from '@/types/account';
import { type BreadcrumbItem } from '@/types';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { getStoredKey } from '@/lib/key-storage';
import { importKey, decrypt } from '@/lib/crypto';
interface AccountsProps {
accounts: Account[];
banks: Bank[];
}
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Accounts settings',
href: accountsIndex(),
href: accountsIndex.url(),
},
];
@ -101,50 +98,12 @@ function AccountActions({ account }: { account: Account }) {
);
}
export default function Accounts({ accounts, banks }: AccountsProps) {
export default function Accounts({ accounts }: AccountsProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const [decryptedNames, setDecryptedNames] = useState<
Record<number, string>
>({});
useEffect(() => {
async function decryptAccountNames() {
const keyString = getStoredKey();
if (!keyString) return;
try {
const key = await importKey(keyString);
const decrypted: Record<number, string> = {};
for (const account of accounts) {
try {
const name = await decrypt(
account.name,
key,
account.name_iv,
);
decrypted[account.id] = name;
} catch (err) {
console.error(
`Failed to decrypt account ${account.id}:`,
err,
);
decrypted[account.id] = '[Encrypted]';
}
}
setDecryptedNames(decrypted);
} catch (err) {
console.error('Failed to decrypt account names:', err);
}
}
decryptAccountNames();
}, [accounts]);
const columns: ColumnDef<Account>[] = [
{
@ -163,8 +122,14 @@ export default function Accounts({ accounts, banks }: AccountsProps) {
);
},
cell: ({ row }) => {
const name = decryptedNames[row.original.id] || '[Encrypted]';
return <div className="pl-3 font-medium">{name}</div>;
return (
<div className="pl-3 font-medium">
<EncryptedText
encryptedText={row.original.name}
iv={row.original.name_iv}
/>
</div>
);
},
},
{
@ -235,14 +200,7 @@ export default function Accounts({ accounts, banks }: AccountsProps) {
});
return (
<AppLayout
breadcrumbs={breadcrumbs}
header={
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Accounts
</h2>
}
>
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Accounts settings" />
<SettingsLayout>
@ -268,7 +226,7 @@ export default function Accounts({ accounts, banks }: AccountsProps) {
}
className="max-w-sm"
/>
<CreateAccountDialog banks={banks} />
<CreateAccountDialog />
</div>
<div className="rounded-md border">

View File

@ -71,6 +71,7 @@ function CategoryActions({ category }: { category: Category }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\BankSearchController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
@ -15,6 +16,7 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::get('settings/password', [PasswordController::class, 'edit'])->name('user-password.edit');
Route::put('settings/password', [PasswordController::class, 'update'])
@ -25,6 +27,7 @@ Route::middleware('auth')->group(function () {
Route::post('settings/accounts', [AccountController::class, 'store'])->name('accounts.store');
Route::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update');
Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy');
Route::get('settings/banks/search', BankSearchController::class)->name('banks.search');
Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index');
Route::post('settings/categories', [CategoryController::class, 'store'])->name('categories.store');

View File

@ -8,7 +8,6 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\assertDatabaseMissing;
beforeEach(function () {
$this->user = User::factory()->create();
@ -28,8 +27,7 @@ it('displays user accounts on index page', function () {
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/accounts')
->has('accounts', 1)
->has('banks'));
->has('accounts', 1));
});
it('can create a new account', function () {
@ -215,20 +213,19 @@ it('prevents deleting another users account', function () {
assertDatabaseHas('accounts', ['id' => $account->id]);
});
it('only shows banks owned by user or global banks', function () {
it('only shows banks owned by user or global banks via search', function () {
Bank::query()->delete();
actingAs($this->user);
$userBank = Bank::factory()->create(['user_id' => $this->user->id]);
$globalBank = Bank::factory()->create(['user_id' => null]);
$otherUserBank = Bank::factory()->create(['user_id' => User::factory()->create()->id]);
$userBank = Bank::factory()->create(['user_id' => $this->user->id, 'name' => 'Test User Bank']);
$globalBank = Bank::factory()->create(['user_id' => null, 'name' => 'Test Global Bank']);
$otherUserBank = Bank::factory()->create(['user_id' => User::factory()->create()->id, 'name' => 'Test Other Bank']);
$response = $this->get(route('accounts.index'));
$response = $this->get(route('banks.search', ['query' => 'Test']));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/accounts')
$response->assertJson(fn ($json) => $json
->has('banks', 2)
->where('banks.0.id', fn ($id) => in_array($id, [$userBank->id, $globalBank->id]))
->where('banks.1.id', fn ($id) => in_array($id, [$userBank->id, $globalBank->id])));