feat(settings): complete privacy usage and data recovery presentation
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import AiPrivacySettingsCard from "./components/AiPrivacySettingsCard";
|
||||
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { api } from "./api";
|
||||
|
||||
const mockToast = jest.fn();
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn() } }));
|
||||
jest.mock("./toast", () => ({ useToast: () => ({ toast: mockToast }), useOptionalToast: () => null }));
|
||||
const saved = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: true, effectiveExternalProcessing: false, provider: "local" };
|
||||
function deferred<T>() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise<T>((yes, no) => { resolve=yes; reject=no; }); return { promise, resolve, reject }; }
|
||||
function Locale() { const { setLanguage }=useI18n(); return <button onClick={() => void setLanguage("nb")}>Bokmål</button>; }
|
||||
function mount() { return render(<I18nProvider><Locale /><AiPrivacySettingsCard /></I18nProvider>); }
|
||||
beforeEach(() => { jest.clearAllMocks(); window.localStorage.clear(); (api.get as jest.Mock).mockResolvedValue({ data: saved }); });
|
||||
|
||||
test("[P7-09 privacy] failed initial read has a read-only Retry and no perpetual loading", async () => {
|
||||
(api.get as jest.Mock).mockRejectedValueOnce(new Error("offline")); mount();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.retry }));
|
||||
expect(await screen.findByRole("switch", { name: translations.en.settingsAiEnabled })).toBeChecked();
|
||||
await waitFor(() => expect(screen.getByRole("group", { name: translations.en.settingsAiPrivacyTitle })).toHaveFocus());
|
||||
expect(api.get).toHaveBeenCalledTimes(2); expect(api.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-09 privacy] submitted acknowledgement preserves a newer edit and serializes Save", async () => {
|
||||
const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); mount();
|
||||
const enabled=await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
fireEvent.click(enabled); const save=screen.getByRole("button", { name: translations.en.settingsAiSave });
|
||||
fireEvent.click(save); fireEvent.click(save);
|
||||
expect(api.put).toHaveBeenCalledTimes(1);
|
||||
expect(api.put).toHaveBeenCalledWith("/ai/settings", { enabled: false, externalProcessingAllowed: false });
|
||||
fireEvent.click(enabled);
|
||||
await act(async () => held.resolve({ data: { ...saved, enabled: false } }));
|
||||
expect(enabled).toBeChecked(); expect(screen.getByText(translations.en.assetsUnsavedChanges)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P7-09 privacy] a failed save retains the draft and never replays through read recovery", async () => {
|
||||
(api.put as jest.Mock).mockRejectedValue(new Error("offline")); mount();
|
||||
const enabled=await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); fireEvent.click(enabled);
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave })); await screen.findByRole("alert");
|
||||
expect(enabled).not.toBeChecked(); expect(api.get).toHaveBeenCalledTimes(1); expect(api.put).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText(translations.en.assetsUnsavedChanges)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.assetsDiscardChanges }));
|
||||
expect(enabled).toBeChecked(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(api.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[P7-09 privacy] unmount ignores an old save acknowledgement and success notification", async () => {
|
||||
const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); const view=mount();
|
||||
await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave })); view.unmount();
|
||||
await act(async () => held.resolve({ data: saved })); expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-09 privacy] pending save uses the current language without refetching the draft", async () => {
|
||||
const held=deferred<{ data: typeof saved }>(); (api.put as jest.Mock).mockReturnValue(held.promise); mount();
|
||||
await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); fireEvent.click(screen.getByRole("button", { name: translations.en.settingsAiSave }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Bokmål" }));
|
||||
await screen.findByRole("switch", { name: translations.nb.settingsAiEnabled });
|
||||
await act(async () => held.resolve({ data: saved }));
|
||||
await waitFor(() => expect(mockToast).toHaveBeenCalledWith(translations.nb.settingsAiSaved, "success")); expect(api.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[P7-09 privacy] local-only capability permits revoking a historical external consent", async () => {
|
||||
(api.get as jest.Mock).mockResolvedValue({ data: { ...saved, externalProcessingAvailable: false, externalProcessingAllowed: true } }); mount();
|
||||
const external=await screen.findByRole("switch", { name: translations.en.settingsAiExternalAllowed }); expect(external).toBeEnabled(); fireEvent.click(external); expect(external).not.toBeChecked(); expect(external).toBeDisabled();
|
||||
expect(api.put).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import AiUsageCard from "./components/AiUsageCard";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() } }));
|
||||
jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => {
|
||||
let mockLanguage = "en";
|
||||
let mockSession = "one";
|
||||
jest.mock("./sessionBoundary", () => ({ checkSessionCookie: jest.fn(), getSessionContext: () => mockSession, isSessionInvalidated: () => false }));
|
||||
jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ language: mockLanguage, t: (key: string, params?: Record<string, unknown>) => {
|
||||
const messages: Record<string, string> = {
|
||||
settingsUsageTitle: "Account usage",
|
||||
settingsUsageUnavailable: "Account usage is temporarily unavailable.",
|
||||
@@ -16,12 +19,103 @@ jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, par
|
||||
settingsUsageReset: "AI limits reset at the start of each calendar month.",
|
||||
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
|
||||
settingsBillingUpgrade: "Upgrade to Pro",
|
||||
settingsBillingUnavailable: "Billing is temporarily unavailable.",
|
||||
settingsUsageStorageProgress: "Attachment storage used",
|
||||
loading: "Loading",
|
||||
retry: "Retry",
|
||||
settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.",
|
||||
};
|
||||
return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key);
|
||||
} }) }));
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
beforeEach(() => { jest.clearAllMocks(); mockLanguage = "en"; mockSession = "one"; });
|
||||
|
||||
const usage = { currentMonth: { calls: 1234, estimatedTokens: 12000 }, plan: "pro", monthlyCallLimit: 2000, monthlyTokenLimit: 40000, storageUsedBytes: 1250000, storageLimitBytes: 250000000 };
|
||||
const billing = { enabled: true, canCheckout: true, canManage: false };
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
||||
|
||||
test("keeps its heading and named loading state, then retries only a failed usage GET", async () => {
|
||||
const held = deferred<unknown>();
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.resolve({ data: billing }) : held.promise);
|
||||
render(<AiUsageCard />);
|
||||
expect(screen.getByRole("heading", { name: "Account usage" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
|
||||
await act(async () => { held.resolve(Promise.reject(new Error("offline"))); });
|
||||
expect(await screen.findByText("Account usage is temporarily unavailable.")).toBeInTheDocument();
|
||||
(api.get as jest.Mock).mockResolvedValue({ data: usage });
|
||||
const retry = screen.getByRole("button", { name: "Retry" });
|
||||
act(() => retry.focus());
|
||||
fireEvent.click(retry);
|
||||
expect(screen.getByRole("group", { name: "Account usage" })).toHaveFocus();
|
||||
expect(await screen.findByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument();
|
||||
expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/ai/usage"]);
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("billing failure remains visible beside usage and its Retry only reads billing", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => url === "/billing/status" ? Promise.reject(new Error("offline")) : Promise.resolve({ data: usage }));
|
||||
render(<AiUsageCard />);
|
||||
expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.3 MB of 250.0 MB attachment storage")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not available in this deployment/)).not.toBeInTheDocument();
|
||||
(api.get as jest.Mock).mockResolvedValue({ data: billing });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument();
|
||||
expect((api.get as jest.Mock).mock.calls.map(([url]) => url)).toEqual(["/ai/usage", "/billing/status", "/billing/status"]);
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("locale changes format numbers and byte decimals without reloading; zero storage limit stays finite", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : { ...usage, storageLimitBytes: 0 } }));
|
||||
const view = render(<AiUsageCard />);
|
||||
expect(await screen.findByText("1,234 of 2,000 generations this month")).toBeInTheDocument();
|
||||
mockLanguage = "nb";
|
||||
view.rerender(<AiUsageCard />);
|
||||
expect(screen.getByText((_, element) => element?.tagName === "P" && element.textContent === `${(1234).toLocaleString("nb-NO")} of ${(2000).toLocaleString("nb-NO")} generations this month`)).toBeInTheDocument();
|
||||
expect(screen.getByText("1,3 MB of 0 KB attachment storage")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar", { name: "Attachment storage used" })).toHaveAttribute("aria-valuenow", "0");
|
||||
expect(api.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each(["unmount", "session"])("one billing POST ignores a late failure after %s", async boundary => {
|
||||
const held = deferred<unknown>();
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({ data: url === "/billing/status" ? billing : usage }));
|
||||
(api.post as jest.Mock).mockReturnValue(held.promise);
|
||||
const view = render(<AiUsageCard />);
|
||||
const button = await screen.findByRole("button", { name: "Upgrade to Pro" });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
if (boundary === "unmount") view.unmount(); else mockSession = "two";
|
||||
await act(async () => { held.resolve(Promise.reject(new Error("offline"))); });
|
||||
expect(screen.queryByText("Billing is temporarily unavailable.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(["unmount", "session"])("late billing success after %s never consumes the redirect URL", async boundary => {
|
||||
const held = deferred<unknown>();
|
||||
const url = jest.fn(() => "https://billing.example/checkout");
|
||||
(api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? billing : usage }));
|
||||
(api.post as jest.Mock).mockReturnValue(held.promise);
|
||||
const view = render(<AiUsageCard />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Upgrade to Pro" }));
|
||||
if (boundary === "unmount") view.unmount(); else mockSession = "two";
|
||||
await act(async () => { held.resolve({ data: { get url() { return url(); } } }); });
|
||||
expect(url).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a failed billing POST offers the existing explicit action without automatic replay", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((path: string) => Promise.resolve({ data: path === "/billing/status" ? { ...billing, canCheckout: false, canManage: true } : usage }));
|
||||
(api.post as jest.Mock).mockRejectedValue(new Error("offline"));
|
||||
render(<AiUsageCard />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "settingsBillingManage" }));
|
||||
expect(await screen.findByText("Billing is temporarily unavailable.")).toBeInTheDocument();
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
expect(api.post).toHaveBeenCalledWith("/billing/portal");
|
||||
expect(api.get).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "settingsBillingManage" }));
|
||||
await screen.findByText("Billing is temporarily unavailable.");
|
||||
expect(api.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("shows monthly AI call and token limits", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import BackupCard from "./components/BackupCard";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { api } from "./api";
|
||||
|
||||
const mockToast = jest.fn(); const mockPrompt = jest.fn();
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback }));
|
||||
jest.mock("./toast", () => ({ useToast: () => ({ toast: mockToast }), useOptionalToast: () => null }));
|
||||
jest.mock("./prompt", () => ({ usePrompt: () => ({ prompt: mockPrompt }) }));
|
||||
const saved={ deletionEnabled: false, deletionStatus: "active", requiredConfirmation: "DELETE owner@example.test", request: null };
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise=new Promise<T>(yes => { resolve=yes; }); return { promise, resolve }; }
|
||||
function mount() { return render(<I18nProvider><BackupCard /></I18nProvider>); }
|
||||
beforeEach(() => { jest.clearAllMocks(); window.localStorage.clear(); (api.get as jest.Mock).mockResolvedValue({ data: saved }); (api.post as jest.Mock).mockReset().mockRejectedValue(new Error('Unexpected mutation')); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test') }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }); jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); });
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
test("[P7-09 backup] unknown lifecycle never claims disabled and Retry only reads", async () => {
|
||||
(api.get as jest.Mock).mockRejectedValueOnce(new Error("offline")); mount();
|
||||
const retry=await screen.findByRole('button', { name: translations.en.retry });
|
||||
expect(screen.queryByText(translations.en.accountDeleteDisabled)).not.toBeInTheDocument();
|
||||
retry.focus(); fireEvent.click(retry); await screen.findByText(translations.en.accountDeleteDisabled);
|
||||
expect(screen.getByRole('group', { name: translations.en.backupTitle })).toHaveFocus();
|
||||
expect(api.get).toHaveBeenCalledTimes(2); expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-09 backup] synchronous duplicate and competing downloads admit only one request", async () => {
|
||||
const held=deferred<unknown>(); (api.post as jest.Mock).mockReturnValue(held.promise); mount();
|
||||
await screen.findByText(translations.en.accountDeleteDisabled);
|
||||
const readable=screen.getByRole('button', { name: translations.en.accountExportDownload }); const encrypted=screen.getByRole('button', { name: translations.en.backupDownload });
|
||||
act(() => { readable.click(); readable.click(); encrypted.click(); });
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
await act(async () => held.resolve({ data: new Blob(['zip']), headers: {} }));
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[P7-09 backup] disposal rejects a late readable export without downloading private bytes or notifying", async () => {
|
||||
const held=deferred<unknown>(); (api.post as jest.Mock).mockReturnValue(held.promise); const view=mount();
|
||||
fireEvent.click(screen.getByRole('button', { name: translations.en.accountExportDownload })); view.unmount();
|
||||
await act(async () => held.resolve({ data: new Blob(['private zip']), headers: {} }));
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P7-09 backup] disposal during the existing deletion prompt cannot issue a later delete", async () => {
|
||||
(api.get as jest.Mock).mockResolvedValue({ data: { ...saved, deletionEnabled: true } });
|
||||
const held=deferred<string>(); mockPrompt.mockReturnValue(held.promise); const view=mount();
|
||||
fireEvent.click(await screen.findByRole('button', { name: translations.en.accountDeleteButton })); view.unmount();
|
||||
await act(async () => held.resolve(saved.requiredConfirmation)); expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1238,6 +1238,7 @@ export const translations = {
|
||||
settingsAiExternalLocalOnly: "This deployment is local-only. External processing cannot be enabled.",
|
||||
settingsAiSave: "Save AI privacy settings",
|
||||
settingsAiSaved: "AI privacy settings saved.",
|
||||
settingsAiSaveFailed: "AI privacy settings could not be saved. Your changes are retained; review them before saving again.",
|
||||
settingsUsageTitle: "Account usage",
|
||||
settingsUsageUnavailable: "Account usage is temporarily unavailable.",
|
||||
settingsUsagePlan: "{plan} plan",
|
||||
@@ -2287,6 +2288,7 @@ export const translations = {
|
||||
accountDeleteFailed: "Account deletion could not be started.",
|
||||
accountDeletePending: "Account deletion is in progress ({stage}). Access has already been disabled.",
|
||||
accountDeleteDisabled: "Account deletion is not enabled yet. The deletion workflow remains safely off until retention, restore, and production cache-purge safeguards are approved.",
|
||||
accountLifecycleUnavailable: "Account deletion availability could not be loaded. Retry checks availability only.",
|
||||
authStatusTitle: "Authentication",
|
||||
authStatusNotSignedIn: "Not signed in.",
|
||||
authStatusRoles: "Roles: {roles}",
|
||||
@@ -4008,6 +4010,7 @@ export const translations = {
|
||||
settingsAiExternalLocalOnly: "Denne installasjonen bruker bare lokal KI. Ekstern behandling kan ikke aktiveres.",
|
||||
settingsAiSave: "Lagre KI-personvern",
|
||||
settingsAiSaved: "Innstillinger for KI-personvern er lagret.",
|
||||
settingsAiSaveFailed: "Kunne ikke lagre KI-personverninnstillingene. Endringene er beholdt; se gjennom dem før du lagrer på nytt.",
|
||||
settingsUsageTitle: "Kontobruk",
|
||||
settingsUsageUnavailable: "Kontobruk er midlertidig utilgjengelig.",
|
||||
settingsUsagePlan: "{plan}-abonnement",
|
||||
@@ -5057,6 +5060,7 @@ export const translations = {
|
||||
accountDeleteFailed: "Kontosletting kunne ikke startes.",
|
||||
accountDeletePending: "Kontosletting pågår ({stage}). Tilgangen er allerede deaktivert.",
|
||||
accountDeleteDisabled: "Kontosletting er ikke aktivert ennå. Arbeidsflyten forblir trygt avslått til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.",
|
||||
accountLifecycleUnavailable: "Kunne ikke hente status for kontosletting. Prøv igjen for å kontrollere tilgjengeligheten.",
|
||||
authStatusTitle: "Autentisering",
|
||||
authStatusNotSignedIn: "Ikke logget inn.",
|
||||
authStatusRoles: "Roller: {roles}",
|
||||
|
||||
Reference in New Issue
Block a user