feat(account): show the deletion grace window and its undo

The deletion dialogs, the managed-profile delete dialog and the Data &
Privacy page read the window from the privacy summary and say what
happens: the account is locked at once, erased after the stated number
of days, and an admin of the server can undo it until then; a wipe keeps
a copy an admin can restore for the same time. Copies in the server's
backups age out after their retention period, and the text says that
too. With the window set to 0 they keep the immediate wording.

The sign-in page names the erasure date when a sign-in, a passkey or the
single sign-on callback is refused for an account pending deletion. The
admin users list shows a pending account with its date and an "Undo
deletion" action, and the backups list labels the copy taken before a
wipe. Every new string is translated in all nine languages.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:51:01 +01:00
1 parent a69666fa16
commit 63574b98a6
21 files changed
+573 -141

No files matched your search

+10
View File
@@ -34,6 +34,16 @@ export interface AdminUser {
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
/**
* Set while the account is pending deletion: when the purge job
* erases it, and whether the account asked itself or a Guardian deleted
* the managed profile. The row offers "Undo deletion" until then.
*/
deletion: {
requestedAt: string;
purgeAt: string | null;
requestedBy: "self" | "guardian";
} | null;
}
export interface WorkerStatus {
@@ -8,6 +8,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BackupRow } from "@/types/backups";
import { RestoreRowDialog } from "./restore-dialog";
import { isPreWipeBackupType } from "@/lib/account-deletion/pre-wipe-type";
/** What a row's Download and Restore controls act through. */
export type BackupRowActionProps = {
@@ -31,6 +32,9 @@ function formatBackupType(
) {
if (type === "WEEKLY_AUTO") return t("admin.section.backups.typeWeeklyAuto");
if (type === "MANUAL") return t("admin.section.backups.typeManual");
// The copy "Delete all entries" takes first; restorable until the deletion
// grace window ends, when the purge job deletes it.
if (isPreWipeBackupType(type)) return t("admin.section.backups.typePreWipe");
return type;
}
@@ -0,0 +1,79 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Undo2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiPost } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { AdminUser } from "./_shared";
/**
* An account pending deletion, in the admin users list: the date the purge
* job erases it, and the one action that stops it.
*
* The account, its stored copies and its off-host objects are untouched
* until that date, so "Undo deletion" (`POST /api/admin/users/{id}/undo-deletion`)
* gives it back exactly as it was. Renders nothing for any other account.
*/
export function PendingDeletionControls({ user }: { user: AdminUser }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const undo = useMutation({
mutationFn: () => apiPost(`/api/admin/users/${user.id}/undo-deletion`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
toast.success(
t("admin.section.users.undoDeletionDone", { name: user.username }),
);
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.undoDeletionFailed"),
{ cause: err },
);
},
});
if (!user.deletion) return null;
return (
<span
className="flex flex-wrap items-center gap-1"
data-slot="admin-user-deletion-pending"
>
<Badge variant="destructive" className="text-xs">
{user.deletion.purgeAt
? t("admin.section.users.deletionPending", {
date: formatDate(user.deletion.purgeAt),
})
: t("admin.section.users.deletionPendingNoDate")}
</Badge>
<ConfirmButton
slot="admin-user-undo-deletion"
size="sm"
className="min-h-11 text-xs"
icon={<Undo2 className="h-3 w-3" aria-hidden="true" />}
label={t("admin.section.users.undoDeletion")}
title={t("admin.section.users.undoDeletionTitle", {
name: user.username,
})}
body={t(
user.deletion.requestedBy === "guardian"
? "admin.section.users.undoDeletionBodyGuardian"
: "admin.section.users.undoDeletionBody",
{ name: user.username },
)}
confirmLabel={t("admin.section.users.undoDeletion")}
pending={undo.isPending}
onConfirm={() => undo.mutate()}
/>
</span>
);
}
@@ -39,6 +39,7 @@ import { ListRow } from "@/components/ui/list-row";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
@@ -56,8 +57,7 @@ type UserFilter = "all" | "admin" | "user";
export function UserManagementSection() {
const { t } = useTranslations();
const { user } = useAuth();
const currentUserId = user?.id ?? "";
const currentUserId = useAuth().user?.id ?? "";
const queryClient = useQueryClient();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editUsername, setEditUsername] = useState("");
@@ -182,6 +182,7 @@ export function UserManagementSection() {
*/
const renderUserActions = (u: AdminUser) => (
<>
<PendingDeletionControls user={u} />
<Button
variant="ghost"
size="sm"