From 0d37061d435413c79bad3a55e5cb74745f166e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 13:34:55 +0200 Subject: [PATCH] feat(admin): document-vault limit fields on the admin surfaces The two vault knobs become editable where operators already work: the app-settings card gains the per-file upload cap (MB, 1-100, mirrors the hard server ceiling) and the default per-account storage quota (GB, commit-on-blur with clamped conversion to bytes); the user edit form gains the per-user quota override (GB, empty = instance default). The admin users list now carries the override so the form can show it. Refs: document-vault plan W3-T7 --- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/pl.json | 9 +- .../api/admin/users/__tests__/route.test.ts | 10 ++ src/app/api/admin/users/route.ts | 6 + src/components/admin/_shared.tsx | 8 ++ .../admin/general-settings-section.tsx | 113 +++++++++++++++++- .../admin/user-management-section.tsx | 45 +++++++ 11 files changed, 229 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index ebae2105b..a38879ea5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen.", "copyIp": "IP-Adresse kopieren", "ipCopied": "IP-Adresse kopiert", - "copyFailed": "Kopieren fehlgeschlagen." + "copyFailed": "Kopieren fehlgeschlagen.", + "documentMaxFile": "Upload-Limit für Dokumente", + "documentMaxFileDescription": "Maximale Dateigröße im Dokumenten-Bestand (1–100 MB).", + "documentQuota": "Speicherkontingent für Dokumente", + "documentQuotaDescription": "Standard-Speicherplatz pro Konto für Dokumente (0,1–1024 GB).", + "userDocumentQuota": "Abweichendes Dokumenten-Kontingent", + "userDocumentQuotaDefault": "Standard", + "userDocumentQuotaHint": "Leer lassen, um den Instanz-Standard zu verwenden." }, "achievements": { "title": "Erfolge", diff --git a/messages/en.json b/messages/en.json index 046281f2c..567b7ea0b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Require a second factor for this user.", "copyIp": "Copy IP address", "ipCopied": "IP address copied", - "copyFailed": "Could not copy." + "copyFailed": "Could not copy.", + "documentMaxFile": "Document upload limit", + "documentMaxFileDescription": "Per-file size cap for the document vault (1–100 MB).", + "documentQuota": "Document storage quota", + "documentQuotaDescription": "Default per-account document storage (0.1–1024 GB).", + "userDocumentQuota": "Document quota override", + "userDocumentQuotaDefault": "Default", + "userDocumentQuotaHint": "Leave empty to use the instance default." }, "achievements": { "title": "Achievements", diff --git a/messages/es.json b/messages/es.json index e128273ad..9d71afd02 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Exigir un segundo factor a este usuario.", "copyIp": "Copiar dirección IP", "ipCopied": "Dirección IP copiada", - "copyFailed": "No se pudo copiar." + "copyFailed": "No se pudo copiar.", + "documentMaxFile": "Límite de subida de documentos", + "documentMaxFileDescription": "Tamaño máximo por archivo en el archivo de documentos (1–100 MB).", + "documentQuota": "Cuota de almacenamiento de documentos", + "documentQuotaDescription": "Almacenamiento de documentos por cuenta por defecto (0,1–1024 GB).", + "userDocumentQuota": "Cuota de documentos personalizada", + "userDocumentQuotaDefault": "Predeterminado", + "userDocumentQuotaHint": "Déjalo vacío para usar el valor predeterminado de la instancia." }, "achievements": { "title": "Logros", diff --git a/messages/fr.json b/messages/fr.json index 3116f08d9..0b264b707 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur.", "copyIp": "Copier l’adresse IP", "ipCopied": "Adresse IP copiée", - "copyFailed": "Impossible de copier." + "copyFailed": "Impossible de copier.", + "documentMaxFile": "Limite de téléversement de documents", + "documentMaxFileDescription": "Taille maximale par fichier dans les documents (1–100 Mo).", + "documentQuota": "Quota de stockage des documents", + "documentQuotaDescription": "Stockage de documents par compte par défaut (0,1–1024 Go).", + "userDocumentQuota": "Quota de documents personnalisé", + "userDocumentQuotaDefault": "Par défaut", + "userDocumentQuotaHint": "Laisser vide pour utiliser la valeur par défaut de l'instance." }, "achievements": { "title": "Réussites", diff --git a/messages/it.json b/messages/it.json index 4a82c1afd..a8c2438d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente.", "copyIp": "Copia indirizzo IP", "ipCopied": "Indirizzo IP copiato", - "copyFailed": "Impossibile copiare." + "copyFailed": "Impossibile copiare.", + "documentMaxFile": "Limite di caricamento documenti", + "documentMaxFileDescription": "Dimensione massima per file nell'archivio documenti (1–100 MB).", + "documentQuota": "Quota di archiviazione documenti", + "documentQuotaDescription": "Spazio documenti predefinito per account (0,1–1024 GB).", + "userDocumentQuota": "Quota documenti personalizzata", + "userDocumentQuotaDefault": "Predefinito", + "userDocumentQuotaHint": "Lascia vuoto per usare il valore predefinito dell'istanza." }, "achievements": { "title": "Obiettivi", diff --git a/messages/pl.json b/messages/pl.json index bee86ea6a..c038f1139 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5936,7 +5936,14 @@ "mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika.", "copyIp": "Kopiuj adres IP", "ipCopied": "Skopiowano adres IP", - "copyFailed": "Nie udało się skopiować." + "copyFailed": "Nie udało się skopiować.", + "documentMaxFile": "Limit przesyłania dokumentów", + "documentMaxFileDescription": "Maksymalny rozmiar pliku w archiwum dokumentów (1–100 MB).", + "documentQuota": "Limit miejsca na dokumenty", + "documentQuotaDescription": "Domyślne miejsce na dokumenty na konto (0,1–1024 GB).", + "userDocumentQuota": "Indywidualny limit dokumentów", + "userDocumentQuotaDefault": "Domyślny", + "userDocumentQuotaHint": "Pozostaw puste, aby użyć domyślnego limitu instancji." }, "achievements": { "title": "Osiągnięcia", diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index 4e7a8bd1b..6ed74c4fc 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -67,6 +67,8 @@ describe("GET /api/admin/users", () => { email: "alice@example.com", role: "ADMIN", createdAt: created, + mfaEnforced: true, + documentQuotaBytes: BigInt(2_147_483_648), _count: { passkeys: 2 }, }, { @@ -75,6 +77,8 @@ describe("GET /api/admin/users", () => { email: null, role: "USER", createdAt: created, + mfaEnforced: false, + documentQuotaBytes: null, _count: { passkeys: 0 }, }, ] as never); @@ -91,10 +95,15 @@ describe("GET /api/admin/users", () => { email: "alice@example.com", role: "ADMIN", createdAt: created.toISOString(), + mfaEnforced: true, + // BigInt in Prisma, plain number on the wire. + documentQuotaBytes: 2_147_483_648, passkeyCount: 2, }); expect(body.data[1].passkeyCount).toBe(0); expect(body.data[1].email).toBeNull(); + // No override → explicit null (the instance default applies). + expect(body.data[1].documentQuotaBytes).toBeNull(); }); it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => { @@ -112,6 +121,7 @@ describe("GET /api/admin/users", () => { role: true, createdAt: true, mfaEnforced: true, + documentQuotaBytes: true, _count: { select: { passkeys: true } }, }); expect(args.select).not.toHaveProperty("passwordHash"); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 60d0097ba..37baaf290 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -17,6 +17,7 @@ export const GET = apiHandler(async () => { role: true, createdAt: true, mfaEnforced: true, + documentQuotaBytes: true, _count: { select: { passkeys: true } }, }, orderBy: { createdAt: "asc" }, @@ -32,6 +33,11 @@ export const GET = apiHandler(async () => { // v1.23 — per-user "require a second factor" override. The effective // requirement is the OR of this and the instance-wide policy. mfaEnforced: u.mfaEnforced, + // Document vault — per-user storage-quota override. Null = the + // instance default applies. BigInt in Prisma, number on the wire + // (max override 1 TiB, comfortably inside Number's safe range). + documentQuotaBytes: + u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes), passkeyCount: u._count.passkeys, })), ); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index bb9b87736..b9c8ff1aa 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -28,6 +28,9 @@ export interface AdminUser { createdAt: string; // v1.23 — per-user "require a second factor" override. mfaEnforced: boolean; + // Document vault — per-user storage-quota override in bytes; null means + // the instance-wide default applies. + documentQuotaBytes: number | null; passkeyCount: number; } @@ -88,6 +91,11 @@ export interface AdminSettings { // v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver". defaultUserTimezone: string | null; moodLogGlobal?: boolean; + // Document vault — the two admin-tunable limits (bytes on the wire; the + // UI edits them as MB / GB). The per-file cap clamps to the hard 100 MiB + // ceiling server-side. + documentMaxFileBytes: number; + documentQuotaBytes: number; } export interface AdminAuditEntry { diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 008edf4fb..84bda6238 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -1,14 +1,29 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Settings } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; import { listSupportedTimezones } from "@/lib/tz/format"; +// Document-vault limits: bytes on the wire, MB / GB in the UI. The bounds +// mirror the server-side schema (per-file cap hard-clamped to 100 MiB — +// single-shot GCM + bounded in-memory reads are load-bearing past that). +const MIB = 1_048_576; +const GIB = 1_073_741_824; +const MAX_FILE_MB_MIN = 1; +const MAX_FILE_MB_MAX = 100; +const QUOTA_GB_MIN = 0.1; +const QUOTA_GB_MAX = 1024; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + // v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant // retired; the shared `` primitive owns the visual // contract now. The admin copy previously diverged on the @@ -26,6 +41,43 @@ export function GeneralSettingsSection() { const currentDefaultTz = settings?.defaultUserTimezone ?? ""; + // Draft-while-editing pattern for the two limit inputs: null = not + // editing (show the stored value), string = the in-progress draft. + // Commit clamps to the schema bounds and converts to bytes. + const [maxFileDraft, setMaxFileDraft] = useState(null); + const [quotaDraft, setQuotaDraft] = useState(null); + + const storedMaxFileMb = + settings?.documentMaxFileBytes != null + ? String(Math.round(settings.documentMaxFileBytes / MIB)) + : ""; + const storedQuotaGb = + settings?.documentQuotaBytes != null + ? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) + : ""; + + const commitMaxFile = () => { + if (maxFileDraft === null) return; + const parsed = Number(maxFileDraft); + setMaxFileDraft(null); + if (!Number.isFinite(parsed) || maxFileDraft.trim() === "") return; + const mb = clamp(Math.round(parsed), MAX_FILE_MB_MIN, MAX_FILE_MB_MAX); + const bytes = mb * MIB; + if (bytes === settings?.documentMaxFileBytes) return; + updateSettings.mutate({ documentMaxFileBytes: bytes }); + }; + + const commitQuota = () => { + if (quotaDraft === null) return; + const parsed = Number(quotaDraft); + setQuotaDraft(null); + if (!Number.isFinite(parsed) || quotaDraft.trim() === "") return; + const gb = clamp(parsed, QUOTA_GB_MIN, QUOTA_GB_MAX); + const bytes = Math.round(gb * GIB); + if (bytes === settings?.documentQuotaBytes) return; + updateSettings.mutate({ documentQuotaBytes: bytes }); + }; + return ( + + {/* Document vault — the two admin-tunable limits (per-file cap + + per-user storage quota). Everything else about the vault is + committed behaviour; these are deliberately the only knobs. */} +
+
+

{t("admin.documentMaxFile")}

+

+ {t("admin.documentMaxFileDescription")} +

+
+
+ setMaxFileDraft(e.target.value)} + onBlur={commitMaxFile} + onKeyDown={(e) => { + if (e.key === "Enter") commitMaxFile(); + }} + disabled={updateSettings.isPending || settings == null} + /> + MB +
+
+ +
+
+

