1. Wrap anonymizeUserAccount in a DB transaction so partial failures (e.g. session delete succeeds but user update fails) can't leave accounts in an inconsistent state 2. Escape user email and notes with escapeHtml() before interpolating into the data request email body 3. Reset AlertDialog confirmed state when dialog is dismissed via backdrop click or Escape key (onOpenChange handler) 4. Extract accounts DB query to app/models/account.ts (findLinkedAccountsByUserId) to comply with the "always query through app/models/" convention 5. Replace dynamic imports() in action handlers with static top-level imports 6. Remove the unused hard-delete deleteUser() function 7. Add lastDataRequestAt timestamp to users (migration 0101) and enforce a 30-day server-side cooldown on data export requests 8. Replace fragile actionData type casts with a proper ActionData discriminated union; narrowing now works without `as` assertions 9. Strengthen tests: verify which schema tables are passed to delete() and that operations run inside the transaction https://claude.ai/code/session_017Hvmof82Xr3UwKFc3pnC4X
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import { Form } from "react-router";
|
|
import { useState } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Textarea } from "~/components/ui/textarea";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
AlertDialogCancel,
|
|
} from "~/components/ui/alert-dialog";
|
|
|
|
type Props = {
|
|
userEmail: string;
|
|
dataRequestCooldownUntil: Date | null;
|
|
dataRequestSuccess?: boolean;
|
|
dataRequestError?: string;
|
|
};
|
|
|
|
export function PrivacySection({
|
|
userEmail,
|
|
dataRequestCooldownUntil,
|
|
dataRequestSuccess,
|
|
dataRequestError,
|
|
}: Props) {
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
|
|
const handleDialogOpenChange = (open: boolean) => {
|
|
setDialogOpen(open);
|
|
if (!open) setConfirmed(false);
|
|
};
|
|
|
|
const inCooldown = dataRequestCooldownUntil != null && dataRequestCooldownUntil > new Date();
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Data & Privacy</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Request a copy of your data or permanently delete your account.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Data Request */}
|
|
<div className="rounded-lg border p-5 space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Request My Data</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Under GDPR and CCPA you have the right to a copy of all personal data
|
|
we hold about you. We will respond within 30 days to{" "}
|
|
<span className="font-medium">{userEmail}</span>.
|
|
</p>
|
|
</div>
|
|
|
|
{dataRequestSuccess && (
|
|
<p className="text-sm text-green-500">
|
|
Request received. We'll email you within 30 days.
|
|
</p>
|
|
)}
|
|
{dataRequestError && (
|
|
<p className="text-sm text-destructive">{dataRequestError}</p>
|
|
)}
|
|
|
|
{inCooldown && !dataRequestSuccess && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Your data request is being processed. You can submit another request
|
|
after {dataRequestCooldownUntil!.toLocaleDateString()}.
|
|
</p>
|
|
)}
|
|
|
|
{!dataRequestSuccess && !inCooldown && (
|
|
<Form method="post" className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dataRequestNotes">
|
|
Additional notes{" "}
|
|
<span className="text-muted-foreground font-normal">(optional)</span>
|
|
</Label>
|
|
<Textarea
|
|
id="dataRequestNotes"
|
|
name="dataRequestNotes"
|
|
placeholder="e.g. specific data types you are interested in"
|
|
rows={3}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
name="intent"
|
|
value="request-data"
|
|
variant="outline"
|
|
>
|
|
Submit Data Request
|
|
</Button>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
|
|
{/* Danger Zone */}
|
|
<div className="rounded-lg border border-destructive/40 p-5 space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-destructive">Delete Account</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Permanently remove your personal information from Brackt. Your leagues and
|
|
draft history will be preserved but your name, email, and avatar will be
|
|
wiped. This action cannot be undone.
|
|
</p>
|
|
</div>
|
|
|
|
<AlertDialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" size="sm">
|
|
Delete Account
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Your email, display name, username, and avatar will be permanently
|
|
erased. Your leagues and draft history remain intact but will show{" "}
|
|
<span className="font-medium">Deleted User</span> in your place. You
|
|
will be signed out immediately.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
|
<input
|
|
type="checkbox"
|
|
id="confirm-delete"
|
|
checked={confirmed}
|
|
onChange={(e) => setConfirmed(e.target.checked)}
|
|
className="mt-0.5 h-4 w-4 shrink-0"
|
|
/>
|
|
<Label htmlFor="confirm-delete" className="text-sm leading-snug cursor-pointer">
|
|
I understand this is permanent and cannot be undone
|
|
</Label>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<Form method="post">
|
|
<Button
|
|
type="submit"
|
|
name="intent"
|
|
value="delete-account"
|
|
variant="destructive"
|
|
disabled={!confirmed}
|
|
>
|
|
Delete My Account
|
|
</Button>
|
|
</Form>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|