3.14 Password Vault encryption AES-256-GCM server-side

- Created src/core/crypto/ with AES-256-GCM encrypt/decrypt (PBKDF2 key derivation)
- Created /api/vault route: CRUD with server-side password encryption
- PATCH /api/vault migration endpoint to re-encrypt legacy plaintext passwords
- Rewrote use-vault hook to use dedicated /api/vault instead of generic storage
- Updated UI: amber 'not encrypted' warning  green 'encrypted' badge
- Added ENCRYPTION_SECRET env var to docker-compose.yml and stack.env
- Module version bumped to 0.2.0
This commit is contained in:
AI Assistant
2026-02-28 04:12:44 +02:00
parent f0b3659247
commit 85bdb59da4
10 changed files with 366 additions and 59 deletions
@@ -169,9 +169,9 @@ export function PasswordVaultModule() {
return (
<div className="space-y-6">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
Folosiți un manager de parole dedicat pentru date sensibile.
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-2 text-xs text-emerald-700 dark:text-emerald-400">
Parolele sunt criptate (AES-256-GCM) pe server înainte de stocare.
Datele sunt protejate la rest în baza de date.
</div>
{/* Stats */}
+13 -12
View File
@@ -1,17 +1,18 @@
import type { ModuleConfig } from '@/core/module-registry/types';
import type { ModuleConfig } from "@/core/module-registry/types";
export const passwordVaultConfig: ModuleConfig = {
id: 'password-vault',
name: 'Seif Parole',
description: 'Manager securizat de parole și credențiale cu criptare locală',
icon: 'lock',
route: '/password-vault',
category: 'operations',
featureFlag: 'module.password-vault',
visibility: 'admin',
version: '0.1.0',
id: "password-vault",
name: "Seif Parole",
description:
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
icon: "lock",
route: "/password-vault",
category: "operations",
featureFlag: "module.password-vault",
visibility: "admin",
version: "0.2.0",
dependencies: [],
storageNamespace: 'password-vault',
storageNamespace: "password-vault",
navOrder: 11,
tags: ['parole', 'securitate', 'credențiale'],
tags: ["parole", "securitate", "credențiale", "criptare"],
};
+32 -21
View File
@@ -1,19 +1,19 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { VaultEntry, VaultEntryCategory } from "../types";
const PREFIX = "vault:";
export interface VaultFilters {
search: string;
category: VaultEntryCategory | "all";
}
/**
* Vault hook — uses dedicated /api/vault endpoint with server-side encryption.
* Passwords are encrypted at rest (AES-256-GCM) and decrypted only on read.
*/
export function useVault() {
const storage = useStorage("password-vault");
const [entries, setEntries] = useState<VaultEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<VaultFilters>({
@@ -23,17 +23,18 @@ export function useVault() {
const refresh = useCallback(async () => {
setLoading(true);
const all = await storage.exportAll();
const results: VaultEntry[] = [];
for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) {
results.push(value as VaultEntry);
}
try {
const res = await fetch("/api/vault");
const data = await res.json();
const results = (data.entries ?? []) as VaultEntry[];
results.sort((a, b) => a.label.localeCompare(b.label));
setEntries(results);
} catch (err) {
console.error("Failed to load vault entries:", err);
} finally {
setLoading(false);
}
results.sort((a, b) => a.label.localeCompare(b.label));
setEntries(results);
setLoading(false);
}, [storage]);
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => {
@@ -49,36 +50,46 @@ export function useVault() {
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${entry.id}`, entry);
await fetch("/api/vault", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry }),
});
await refresh();
return entry;
},
[storage, refresh],
[refresh],
);
const updateEntry = useCallback(
async (id: string, updates: Partial<VaultEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated = {
const updated: VaultEntry = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await fetch("/api/vault", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry: updated }),
});
await refresh();
},
[storage, refresh, entries],
[refresh, entries],
);
const removeEntry = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await fetch(`/api/vault?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
await refresh();
},
[storage, refresh],
[refresh],
);
const updateFilter = useCallback(
@@ -122,8 +122,9 @@ export function WordTemplatesModule() {
null,
);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [revisingTemplate, setRevisingTemplate] =
useState<WordTemplate | null>(null);
const [revisingTemplate, setRevisingTemplate] = useState<WordTemplate | null>(
null,
);
const [revisionUrl, setRevisionUrl] = useState("");
const [revisionNotes, setRevisionNotes] = useState("");
const [historyTemplate, setHistoryTemplate] = useState<WordTemplate | null>(
@@ -172,7 +173,12 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Word/Excel</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => (t.fileType ?? "docx") === "docx" || t.fileType === "xlsx").length}
{
allTemplates.filter(
(t) =>
(t.fileType ?? "docx") === "docx" || t.fileType === "xlsx",
).length
}
</p>
</CardContent>
</Card>
@@ -180,7 +186,11 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">DWG/Archicad</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.fileType === "dwg" || t.fileType === "archicad").length}
{
allTemplates.filter(
(t) => t.fileType === "dwg" || t.fileType === "archicad",
).length
}
</p>
</CardContent>
</Card>
@@ -188,7 +198,10 @@ export function WordTemplatesModule() {
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Cu versiuni</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => (t.versionHistory ?? []).length > 0).length}
{
allTemplates.filter((t) => (t.versionHistory ?? []).length > 0)
.length
}
</p>
</CardContent>
</Card>
@@ -431,9 +444,7 @@ export function WordTemplatesModule() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Revizie nouă {revisingTemplate?.name}
</DialogTitle>
<DialogTitle>Revizie nouă {revisingTemplate?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
@@ -546,10 +557,7 @@ export function WordTemplatesModule() {
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHistoryTemplate(null)}
>
<Button variant="outline" onClick={() => setHistoryTemplate(null)}>
Închide
</Button>
</DialogFooter>
@@ -726,13 +734,13 @@ function TemplateForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]
).map((ft) => (
<SelectItem key={ft} value={ft}>
{FILE_TYPE_LABELS[ft]}
</SelectItem>
))}
{(Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]).map(
(ft) => (
<SelectItem key={ft} value={ft}>
{FILE_TYPE_LABELS[ft]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
@@ -112,7 +112,8 @@ export function useTemplates() {
updatedAt: now,
};
if (notes) {
updated.versionHistory[updated.versionHistory.length - 1]!.notes = notes;
updated.versionHistory[updated.versionHistory.length - 1]!.notes =
notes;
}
await storage.set(`${PREFIX}${id}`, updated);
await refresh();