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 { 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 că vrei să ștergi acest element? Acțiunea este ireversibilă.</p>
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ș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
|
||||||
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit({
|
onSubmit({
|
||||||
label, type, imageUrl, owner, company,
|
label,
|
||||||
|
type,
|
||||||
|
imageUrl,
|
||||||
|
owner,
|
||||||
|
company,
|
||||||
expirationDate: expirationDate || undefined,
|
expirationDate: expirationDate || undefined,
|
||||||
legalStatus, usageNotes,
|
legalStatus,
|
||||||
|
usageNotes,
|
||||||
versions: initial?.versions ?? [],
|
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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +278,16 @@ 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>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><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></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">
|
||||||
@@ -260,7 +296,19 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
|||||||
<KeyRound className="h-4 w-4" />
|
<KeyRound className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
<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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user