136 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|