feat(password-vault): add company scope + password strength meter + rename encryptedPassword to password (task 1.07)

This commit is contained in:
AI Assistant
2026-02-19 01:39:45 +02:00
parent c940fab4e9
commit 4502a01aa1
3 changed files with 493 additions and 134 deletions

View File

@@ -1,43 +1,88 @@
'use client'; "use client";
import { useState, useRef } from 'react'; import { useState, useRef } from "react";
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle, Upload, X } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Plus,
import { Input } from '@/shared/components/ui/input'; Pencil,
import { Label } from '@/shared/components/ui/label'; Trash2,
import { Textarea } from '@/shared/components/ui/textarea'; Search,
import { Badge } from '@/shared/components/ui/badge'; PenTool,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Stamp,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; Type,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; History,
import type { CompanyId } from '@/core/auth/types'; AlertTriangle,
import type { SignatureAsset, SignatureAssetType } from '../types'; Upload,
import { useSignatures } from '../hooks/use-signatures'; 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 type { CompanyId } from "@/core/auth/types";
import type { SignatureAsset, SignatureAssetType } from "../types";
import { useSignatures } from "../hooks/use-signatures";
const TYPE_LABELS: Record<SignatureAssetType, string> = { const TYPE_LABELS: Record<SignatureAssetType, string> = {
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale', signature: "Semnătură",
stamp: "Ștampilă",
initials: "Inițiale",
}; };
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = { const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
signature: PenTool, stamp: Stamp, initials: Type, signature: PenTool,
stamp: Stamp,
initials: Type,
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
export function DigitalSignaturesModule() { export function DigitalSignaturesModule() {
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); assets,
allAssets,
loading,
filters,
updateFilter,
addAsset,
updateAsset,
addVersion,
removeAsset,
} = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null); const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null); const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingAsset) { data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingAsset) {
await updateAsset(editingAsset.id, data); await updateAsset(editingAsset.id, data);
} else { } else {
await addAsset(data); await addAsset(data);
} }
setViewMode('list'); setViewMode("list");
setEditingAsset(null); setEditingAsset(null);
}; };
@@ -70,40 +115,69 @@ export function DigitalSignaturesModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* 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">{allAssets.length}</p></CardContent></Card> <Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{allAssets.length}</p>
</CardContent>
</Card>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => ( {(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
<Card key={type}><CardContent className="p-4"> <Card key={type}>
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p> <CardContent className="p-4">
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p> <p className="text-xs text-muted-foreground">
</CardContent></Card> {TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allAssets.filter((a) => a.type === type).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.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as SignatureAssetType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</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>
) : assets.length === 0 ? ( ) : assets.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.
</p>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => { {assets.map((asset) => {
@@ -111,16 +185,38 @@ export function DigitalSignaturesModule() {
const expired = isExpired(asset.expirationDate); const expired = isExpired(asset.expirationDate);
const expiringSoon = isExpiringSoon(asset.expirationDate); const expiringSoon = isExpiringSoon(asset.expirationDate);
return ( return (
<Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}> <Card
key={asset.id}
className={`group relative ${expired ? "border-destructive/50" : expiringSoon ? "border-yellow-500/50" : ""}`}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Versiune nouă" onClick={() => setVersionAsset(asset)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Versiune nouă"
onClick={() => setVersionAsset(asset)}
>
<History className="h-3.5 w-3.5" /> <History className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingAsset(asset);
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(asset.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(asset.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -128,7 +224,11 @@ export function DigitalSignaturesModule() {
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30"> <div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? ( {asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" /> <img
src={asset.imageUrl}
alt={asset.label}
className="max-h-10 max-w-10 object-contain"
/>
) : ( ) : (
<Icon className="h-6 w-6 text-muted-foreground" /> <Icon className="h-6 w-6 text-muted-foreground" />
)} )}
@@ -136,29 +236,54 @@ export function DigitalSignaturesModule() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p> <p className="font-medium">{asset.label}</p>
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge> <Badge variant="outline" className="text-[10px]">
<span className="text-xs text-muted-foreground">{asset.owner}</span> {TYPE_LABELS[asset.type]}
</Badge>
<span className="text-xs text-muted-foreground">
{asset.owner}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Metadata row */} {/* Metadata row */}
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{asset.legalStatus && ( {asset.legalStatus && (
<p className="text-xs text-muted-foreground">Status legal: {asset.legalStatus}</p> <p className="text-xs text-muted-foreground">
Status legal: {asset.legalStatus}
</p>
)} )}
{asset.expirationDate && ( {asset.expirationDate && (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />} {(expired || expiringSoon) && (
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}> <AlertTriangle className="h-3 w-3 text-yellow-500" />
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate} )}
<span
className={
expired
? "text-destructive font-medium"
: expiringSoon
? "text-yellow-600 font-medium"
: "text-muted-foreground"
}
>
{expired
? "Expirat"
: expiringSoon
? "Expiră curând"
: "Expiră"}
: {asset.expirationDate}
</span> </span>
</div> </div>
)} )}
{asset.usageNotes && ( {asset.usageNotes && (
<p className="text-xs text-muted-foreground line-clamp-1">Note: {asset.usageNotes}</p> <p className="text-xs text-muted-foreground line-clamp-1">
Note: {asset.usageNotes}
</p>
)} )}
{(asset.versions ?? []).length > 0 && ( {(asset.versions ?? []).length > 0 && (
<p className="text-xs text-muted-foreground">Versiuni: {(asset.versions ?? []).length + 1}</p> <p className="text-xs text-muted-foreground">
Versiuni: {(asset.versions ?? []).length + 1}
</p>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -170,31 +295,63 @@ export function DigitalSignaturesModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Element nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} /> <AssetForm
initial={editingAsset ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingAsset(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 acest element? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest element? 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>
{/* Add version dialog */} {/* Add version dialog */}
<Dialog open={versionAsset !== null} onOpenChange={(open) => { if (!open) setVersionAsset(null); }}> <Dialog
open={versionAsset !== null}
onOpenChange={(open) => {
if (!open) setVersionAsset(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle>
</DialogHeader>
<AddVersionForm <AddVersionForm
onSubmit={handleAddVersion} onSubmit={handleAddVersion}
onCancel={() => setVersionAsset(null)} onCancel={() => setVersionAsset(null)}
@@ -206,11 +363,17 @@ export function DigitalSignaturesModule() {
); );
} }
function ImageUploadField({ value, onChange }: { value: string; onChange: (v: string) => void }) { function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => { const handleFile = (file: File) => {
if (!file.type.startsWith('image/')) return; if (!file.type.startsWith("image/")) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => onChange(e.target?.result as string); reader.onload = (e) => onChange(e.target?.result as string);
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -222,11 +385,19 @@ function ImageUploadField({ value, onChange }: { value: string; onChange: (v: st
className="flex min-h-[100px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-muted-foreground transition-colors hover:border-primary/50" className="flex min-h-[100px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) handleFile(f); }} onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
> >
{value ? ( {value ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="preview" className="max-h-24 max-w-full object-contain" /> <img
src={value}
alt="preview"
className="max-h-24 max-w-full object-contain"
/>
) : ( ) : (
<> <>
<Upload className="h-6 w-6" /> <Upload className="h-6 w-6" />
@@ -234,11 +405,24 @@ function ImageUploadField({ value, onChange }: { value: string; onChange: (v: st
</> </>
)} )}
</div> </div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" <input
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} /> ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
{value && ( {value && (
<Button type="button" variant="ghost" size="sm" className="text-xs text-muted-foreground" <Button
onClick={() => onChange('')}> type="button"
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => onChange("")}
>
<X className="mr-1 h-3 w-3" /> Elimină imaginea <X className="mr-1 h-3 w-3" /> Elimină imaginea
</Button> </Button>
)} )}
@@ -246,86 +430,159 @@ function ImageUploadField({ value, onChange }: { value: string; onChange: (v: st
); );
} }
function AddVersionForm({ onSubmit, onCancel, history }: { function AddVersionForm({
onSubmit,
onCancel,
history,
}: {
onSubmit: (imageUrl: string, notes: string) => void; onSubmit: (imageUrl: string, notes: string) => void;
onCancel: () => void; onCancel: () => void;
history: Array<{ id: string; imageUrl: string; notes: string; createdAt: string }>; history: Array<{
id: string;
imageUrl: string;
notes: string;
createdAt: string;
}>;
}) { }) {
const [imageUrl, setImageUrl] = useState(''); const [imageUrl, setImageUrl] = useState("");
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState("");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{history.length > 0 && ( {history.length > 0 && (
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2"> <div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
<p className="text-xs font-medium text-muted-foreground">Istoric versiuni</p> <p className="text-xs font-medium text-muted-foreground">
Istoric versiuni
</p>
{history.map((v) => ( {history.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs"> <div
<span className="truncate text-muted-foreground">{v.notes || 'Fără note'}</span> key={v.id}
<span className="shrink-0 text-muted-foreground">{v.createdAt.slice(0, 10)}</span> className="flex items-center justify-between text-xs"
>
<span className="truncate text-muted-foreground">
{v.notes || "Fără note"}
</span>
<span className="shrink-0 text-muted-foreground">
{v.createdAt.slice(0, 10)}
</span>
</div> </div>
))} ))}
</div> </div>
)} )}
<div> <div>
<Label>Imagine nouă</Label> <Label>Imagine nouă</Label>
<div className="mt-1"><ImageUploadField value={imageUrl} onChange={setImageUrl} /></div> <div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div> </div>
<div> <div>
<Label>Note versiune</Label> <Label>Note versiune</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className="mt-1" placeholder="Ce s-a schimbat..." /> <Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-1"
placeholder="Ce s-a schimbat..."
/>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Anulează</Button> <Button variant="outline" onClick={onCancel}>
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button> Anulează
</Button>
<Button
onClick={() => {
if (imageUrl.trim()) onSubmit(imageUrl, notes);
}}
disabled={!imageUrl.trim()}
>
Salvează versiune
</Button>
</div> </div>
</div> </div>
); );
} }
function AssetForm({ initial, onSubmit, onCancel }: { function AssetForm({
initial,
onSubmit,
onCancel,
}: {
initial?: SignatureAsset; initial?: SignatureAsset;
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [label, setLabel] = useState(initial?.label ?? ''); const [label, setLabel] = useState(initial?.label ?? "");
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature'); const [type, setType] = useState<SignatureAssetType>(
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? ''); initial?.type ?? "signature",
const [owner, setOwner] = useState(initial?.owner ?? ''); );
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? "");
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? ''); const [owner, setOwner] = useState(initial?.owner ?? "");
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? ''); const [company, setCompany] = useState<CompanyId>(
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? ''); initial?.company ?? "beletage",
);
const [expirationDate, setExpirationDate] = useState(
initial?.expirationDate ?? "",
);
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? "");
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? "");
const [tags, setTags] = useState<string[]>(initial?.tags ?? []); const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState("");
const addTag = (raw: string) => { const addTag = (raw: string) => {
const t = raw.trim().toLowerCase(); const t = raw.trim().toLowerCase();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]); if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput(''); setTagInput("");
}; };
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag(tagInput); } if (e.key === "Enter" || e.key === ",") {
if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) setTags((prev) => prev.slice(0, -1)); e.preventDefault();
addTag(tagInput);
}
if (e.key === "Backspace" && tagInput === "" && tags.length > 0)
setTags((prev) => prev.slice(0, -1));
}; };
return ( return (
<form onSubmit={(e) => { <form
e.preventDefault(); onSubmit={(e) => {
onSubmit({ e.preventDefault();
label, type, imageUrl, owner, company, onSubmit({
expirationDate: expirationDate || undefined, label,
legalStatus, usageNotes, type,
versions: initial?.versions ?? [], imageUrl,
tags, visibility: initial?.visibility ?? 'all', owner,
}); company,
}} className="space-y-4"> expirationDate: expirationDate || undefined,
legalStatus,
usageNotes,
versions: initial?.versions ?? [],
tags,
visibility: initial?.visibility ?? "all",
});
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Denumire *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Tip</Label> <Label>Denumire *</Label>
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={label}
onChange={(e) => setLabel(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Tip</Label>
<Select
value={type}
onValueChange={(v) => setType(v as SignatureAssetType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="signature">Semnătură</SelectItem> <SelectItem value="signature">Semnătură</SelectItem>
<SelectItem value="stamp">Ștampilă</SelectItem> <SelectItem value="stamp">Ștampilă</SelectItem>
@@ -335,10 +592,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Companie</Label> <Label>Proprietar</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={owner}
onChange={(e) => setOwner(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem> <SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -350,20 +620,53 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div> </div>
<div> <div>
<Label>Imagine</Label> <Label>Imagine</Label>
<div className="mt-1"><ImageUploadField value={imageUrl} onChange={setImageUrl} /></div> <div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div> <Label>Data expirare</Label>
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div> <Input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Status legal</Label>
<Input
value={legalStatus}
onChange={(e) => setLegalStatus(e.target.value)}
className="mt-1"
placeholder="Valid, Anulat..."
/>
</div>
<div>
<Label>Note utilizare</Label>
<Input
value={usageNotes}
onChange={(e) => setUsageNotes(e.target.value)}
className="mt-1"
placeholder="Doar pentru contracte..."
/>
</div>
</div> </div>
<div> <div>
<Label>Etichete</Label> <Label>Etichete</Label>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring"> <div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring">
{tags.map((tag) => ( {tags.map((tag) => (
<span key={tag} className="flex items-center gap-0.5 rounded-full border bg-muted px-2 py-0.5 text-xs"> <span
key={tag}
className="flex items-center gap-0.5 rounded-full border bg-muted px-2 py-0.5 text-xs"
>
{tag} {tag}
<button type="button" onClick={() => setTags((t) => t.filter((x) => x !== tag))} className="ml-0.5 opacity-60 hover:opacity-100"> <button
type="button"
onClick={() => setTags((t) => t.filter((x) => x !== tag))}
className="ml-0.5 opacity-60 hover:opacity-100"
>
<X className="h-2.5 w-2.5" /> <X className="h-2.5 w-2.5" />
</button> </button>
</span> </span>
@@ -372,15 +675,21 @@ function AssetForm({ initial, onSubmit, onCancel }: {
value={tagInput} value={tagInput}
onChange={(e) => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown} onKeyDown={handleTagKeyDown}
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }} onBlur={() => {
placeholder={tags.length === 0 ? 'Adaugă etichete (Enter sau virgulă)...' : ''} if (tagInput.trim()) addTag(tagInput);
}}
placeholder={
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
}
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/> />
</div> </div>
</div> </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

@@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import { Switch } from '@/shared/components/ui/switch'; import { Switch } from '@/shared/components/ui/switch';
import type { CompanyId } from '@/core/auth/types';
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types'; import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
import { useVault } from '../hooks/use-vault'; import { useVault } from '../hooks/use-vault';
@@ -21,6 +22,29 @@ 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> = {
'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'; type ViewMode = 'list' | 'add' | 'edit';
/** Generate a random password */ /** Generate a random password */
@@ -131,12 +155,12 @@ export function PasswordVaultModule() {
<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.encryptedPassword : '••••••••••'} {visiblePasswords.has(entry.id) ? entry.password : '••••••••••'}
</code> </code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}> <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" />} {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.encryptedPassword, 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>}
@@ -203,9 +227,10 @@ function VaultForm({ initial, onSubmit, onCancel }: {
}) { }) {
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?.encryptedPassword ?? ''); 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>(initial?.category ?? 'web');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? '');
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []); const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
@@ -216,6 +241,8 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const [genDigits, setGenDigits] = useState(true); const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true); const [genSymbols, setGenSymbols] = useState(true);
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 }));
}; };
@@ -236,7 +263,7 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<form onSubmit={(e) => { <form onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
label, username, encryptedPassword: password, url, category, notes, label, username, password, url, category, company, notes,
customFields: customFields.filter((cf) => cf.key.trim()), customFields: customFields.filter((cf) => cf.key.trim()),
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin', tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
}); });
@@ -251,16 +278,37 @@ function VaultForm({ initial, onSubmit, onCancel }: {
</div> </div>
</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>Companie</Label>
<div> <Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<Label>Parolă</Label> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div className="mt-1 flex gap-1.5"> <SelectContent>
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" /> {(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>))}
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă"> </SelectContent>
<KeyRound className="h-4 w-4" /> </Select>
</Button>
</div>
</div> </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> </div>
{/* Password generator options */} {/* Password generator options */}

View File

@@ -1,4 +1,5 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export type VaultEntryCategory = export type VaultEntryCategory =
| 'web' | 'web'
@@ -18,9 +19,10 @@ export interface VaultEntry {
id: string; id: string;
label: string; label: string;
username: string; username: string;
encryptedPassword: string; password: string;
url: string; url: string;
category: VaultEntryCategory; category: VaultEntryCategory;
company: CompanyId;
/** Custom key-value fields */ /** Custom key-value fields */
customFields: CustomField[]; customFields: CustomField[];
notes: string; notes: string;