feat(password-vault): add company scope and password strength meter

This commit is contained in:
AI Assistant
2026-02-19 06:43:30 +02:00
parent 8b0ad5c2d7
commit b96b004baf
2 changed files with 504 additions and 138 deletions

View File

@@ -1,61 +1,109 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { import {
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink, Plus,
KeyRound, X, Pencil,
} from 'lucide-react'; Trash2,
import { Button } from '@/shared/components/ui/button'; Search,
import { Input } from '@/shared/components/ui/input'; Eye,
import { Label } from '@/shared/components/ui/label'; EyeOff,
import { Textarea } from '@/shared/components/ui/textarea'; Copy,
import { Badge } from '@/shared/components/ui/badge'; ExternalLink,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; KeyRound,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; X,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; } from "lucide-react";
import { Switch } from '@/shared/components/ui/switch'; import { Button } from "@/shared/components/ui/button";
import type { CompanyId } from '@/core/auth/types'; import { Input } from "@/shared/components/ui/input";
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types'; import { Label } from "@/shared/components/ui/label";
import { useVault } from '../hooks/use-vault'; import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import { Switch } from "@/shared/components/ui/switch";
import type { CompanyId } from "@/core/auth/types";
import type { VaultEntry, VaultEntryCategory, CustomField } from "../types";
import { useVault } from "../hooks/use-vault";
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = { const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele', web: "Web",
email: "Email",
server: "Server",
database: "Bază de date",
api: "API",
other: "Altele",
}; };
const COMPANY_LABELS: Record<CompanyId, string> = { const COMPANY_LABELS: Record<CompanyId, string> = {
'beletage': 'Beletage', beletage: "Beletage",
'urban-switch': 'Urban Switch', "urban-switch": "Urban Switch",
'studii-de-teren': 'Studii de Teren', "studii-de-teren": "Studii de Teren",
'group': 'Grup', group: "Grup",
}; };
/** Calculate password strength: 0-3 (weak, medium, strong, very strong) */ /** Calculate password strength: 0-3 (weak, medium, strong, very strong) */
function getPasswordStrength(pwd: string): { level: 0 | 1 | 2 | 3; label: string; color: string } { function getPasswordStrength(pwd: string): {
if (!pwd) return { level: 0, label: 'Nicio parolă', color: 'bg-gray-300' }; level: 0 | 1 | 2 | 3;
label: string;
color: string;
} {
if (!pwd) return { level: 0, label: "Nicio parolă", color: "bg-gray-300" };
const len = pwd.length; const len = pwd.length;
const hasUpper = /[A-Z]/.test(pwd); const hasUpper = /[A-Z]/.test(pwd);
const hasLower = /[a-z]/.test(pwd); const hasLower = /[a-z]/.test(pwd);
const hasDigit = /\d/.test(pwd); const hasDigit = /\d/.test(pwd);
const hasSymbol = /[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/.test(pwd); const hasSymbol = /[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/.test(pwd);
const varietyScore = (hasUpper ? 1 : 0) + (hasLower ? 1 : 0) + (hasDigit ? 1 : 0) + (hasSymbol ? 1 : 0); const varietyScore =
(hasUpper ? 1 : 0) +
(hasLower ? 1 : 0) +
(hasDigit ? 1 : 0) +
(hasSymbol ? 1 : 0);
const score = len + varietyScore * 2; const score = len + varietyScore * 2;
if (score < 8) return { level: 0, label: 'Slabă', color: 'bg-red-500' }; if (score < 8) return { level: 0, label: "Slabă", color: "bg-red-500" };
if (score < 16) return { level: 1, label: 'Medie', color: 'bg-yellow-500' }; if (score < 16) return { level: 1, label: "Medie", color: "bg-yellow-500" };
if (score < 24) return { level: 2, label: 'Puternică', color: 'bg-green-500' }; if (score < 24)
return { level: 3, label: 'Foarte puternică', color: 'bg-emerald-600' }; return { level: 2, label: "Puternică", color: "bg-green-500" };
return { level: 3, label: "Foarte puternică", color: "bg-emerald-600" };
} }
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
/** Generate a random password */ /** Generate a random password */
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): string { function generatePassword(
let chars = ''; length: number,
if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; options: {
if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz'; upper: boolean;
if (options.digits) chars += '0123456789'; lower: boolean;
if (options.symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?'; digits: boolean;
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; symbols: boolean;
let result = ''; },
): 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++) { for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }
@@ -63,17 +111,29 @@ function generatePassword(length: number, options: { upper: boolean; lower: bool
} }
export function PasswordVaultModule() { export function PasswordVaultModule() {
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); entries,
allEntries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
} = useVault();
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 [deletingId, setDeletingId] = useState<string | null>(null);
const togglePassword = (id: string) => { const togglePassword = (id: string) => {
setVisiblePasswords((prev) => { setVisiblePasswords((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });
}; };
@@ -83,16 +143,20 @@ export function PasswordVaultModule() {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopiedId(id); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingEntry) { data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingEntry) {
await updateEntry(editingEntry.id, data); await updateEntry(editingEntry.id, data);
} else { } else {
await addEntry(data); await addEntry(data);
} }
setViewMode('list'); setViewMode("list");
setEditingEntry(null); setEditingEntry(null);
}; };
@@ -106,42 +170,89 @@ export function PasswordVaultModule() {
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">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate. Folosiți un manager de parole dedicat pentru date sensibile. Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
Folosiți un manager de parole dedicat pentru date sensibile.
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allEntries.length}</p></CardContent></Card> <Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Web</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'web').length}</p></CardContent></Card> <CardContent className="p-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Server</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'server').length}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Total</p>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">API</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'api').length}</p></CardContent></Card> <p className="text-2xl font-bold">{allEntries.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Web</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "web").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Server</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "server").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">API</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "api").length}
</p>
</CardContent>
</Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input
placeholder="Caută..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div> </div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as VaultEntryCategory | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate</SelectItem>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => ( {(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem> (c) => (
))} <SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => setViewMode('add')} className="shrink-0"> <Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Nicio intrare găsită.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Nicio intrare găsită.
</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry) => ( {entries.map((entry) => (
@@ -150,20 +261,44 @@ export function PasswordVaultModule() {
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{entry.label}</p> <p className="font-medium">{entry.label}</p>
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[entry.category]}</Badge> <Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[entry.category]}
</Badge>
</div> </div>
<p className="text-xs text-muted-foreground">{entry.username}</p> <p className="text-xs text-muted-foreground">
{entry.username}
</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs"> <code className="text-xs">
{visiblePasswords.has(entry.id) ? entry.password : '••••••••••'} {visiblePasswords.has(entry.id)
? entry.password
: "••••••••••"}
</code> </code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}> <Button
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />} variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => togglePassword(entry.id)}
>
{visiblePasswords.has(entry.id) ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button> </Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.password, entry.id)}> <Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleCopy(entry.password, entry.id)}
>
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</Button> </Button>
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>} {copiedId === entry.id && (
<span className="text-[10px] text-green-500">
Copiat!
</span>
)}
</div> </div>
{entry.url && ( {entry.url && (
<p className="flex items-center gap-1 text-xs text-muted-foreground"> <p className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -173,7 +308,11 @@ export function PasswordVaultModule() {
{entry.customFields && entry.customFields.length > 0 && ( {entry.customFields && entry.customFields.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{entry.customFields.map((cf, i) => ( {entry.customFields.map((cf, i) => (
<Badge key={i} variant="secondary" className="text-[10px]"> <Badge
key={i}
variant="secondary"
className="text-[10px]"
>
{cf.key}: {cf.value} {cf.key}: {cf.value}
</Badge> </Badge>
))} ))}
@@ -181,10 +320,23 @@ export function PasswordVaultModule() {
)} )}
</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={() => setDeletingId(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>
@@ -196,23 +348,48 @@ export function PasswordVaultModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Intrare nouă"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} /> <VaultForm
initial={editingEntry ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingEntry(null);
}}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}> <Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader> <DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi această intrare? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi această intrare? Acțiunea este
ireversibilă.
</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button> <Button variant="outline" onClick={() => setDeletingId(null)}>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button> Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -220,19 +397,29 @@ export function PasswordVaultModule() {
); );
} }
function VaultForm({ initial, onSubmit, onCancel }: { function VaultForm({
initial,
onSubmit,
onCancel,
}: {
initial?: VaultEntry; initial?: VaultEntry;
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [label, setLabel] = useState(initial?.label ?? ''); const [label, setLabel] = useState(initial?.label ?? "");
const [username, setUsername] = useState(initial?.username ?? ''); const [username, setUsername] = useState(initial?.username ?? "");
const [password, setPassword] = useState(initial?.password ?? ''); const [password, setPassword] = useState(initial?.password ?? "");
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>(
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); initial?.category ?? "web",
const [notes, setNotes] = useState(initial?.notes ?? ''); );
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []); const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [customFields, setCustomFields] = useState<CustomField[]>(
initial?.customFields ?? [],
);
// Password generator state // Password generator state
const [genLength, setGenLength] = useState(16); const [genLength, setGenLength] = useState(16);
@@ -244,15 +431,30 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const strength = getPasswordStrength(password); const strength = getPasswordStrength(password);
const handleGenerate = () => { const handleGenerate = () => {
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols })); setPassword(
generatePassword(genLength, {
upper: genUpper,
lower: genLower,
digits: genDigits,
symbols: genSymbols,
}),
);
}; };
const addCustomField = () => { const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }]); setCustomFields([...customFields, { key: "", value: "" }]);
}; };
const updateCustomField = (index: number, field: keyof CustomField, value: string) => { const updateCustomField = (
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf)); index: number,
field: keyof CustomField,
value: string,
) => {
setCustomFields(
customFields.map((cf, i) =>
i === index ? { ...cf, [field]: value } : cf,
),
);
}; };
const removeCustomField = (index: number) => { const removeCustomField = (index: number) => {
@@ -260,39 +462,99 @@ function VaultForm({ initial, onSubmit, onCancel }: {
}; };
return ( return (
<form onSubmit={(e) => { <form
e.preventDefault(); onSubmit={(e) => {
onSubmit({ e.preventDefault();
label, username, password, url, category, company, notes, onSubmit({
customFields: customFields.filter((cf) => cf.key.trim()), label,
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin', username,
}); password,
}} className="space-y-4"> url,
category,
company,
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>
<div><Label>Categorie</Label> <Label>Nume/Etichetă *</Label>
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={label}
<SelectContent>{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}</SelectContent> onChange={(e) => setLabel(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Categorie</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as VaultEntryCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Companie</Label> <div>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Label>Companie</Label>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>))} {(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<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> </div>
<div> <div>
<Label>Parolă</Label> <Label>Parolă</Label>
<div className="mt-1 flex gap-1.5"> <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" /> <Input
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă"> 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" /> <KeyRound className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -300,12 +562,25 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Forță:</span> <span className="text-muted-foreground">Forță:</span>
<span className={strength.level === 3 ? 'text-emerald-600 font-medium' : strength.level === 2 ? 'text-green-600 font-medium' : strength.level === 1 ? 'text-yellow-600 font-medium' : 'text-red-600 font-medium'}> <span
className={
strength.level === 3
? "text-emerald-600 font-medium"
: strength.level === 2
? "text-green-600 font-medium"
: strength.level === 1
? "text-yellow-600 font-medium"
: "text-red-600 font-medium"
}
>
{strength.label} {strength.label}
</span> </span>
</div> </div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full ${strength.color} transition-all`} style={{ width: `${(strength.level + 1) * 25}%` }} /> <div
className={`h-full ${strength.color} transition-all`}
style={{ width: `${(strength.level + 1) * 25}%` }}
/>
</div> </div>
</div> </div>
)} )}
@@ -313,29 +588,92 @@ function VaultForm({ initial, onSubmit, onCancel }: {
{/* Password generator options */} {/* Password generator options */}
<div className="rounded border p-3 space-y-2"> <div className="rounded border p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Generator parolă</p> <p className="text-xs font-medium text-muted-foreground">
Generator parolă
</p>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-xs">Lungime:</Label> <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} /> <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>
<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">
<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> <Switch
<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> checked={genUpper}
<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> onCheckedChange={setGenUpper}
<Button type="button" variant="secondary" size="sm" onClick={handleGenerate}> 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ă <KeyRound className="mr-1 h-3 w-3" /> Generează
</Button> </Button>
</div> </div>
</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 */} {/* Custom fields */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Câmpuri personalizate</Label> <Label>Câmpuri personalizate</Label>
<Button type="button" variant="outline" size="sm" onClick={addCustomField}> <Button
type="button"
variant="outline"
size="sm"
onClick={addCustomField}
>
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp <Plus className="mr-1 h-3 w-3" /> Adaugă câmp
</Button> </Button>
</div> </div>
@@ -343,9 +681,27 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
{customFields.map((cf, i) => ( {customFields.map((cf, i) => (
<div key={i} className="flex items-center gap-2"> <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
<Input placeholder="Valoare" value={cf.value} onChange={(e) => updateCustomField(i, 'value', e.target.value)} className="flex-1 text-sm" /> placeholder="Cheie"
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeCustomField(i)}> 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" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -354,10 +710,20 @@ function VaultForm({ initial, onSubmit, onCancel }: {
)} )}
</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}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

@@ -1,13 +1,13 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
export type VaultEntryCategory = export type VaultEntryCategory =
| 'web' | "web"
| 'email' | "email"
| 'server' | "server"
| 'database' | "database"
| 'api' | "api"
| 'other'; | "other";
/** Custom key-value field */ /** Custom key-value field */
export interface CustomField { export interface CustomField {