Add GDPR privacy controls UI
This commit is contained in:
parent
c1ff8a44b8
commit
3ed58738b1
6
app/api/auth/me/data-export/route.ts
Normal file
6
app/api/auth/me/data-export/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/me/data-export");
|
||||||
|
}
|
||||||
6
app/api/auth/me/privacy-summary/route.ts
Normal file
6
app/api/auth/me/privacy-summary/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/me/privacy-summary");
|
||||||
|
}
|
||||||
@ -20,9 +20,27 @@ type ProfileData = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PrivacySummary = {
|
||||||
|
subject: {
|
||||||
|
email: string;
|
||||||
|
createdAt: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
dataCategories: Record<string, number>;
|
||||||
|
controls: {
|
||||||
|
exportEndpoint: string;
|
||||||
|
deletionEndpoint: string;
|
||||||
|
excludedFromExport?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
const [privacySummary, setPrivacySummary] = useState<PrivacySummary | null>(null);
|
||||||
|
const [privacyStatus, setPrivacyStatus] = useState("");
|
||||||
|
const [privacyError, setPrivacyError] = useState(false);
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
@ -52,6 +70,14 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
apiFetch<PrivacySummary>("/api/auth/me/privacy-summary")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setPrivacySummary(res.data as PrivacySummary);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
@ -80,12 +106,49 @@ export default function ProfilePage() {
|
|||||||
setStatus("Profile saved successfully.");
|
setStatus("Profile saved successfully.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadPersonalData = async () => {
|
||||||
|
setPrivacyStatus("Preparing personal data export...");
|
||||||
|
setPrivacyError(false);
|
||||||
|
const res = await apiFetch<Record<string, unknown>>("/api/auth/me/data-export");
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setPrivacyStatus(res.error?.message ?? "Personal data export failed.");
|
||||||
|
setPrivacyError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `ledgerone-personal-data-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setPrivacyStatus("Personal data export downloaded.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
const confirmed = window.confirm("Delete this LedgerOne account and associated personal data? This cannot be undone.");
|
||||||
|
if (!confirmed) return;
|
||||||
|
setPrivacyStatus("Deleting account...");
|
||||||
|
setPrivacyError(false);
|
||||||
|
const res = await apiFetch<{ message: string }>("/api/auth/me", { method: "DELETE" });
|
||||||
|
if (res.error) {
|
||||||
|
setPrivacyStatus(res.error.message ?? "Account deletion failed.");
|
||||||
|
setPrivacyError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrivacyStatus("Account deleted. Redirecting to sign in...");
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
|
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
|
||||||
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
|
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
|
||||||
|
const dataCategoryEntries = privacySummary ? Object.entries(privacySummary.dataCategories) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
||||||
<div className="max-w-2xl">
|
<div className="grid max-w-4xl gap-6">
|
||||||
<div className="glass-panel p-8 rounded-2xl">
|
<div className="glass-panel p-8 rounded-2xl">
|
||||||
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
|
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@ -143,6 +206,59 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="glass-panel p-8 rounded-2xl">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Data & Privacy</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Review stored data categories, download a minimized personal data export, or erase your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{dataCategoryEntries.length ? dataCategoryEntries.map(([key, value]) => (
|
||||||
|
<div key={key} className="rounded-lg border border-border bg-background/40 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{key.replace(/([A-Z])/g, " $1")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-lg border border-border bg-background/40 p-3 text-sm text-muted-foreground sm:col-span-2 lg:col-span-3">
|
||||||
|
Privacy inventory is not available yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{privacySummary?.controls.excludedFromExport?.length ? (
|
||||||
|
<div className="mt-5 rounded-lg border border-border bg-background/40 p-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Excluded from export</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{privacySummary.controls.excludedFromExport.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadPersonalData}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Download personal data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteAccount}
|
||||||
|
className="rounded-lg border border-red-500/40 px-4 py-2.5 text-sm font-bold text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{privacyStatus && (
|
||||||
|
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${privacyError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
|
||||||
|
{privacyStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user