mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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
This commit is contained in:
1 parent
0ade02e163
commit
0d37061d43
11 files changed
+229
-7
No files matched your search
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 `<NativeSelect>` 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<string | null>(null);
|
||||
const [quotaDraft, setQuotaDraft] = useState<string | null>(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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
@@ -117,6 +169,65 @@ export function GeneralSettingsSection() {
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t("admin.documentMaxFile")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.documentMaxFileDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MAX_FILE_MB_MIN}
|
||||
max={MAX_FILE_MB_MAX}
|
||||
step={1}
|
||||
className="w-24 text-right tabular-nums"
|
||||
aria-label={t("admin.documentMaxFile")}
|
||||
value={maxFileDraft ?? storedMaxFileMb}
|
||||
onChange={(e) => setMaxFileDraft(e.target.value)}
|
||||
onBlur={commitMaxFile}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitMaxFile();
|
||||
}}
|
||||
disabled={updateSettings.isPending || settings == null}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t("admin.documentQuota")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.documentQuotaDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={QUOTA_GB_MIN}
|
||||
max={QUOTA_GB_MAX}
|
||||
step={0.1}
|
||||
className="w-24 text-right tabular-nums"
|
||||
aria-label={t("admin.documentQuota")}
|
||||
value={quotaDraft ?? storedQuotaGb}
|
||||
onChange={(e) => setQuotaDraft(e.target.value)}
|
||||
onBlur={commitQuota}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitQuota();
|
||||
}}
|
||||
disabled={updateSettings.isPending || settings == null}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,9 @@ export function UserManagementSection() {
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(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<AdminUser | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-document-quota">
|
||||
{t("admin.userDocumentQuota")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="edit-document-quota"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0.1}
|
||||
max={1024}
|
||||
step={0.1}
|
||||
className="w-28 text-right tabular-nums"
|
||||
value={editQuotaGb}
|
||||
onChange={(e) => setEditQuotaGb(e.target.value)}
|
||||
placeholder={t("admin.userDocumentQuotaDefault")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">GB</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.userDocumentQuotaHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
Reference in new issue
Block a user