Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Download, ShieldAlert, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { gdprService } from "@/services/gdpr.service";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
|
|
export default function PrivacyCenter() {
|
|
const navigate = useNavigate();
|
|
const { logout } = useAuth();
|
|
const [exporting, setExporting] = useState(false);
|
|
const [erasing, setErasing] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [confirmText, setConfirmText] = useState("");
|
|
|
|
const downloadExport = async () => {
|
|
try {
|
|
setExporting(true);
|
|
const payload = await gdprService.exportMyData();
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
|
type: "application/json",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `encoach-data-export-${new Date()
|
|
.toISOString()
|
|
.slice(0, 10)}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toast.success("Data export downloaded");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const eraseAccount = async () => {
|
|
try {
|
|
setErasing(true);
|
|
await gdprService.eraseMyAccount();
|
|
toast.success("Your account has been erased. Signing out…");
|
|
setTimeout(async () => {
|
|
try {
|
|
await logout();
|
|
} catch {
|
|
// ignore — the server may have already invalidated our tokens
|
|
}
|
|
navigate("/login", { replace: true });
|
|
}, 1200);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Erasure failed");
|
|
setErasing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
<ShieldAlert className="mr-1 inline h-5 w-5" />
|
|
Privacy Center
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Manage your personal data held by EnCoach under GDPR and equivalent
|
|
regulations.
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Download your data</CardTitle>
|
|
<CardDescription>
|
|
We'll package your profile, entity memberships, exam attempts,
|
|
answers, AI feedback, and tickets into a single JSON file.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button onClick={downloadExport} disabled={exporting}>
|
|
<Download className="mr-1 h-4 w-4" />
|
|
{exporting ? "Preparing export…" : "Download my data"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-destructive/40">
|
|
<CardHeader>
|
|
<CardTitle className="text-destructive">Delete my account</CardTitle>
|
|
<CardDescription>
|
|
This anonymises your profile and removes personal fields from our
|
|
records. Exam attempts are retained (without identifying
|
|
information) for statistical integrity. This action cannot be
|
|
undone.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => setConfirmOpen(true)}
|
|
disabled={erasing}
|
|
>
|
|
<Trash2 className="mr-1 h-4 w-4" />
|
|
{erasing ? "Erasing…" : "Erase my account"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
|
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
We'll anonymise your personal data and deactivate your login.
|
|
Aggregate analytics (exam scores, attempt counts) will remain
|
|
but will no longer be linked to you.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="confirm-delete">
|
|
Type <strong>DELETE</strong> to confirm:
|
|
</Label>
|
|
<Input
|
|
id="confirm-delete"
|
|
value={confirmText}
|
|
onChange={(e) => setConfirmText(e.target.value)}
|
|
placeholder="DELETE"
|
|
/>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setConfirmText("")}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={confirmText !== "DELETE" || erasing}
|
|
onClick={async () => {
|
|
setConfirmOpen(false);
|
|
await eraseAccount();
|
|
setConfirmText("");
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Erase account
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|