Settings: add account view
This commit is contained in:
parent
08baf3b19a
commit
b9679b9328
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
8
bun.lock
8
bun.lock
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ function CategoryActions({ category }: { category: Category }) {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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])));
|
||||
|
|
|
|||
Loading…
Reference in New Issue