feat(password-vault): add password generator, custom fields, and delete confirmation
- Password generator with configurable length and character types (upper/lower/digits/symbols) - Custom fields support (key-value pairs per entry) - Delete confirmation dialog - Custom fields displayed as badges in list view Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } from 'lucide-react';
|
import {
|
||||||
|
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink,
|
||||||
|
KeyRound, X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
@@ -9,7 +12,9 @@ import { Textarea } from '@/shared/components/ui/textarea';
|
|||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
import type { VaultEntry, VaultEntryCategory } from '../types';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||||
|
import { Switch } from '@/shared/components/ui/switch';
|
||||||
|
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
|
||||||
import { useVault } from '../hooks/use-vault';
|
import { useVault } from '../hooks/use-vault';
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||||
@@ -18,12 +23,28 @@ const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
|||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
|
/** Generate a random password */
|
||||||
|
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): string {
|
||||||
|
let chars = '';
|
||||||
|
if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
if (options.digits) chars += '0123456789';
|
||||||
|
if (options.symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||||
|
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function PasswordVaultModule() {
|
export function PasswordVaultModule() {
|
||||||
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
||||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const togglePassword = (id: string) => {
|
const togglePassword = (id: string) => {
|
||||||
setVisiblePasswords((prev) => {
|
setVisiblePasswords((prev) => {
|
||||||
@@ -51,6 +72,13 @@ export function PasswordVaultModule() {
|
|||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeEntry(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
@@ -118,12 +146,21 @@ export function PasswordVaultModule() {
|
|||||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{entry.customFields && entry.customFields.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{entry.customFields.map((cf, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-[10px]">
|
||||||
|
{cf.key}: {cf.value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeEntry(entry.id)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(entry.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,6 +180,18 @@ export function PasswordVaultModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm">Ești sigur că vrei să ștergi această intrare? Acțiunea este ireversibilă.</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,11 +207,42 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
|||||||
const [url, setUrl] = useState(initial?.url ?? '');
|
const [url, setUrl] = useState(initial?.url ?? '');
|
||||||
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
|
||||||
|
|
||||||
|
// Password generator state
|
||||||
|
const [genLength, setGenLength] = useState(16);
|
||||||
|
const [genUpper, setGenUpper] = useState(true);
|
||||||
|
const [genLower, setGenLower] = useState(true);
|
||||||
|
const [genDigits, setGenDigits] = useState(true);
|
||||||
|
const [genSymbols, setGenSymbols] = useState(true);
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
setCustomFields([...customFields, { key: '', value: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustomField = (index: number, field: keyof CustomField, value: string) => {
|
||||||
|
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomField = (index: number) => {
|
||||||
|
setCustomFields(customFields.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, username, encryptedPassword: password, url, category, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin' }); }} className="space-y-4">
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label, username, encryptedPassword: password, url, category, notes,
|
||||||
|
customFields: customFields.filter((cf) => cf.key.trim()),
|
||||||
|
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume/Etichetă</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
<div><Label>Nume/Etichetă *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
||||||
<div><Label>Categorie</Label>
|
<div><Label>Categorie</Label>
|
||||||
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
@@ -172,9 +252,60 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
|
<Label>Parolă</Label>
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password generator options */}
|
||||||
|
<div className="rounded border p-3 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Generator parolă</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs">Lungime:</Label>
|
||||||
|
<Input type="number" value={genLength} onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)} className="w-16 text-sm" min={4} max={64} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genUpper} onCheckedChange={setGenUpper} id="gen-upper" /><Label htmlFor="gen-upper" className="text-xs cursor-pointer">A-Z</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genLower} onCheckedChange={setGenLower} id="gen-lower" /><Label htmlFor="gen-lower" className="text-xs cursor-pointer">a-z</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genDigits} onCheckedChange={setGenDigits} id="gen-digits" /><Label htmlFor="gen-digits" className="text-xs cursor-pointer">0-9</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genSymbols} onCheckedChange={setGenSymbols} id="gen-symbols" /><Label htmlFor="gen-symbols" className="text-xs cursor-pointer">!@#$</Label></div>
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={handleGenerate}>
|
||||||
|
<KeyRound className="mr-1 h-3 w-3" /> Generează
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
||||||
|
|
||||||
|
{/* Custom fields */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Câmpuri personalizate</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addCustomField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{customFields.map((cf, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input placeholder="Cheie" value={cf.key} onChange={(e) => updateCustomField(i, 'key', e.target.value)} className="w-[140px] text-sm" />
|
||||||
|
<Input placeholder="Valoare" value={cf.value} onChange={(e) => updateCustomField(i, 'value', e.target.value)} className="flex-1 text-sm" />
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeCustomField(i)}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type VaultEntryCategory =
|
|||||||
| 'api'
|
| 'api'
|
||||||
| 'other';
|
| 'other';
|
||||||
|
|
||||||
|
/** Custom key-value field */
|
||||||
|
export interface CustomField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VaultEntry {
|
export interface VaultEntry {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,6 +21,8 @@ export interface VaultEntry {
|
|||||||
encryptedPassword: string;
|
encryptedPassword: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: VaultEntryCategory;
|
category: VaultEntryCategory;
|
||||||
|
/** Custom key-value fields */
|
||||||
|
customFields: CustomField[];
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user