feat(account): add deletion lifecycle
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 19:03:54 +02:00
parent 1ec9dd037e
commit 842e793f69
28 changed files with 4418 additions and 129 deletions
@@ -1,11 +1,12 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import BackupCard from './components/BackupCard';
import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast';
import { api } from './api';
import { PromptProvider } from './prompt';
jest.mock('./api', () => ({
api: {
@@ -22,7 +23,9 @@ jest.mock('./api', () => ({
const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
mockedApi.get.mockReset();
mockedApi.post.mockReset();
mockedApi.get.mockResolvedValue({ data: { deletionEnabled: false, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any);
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:account-export') });
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() });
jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
@@ -35,7 +38,7 @@ test('downloads the readable account ZIP from the protected endpoint', async ()
data: new Blob(['zip']),
headers: { 'content-disposition': 'attachment; filename="jobjakt-account-export.zip"' },
} as any);
render(<ToastProvider><I18nProvider><BackupCard /></I18nProvider></ToastProvider>);
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole('button', { name: /download readable account export/i }));
@@ -46,9 +49,36 @@ test('downloads the readable account ZIP from the protected endpoint', async ()
test('explains the recent-sign-in requirement without weakening it', async () => {
mockedApi.post.mockRejectedValue({ response: { status: 403, data: { detail: 'Sign in again before downloading a complete account export.' } } });
render(<ToastProvider><I18nProvider><BackupCard /></I18nProvider></ToastProvider>);
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
expect(screen.getByText(/requires a sign-in from the last 15 minutes/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /download readable account export/i }));
expect(await screen.findByText(/sign in again before downloading/i)).toBeInTheDocument();
});
test('keeps account deletion hidden behind the server flag', async () => {
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
expect(await screen.findByText(/account deletion is not enabled yet/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument();
});
test('requires the exact server-provided deletion phrase', async () => {
mockedApi.get.mockResolvedValue({ data: { deletionEnabled: true, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any);
mockedApi.post.mockRejectedValue({ response: { status: 503, data: { detail: 'Safeguard rehearsal is incomplete.' } } });
render(<ToastProvider><I18nProvider><PromptProvider><BackupCard /></PromptProvider></I18nProvider></ToastProvider>);
fireEvent.click(await screen.findByRole('button', { name: /delete my account/i }));
let dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE somebody-else@example.test' } });
fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i }));
expect(await screen.findByText(/confirmation did not match/i)).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalledWith('/account-lifecycle/delete', expect.anything());
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE owner@example.test' } });
fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/account-lifecycle/delete', { confirmation: 'DELETE owner@example.test' }));
});
@@ -21,6 +21,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
function renderPage(users: unknown[]) {
mockedApi.get.mockResolvedValue({ data: users } as any);
mockedApi.put.mockResolvedValue({ data: null } as any);
mockedApi.delete.mockResolvedValue({ data: null } as any);
render(
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
<I18nProvider>
@@ -82,3 +83,14 @@ test("disables demotion and deletion for the final administrator", async () => {
expect(await screen.findByRole("button", { name: "Remove admin" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
});
test("sends the exact account email after confirming deletion", async () => {
renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]);
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", {
headers: { "X-Confirm-Account-Deletion": "other@example.com" },
}));
});
+60 -1
View File
@@ -1,15 +1,35 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, Divider, Paper, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { usePrompt } from "../prompt";
type AccountLifecycleStatus = {
deletionEnabled: boolean;
deletionStatus: string;
requiredConfirmation: string;
request?: { status: string; stage: string } | null;
};
export default function BackupCard() {
const { toast } = useToast();
const { t } = useI18n();
const { prompt } = usePrompt();
const [downloading, setDownloading] = useState(false);
const [exportingAccount, setExportingAccount] = useState(false);
const [deletingAccount, setDeletingAccount] = useState(false);
const [lifecycle, setLifecycle] = useState<AccountLifecycleStatus | null>(null);
useEffect(() => {
let active = true;
api.get<AccountLifecycleStatus>("/account-lifecycle/status")
.then((response) => { if (active) setLifecycle(response.data); })
.catch(() => { if (active) setLifecycle(null); });
return () => { active = false; };
}, []);
const downloadBlob = (blob: Blob, contentDisposition: string | undefined, fallbackName: string) => {
const url = URL.createObjectURL(blob);
@@ -49,6 +69,31 @@ export default function BackupCard() {
}
};
const deleteAccount = async () => {
if (!lifecycle?.deletionEnabled || lifecycle.deletionStatus !== "active") return;
const confirmation = await prompt({
title: t("accountDeleteTitle"),
message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }),
confirmLabel: t("accountDeleteButton"),
cancelLabel: t("cancel"),
});
if (confirmation === null) return;
if (confirmation.trim() !== lifecycle.requiredConfirmation) {
toast(t("accountDeleteConfirmationMismatch"), "error");
return;
}
setDeletingAccount(true);
try {
const response = await api.post("/account-lifecycle/delete", { confirmation });
setLifecycle((current) => current ? { ...current, deletionStatus: "pending", request: response.data } : current);
clearAuthClientState();
window.location.assign("/login");
} catch (error: any) {
toast(getApiErrorMessage(error, t("accountDeleteFailed")), "error");
setDeletingAccount(false);
}
};
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
@@ -73,6 +118,20 @@ export default function BackupCard() {
{downloading ? t("backupPreparing") : t("backupDownload")}
</Button>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 0.5 }}>{t("accountDeleteTitle")}</Typography>
{lifecycle?.deletionStatus === "pending" ? (
<Alert severity="warning">{t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })}</Alert>
) : lifecycle?.deletionEnabled ? (
<Box>
<Alert severity="error" sx={{ mb: 1.5 }}>{t("accountDeleteWarning")}</Alert>
<Button color="error" variant="outlined" onClick={() => void deleteAccount()} disabled={deletingAccount}>
{deletingAccount ? t("accountDeleteStarting") : t("accountDeleteButton")}
</Button>
</Box>
) : (
<Alert severity="info">{t("accountDeleteDisabled")}</Alert>
)}
</Paper>
);
}
+18
View File
@@ -821,6 +821,15 @@ export const translations = {
backupDownload: "Download encrypted backup",
backupDownloaded: "Backup downloaded.",
backupFailed: "Backup failed.",
accountDeleteTitle: "Delete account",
accountDeleteWarning: "This permanently removes your account, job history, career data, documents, sessions, and connected-account credentials. Export your data first. You will be signed out immediately.",
accountDeletePrompt: "This cannot be undone. Type {confirmation} exactly to continue.",
accountDeleteButton: "Delete my account",
accountDeleteStarting: "Starting deletion...",
accountDeleteConfirmationMismatch: "The confirmation did not match. Your account was not changed.",
accountDeleteFailed: "Account deletion could not be started.",
accountDeletePending: "Account deletion is in progress ({stage}). Access has already been disabled.",
accountDeleteDisabled: "Account deletion is not enabled yet. The deletion workflow remains safely off until retention, restore, and production cache-purge safeguards are approved.",
authStatusTitle: "Authentication",
authStatusNotSignedIn: "Not signed in.",
authStatusRoles: "Roles: {roles}",
@@ -1981,6 +1990,15 @@ export const translations = {
backupDownload: "Last ned kryptert sikkerhetskopi",
backupDownloaded: "Sikkerhetskopi lastet ned.",
backupFailed: "Sikkerhetskopiering mislyktes.",
accountDeleteTitle: "Slett konto",
accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.",
accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for å fortsette.",
accountDeleteButton: "Slett kontoen min",
accountDeleteStarting: "Starter sletting...",
accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.",
accountDeleteFailed: "Kontosletting kunne ikke startes.",
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.",
authStatusTitle: "Autentisering",
authStatusNotSignedIn: "Ikke logget inn.",
authStatusRoles: "Roller: {roles}",
+1 -1
View File
@@ -114,7 +114,7 @@ export default function AdminUsersPage() {
: t("adminUsersDeleteConfirmNamed", { name });
if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/users/${u.id}`);
await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } });
toast(t("adminUsersDeleted"), "info");
await load();
} catch (e) {