Files
jobtrackingapp/job-tracker-ui/src/components/BackupCard.tsx
T

172 lines
8.3 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Divider, Typography } from "@mui/material";
import { PageSection } from "../layout/PageLayout";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { usePrompt } from "../prompt";
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
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);
const [lifecycleFailed, setLifecycleFailed] = useState(false);
const [loadingLifecycle, setLoadingLifecycle] = useState(true);
const [readAttempt, setReadAttempt] = useState(0);
const session = useRef({ mounted: true, generation: 0, busy: false });
const currentTranslation = useRef(t);
const body = useRef<HTMLDivElement>(null);
useEffect(() => { currentTranslation.current = t; }, [t]);
useEffect(() => {
const owner=session.current; owner.mounted=true; owner.generation += 1;
return () => { owner.mounted=false; owner.generation += 1; };
}, []);
const beginAction = () => {
checkSessionCookie();
const owner=session.current;
if (!owner.mounted || owner.busy || isSessionInvalidated()) return null;
owner.busy=true;
if (body.current?.contains(document.activeElement)) body.current.focus();
const context=getSessionContext(); const generation=owner.generation;
return () => { checkSessionCookie(); return owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context; };
};
useEffect(() => {
let active = true;
const context=getSessionContext();
api.get<AccountLifecycleStatus>("/account-lifecycle/status")
.then((response) => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLifecycle(response.data); })
.catch(() => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLifecycleFailed(true); })
.finally(() => { if (active && !isSessionInvalidated() && getSessionContext() === context) setLoadingLifecycle(false); });
return () => { active = false; };
}, [readAttempt]);
const downloadBlob = (blob: Blob, contentDisposition: string | undefined, fallbackName: string) => {
const url = URL.createObjectURL(blob);
const match = /filename="?([^";]+)"?/i.exec(contentDisposition || "");
const link = document.createElement("a");
link.href = url;
link.download = match?.[1] ?? fallbackName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 5000);
};
const downloadAccountExport = async () => {
const active=beginAction(); if (!active) return;
setExportingAccount(true);
try {
const res = await api.post("/export/account", null, { responseType: "blob" });
if (!active()) return;
downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobjakt-account-export-${new Date().toISOString().slice(0, 10)}.zip`);
toast(currentTranslation.current("accountExportDownloaded"), "success");
} catch (error: any) {
if (active()) toast(getApiErrorMessage(error, currentTranslation.current("accountExportFailed")), "error");
} finally {
session.current.busy=false;
if (active()) setExportingAccount(false);
}
};
const downloadEncrypted = async () => {
const active=beginAction(); if (!active) return;
setDownloading(true);
try {
const res = await api.post("/backup/encrypted", null, { responseType: "blob" });
if (!active()) return;
downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobtracker_backup_${new Date().toISOString().slice(0, 10)}.jtbackup`);
toast(currentTranslation.current("backupDownloaded"), "success");
} catch (error: any) {
if (active()) toast(getApiErrorMessage(error, currentTranslation.current("backupFailed")), "error");
} finally {
session.current.busy=false;
if (active()) setDownloading(false);
}
};
const deleteAccount = async () => {
if (!lifecycle?.deletionEnabled || lifecycle.deletionStatus !== "active") return;
const active=beginAction(); if (!active) return;
setDeletingAccount(true);
try {
const confirmation = await prompt({
title: t("accountDeleteTitle"),
message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }),
confirmLabel: t("accountDeleteButton"),
cancelLabel: t("cancel"),
});
if (!active() || confirmation === null) return;
if (confirmation.trim() !== lifecycle.requiredConfirmation) {
toast(currentTranslation.current("accountDeleteConfirmationMismatch"), "error");
return;
}
const response = await api.post("/account-lifecycle/delete", { confirmation });
if (!active()) return;
setLifecycle((current) => current ? { ...current, deletionStatus: "pending", request: response.data } : current);
clearAuthClientState();
window.location.assign("/login");
} catch (error: any) {
if (active()) toast(getApiErrorMessage(error, currentTranslation.current("accountDeleteFailed")), "error");
} finally {
session.current.busy=false;
if (active()) setDeletingAccount(false);
}
};
return (
<PageSection surface title={t("backupTitle")}>
<Box ref={body} role="group" aria-label={t("backupTitle")} tabIndex={-1} aria-busy={downloading || exportingAccount || deletingAccount}>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
{t("accountExportBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={downloadAccountExport} disabled={exportingAccount || downloading || deletingAccount} aria-busy={exportingAccount}>
{exportingAccount ? t("accountExportPreparing") : t("accountExportDownload")}
</Button>
</Box>
<Alert severity="info" sx={{ mt: 1.5 }}>{t("accountExportRecentSignIn")}</Alert>
<Divider sx={{ my: 2 }} />
<Typography component="h3" variant="sectionTitle">{t("backupEncryptedTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
{t("backupBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={downloadEncrypted} disabled={downloading || exportingAccount || deletingAccount} aria-busy={downloading}>
{downloading ? t("backupPreparing") : t("backupDownload")}
</Button>
</Box>
<Divider sx={{ my: 2 }} />
<Typography component="h3" variant="sectionTitle">{t("accountDeleteTitle")}</Typography>
{loadingLifecycle ? <Typography role="status">{t("loading")}</Typography> : lifecycleFailed ? <Alert severity="error" action={<Button color="inherit" onClick={() => { body.current?.focus(); setLifecycleFailed(false); setLoadingLifecycle(true); setReadAttempt(value => value+1); }}>{t("retry")}</Button>}>{t("accountLifecycleUnavailable")}</Alert> : 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 || exportingAccount || downloading} aria-busy={deletingAccount}>
{deletingAccount ? t("accountDeleteStarting") : t("accountDeleteButton")}
</Button>
</Box>
) : (
<Alert severity="info">{t("accountDeleteDisabled")}</Alert>
)}
</Box>
</PageSection>
);
}