Files
provenance/frontend/app/trees/page.tsx
T
justin a6179037c2 Close citation/source living-person leak; add on-demand tree purge
Two changes.

1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only
   on can_view_tree, so a non-member on a public/unlisted/site_members tree could
   enumerate citations and sources tied to a redacted living person, leaking that
   the person exists and has sourced facts (and possibly their name via a source
   title). #46 closed this for events/media/names/relationships but not
   citations/sources. Now citation_service.list_citations and
   source_service.{list_sources,get_source} delegate non-member reads to
   public_view_service, mirroring the #46 pattern:
   - citations: shown only when the cited fact resolves to FULL-visibility
     person(s) — covers the person_id, name_id, event_id (person or both-partner),
     and relationship_id (both-partner) target paths.
   - sources: shown only when they back at least one visible citation; a withheld
     source 404s (don't reveal it exists).
   Tests cover all four citation target types + source withholding + member-sees-all.

2. On-demand tree purge — owners can permanently delete a soft-deleted tree now
   instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge
   (owner-only): the tree must already be in the trash, and the caller retypes its
   name to confirm. Media objects are deleted from storage, then a single
   DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE;
   the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever"
   button to the Recently-deleted list. No migration.

Suite: 102 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-10 22:38:59 -04:00

194 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() {
const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState("");
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees");
if (response.status === 401) {
router.push("/login");
return;
}
setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true);
}, [router]);
useEffect(() => {
load();
}, [load]);
async function createTree(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
const { error } = await api.POST("/api/v1/trees", { body: { name } });
if (!error) {
setName("");
load();
}
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
load();
}
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
}
async function purge(id: string, treeName: string) {
const typed = window.prompt(
`Permanently delete "${treeName}" and ALL its data (people, sources, media, …)?\n\n` +
"This CANNOT be undone. Type the tree name to confirm:",
);
if (typed == null) return; // cancelled
const { error, response } = await api.POST("/api/v1/trees/{tree_id}/purge", {
params: { path: { tree_id: id } },
body: { confirm_name: typed },
});
if (error) {
window.alert(
response.status === 403
? "The name didn't match — nothing was deleted."
: "Couldn't purge that tree.",
);
return;
}
load();
}
// Optimistic visibility change so the dropdown reflects the pick immediately.
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
await api.PATCH("/api/v1/trees/{tree_id}", {
params: { path: { tree_id: id } },
body: { visibility },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold">Your trees</h1>
<Card>
<CardContent className="p-5">
<form onSubmit={createTree} className="flex gap-2">
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
<Button type="submit">Create tree</Button>
</form>
</CardContent>
</Card>
{trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => (
<li key={tree.id}>
<Card className="transition-colors hover:border-bronze/50">
<CardContent className="flex items-center justify-between gap-3 p-4">
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
<div className="truncate font-medium">{tree.name}</div>
</Link>
<select
value={tree.visibility ?? "private"}
onChange={(e) =>
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
}
aria-label="Tree visibility"
title={
"Who can see this tree (living people stay protected regardless):\n" +
"• Private — only you and people you invite\n" +
"• Members — any signed-in user on this site\n" +
"• Unlisted — anyone with the link (not listed or indexed)\n" +
"• Public — anyone on the web; listed and search-indexable"
}
className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs uppercase tracking-wide text-bronze focus-visible:border-bronze focus-visible:outline-none"
>
<option value="private">Private</option>
<option value="site_members">Public Members</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
{tree.visibility && tree.visibility !== "private" && (
<a
href={`/p/${tree.id}`}
target="_blank"
rel="noreferrer"
title="Open the public, no-login view"
className="shrink-0 text-xs text-bronze hover:underline"
>
Public page
</a>
)}
<button
onClick={() => remove(tree.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<p className="text-xs text-[var(--muted)]">
Restorable for 30 days, after which they&apos;re purged automatically. Use
Delete forever to purge one now.
</p>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between gap-2 p-4">
<span className="min-w-0 flex-1 truncate text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
<Button
variant="outline"
size="sm"
onClick={() => purge(tree.id, tree.name)}
className="border-bronze/40 text-bronze hover:bg-bronze/10"
title="Permanently delete this tree and all its data"
>
Delete forever
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div>
);
}