feat(account): add deletion lifecycle
This commit is contained in:
@@ -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" },
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user