feat(settings): complete privacy usage and data recovery presentation

This commit is contained in:
cesnimda
2026-10-20 09:58:46 +02:00
parent 36fc5d54e7
commit 8af9168014
18 changed files with 1757 additions and 74 deletions
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, FormControlLabel, Skeleton, Switch, Typography } from "@mui/material";
import { PageSection } from "../layout/PageLayout";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { useToast } from "../toast";
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
type AiSettings = {
enabled: boolean;
@@ -17,19 +18,47 @@ export default function AiPrivacySettingsCard() {
const { t } = useI18n();
const { toast } = useToast();
const [settings, setSettings] = useState<AiSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<AiSettings | null>(null);
const [failed, setFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [readAttempt, setReadAttempt] = useState(0);
const [loading, setLoading] = useState(true);
const session = useRef({ mounted: true, generation: 0, saving: false });
const currentTranslation = useRef(t);
const body = useRef<HTMLDivElement>(null);
const retryFocus = useRef<HTMLElement | null>(null);
const dirty = Boolean(settings && savedSettings && (settings.enabled !== savedSettings.enabled || settings.externalProcessingAllowed !== savedSettings.externalProcessingAllowed));
useEffect(() => { currentTranslation.current = t; }, [t]);
useEffect(() => {
if (!loading && settings && retryFocus.current) {
if (document.activeElement === document.body || document.activeElement === retryFocus.current) body.current?.focus();
retryFocus.current = null;
}
}, [loading, settings]);
useEffect(() => {
let active = true;
const owner = session.current;
owner.mounted = true;
const generation = ++owner.generation;
const context = getSessionContext();
const active = () => owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
api.get<AiSettings>("/ai/settings")
.then((response) => { if (active) setSettings(response.data); })
.catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, []);
.then((response) => { if (active()) { setSettings(response.data); setSavedSettings(response.data); } })
.catch(() => { if (active()) setFailed(true); })
.finally(() => { if (active()) setLoading(false); });
return () => { owner.mounted = false; owner.generation += 1; };
}, [readAttempt]);
const save = async () => {
if (!settings) return;
checkSessionCookie();
const owner = session.current;
if (!settings || !owner.mounted || owner.saving || isSessionInvalidated()) return;
const submitted = settings;
const context = getSessionContext();
const generation = owner.generation;
const active = () => owner.mounted && owner.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
owner.saving = true;
if (body.current?.contains(document.activeElement)) body.current.focus();
setSaving(true);
setFailed(false);
try {
@@ -37,19 +66,21 @@ export default function AiPrivacySettingsCard() {
enabled: settings.enabled,
externalProcessingAllowed: settings.externalProcessingAllowed,
});
setSettings(response.data);
toast(t("settingsAiSaved"), "success");
if (!active()) return;
setSavedSettings(response.data);
setSettings(current => current === submitted || !current ? response.data : { ...response.data, enabled: current.enabled, externalProcessingAllowed: current.externalProcessingAllowed });
toast(currentTranslation.current("settingsAiSaved"), "success");
} catch {
setFailed(true);
if (active()) setFailed(true);
} finally {
setSaving(false);
if (active()) { owner.saving = false; setSaving(false); }
}
};
return (
<PageSection surface title={t("settingsAiPrivacyTitle")} description={t("settingsAiPrivacyBody")}>
{failed ? <Alert severity="error" sx={{ mb: 1.5 }}>{t("settingsAiPrivacyUnavailable")}</Alert> : null}
{!settings ? <Skeleton variant="rounded" height={96} /> : <Box sx={{ display: "grid", gap: 1 }}>
{failed ? <Alert severity="error" sx={{ mb: 1.5 }} action={!settings ? <Button color="inherit" disabled={loading} onClick={() => { retryFocus.current = document.activeElement as HTMLElement; setFailed(false); setLoading(true); setReadAttempt(value => value + 1); }}>{t("retry")}</Button> : undefined}>{t(settings ? "settingsAiSaveFailed" : "settingsAiPrivacyUnavailable")}</Alert> : null}
{!settings ? loading ? <Box role="status" aria-label={t("loading")}><Skeleton variant="rounded" height={96} /></Box> : null : <Box ref={body} role="group" aria-label={t("settingsAiPrivacyTitle")} aria-busy={saving} tabIndex={-1} sx={{ display: "grid", gap: 1 }}>
<FormControlLabel
control={<Switch checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} />}
label={t("settingsAiEnabled")}
@@ -67,7 +98,10 @@ export default function AiPrivacySettingsCard() {
? t("settingsAiExternalAvailable")
: t("settingsAiExternalLocalOnly")}
</Typography>
<Box><Button variant="contained" disabled={saving} onClick={() => void save()}>{t("settingsAiSave")}</Button></Box>
{dirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}><Button variant="contained" disabled={saving} onClick={() => void save()}>{t(saving ? "saving" : "settingsAiSave")}</Button>
{dirty ? <Button disabled={saving} onClick={() => { body.current?.focus(); setSettings(savedSettings); setFailed(false); }}>{t("assetsDiscardChanges")}</Button> : null}
</Box>
</Box>}
</PageSection>
);
+62 -23
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, LinearProgress, Skeleton, Typography } from "@mui/material";
import { PageSection } from "../layout/PageLayout";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { checkSessionCookie, getSessionContext, isSessionInvalidated } from "../sessionBoundary";
type Usage = {
currentMonth: { calls: number; estimatedTokens: number };
@@ -15,73 +16,111 @@ type Usage = {
type BillingStatus = { enabled: boolean; canCheckout: boolean; canManage: boolean };
function formatBytes(bytes: number) {
if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`;
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
function formatBytes(bytes: number, locale: string) {
if (bytes < 1_000_000) return `${Math.round(bytes / 1_000).toLocaleString(locale)} KB`;
const options = { minimumFractionDigits: 1, maximumFractionDigits: 1 };
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toLocaleString(locale, options)} MB`;
return `${(bytes / 1_000_000_000).toLocaleString(locale, options)} GB`;
}
export default function AiUsageCard() {
const { t } = useI18n();
const { t, language } = useI18n();
const locale = language === "nb" ? "nb-NO" : "en";
const [usage, setUsage] = useState<Usage | null>(null);
const [billing, setBilling] = useState<BillingStatus | null>(null);
const [billingBusy, setBillingBusy] = useState(false);
const [billingFailed, setBillingFailed] = useState(false);
const [failed, setFailed] = useState(false);
const [billingReadFailed, setBillingReadFailed] = useState(false);
const [usageAttempt, setUsageAttempt] = useState(0);
const [billingAttempt, setBillingAttempt] = useState(0);
const owner = useRef({ mounted: false, generation: 0, billingBusy: false });
const body = useRef<HTMLDivElement>(null);
useEffect(() => {
const lifetime = owner.current;
lifetime.mounted = true;
lifetime.generation += 1;
return () => { lifetime.mounted = false; lifetime.generation += 1; };
}, []);
useEffect(() => {
let active = true;
const context = getSessionContext();
const current = () => active && !isSessionInvalidated() && getSessionContext() === context;
api.get<Usage>("/ai/usage")
.then((response) => { if (active) setUsage(response.data); })
.catch(() => { if (active) setFailed(true); });
api.get<BillingStatus>("/billing/status")
.then((response) => { if (active) setBilling(response.data); })
.catch(() => { /* Billing is optional; usage remains available. */ });
.then((response) => { if (current()) setUsage(response.data); })
.catch(() => { if (current()) setFailed(true); });
return () => { active = false; };
}, []);
}, [usageAttempt]);
useEffect(() => {
let active = true;
const context = getSessionContext();
const current = () => active && !isSessionInvalidated() && getSessionContext() === context;
api.get<BillingStatus>("/billing/status")
.then((response) => { if (current()) setBilling(response.data); })
.catch(() => { if (current()) setBillingReadFailed(true); });
return () => { active = false; };
}, [billingAttempt]);
const openBilling = async (path: "checkout" | "portal") => {
checkSessionCookie();
const lifetime = owner.current;
if (!lifetime.mounted || lifetime.billingBusy || isSessionInvalidated()) return;
const generation = lifetime.generation;
const context = getSessionContext();
const active = () => lifetime.mounted && lifetime.generation === generation && !isSessionInvalidated() && getSessionContext() === context;
lifetime.billingBusy = true;
setBillingBusy(true);
setBillingFailed(false);
try {
const response = await api.post<{ url: string }>(`/billing/${path}`);
checkSessionCookie();
if (!active()) return;
window.location.assign(response.data.url);
} catch {
checkSessionCookie();
if (!active()) return;
setBillingFailed(true);
setBillingBusy(false);
lifetime.billingBusy = false;
}
};
if (failed) return <Alert severity="warning">{t("settingsUsageUnavailable")}</Alert>;
if (!usage) return <Skeleton variant="rounded" height={150} />;
const callsPercent = usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0;
const tokensPercent = usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0;
const storagePercent = Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100);
const callsPercent = usage && usage.monthlyCallLimit > 0 ? Math.min(100, usage.currentMonth.calls / usage.monthlyCallLimit * 100) : 0;
const tokensPercent = usage && usage.monthlyTokenLimit > 0 ? Math.min(100, usage.currentMonth.estimatedTokens / usage.monthlyTokenLimit * 100) : 0;
const storagePercent = usage && usage.storageLimitBytes > 0 ? Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100) : 0;
return (
<PageSection surface title={t("settingsUsageTitle")} actions={<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography>}>
<PageSection surface title={t("settingsUsageTitle")} actions={usage ? <Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography> : undefined}>
<Box ref={body} role="group" aria-label={t("settingsUsageTitle")} tabIndex={-1}>
{failed ? <Alert severity="warning" action={<Button color="inherit" onClick={() => { if (body.current?.contains(document.activeElement)) body.current.focus(); setFailed(false); setUsageAttempt(value => value + 1); }}>{t("retry")}</Button>}>{t("settingsUsageUnavailable")}</Alert> : !usage ? <Box role="status" aria-label={t("loading")}><Skeleton variant="rounded" height={150} /></Box> : null}
{usage ? <>
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}>
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })}</Typography>
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(locale), limit: usage.monthlyCallLimit.toLocaleString(locale) })}</Typography>
<LinearProgress variant="determinate" value={callsPercent} aria-label={t("settingsUsageGenerationsProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Box>
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(), limit: usage.monthlyTokenLimit.toLocaleString() })}</Typography>
<Typography variant="body2">{t("settingsUsageTokens", { used: usage.currentMonth.estimatedTokens.toLocaleString(locale), limit: usage.monthlyTokenLimit.toLocaleString(locale) })}</Typography>
<LinearProgress variant="determinate" value={tokensPercent} aria-label={t("settingsUsageTokensProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box></>}
<Box sx={{ mt: 2 }}>
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes), limit: formatBytes(usage.storageLimitBytes) })}</Typography>
<Typography variant="body2">{t("settingsUsageStorage", { used: formatBytes(usage.storageUsedBytes, locale), limit: formatBytes(usage.storageLimitBytes, locale) })}</Typography>
<LinearProgress variant="determinate" value={storagePercent} aria-label={t("settingsUsageStorageProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsUsageReset")}</Typography>
</> : null}
{billingReadFailed ? <Alert severity="warning" sx={{ mt: 2 }} action={<Button color="inherit" onClick={() => { if (body.current?.contains(document.activeElement)) body.current.focus(); setBillingReadFailed(false); setBillingAttempt(value => value + 1); }}>{t("retry")}</Button>}>{t("settingsBillingUnavailable")}</Alert> : !billing ? <Box role="status" aria-label={t("loading")}><Skeleton variant="rounded" height={36} /></Box> : null}
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
{billing?.canManage ? <Button sx={{ mt: 2 }} variant="outlined" disabled={billingBusy} onClick={() => void openBilling("portal")}>{t("settingsBillingManage")}</Button> : null}
{billingFailed ? <Alert severity="error" sx={{ mt: 2 }}>{t("settingsBillingUnavailable")}</Alert> : null}
{usage.plan === "free" && billing?.enabled === false ? (
{usage?.plan === "free" && billing?.enabled === false ? (
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsBillingNotConfigured")}
</Typography>
) : null}
</Box>
</PageSection>
);
}
+63 -27
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Divider, Typography } from "@mui/material";
import { PageSection } from "../layout/PageLayout";
@@ -7,6 +7,7 @@ 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;
@@ -23,14 +24,37 @@ export default function BackupCard() {
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) setLifecycle(response.data); })
.catch(() => { if (active) setLifecycle(null); });
.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);
@@ -45,63 +69,74 @@ export default function BackupCard() {
};
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(t("accountExportDownloaded"), "success");
toast(currentTranslation.current("accountExportDownloaded"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("accountExportFailed")), "error");
if (active()) toast(getApiErrorMessage(error, currentTranslation.current("accountExportFailed")), "error");
} finally {
setExportingAccount(false);
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(t("backupDownloaded"), "success");
toast(currentTranslation.current("backupDownloaded"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("backupFailed")), "error");
if (active()) toast(getApiErrorMessage(error, currentTranslation.current("backupFailed")), "error");
} finally {
setDownloading(false);
session.current.busy=false;
if (active()) 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;
}
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) {
toast(getApiErrorMessage(error, t("accountDeleteFailed")), "error");
setDeletingAccount(false);
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}>
<Button variant="contained" onClick={downloadAccountExport} disabled={exportingAccount || downloading || deletingAccount} aria-busy={exportingAccount}>
{exportingAccount ? t("accountExportPreparing") : t("accountExportDownload")}
</Button>
</Box>
@@ -112,24 +147,25 @@ export default function BackupCard() {
{t("backupBody")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={downloadEncrypted} disabled={downloading || exportingAccount}>
<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>
{lifecycle?.deletionStatus === "pending" ? (
{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}>
<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>
);
}