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:
+3
-3
@@ -390,12 +390,12 @@
|
|||||||
- **Versionare Fișier (Backup):** La fiecare modificare a fișierului `Tags.txt` din ArchiTools, sistemul trebuie să creeze automat un backup al versiunii anterioare (ex: `Tags_backup_YYYYMMDD_HHMMSS.txt`) în același folder, pentru a preveni pierderea accidentală a structurii de tag-uri.
|
- **Versionare Fișier (Backup):** La fiecare modificare a fișierului `Tags.txt` din ArchiTools, sistemul trebuie să creeze automat un backup al versiunii anterioare (ex: `Tags_backup_YYYYMMDD_HHMMSS.txt`) în același folder, pentru a preveni pierderea accidentală a structurii de tag-uri.
|
||||||
- **Validare Ierarhie:** Asigurarea că tag-urile adăugate respectă formatul ierarhic cerut de ManicTime (ex: `Proiect, Faza, Activitate`).
|
- **Validare Ierarhie:** Asigurarea că tag-urile adăugate respectă formatul ierarhic cerut de ManicTime (ex: `Proiect, Faza, Activitate`).
|
||||||
|
|
||||||
### 3.14 `[ARCHITECTURE]` Storage & Securitate
|
### 3.14 `[ARCHITECTURE]` Storage & Securitate ✅
|
||||||
|
|
||||||
**Cerințe noi:**
|
**Cerințe noi:**
|
||||||
|
|
||||||
- **Migrare Storage (Prioritate):** Devansarea migrării de la `localStorage` la o soluție robustă (MinIO pentru fișiere și o bază de date reală, ex: PostgreSQL/Prisma) pentru a asigura persistența și partajarea datelor între toți utilizatorii.
|
- **Migrare Storage (Prioritate):** ✅ PostgreSQL via Prisma — realizat anterior (DatabaseStorageAdapter).
|
||||||
- **Criptare Parole:** În modulul Password Vault, parolele trebuie criptate real în baza de date (nu doar ascunse în UI), eliminând warning-ul actual de securitate.
|
- **Criptare Parole:** ✅ AES-256-GCM server-side encryption. Dedicated `/api/vault` route, `src/core/crypto/` service, ENCRYPTION_SECRET env var. Legacy plaintext auto-detected at decrypt. PATCH migration endpoint.
|
||||||
- **Integrare Passbolt (Wishlist):** Studierea posibilității de a lega Password Vault-ul din ArchiTools direct de instanța voastră de Passbolt (via API), pentru a avea un singur "source of truth" securizat pentru parole.
|
- **Integrare Passbolt (Wishlist):** Studierea posibilității de a lega Password Vault-ul din ArchiTools direct de instanța voastră de Passbolt (via API), pentru a avea un singur "source of truth" securizat pentru parole.
|
||||||
|
|
||||||
### 3.15 `[BUSINESS]` AI Tools — Extindere și Integrare
|
### 3.15 `[BUSINESS]` AI Tools — Extindere și Integrare
|
||||||
|
|||||||
@@ -29,5 +29,7 @@ services:
|
|||||||
- AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi
|
- AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi
|
||||||
- AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr
|
- AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr
|
||||||
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||||
|
# Vault encryption
|
||||||
|
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { encrypt, decrypt, isEncrypted } from "@/core/crypto";
|
||||||
|
|
||||||
|
const NAMESPACE = "password-vault";
|
||||||
|
const KEY_PREFIX = "vault:";
|
||||||
|
|
||||||
|
interface VaultEntryData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
company: string;
|
||||||
|
customFields: { key: string; value: string }[];
|
||||||
|
notes: string;
|
||||||
|
tags: string[];
|
||||||
|
visibility: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the password field in a vault entry */
|
||||||
|
function decryptEntry(entry: VaultEntryData): VaultEntryData {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
password: entry.password ? decrypt(entry.password) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt the password field in a vault entry */
|
||||||
|
function encryptEntry(entry: VaultEntryData): VaultEntryData {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
password: entry.password ? encrypt(entry.password) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vault — List all vault entries (passwords decrypted)
|
||||||
|
* GET /api/vault?id=xxx — Get single entry (password decrypted)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const id = request.nextUrl.searchParams.get("id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
const item = await prisma.keyValueStore.findUnique({
|
||||||
|
where: {
|
||||||
|
namespace_key: { namespace: NAMESPACE, key: `${KEY_PREFIX}${id}` },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!item || !item.value) {
|
||||||
|
return NextResponse.json({ entry: null });
|
||||||
|
}
|
||||||
|
const entry = decryptEntry(item.value as unknown as VaultEntryData);
|
||||||
|
return NextResponse.json({ entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all
|
||||||
|
const items = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries: VaultEntryData[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.key.startsWith(KEY_PREFIX) && item.value) {
|
||||||
|
try {
|
||||||
|
entries.push(decryptEntry(item.value as unknown as VaultEntryData));
|
||||||
|
} catch {
|
||||||
|
// If decryption fails, return the entry as-is (migration fallback)
|
||||||
|
entries.push(item.value as unknown as VaultEntryData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ entries });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vault GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vault — Create or update a vault entry (password encrypted before storage)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const entry = body.entry as VaultEntryData;
|
||||||
|
|
||||||
|
if (!entry?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Entry with id is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = encryptEntry(entry);
|
||||||
|
const key = `${KEY_PREFIX}${entry.id}`;
|
||||||
|
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key } },
|
||||||
|
update: { value: encrypted as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
key,
|
||||||
|
value: encrypted as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vault POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/vault?id=xxx — Delete a vault entry
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const id = request.nextUrl.searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "id parameter is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.keyValueStore
|
||||||
|
.delete({
|
||||||
|
where: {
|
||||||
|
namespace_key: { namespace: NAMESPACE, key: `${KEY_PREFIX}${id}` },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* Ignore if not found */
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vault DELETE error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/vault — Re-encrypt all existing entries (migration endpoint)
|
||||||
|
* Call once after setting ENCRYPTION_SECRET to encrypt legacy plaintext passwords.
|
||||||
|
*/
|
||||||
|
export async function PATCH() {
|
||||||
|
try {
|
||||||
|
const items = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.key.startsWith(KEY_PREFIX) || !item.value) continue;
|
||||||
|
const entry = item.value as unknown as VaultEntryData;
|
||||||
|
|
||||||
|
// Skip if already encrypted
|
||||||
|
if (entry.password && isEncrypted(entry.password)) continue;
|
||||||
|
|
||||||
|
const encrypted = encryptEntry(entry);
|
||||||
|
await prisma.keyValueStore.update({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: item.key } },
|
||||||
|
data: { value: encrypted as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, migrated });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Vault PATCH (migration) error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Server-side encryption service using AES-256-GCM.
|
||||||
|
* Only import this in server-side code (API routes).
|
||||||
|
*
|
||||||
|
* Format: enc:v1:<iv-hex>:<authTag-hex>:<ciphertext-hex>
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
pbkdf2Sync,
|
||||||
|
} from "crypto";
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
const IV_LENGTH = 12; // 96 bits recommended for GCM
|
||||||
|
const TAG_LENGTH = 16; // 128 bits
|
||||||
|
const PREFIX = "enc:v1:";
|
||||||
|
|
||||||
|
/** Derive a 256-bit key from the secret using PBKDF2 */
|
||||||
|
function deriveKey(secret: string): Buffer {
|
||||||
|
// Fixed salt — acceptable because the secret itself is high-entropy
|
||||||
|
const salt = Buffer.from("architools-vault-v1", "utf8");
|
||||||
|
return pbkdf2Sync(secret, salt, 100_000, 32, "sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const secret = process.env.ENCRYPTION_SECRET;
|
||||||
|
if (!secret || secret.length < 16) {
|
||||||
|
throw new Error(
|
||||||
|
"ENCRYPTION_SECRET environment variable must be set (min 16 chars)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deriveKey(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt a plaintext string. Returns prefixed format. */
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||||
|
authTagLength: TAG_LENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||||
|
encrypted += cipher.final("hex");
|
||||||
|
const authTag = cipher.getAuthTag().toString("hex");
|
||||||
|
|
||||||
|
return `${PREFIX}${iv.toString("hex")}:${authTag}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a previously encrypted string. Handles both encrypted and plaintext (migration). */
|
||||||
|
export function decrypt(value: string): string {
|
||||||
|
// If not encrypted (legacy plaintext), return as-is
|
||||||
|
if (!value.startsWith(PREFIX)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getKey();
|
||||||
|
const parts = value.slice(PREFIX.length).split(":");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("Invalid encrypted format");
|
||||||
|
}
|
||||||
|
const [ivHex, authTagHex, ciphertext] = parts;
|
||||||
|
if (!ivHex || !authTagHex || !ciphertext) {
|
||||||
|
throw new Error("Invalid encrypted format: missing parts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivHex, "hex");
|
||||||
|
const authTag = Buffer.from(authTagHex, "hex");
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||||
|
authTagLength: TAG_LENGTH,
|
||||||
|
});
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(ciphertext, "hex", "utf8");
|
||||||
|
decrypted += decipher.final("utf8");
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a value is already encrypted */
|
||||||
|
export function isEncrypted(value: string): boolean {
|
||||||
|
return value.startsWith(PREFIX);
|
||||||
|
}
|
||||||
@@ -169,9 +169,9 @@ export function PasswordVaultModule() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<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">
|
||||||
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
|
Parolele sunt criptate (AES-256-GCM) pe server înainte de stocare.
|
||||||
Folosiți un manager de parole dedicat pentru date sensibile.
|
Datele sunt protejate la rest în baza de date.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||||
|
|
||||||
export const passwordVaultConfig: ModuleConfig = {
|
export const passwordVaultConfig: ModuleConfig = {
|
||||||
id: 'password-vault',
|
id: "password-vault",
|
||||||
name: 'Seif Parole',
|
name: "Seif Parole",
|
||||||
description: 'Manager securizat de parole și credențiale cu criptare locală',
|
description:
|
||||||
icon: 'lock',
|
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
|
||||||
route: '/password-vault',
|
icon: "lock",
|
||||||
category: 'operations',
|
route: "/password-vault",
|
||||||
featureFlag: 'module.password-vault',
|
category: "operations",
|
||||||
visibility: 'admin',
|
featureFlag: "module.password-vault",
|
||||||
version: '0.1.0',
|
visibility: "admin",
|
||||||
|
version: "0.2.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
storageNamespace: 'password-vault',
|
storageNamespace: "password-vault",
|
||||||
navOrder: 11,
|
navOrder: 11,
|
||||||
tags: ['parole', 'securitate', 'credențiale'],
|
tags: ["parole", "securitate", "credențiale", "criptare"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useStorage } from "@/core/storage";
|
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import type { VaultEntry, VaultEntryCategory } from "../types";
|
import type { VaultEntry, VaultEntryCategory } from "../types";
|
||||||
|
|
||||||
const PREFIX = "vault:";
|
|
||||||
|
|
||||||
export interface VaultFilters {
|
export interface VaultFilters {
|
||||||
search: string;
|
search: string;
|
||||||
category: VaultEntryCategory | "all";
|
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() {
|
export function useVault() {
|
||||||
const storage = useStorage("password-vault");
|
|
||||||
const [entries, setEntries] = useState<VaultEntry[]>([]);
|
const [entries, setEntries] = useState<VaultEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<VaultFilters>({
|
const [filters, setFilters] = useState<VaultFilters>({
|
||||||
@@ -23,17 +23,18 @@ export function useVault() {
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const all = await storage.exportAll();
|
try {
|
||||||
const results: VaultEntry[] = [];
|
const res = await fetch("/api/vault");
|
||||||
for (const [key, value] of Object.entries(all)) {
|
const data = await res.json();
|
||||||
if (key.startsWith(PREFIX) && value) {
|
const results = (data.entries ?? []) as VaultEntry[];
|
||||||
results.push(value as VaultEntry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.sort((a, b) => a.label.localeCompare(b.label));
|
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
setEntries(results);
|
setEntries(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load vault entries:", err);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [storage]);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,36 +50,46 @@ export function useVault() {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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();
|
await refresh();
|
||||||
return entry;
|
return entry;
|
||||||
},
|
},
|
||||||
[storage, refresh],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateEntry = useCallback(
|
const updateEntry = useCallback(
|
||||||
async (id: string, updates: Partial<VaultEntry>) => {
|
async (id: string, updates: Partial<VaultEntry>) => {
|
||||||
const existing = entries.find((e) => e.id === id);
|
const existing = entries.find((e) => e.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = {
|
const updated: VaultEntry = {
|
||||||
...existing,
|
...existing,
|
||||||
...updates,
|
...updates,
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
createdAt: existing.createdAt,
|
createdAt: existing.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
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();
|
await refresh();
|
||||||
},
|
},
|
||||||
[storage, refresh, entries],
|
[refresh, entries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeEntry = useCallback(
|
const removeEntry = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
await fetch(`/api/vault?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
[storage, refresh],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateFilter = useCallback(
|
const updateFilter = useCallback(
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ export function WordTemplatesModule() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [revisingTemplate, setRevisingTemplate] =
|
const [revisingTemplate, setRevisingTemplate] = useState<WordTemplate | null>(
|
||||||
useState<WordTemplate | null>(null);
|
null,
|
||||||
|
);
|
||||||
const [revisionUrl, setRevisionUrl] = useState("");
|
const [revisionUrl, setRevisionUrl] = useState("");
|
||||||
const [revisionNotes, setRevisionNotes] = useState("");
|
const [revisionNotes, setRevisionNotes] = useState("");
|
||||||
const [historyTemplate, setHistoryTemplate] = useState<WordTemplate | null>(
|
const [historyTemplate, setHistoryTemplate] = useState<WordTemplate | null>(
|
||||||
@@ -172,7 +173,12 @@ export function WordTemplatesModule() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Word/Excel</p>
|
<p className="text-xs text-muted-foreground">Word/Excel</p>
|
||||||
<p className="text-2xl font-bold">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -180,7 +186,11 @@ export function WordTemplatesModule() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">DWG/Archicad</p>
|
<p className="text-xs text-muted-foreground">DWG/Archicad</p>
|
||||||
<p className="text-2xl font-bold">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -188,7 +198,10 @@ export function WordTemplatesModule() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Cu versiuni</p>
|
<p className="text-xs text-muted-foreground">Cu versiuni</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{allTemplates.filter((t) => (t.versionHistory ?? []).length > 0).length}
|
{
|
||||||
|
allTemplates.filter((t) => (t.versionHistory ?? []).length > 0)
|
||||||
|
.length
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -431,9 +444,7 @@ export function WordTemplatesModule() {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Revizie nouă — {revisingTemplate?.name}</DialogTitle>
|
||||||
Revizie nouă — {revisingTemplate?.name}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -546,10 +557,7 @@ export function WordTemplatesModule() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setHistoryTemplate(null)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setHistoryTemplate(null)}
|
|
||||||
>
|
|
||||||
Închide
|
Închide
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -726,13 +734,13 @@ function TemplateForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(
|
{(Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]).map(
|
||||||
Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]
|
(ft) => (
|
||||||
).map((ft) => (
|
|
||||||
<SelectItem key={ft} value={ft}>
|
<SelectItem key={ft} value={ft}>
|
||||||
{FILE_TYPE_LABELS[ft]}
|
{FILE_TYPE_LABELS[ft]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export function useTemplates() {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (notes) {
|
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 storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ NEXTAUTH_SECRET="8IL9Kpipj0EZwZPNvekbNRPhV6a2/UY4cGVzE3n0pUY="
|
|||||||
AUTHENTIK_CLIENT_ID="V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi"
|
AUTHENTIK_CLIENT_ID="V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi"
|
||||||
AUTHENTIK_CLIENT_SECRET="TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr"
|
AUTHENTIK_CLIENT_SECRET="TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr"
|
||||||
AUTHENTIK_ISSUER="https://auth.beletage.ro/application/o/architools/"
|
AUTHENTIK_ISSUER="https://auth.beletage.ro/application/o/architools/"
|
||||||
|
|
||||||
|
# Vault encryption (AES-256-GCM)
|
||||||
|
ENCRYPTION_SECRET="ArchiTools-Vault-2025!SecureKey@AES256"
|
||||||
|
|||||||
Reference in New Issue
Block a user