Merge pull request 'Account export / restore-into-new-tree / delete' (#27) from account-export-restore-delete into main
This commit was merged in pull request #27.
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||||
from app.services import user_service
|
from app.services import account_service, user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -21,3 +21,37 @@ async def set_self_person(
|
|||||||
session, user=current, person_id=data.self_person_id
|
session, user=current, person_id=data.self_person_id
|
||||||
)
|
)
|
||||||
return UserRead.model_validate(user)
|
return UserRead.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/export")
|
||||||
|
async def export_account(
|
||||||
|
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||||
|
) -> Response:
|
||||||
|
"""Download a full backup (JSON + media) of every tree the user owns."""
|
||||||
|
data = await account_service.export_account(session, store, user=current)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/import")
|
||||||
|
async def import_account(
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict:
|
||||||
|
"""Restore a previously-exported backup into new trees (non-destructive)."""
|
||||||
|
raw = await file.read()
|
||||||
|
return await account_service.import_account(session, store, user=current, raw_zip=raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me", status_code=204)
|
||||||
|
async def delete_account(
|
||||||
|
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
|
||||||
|
) -> None:
|
||||||
|
"""Delete the account: the user, their owned trees, and their sessions.
|
||||||
|
Requires retyping the account email as a guard."""
|
||||||
|
await account_service.delete_account(session, user=current, confirm_email=confirm_email)
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Account-level data portability: export the signed-in user's owned trees as a
|
||||||
|
zip (JSON + media bytes), restore such a zip into a brand-new tree
|
||||||
|
(non-destructive), and delete the account.
|
||||||
|
|
||||||
|
The export format is a zip containing ``account.json`` plus ``media/<id>`` blobs.
|
||||||
|
Restore always creates new trees and remaps ids, so it can't clobber existing
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.auth import Session as SessionModel
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
EXPORT_VERSION = 1
|
||||||
|
_DROP = {"created_at", "updated_at", "deleted_at", "tree_id"}
|
||||||
|
# Media columns rebuilt on import (storage is re-keyed, checksum recomputed).
|
||||||
|
_MEDIA_DROP = _DROP | {"uploader_id", "storage_key", "byte_size", "checksum_sha256"}
|
||||||
|
_DATE_FIELDS = {"date_start", "date_end"}
|
||||||
|
|
||||||
|
|
||||||
|
def _row(obj, drop: set[str]) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
for col in obj.__table__.columns.keys(): # noqa: SIM118
|
||||||
|
if col in drop:
|
||||||
|
continue
|
||||||
|
out[col] = getattr(obj, col)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _entities(session: AsyncSession, model, tree_id: uuid.UUID):
|
||||||
|
stmt = select(model).where(model.tree_id == tree_id, model.deleted_at.is_(None))
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def export_account(session: AsyncSession, store: ObjectStore, *, user: User) -> bytes:
|
||||||
|
"""Build a zip of every tree the user owns: account.json + media blobs."""
|
||||||
|
trees = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Tree).where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"version": EXPORT_VERSION,
|
||||||
|
"user": {"email": user.email, "display_name": user.display_name},
|
||||||
|
"trees": [],
|
||||||
|
}
|
||||||
|
media_blobs: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
for tree in trees:
|
||||||
|
media_rows = await _entities(session, Media, tree.id)
|
||||||
|
media_out = []
|
||||||
|
for m in media_rows:
|
||||||
|
ref = f"media/{m.id}"
|
||||||
|
rec = _row(m, _MEDIA_DROP)
|
||||||
|
rec["_file"] = ref
|
||||||
|
media_out.append(rec)
|
||||||
|
try:
|
||||||
|
media_blobs.append((ref, await store.get_object(key=m.storage_key)))
|
||||||
|
except Exception: # noqa: BLE001 — a missing blob shouldn't abort the export
|
||||||
|
rec["_file"] = None
|
||||||
|
|
||||||
|
payload["trees"].append({
|
||||||
|
"tree": {
|
||||||
|
"name": tree.name,
|
||||||
|
"description": tree.description,
|
||||||
|
"visibility": tree.visibility,
|
||||||
|
"home_person_id": tree.home_person_id,
|
||||||
|
},
|
||||||
|
"places": [_row(p, _DROP) for p in await _entities(session, Place, tree.id)],
|
||||||
|
"persons": [_row(p, _DROP) for p in await _entities(session, Person, tree.id)],
|
||||||
|
"names": [_row(n, _DROP) for n in await _entities(session, Name, tree.id)],
|
||||||
|
"relationships": [
|
||||||
|
_row(r, _DROP) for r in await _entities(session, Relationship, tree.id)
|
||||||
|
],
|
||||||
|
"events": [_row(e, _DROP) for e in await _entities(session, Event, tree.id)],
|
||||||
|
"sources": [_row(s, _DROP) for s in await _entities(session, Source, tree.id)],
|
||||||
|
"citations": [_row(c, _DROP) for c in await _entities(session, Citation, tree.id)],
|
||||||
|
"media": media_out,
|
||||||
|
})
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("account.json", json.dumps(payload, default=str, indent=2))
|
||||||
|
for ref, blob in media_blobs:
|
||||||
|
zf.writestr(ref, blob)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_uuid(v) -> uuid.UUID | None:
|
||||||
|
return uuid.UUID(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_date(v) -> date | None:
|
||||||
|
return date.fromisoformat(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
async def import_account(
|
||||||
|
session: AsyncSession, store: ObjectStore, *, user: User, raw_zip: bytes
|
||||||
|
) -> dict:
|
||||||
|
"""Restore an exported zip into NEW trees owned by the user. Non-destructive:
|
||||||
|
every record gets a fresh id; nothing existing is touched."""
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(raw_zip))
|
||||||
|
payload = json.loads(zf.read("account.json"))
|
||||||
|
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e:
|
||||||
|
raise NotFound("not a valid Provenance export") from e
|
||||||
|
|
||||||
|
counts: dict[str, int] = {"trees": 0, "persons": 0, "events": 0, "media": 0}
|
||||||
|
|
||||||
|
for tdata in payload.get("trees", []):
|
||||||
|
t = tdata.get("tree", {})
|
||||||
|
tree = Tree(
|
||||||
|
owner_id=user.id,
|
||||||
|
name=(t.get("name") or "Imported tree"),
|
||||||
|
description=t.get("description"),
|
||||||
|
visibility=t.get("visibility") or "private",
|
||||||
|
)
|
||||||
|
session.add(tree)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
TreeMembership(tree_id=tree.id, user_id=user.id, role=MembershipRole.owner)
|
||||||
|
)
|
||||||
|
counts["trees"] += 1
|
||||||
|
|
||||||
|
# id remaps from the export's ids to the freshly created ones.
|
||||||
|
pmap: dict[str, uuid.UUID] = {}
|
||||||
|
rmap: dict[str, uuid.UUID] = {}
|
||||||
|
smap: dict[str, uuid.UUID] = {}
|
||||||
|
nmap: dict[str, uuid.UUID] = {}
|
||||||
|
emap: dict[str, uuid.UUID] = {}
|
||||||
|
plmap: dict[str, uuid.UUID] = {}
|
||||||
|
|
||||||
|
for pl in tdata.get("places", []):
|
||||||
|
obj = Place(
|
||||||
|
tree_id=tree.id,
|
||||||
|
name=pl.get("name") or "",
|
||||||
|
place_type=pl.get("place_type"),
|
||||||
|
latitude=pl.get("latitude"),
|
||||||
|
longitude=pl.get("longitude"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
plmap[pl["id"]] = obj.id
|
||||||
|
|
||||||
|
for p in tdata.get("persons", []):
|
||||||
|
obj = Person(
|
||||||
|
tree_id=tree.id,
|
||||||
|
gender=p.get("gender"),
|
||||||
|
is_living=p.get("is_living"),
|
||||||
|
privacy=p.get("privacy") or "inherit",
|
||||||
|
notes=p.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
pmap[p["id"]] = obj.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
|
||||||
|
for n in tdata.get("names", []):
|
||||||
|
pid = pmap.get(n.get("person_id"))
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
obj = Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=pid,
|
||||||
|
name_type=n.get("name_type") or "birth",
|
||||||
|
given=n.get("given"),
|
||||||
|
surname=n.get("surname"),
|
||||||
|
prefix=n.get("prefix"),
|
||||||
|
suffix=n.get("suffix"),
|
||||||
|
nickname=n.get("nickname"),
|
||||||
|
display_name=n.get("display_name"),
|
||||||
|
is_primary=bool(n.get("is_primary")),
|
||||||
|
sort_order=n.get("sort_order") or 0,
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
nmap[n["id"]] = obj.id
|
||||||
|
|
||||||
|
for r in tdata.get("relationships", []):
|
||||||
|
a = pmap.get(r.get("person_from_id"))
|
||||||
|
b = pmap.get(r.get("person_to_id"))
|
||||||
|
if a is None or b is None:
|
||||||
|
continue
|
||||||
|
obj = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=r.get("type"),
|
||||||
|
person_from_id=a,
|
||||||
|
person_to_id=b,
|
||||||
|
qualifier=r.get("qualifier"),
|
||||||
|
notes=r.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
rmap[r["id"]] = obj.id
|
||||||
|
|
||||||
|
for e in tdata.get("events", []):
|
||||||
|
obj = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
event_type=e.get("event_type") or "other",
|
||||||
|
person_id=pmap.get(e.get("person_id")),
|
||||||
|
relationship_id=rmap.get(e.get("relationship_id")),
|
||||||
|
place_id=plmap.get(e.get("place_id")),
|
||||||
|
date_value=e.get("date_value"),
|
||||||
|
date_start=_as_date(e.get("date_start")),
|
||||||
|
date_end=_as_date(e.get("date_end")),
|
||||||
|
date_precision=e.get("date_precision"),
|
||||||
|
calendar=e.get("calendar") or "gregorian",
|
||||||
|
detail=e.get("detail"),
|
||||||
|
notes=e.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
emap[e["id"]] = obj.id
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for s in tdata.get("sources", []):
|
||||||
|
obj = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=s.get("title") or "Untitled source",
|
||||||
|
author=s.get("author"),
|
||||||
|
source_type=s.get("source_type"),
|
||||||
|
repository=s.get("repository"),
|
||||||
|
url=s.get("url"),
|
||||||
|
citation_text=s.get("citation_text"),
|
||||||
|
publication_info=s.get("publication_info"),
|
||||||
|
quality_note=s.get("quality_note"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
smap[s["id"]] = obj.id
|
||||||
|
|
||||||
|
for c in tdata.get("citations", []):
|
||||||
|
sid = smap.get(c.get("source_id"))
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Citation(
|
||||||
|
tree_id=tree.id,
|
||||||
|
source_id=sid,
|
||||||
|
person_id=pmap.get(c.get("person_id")),
|
||||||
|
event_id=emap.get(c.get("event_id")),
|
||||||
|
name_id=nmap.get(c.get("name_id")),
|
||||||
|
relationship_id=rmap.get(c.get("relationship_id")),
|
||||||
|
page=c.get("page"),
|
||||||
|
detail=c.get("detail"),
|
||||||
|
confidence=c.get("confidence"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in tdata.get("media", []):
|
||||||
|
ref = m.get("_file")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = zf.read(ref)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
filename = m.get("original_filename") or "upload"
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(
|
||||||
|
key=key,
|
||||||
|
data=blob,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=user.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
byte_size=len(blob),
|
||||||
|
checksum_sha256=hashlib.sha256(blob).hexdigest(),
|
||||||
|
title=m.get("title"),
|
||||||
|
person_id=pmap.get(m.get("person_id")),
|
||||||
|
event_id=emap.get(m.get("event_id")),
|
||||||
|
source_id=smap.get(m.get("source_id")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["media"] += 1
|
||||||
|
|
||||||
|
# Remap the home person last, once persons exist.
|
||||||
|
home = t.get("home_person_id")
|
||||||
|
if home and home in pmap:
|
||||||
|
tree.home_person_id = pmap[home]
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Account",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(session: AsyncSession, *, user: User, confirm_email: str) -> None:
|
||||||
|
"""Soft-delete the account: the user, the trees they own, and all their
|
||||||
|
sessions. Requires the user to retype their email as a guard."""
|
||||||
|
if confirm_email.strip().lower() != user.email.lower():
|
||||||
|
raise Forbidden("email confirmation does not match")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tree)
|
||||||
|
.where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
.values(deleted_at=now)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(SessionModel.user_id == user.id, SessionModel.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=now)
|
||||||
|
)
|
||||||
|
user.deleted_at = now
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Account export -> restore round-trip, and account deletion."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(client, h):
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"]
|
||||||
|
p1 = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": p1, "date_value": "1815"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("scan.txt", b"hello", "text/plain")},
|
||||||
|
data={"title": "Scan", "person_id": p1},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_then_restore_roundtrip(client):
|
||||||
|
h = auth(await register(client, "exp@example.com"))
|
||||||
|
await _seed(client, h)
|
||||||
|
|
||||||
|
export = await client.get("/api/v1/users/me/export", headers=h)
|
||||||
|
assert export.status_code == 200
|
||||||
|
assert export.headers["content-type"] == "application/zip"
|
||||||
|
blob = export.content
|
||||||
|
assert blob[:2] == b"PK" # zip magic
|
||||||
|
|
||||||
|
# Restore into new trees (non-destructive: the original stays).
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/users/me/import",
|
||||||
|
files={"file": ("provenance-export.zip", blob, "application/zip")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
counts = r.json()
|
||||||
|
assert counts["trees"] == 1 and counts["persons"] == 2
|
||||||
|
assert counts["events"] == 1 and counts["media"] == 1
|
||||||
|
|
||||||
|
trees = (await client.get("/api/v1/trees", headers=h)).json()
|
||||||
|
assert len(trees) == 2 # original + restored
|
||||||
|
|
||||||
|
# The restored tree has the people, with a working relationship and media.
|
||||||
|
restored = [t for t in trees if t["name"] == "Fam"][1]["id"]
|
||||||
|
ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json()
|
||||||
|
assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"}
|
||||||
|
rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json()
|
||||||
|
assert len(rels) == 1
|
||||||
|
med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json()
|
||||||
|
assert len(med) == 1 and med[0]["title"] == "Scan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_account_requires_email_then_revokes(client):
|
||||||
|
token = await register(client, "del@example.com")
|
||||||
|
h = auth(token)
|
||||||
|
await _seed(client, h)
|
||||||
|
|
||||||
|
# Wrong email is rejected.
|
||||||
|
bad = await client.request(
|
||||||
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h
|
||||||
|
)
|
||||||
|
assert bad.status_code == 403
|
||||||
|
|
||||||
|
ok = await client.request(
|
||||||
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h
|
||||||
|
)
|
||||||
|
assert ok.status_code == 204
|
||||||
|
|
||||||
|
# Session is revoked — the token no longer works.
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||||||
|
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
@@ -16,10 +18,72 @@ export default function SettingsPage() {
|
|||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
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) {
|
async function changePassword(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -109,10 +173,55 @@ export default function SettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Your data</CardTitle>
|
<CardTitle className="text-base">Your data</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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)]">
|
<p className="text-sm text-[var(--muted)]">
|
||||||
Full-account export, restore, and account deletion are coming next.
|
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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+142
-1
@@ -168,7 +168,12 @@ export interface paths {
|
|||||||
get: operations["read_me_api_v1_users_me_get"];
|
get: operations["read_me_api_v1_users_me_get"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
/**
|
||||||
|
* Delete Account
|
||||||
|
* @description Delete the account: the user, their owned trees, and their sessions.
|
||||||
|
* Requires retyping the account email as a guard.
|
||||||
|
*/
|
||||||
|
delete: operations["delete_account_api_v1_users_me_delete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@@ -194,6 +199,46 @@ export interface paths {
|
|||||||
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
|
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/users/me/export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Export Account
|
||||||
|
* @description Download a full backup (JSON + media) of every tree the user owns.
|
||||||
|
*/
|
||||||
|
get: operations["export_account_api_v1_users_me_export_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/users/me/import": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Import Account
|
||||||
|
* @description Restore a previously-exported backup into new trees (non-destructive).
|
||||||
|
*/
|
||||||
|
post: operations["import_account_api_v1_users_me_import_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/trees": {
|
"/api/v1/trees": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -638,6 +683,16 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
/** Body_delete_account_api_v1_users_me_delete */
|
||||||
|
Body_delete_account_api_v1_users_me_delete: {
|
||||||
|
/** Confirm Email */
|
||||||
|
confirm_email: string;
|
||||||
|
};
|
||||||
|
/** Body_import_account_api_v1_users_me_import_post */
|
||||||
|
Body_import_account_api_v1_users_me_import_post: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
|
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
|
||||||
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||||
/** File */
|
/** File */
|
||||||
@@ -1624,6 +1679,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
delete_account_api_v1_users_me_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/x-www-form-urlencoded": components["schemas"]["Body_delete_account_api_v1_users_me_delete"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
set_self_person_api_v1_users_me_self_person_patch: {
|
set_self_person_api_v1_users_me_self_person_patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1657,6 +1743,61 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export_account_api_v1_users_me_export_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
import_account_api_v1_users_me_import_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_import_account_api_v1_users_me_import_post"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_my_trees_api_v1_trees_get: {
|
list_my_trees_api_v1_trees_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -312,6 +312,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"users"
|
||||||
|
],
|
||||||
|
"summary": "Delete Account",
|
||||||
|
"description": "Delete the account: the user, their owned trees, and their sessions.\nRequires retyping the account email as a guard.",
|
||||||
|
"operationId": "delete_account_api_v1_users_me_delete",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/x-www-form-urlencoded": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Body_delete_account_api_v1_users_me_delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users/me/self-person": {
|
"/api/v1/users/me/self-person": {
|
||||||
@@ -356,6 +389,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/users/me/export": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"users"
|
||||||
|
],
|
||||||
|
"summary": "Export Account",
|
||||||
|
"description": "Download a full backup (JSON + media) of every tree the user owns.",
|
||||||
|
"operationId": "export_account_api_v1_users_me_export_get",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/users/me/import": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"users"
|
||||||
|
],
|
||||||
|
"summary": "Import Account",
|
||||||
|
"description": "Restore a previously-exported backup into new trees (non-destructive).",
|
||||||
|
"operationId": "import_account_api_v1_users_me_import_post",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Body_import_account_api_v1_users_me_import_post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object",
|
||||||
|
"title": "Response Import Account Api V1 Users Me Import Post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/trees": {
|
"/api/v1/trees": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2608,6 +2705,33 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"Body_delete_account_api_v1_users_me_delete": {
|
||||||
|
"properties": {
|
||||||
|
"confirm_email": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Confirm Email"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"confirm_email"
|
||||||
|
],
|
||||||
|
"title": "Body_delete_account_api_v1_users_me_delete"
|
||||||
|
},
|
||||||
|
"Body_import_account_api_v1_users_me_import_post": {
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"contentMediaType": "application/octet-stream",
|
||||||
|
"title": "File"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"title": "Body_import_account_api_v1_users_me_import_post"
|
||||||
|
},
|
||||||
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
|
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"file": {
|
"file": {
|
||||||
|
|||||||
Reference in New Issue
Block a user