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

136 lines
5.6 KiB
TypeScript

import React, { useEffect, 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";
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);
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 () => {
setExportingAccount(true);
try {
const res = await api.post("/export/account", null, { responseType: "blob" });
downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobjakt-account-export-${new Date().toISOString().slice(0, 10)}.zip`);
toast(t("accountExportDownloaded"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("accountExportFailed")), "error");
} finally {
setExportingAccount(false);
}
};
const downloadEncrypted = async () => {
setDownloading(true);
try {
const res = await api.post("/backup/encrypted", null, { responseType: "blob" });
downloadBlob(res.data as Blob, res.headers?.["content-disposition"] as string | undefined, `jobtracker_backup_${new Date().toISOString().slice(0, 10)}.jtbackup`);
toast(t("backupDownloaded"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("backupFailed")), "error");
} finally {
setDownloading(false);
}
};
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 (
<PageSection surface title={t("backupTitle")}>
<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}>
{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="contained" onClick={downloadEncrypted} disabled={downloading || exportingAccount}>
{downloading ? t("backupPreparing") : t("backupDownload")}
</Button>
</Box>
<Divider sx={{ my: 2 }} />
<Typography component="h3" variant="sectionTitle">{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>
)}
</PageSection>
);
}