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(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(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("/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 ( {t("accountExportBody")} {t("accountExportRecentSignIn")} {t("backupEncryptedTitle")} {t("backupBody")} {t("accountDeleteTitle")} {loadingLifecycle ? {t("loading")} : lifecycleFailed ? { body.current?.focus(); setLifecycleFailed(false); setLoadingLifecycle(true); setReadAttempt(value => value+1); }}>{t("retry")}}>{t("accountLifecycleUnavailable")} : lifecycle?.deletionStatus === "pending" ? ( {t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })} ) : lifecycle?.deletionEnabled ? ( {t("accountDeleteWarning")} ) : ( {t("accountDeleteDisabled")} )} ); }