Merge branch 'feat/vault-w4-qa' into release/v1.27.17

This commit is contained in:
Marc-André Bombeck committed 2026-07-07 15:10:45 +02:00
commit 03058b0210
81 files changed
+10544 -3246

No files matched your search

+8
View File
@@ -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"