Files
ArchiTools/src/modules/password-vault/components/password-vault-module.tsx

731 lines
23 KiB
TypeScript

"use client";
import { useState } from "react";
import {
Plus,
Pencil,
Trash2,
Search,
Eye,
EyeOff,
Copy,
ExternalLink,
KeyRound,
X,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
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> = {
web: "Web",
email: "Email",
server: "Server",
database: "Bază de date",
api: "API",
other: "Altele",
};
const COMPANY_LABELS: Record<CompanyId, string> = {
beletage: "Beletage",
"urban-switch": "Urban Switch",
"studii-de-teren": "Studii de Teren",
group: "Grup",
};
/** Calculate password strength: 0-3 (weak, medium, strong, very strong) */
function getPasswordStrength(pwd: string): {
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 hasUpper = /[A-Z]/.test(pwd);
const hasLower = /[a-z]/.test(pwd);
const hasDigit = /\d/.test(pwd);
const hasSymbol = /[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/.test(pwd);
const varietyScore =
(hasUpper ? 1 : 0) +
(hasLower ? 1 : 0) +
(hasDigit ? 1 : 0) +
(hasSymbol ? 1 : 0);
const score = len + varietyScore * 2;
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 < 24)
return { level: 2, label: "Puternică", color: "bg-green-500" };
return { level: 3, label: "Foarte puternică", color: "bg-emerald-600" };
}
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() {
const {
entries,
allEntries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
} = useVault();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(
new Set(),
);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const togglePassword = (id: string) => {
setVisiblePasswords((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleCopy = async (text: string, id: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
/* silent */
}
};
const handleSubmit = async (
data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingEntry) {
await updateEntry(editingEntry.id, data);
} else {
await addEntry(data);
}
setViewMode("list");
setEditingEntry(null);
};
const handleDeleteConfirm = async () => {
if (deletingId) {
await removeEntry(deletingId);
setDeletingId(null);
}
};
return (
<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">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
Folosiți un manager de parole dedicat pentru date sensibile.
</div>
{/* Stats */}
<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>
<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>
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<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"
/>
</div>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as VaultEntryCategory | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nicio intrare găsită.
</p>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<Card key={entry.id} className="group">
<CardContent className="flex items-center gap-4 p-4">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{entry.label}</p>
<Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[entry.category]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{entry.username}
</p>
<div className="flex items-center gap-2">
<code className="text-xs">
{visiblePasswords.has(entry.id)
? entry.password
: "••••••••••"}
</code>
<Button
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
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleCopy(entry.password, entry.id)}
>
<Copy className="h-3 w-3" />
</Button>
{copiedId === entry.id && (
<span className="text-[10px] text-green-500">
Copiat!
</span>
)}
</div>
{entry.url && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<ExternalLink className="h-3 w-3" /> {entry.url}
</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 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");
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Intrare nouă"}
</CardTitle>
</CardHeader>
<CardContent>
<VaultForm
initial={editingEntry ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingEntry(null);
}}
/>
</CardContent>
</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 vrei ș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>
);
}
function VaultForm({
initial,
onSubmit,
onCancel,
}: {
initial?: VaultEntry;
onSubmit: (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(initial?.label ?? "");
const [username, setUsername] = useState(initial?.username ?? "");
const [password, setPassword] = useState(initial?.password ?? "");
const [url, setUrl] = useState(initial?.url ?? "");
const [category, setCategory] = useState<VaultEntryCategory>(
initial?.category ?? "web",
);
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
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 strength = getPasswordStrength(password);
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 (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
label,
username,
password,
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>
<Label>Nume/Etichetă *</Label>
<Input
value={label}
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>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Utilizator</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1"
/>
</div>
</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>
{password && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-xs">
<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"
}
>
{strength.label}
</span>
</div>
<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>
</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>
{/* 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 className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);
}