From 4502a01aa1e41d92b1c73722d041c1f494d10475 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Thu, 19 Feb 2026 01:39:45 +0200 Subject: [PATCH] feat(password-vault): add company scope + password strength meter + rename encryptedPassword to password (task 1.07) --- .../components/digital-signatures-module.tsx | 549 ++++++++++++++---- .../components/password-vault-module.tsx | 74 ++- src/modules/password-vault/types.ts | 4 +- 3 files changed, 493 insertions(+), 134 deletions(-) diff --git a/src/modules/digital-signatures/components/digital-signatures-module.tsx b/src/modules/digital-signatures/components/digital-signatures-module.tsx index a11a5cf..9548d39 100644 --- a/src/modules/digital-signatures/components/digital-signatures-module.tsx +++ b/src/modules/digital-signatures/components/digital-signatures-module.tsx @@ -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 = { - signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale', + signature: "Semnătură", + stamp: "Ștampilă", + initials: "Inițiale", }; const TYPE_ICONS: Record = { - 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('list'); + const { + assets, + allAssets, + loading, + filters, + updateFilter, + addAsset, + updateAsset, + addVersion, + removeAsset, + } = useSignatures(); + const [viewMode, setViewMode] = useState("list"); const [editingAsset, setEditingAsset] = useState(null); const [deletingId, setDeletingId] = useState(null); const [versionAsset, setVersionAsset] = useState(null); - const handleSubmit = async (data: Omit) => { - if (viewMode === 'edit' && editingAsset) { + const handleSubmit = async ( + data: Omit, + ) => { + 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() {
{/* Stats */}
-

Total

{allAssets.length}

+ + +

Total

+

{allAssets.length}

+
+
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => ( - -

{TYPE_LABELS[type]}

-

{allAssets.filter((a) => a.type === type).length}

-
+ + +

+ {TYPE_LABELS[type]} +

+

+ {allAssets.filter((a) => a.type === type).length} +

+
+
))}
- {viewMode === 'list' && ( + {viewMode === "list" && ( <>
- updateFilter('search', e.target.value)} className="pl-9" /> + updateFilter("search", e.target.value)} + className="pl-9" + />
- + updateFilter("type", v as SignatureAssetType | "all") + } + > + + + Toate tipurile {(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => ( - {TYPE_LABELS[t]} + + {TYPE_LABELS[t]} + ))} -
{loading ? ( -

Se încarcă...

+

+ Se încarcă... +

) : assets.length === 0 ? ( -

Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.

+

+ Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale. +

) : (
{assets.map((asset) => { @@ -111,16 +185,38 @@ export function DigitalSignaturesModule() { const expired = isExpired(asset.expirationDate); const expiringSoon = isExpiringSoon(asset.expirationDate); return ( - +
- - -
@@ -128,7 +224,11 @@ export function DigitalSignaturesModule() {
{asset.imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element - {asset.label} + {asset.label} ) : ( )} @@ -136,29 +236,54 @@ export function DigitalSignaturesModule() {

{asset.label}

- {TYPE_LABELS[asset.type]} - {asset.owner} + + {TYPE_LABELS[asset.type]} + + + {asset.owner} +
{/* Metadata row */}
{asset.legalStatus && ( -

Status legal: {asset.legalStatus}

+

+ Status legal: {asset.legalStatus} +

)} {asset.expirationDate && (
- {(expired || expiringSoon) && } - - {expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate} + {(expired || expiringSoon) && ( + + )} + + {expired + ? "Expirat" + : expiringSoon + ? "Expiră curând" + : "Expiră"} + : {asset.expirationDate}
)} {asset.usageNotes && ( -

Note: {asset.usageNotes}

+

+ Note: {asset.usageNotes} +

)} {(asset.versions ?? []).length > 0 && ( -

Versiuni: {(asset.versions ?? []).length + 1}

+

+ Versiuni: {(asset.versions ?? []).length + 1} +

)}
@@ -170,31 +295,63 @@ export function DigitalSignaturesModule() { )} - {(viewMode === 'add' || viewMode === 'edit') && ( + {(viewMode === "add" || viewMode === "edit") && ( - {viewMode === 'edit' ? 'Editare' : 'Element nou'} + + + {viewMode === "edit" ? "Editare" : "Element nou"} + + - { setViewMode('list'); setEditingAsset(null); }} /> + { + setViewMode("list"); + setEditingAsset(null); + }} + /> )} {/* Delete confirmation */} - { if (!open) setDeletingId(null); }}> + { + if (!open) setDeletingId(null); + }} + > - Confirmare ștergere -

Ești sigur că vrei să ștergi acest element? Acțiunea este ireversibilă.

+ + Confirmare ștergere + +

+ Ești sigur că vrei să ștergi acest element? Acțiunea este + ireversibilă. +

- - + +
{/* Add version dialog */} - { if (!open) setVersionAsset(null); }}> + { + if (!open) setVersionAsset(null); + }} + > - Versiune nouă — {versionAsset?.label} + + Versiune nouă — {versionAsset?.label} + 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(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 - preview + preview ) : ( <> @@ -234,11 +405,24 @@ function ImageUploadField({ value, onChange }: { value: string; onChange: (v: st )}
- { const f = e.target.files?.[0]; if (f) handleFile(f); }} /> + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> {value && ( - )} @@ -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 (
{history.length > 0 && (
-

Istoric versiuni

+

+ Istoric versiuni +

{history.map((v) => ( -
- {v.notes || 'Fără note'} - {v.createdAt.slice(0, 10)} +
+ + {v.notes || "Fără note"} + + + {v.createdAt.slice(0, 10)} +
))}
)}
-
+
+ +
- setNotes(e.target.value)} className="mt-1" placeholder="Ce s-a schimbat..." /> + setNotes(e.target.value)} + className="mt-1" + placeholder="Ce s-a schimbat..." + />
- - + +
); } -function AssetForm({ initial, onSubmit, onCancel }: { +function AssetForm({ + initial, + onSubmit, + onCancel, +}: { initial?: SignatureAsset; - onSubmit: (data: Omit) => void; + onSubmit: ( + data: Omit, + ) => void; onCancel: () => void; }) { - const [label, setLabel] = useState(initial?.label ?? ''); - const [type, setType] = useState(initial?.type ?? 'signature'); - const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? ''); - const [owner, setOwner] = useState(initial?.owner ?? ''); - const [company, setCompany] = useState(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( + initial?.type ?? "signature", + ); + const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? ""); + const [owner, setOwner] = useState(initial?.owner ?? ""); + const [company, setCompany] = useState( + 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(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) => { - 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 ( -
{ - e.preventDefault(); - onSubmit({ - label, type, imageUrl, owner, company, - expirationDate: expirationDate || undefined, - legalStatus, usageNotes, - versions: initial?.versions ?? [], - tags, visibility: initial?.visibility ?? 'all', - }); - }} className="space-y-4"> + { + e.preventDefault(); + onSubmit({ + label, + type, + imageUrl, + owner, + company, + expirationDate: expirationDate || undefined, + legalStatus, + usageNotes, + versions: initial?.versions ?? [], + tags, + visibility: initial?.visibility ?? "all", + }); + }} + className="space-y-4" + >
-
setLabel(e.target.value)} className="mt-1" required />
-
- setLabel(e.target.value)} + className="mt-1" + required + /> +
+
+ + setOwner(e.target.value)} className="mt-1" />
-
- setOwner(e.target.value)} + className="mt-1" + /> +
+
+ + setExpirationDate(e.target.value)} className="mt-1" />
-
setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." />
-
setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." />
+
+ + setExpirationDate(e.target.value)} + className="mt-1" + /> +
+
+ + setLegalStatus(e.target.value)} + className="mt-1" + placeholder="Valid, Anulat..." + /> +
+
+ + setUsageNotes(e.target.value)} + className="mt-1" + placeholder="Doar pentru contracte..." + /> +
{tags.map((tag) => ( - + {tag} - @@ -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" />
- - + +
); diff --git a/src/modules/password-vault/components/password-vault-module.tsx b/src/modules/password-vault/components/password-vault-module.tsx index 18490d0..ac9fd2f 100644 --- a/src/modules/password-vault/components/password-vault-module.tsx +++ b/src/modules/password-vault/components/password-vault-module.tsx @@ -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 = { web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele', }; +const COMPANY_LABELS: Record = { + '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() {

{entry.username}

- {visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'} + {visiblePasswords.has(entry.id) ? entry.password : '••••••••••'} - {copiedId === entry.id && Copiat!} @@ -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(initial?.category ?? 'web'); + const [company, setCompany] = useState(initial?.company ?? 'beletage'); const [notes, setNotes] = useState(initial?.notes ?? ''); const [customFields, setCustomFields] = useState(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 }: {
{ 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,16 +278,37 @@ function VaultForm({ initial, onSubmit, onCancel }: {
-
setUsername(e.target.value)} className="mt-1" />
-
- -
- setPassword(e.target.value)} className="flex-1 font-mono text-sm" /> - -
+
+
+
setUsername(e.target.value)} className="mt-1" />
+
+
+ +
+ setPassword(e.target.value)} className="flex-1 font-mono text-sm" /> + +
+ {password && ( +
+
+ Forță: + + {strength.label} + +
+
+
+
+
+ )}
{/* Password generator options */} diff --git a/src/modules/password-vault/types.ts b/src/modules/password-vault/types.ts index a0ffe7c..6893329 100644 --- a/src/modules/password-vault/types.ts +++ b/src/modules/password-vault/types.ts @@ -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;