feat(export): add readable account archive
This commit is contained in:
@@ -139,6 +139,16 @@ test("a Free account keeps manual work available while AI actions stay honestly
|
||||
await expect(page.getByRole("button", { name: "Upgrade to Pro" })).toHaveCount(0);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByRole("tab", { name: "Backup" }).click();
|
||||
await expect(page.getByText(/requires a sign-in from the last 15 minutes/i)).toBeVisible();
|
||||
const exportResponsePromise = page.waitForResponse((response) => response.url().endsWith("/api/export/account") && response.request().method() === "POST");
|
||||
await page.getByRole("button", { name: "Download readable account export" }).click();
|
||||
const exportResponse = await exportResponsePromise;
|
||||
expect(exportResponse.status()).toBe(200);
|
||||
expect(exportResponse.headers()["content-type"]).toContain("application/zip");
|
||||
expect((await exportResponse.body()).subarray(0, 2).toString()).toBe("PK");
|
||||
await expect(page.getByText(/Readable account export downloaded/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("a saved job can be created through the reviewed UI flow", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import BackupCard from './components/BackupCard';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { ToastProvider } from './toast';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (error: any, fallback?: string) => error?.response?.data?.detail || fallback || 'Request failed.',
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.post.mockReset();
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
test('downloads the readable account ZIP from the protected endpoint', async () => {
|
||||
mockedApi.post.mockResolvedValue({
|
||||
data: new Blob(['zip']),
|
||||
headers: { 'content-disposition': 'attachment; filename="jobjakt-account-export.zip"' },
|
||||
} as any);
|
||||
render(<ToastProvider><I18nProvider><BackupCard /></I18nProvider></ToastProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /download readable account export/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/export/account', null, { responseType: 'blob' }));
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/readable account export downloaded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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>);
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Divider, Paper, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -9,24 +9,38 @@ export default function BackupCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [exportingAccount, setExportingAccount] = useState(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" });
|
||||
const blob: Blob = res.data;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const cd = (res.headers?.["content-disposition"] as string) || "";
|
||||
const m = /filename="?([^";]+)"?/i.exec(cd);
|
||||
const filename = m?.[1] ?? `jobtracker_backup_${new Date().toISOString().slice(0, 10)}.jtbackup`;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
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");
|
||||
@@ -40,11 +54,22 @@ export default function BackupCard() {
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("backupTitle")}
|
||||
</Typography>
|
||||
<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 variant="subtitle1" sx={{ fontWeight: 800, mb: 0.5 }}>{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}>
|
||||
<Button variant="contained" onClick={downloadEncrypted} disabled={downloading || exportingAccount}>
|
||||
{downloading ? t("backupPreparing") : t("backupDownload")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -809,7 +809,14 @@ export const translations = {
|
||||
signedInAs: "Signed in as {name}.",
|
||||
unlinkGoogle: "Unlink Google",
|
||||
backupTitle: "Data safety",
|
||||
backupBody: "One-click encrypted backup of your current data.",
|
||||
accountExportBody: "Download a readable ZIP of your account data and owned files, with a checksum manifest and clear exclusions.",
|
||||
accountExportPreparing: "Preparing account export...",
|
||||
accountExportDownload: "Download readable account export",
|
||||
accountExportDownloaded: "Readable account export downloaded.",
|
||||
accountExportFailed: "Account export failed.",
|
||||
accountExportRecentSignIn: "For your security, a complete account export requires a sign-in from the last 15 minutes. Sign out and sign in again if requested.",
|
||||
backupEncryptedTitle: "Encrypted application backup",
|
||||
backupBody: "Download an application-key-encrypted operational backup. Use the readable export above for personal data portability.",
|
||||
backupPreparing: "Preparing...",
|
||||
backupDownload: "Download encrypted backup",
|
||||
backupDownloaded: "Backup downloaded.",
|
||||
@@ -1962,7 +1969,14 @@ export const translations = {
|
||||
signedInAs: "Logget inn som {name}.",
|
||||
unlinkGoogle: "Koble fra Google",
|
||||
backupTitle: "Datasikkerhet",
|
||||
backupBody: "Kryptert sikkerhetskopi av gjeldende data med ett klikk.",
|
||||
accountExportBody: "Last ned en lesbar ZIP med kontodata og egne filer, med kontrollsummer og tydelige unntak.",
|
||||
accountExportPreparing: "Forbereder kontoeksport...",
|
||||
accountExportDownload: "Last ned lesbar kontoeksport",
|
||||
accountExportDownloaded: "Lesbar kontoeksport lastet ned.",
|
||||
accountExportFailed: "Kontoeksport mislyktes.",
|
||||
accountExportRecentSignIn: "Av sikkerhetsgrunner krever en full kontoeksport at du logget inn de siste 15 minuttene. Logg ut og inn igjen hvis du blir bedt om det.",
|
||||
backupEncryptedTitle: "Kryptert systemsikkerhetskopi",
|
||||
backupBody: "Last ned en operativ sikkerhetskopi kryptert med applikasjonsnøkkelen. Bruk den lesbare eksporten over for dataportabilitet.",
|
||||
backupPreparing: "Forbereder...",
|
||||
backupDownload: "Last ned kryptert sikkerhetskopi",
|
||||
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
||||
|
||||
Reference in New Issue
Block a user