e9b2436ce0
New account_service + endpoints under /users/me: - GET /me/export — zip of every owned tree (account.json + media blobs). - POST /me/import — restore a backup into NEW trees (ids remapped, media re-uploaded); non-destructive, never touches existing data. - DELETE /me — soft-delete the user, their owned trees, and revoke sessions; guarded by retyping the account email. Settings page wires all three (export download, restore upload, delete with typed-email confirmation). No migration — uses existing tables + soft-delete. 52 backend tests pass (export→restore round-trip + delete guards); frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
7.7 KiB
TypeScript
230 lines
7.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
|
||
export default function SettingsPage() {
|
||
const router = useRouter();
|
||
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||
|
||
const [current, setCurrent] = useState("");
|
||
const [next, setNext] = useState("");
|
||
const [confirm, setConfirm] = useState("");
|
||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
// Data export / restore / delete.
|
||
const [exporting, setExporting] = useState(false);
|
||
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
|
||
const [restoring, setRestoring] = useState(false);
|
||
const restoreRef = useRef<HTMLInputElement>(null);
|
||
const [deleteConfirm, setDeleteConfirm] = useState("");
|
||
const [deleting, setDeleting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||
}, []);
|
||
|
||
async function exportData() {
|
||
setExporting(true);
|
||
const resp = await fetch("/api/v1/users/me/export", { credentials: "include" });
|
||
if (resp.ok) {
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "provenance-export.zip";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
setExporting(false);
|
||
}
|
||
|
||
async function restoreData(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (restoreRef.current) restoreRef.current.value = "";
|
||
if (!file) return;
|
||
setRestoring(true);
|
||
setRestoreMsg(null);
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const resp = await fetch("/api/v1/users/me/import", {
|
||
method: "POST",
|
||
body: fd,
|
||
credentials: "include",
|
||
});
|
||
setRestoring(false);
|
||
if (resp.ok) {
|
||
const c = await resp.json();
|
||
setRestoreMsg(`Restored ${c.trees} tree(s), ${c.persons} people into new trees.`);
|
||
} else {
|
||
setRestoreMsg("That doesn't look like a valid Provenance export.");
|
||
}
|
||
}
|
||
|
||
async function deleteAccount() {
|
||
if (deleteConfirm !== me?.email) return;
|
||
setDeleting(true);
|
||
const fd = new FormData();
|
||
fd.append("confirm_email", deleteConfirm);
|
||
const resp = await fetch("/api/v1/users/me", {
|
||
method: "DELETE",
|
||
body: fd,
|
||
credentials: "include",
|
||
});
|
||
setDeleting(false);
|
||
if (resp.ok) {
|
||
await api.POST("/api/v1/auth/logout");
|
||
router.push("/register");
|
||
}
|
||
}
|
||
|
||
async function changePassword(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setMsg(null);
|
||
if (next.length < 8) {
|
||
setMsg({ kind: "err", text: "New password must be at least 8 characters." });
|
||
return;
|
||
}
|
||
if (next !== confirm) {
|
||
setMsg({ kind: "err", text: "New passwords don't match." });
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
const { error } = await api.POST("/api/v1/auth/change-password", {
|
||
body: { current_password: current, new_password: next },
|
||
});
|
||
setBusy(false);
|
||
if (error) {
|
||
setMsg({ kind: "err", text: "Current password is incorrect." });
|
||
return;
|
||
}
|
||
setCurrent("");
|
||
setNext("");
|
||
setConfirm("");
|
||
setMsg({ kind: "ok", text: "Password changed." });
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Account</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-1 text-sm">
|
||
<div>
|
||
<span className="text-[var(--muted)]">Name: </span>
|
||
{me?.display_name ?? "—"}
|
||
</div>
|
||
<div>
|
||
<span className="text-[var(--muted)]">Email: </span>
|
||
{me?.email ?? "—"}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Change password</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form onSubmit={changePassword} className="flex max-w-sm flex-col gap-3">
|
||
<Input
|
||
type="password"
|
||
placeholder="Current password"
|
||
autoComplete="current-password"
|
||
value={current}
|
||
onChange={(e) => setCurrent(e.target.value)}
|
||
/>
|
||
<Input
|
||
type="password"
|
||
placeholder="New password (min 8 chars)"
|
||
autoComplete="new-password"
|
||
value={next}
|
||
onChange={(e) => setNext(e.target.value)}
|
||
/>
|
||
<Input
|
||
type="password"
|
||
placeholder="Confirm new password"
|
||
autoComplete="new-password"
|
||
value={confirm}
|
||
onChange={(e) => setConfirm(e.target.value)}
|
||
/>
|
||
{msg && (
|
||
<p className={msg.kind === "ok" ? "text-sm text-bronze" : "text-sm text-red-600"}>
|
||
{msg.text}
|
||
</p>
|
||
)}
|
||
<Button type="submit" disabled={busy || !current || !next}>
|
||
{busy ? "Saving…" : "Change password"}
|
||
</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Your data</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
Download a complete backup of every tree you own — people, sources, media, and
|
||
all — as a zip.
|
||
</p>
|
||
<Button variant="outline" onClick={exportData} disabled={exporting}>
|
||
{exporting ? "Preparing…" : "Export all my data"}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2 border-t border-[var(--border)] pt-4">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
Restore a backup. It’s imported into <strong>new</strong> trees — nothing existing
|
||
is touched or overwritten.
|
||
</p>
|
||
<input ref={restoreRef} type="file" accept=".zip" onChange={restoreData} className="hidden" />
|
||
<Button variant="outline" onClick={() => restoreRef.current?.click()} disabled={restoring}>
|
||
{restoring ? "Restoring…" : "Restore from backup"}
|
||
</Button>
|
||
{restoreMsg && <p className="text-sm text-bronze">{restoreMsg}</p>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-red-300/60">
|
||
<CardHeader>
|
||
<CardTitle className="text-base text-red-700">Delete account</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<p className="text-sm text-[var(--muted)]">
|
||
This deletes your account, the trees you own, and signs you out everywhere. Export
|
||
your data first if you might want it. Type <strong>{me?.email}</strong> to confirm.
|
||
</p>
|
||
<div className="flex max-w-sm flex-col gap-2">
|
||
<Input
|
||
placeholder="your email"
|
||
value={deleteConfirm}
|
||
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
className="bg-red-600 text-white hover:bg-red-700 hover:text-white"
|
||
onClick={deleteAccount}
|
||
disabled={deleting || deleteConfirm !== me?.email}
|
||
>
|
||
{deleting ? "Deleting…" : "Permanently delete my account"}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|