{t("admin.documentQuota")}

+

+ {t("admin.documentQuotaDescription")} +

+
+
+ setQuotaDraft(e.target.value)} + onBlur={commitQuota} + onKeyDown={(e) => { + if (e.key === "Enter") commitQuota(); + }} + disabled={updateSettings.isPending || settings == null} + /> + GB +
+
); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 6fcea4781..b73d63835 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -55,6 +55,9 @@ export function UserManagementSection() { const [editingUser, setEditingUser] = useState(null); const [editUsername, setEditUsername] = useState(""); const [editEmail, setEditEmail] = useState(""); + // Document vault — per-user storage-quota override, edited in GB. + // Empty string = no override (the instance default applies). + const [editQuotaGb, setEditQuotaGb] = useState(""); const [resetUser, setResetUser] = useState(null); const [resetPassword, setResetPassword] = useState(""); const [resetMsg, setResetMsg] = useState(null); @@ -142,6 +145,11 @@ export function UserManagementSection() { setEditingUser(u); setEditUsername(u.username); setEditEmail(u.email ?? ""); + setEditQuotaGb( + u.documentQuotaBytes === null + ? "" + : String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10), + ); } function startReset(u: AdminUser) { @@ -448,11 +456,22 @@ export function UserManagementSection() { onSubmit={(e) => { e.preventDefault(); if (updateUser.isPending) return; + const quotaTrimmed = editQuotaGb.trim(); + const quotaParsed = Number(quotaTrimmed); updateUser.mutate({ id: editingUser.id, data: { username: editUsername, email: editEmail || null, + // Empty clears the override; a value clamps to the same + // bounds the server schema enforces (0.1–1024 GB). + documentQuotaBytes: + quotaTrimmed === "" || !Number.isFinite(quotaParsed) + ? null + : Math.round( + Math.min(1024, Math.max(0.1, quotaParsed)) * + 1_073_741_824, + ), }, }); }} @@ -481,6 +500,32 @@ export function UserManagementSection() { data-1p-ignore="true" /> +
+ +
+ setEditQuotaGb(e.target.value)} + placeholder={t("admin.userDocumentQuotaDefault")} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + /> + GB +
+

+ {t("admin.userDocumentQuotaHint")} +

+