feat(password-vault): add company scope + password strength meter + rename encryptedPassword to password (task 1.07)
This commit is contained in:
@@ -1,43 +1,88 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle, Upload, 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';
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
PenTool,
|
||||
Stamp,
|
||||
Type,
|
||||
History,
|
||||
AlertTriangle,
|
||||
Upload,
|
||||
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> = {
|
||||
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
|
||||
signature: "Semnătură",
|
||||
stamp: "Ștampilă",
|
||||
initials: "Inițiale",
|
||||
};
|
||||
|
||||
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() {
|
||||
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const {
|
||||
assets,
|
||||
allAssets,
|
||||
loading,
|
||||
filters,
|
||||
updateFilter,
|
||||
addAsset,
|
||||
updateAsset,
|
||||
addVersion,
|
||||
removeAsset,
|
||||
} = useSignatures();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
|
||||
|
||||
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (viewMode === 'edit' && editingAsset) {
|
||||
const handleSubmit = async (
|
||||
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
if (viewMode === "edit" && editingAsset) {
|
||||
await updateAsset(editingAsset.id, data);
|
||||
} else {
|
||||
await addAsset(data);
|
||||
}
|
||||
setViewMode('list');
|
||||
setViewMode("list");
|
||||
setEditingAsset(null);
|
||||
};
|
||||
|
||||
@@ -70,40 +115,69 @@ export function DigitalSignaturesModule() {
|
||||
<div className="space-y-6">
|
||||
{/* 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">{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) => (
|
||||
<Card key={type}><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
||||
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p>
|
||||
</CardContent></Card>
|
||||
<Card key={type}>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[type]}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allAssets.filter((a) => a.type === type).length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' && (
|
||||
{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" />
|
||||
<Input
|
||||
placeholder="Caută..."
|
||||
value={filters.search}
|
||||
onChange={(e) => updateFilter("search", e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<Select
|
||||
value={filters.type}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("type", v as SignatureAssetType | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
{(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>
|
||||
</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ă
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<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">
|
||||
{assets.map((asset) => {
|
||||
@@ -111,16 +185,38 @@ export function DigitalSignaturesModule() {
|
||||
const expired = isExpired(asset.expirationDate);
|
||||
const expiringSoon = isExpiringSoon(asset.expirationDate);
|
||||
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">
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</Button>
|
||||
</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">
|
||||
{asset.imageUrl ? (
|
||||
// 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" />
|
||||
)}
|
||||
@@ -136,29 +236,54 @@ export function DigitalSignaturesModule() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{asset.label}</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{TYPE_LABELS[asset.type]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{asset.owner}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metadata row */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{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 && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />}
|
||||
<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}
|
||||
{(expired || expiringSoon) && (
|
||||
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
</CardContent>
|
||||
@@ -170,31 +295,63 @@ export function DigitalSignaturesModule() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
||||
{(viewMode === "add" || viewMode === "edit") && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{viewMode === "edit" ? "Editare" : "Element nou"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
|
||||
<AssetForm
|
||||
initial={editingAsset ?? undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setViewMode("list");
|
||||
setEditingAsset(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||
<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 acest element? Acțiunea este ireversibilă.</p>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
Ești sigur că vrei să ștergi acest element? Acțiunea este
|
||||
ireversibilă.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
|
||||
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||
Șterge
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
<DialogHeader><DialogTitle>Versiune nouă — {versionAsset?.label}</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Versiune nouă — {versionAsset?.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddVersionForm
|
||||
onSubmit={handleAddVersion}
|
||||
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 handleFile = (file: File) => {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => onChange(e.target?.result as string);
|
||||
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"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
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 ? (
|
||||
// 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" />
|
||||
@@ -234,11 +405,24 @@ function ImageUploadField({ value, onChange }: { value: string; onChange: (v: st
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs text-muted-foreground"
|
||||
onClick={() => onChange('')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Elimină imaginea
|
||||
</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;
|
||||
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 [notes, setNotes] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{history.length > 0 && (
|
||||
<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) => (
|
||||
<div key={v.id} 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
|
||||
key={v.id}
|
||||
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>
|
||||
<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>
|
||||
<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 className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (imageUrl.trim()) onSubmit(imageUrl, notes);
|
||||
}}
|
||||
disabled={!imageUrl.trim()}
|
||||
>
|
||||
Salvează versiune
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
function AssetForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: SignatureAsset;
|
||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onSubmit: (
|
||||
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
|
||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
||||
const [owner, setOwner] = useState(initial?.owner ?? '');
|
||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
|
||||
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
|
||||
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [type, setType] = useState<SignatureAssetType>(
|
||||
initial?.type ?? "signature",
|
||||
);
|
||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? "");
|
||||
const [owner, setOwner] = useState(initial?.owner ?? "");
|
||||
const [company, setCompany] = useState<CompanyId>(
|
||||
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 [tagInput, setTagInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const addTag = (raw: string) => {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
|
||||
setTagInput('');
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag(tagInput); }
|
||||
if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) setTags((prev) => prev.slice(0, -1));
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag(tagInput);
|
||||
}
|
||||
if (e.key === "Backspace" && tagInput === "" && tags.length > 0)
|
||||
setTags((prev) => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label, type, imageUrl, owner, company,
|
||||
label,
|
||||
type,
|
||||
imageUrl,
|
||||
owner,
|
||||
company,
|
||||
expirationDate: expirationDate || undefined,
|
||||
legalStatus, usageNotes,
|
||||
legalStatus,
|
||||
usageNotes,
|
||||
versions: initial?.versions ?? [],
|
||||
tags, visibility: initial?.visibility ?? 'all',
|
||||
tags,
|
||||
visibility: initial?.visibility ?? "all",
|
||||
});
|
||||
}} className="space-y-4">
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<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><Label>Tip</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<div>
|
||||
<Label>Denumire *</Label>
|
||||
<Input
|
||||
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>
|
||||
<SelectItem value="signature">Semnătură</SelectItem>
|
||||
<SelectItem value="stamp">Ștampilă</SelectItem>
|
||||
@@ -335,10 +592,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
</div>
|
||||
</div>
|
||||
<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><Label>Companie</Label>
|
||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<div>
|
||||
<Label>Proprietar</Label>
|
||||
<Input
|
||||
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>
|
||||
<SelectItem value="beletage">Beletage</SelectItem>
|
||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||
@@ -350,20 +620,53 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
</div>
|
||||
<div>
|
||||
<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 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><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>
|
||||
<Label>Data expirare</Label>
|
||||
<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>
|
||||
<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">
|
||||
{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}
|
||||
<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" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -372,15 +675,21 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }}
|
||||
placeholder={tags.length === 0 ? 'Adaugă etichete (Enter sau virgulă)...' : ''}
|
||||
onBlur={() => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
@@ -21,6 +22,29 @@ 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 */
|
||||
@@ -131,12 +155,12 @@ export function PasswordVaultModule() {
|
||||
<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.encryptedPassword : '••••••••••'}
|
||||
{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.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" />
|
||||
</Button>
|
||||
{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 [username, setUsername] = useState(initial?.username ?? '');
|
||||
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
|
||||
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 ?? []);
|
||||
|
||||
@@ -216,6 +241,8 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
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 }));
|
||||
};
|
||||
@@ -236,7 +263,7 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label, username, encryptedPassword: password, url, category, notes,
|
||||
label, username, password, url, category, company, notes,
|
||||
customFields: customFields.filter((cf) => cf.key.trim()),
|
||||
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
|
||||
});
|
||||
@@ -251,7 +278,16 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
</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">
|
||||
@@ -260,7 +296,19 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
<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 */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type VaultEntryCategory =
|
||||
| 'web'
|
||||
@@ -18,9 +19,10 @@ export interface VaultEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
username: string;
|
||||
encryptedPassword: string;
|
||||
password: string;
|
||||
url: string;
|
||||
category: VaultEntryCategory;
|
||||
company: CompanyId;
|
||||
/** Custom key-value fields */
|
||||
customFields: CustomField[];
|
||||
notes: string;
|
||||
|
||||
Reference in New Issue
Block a user