Compare commits
11 Commits
9ee960c4ef
...
e8839b15a0
| Author | SHA1 | Date | |
|---|---|---|---|
| e8839b15a0 | |||
| 548e883d82 | |||
| 37ac49767e | |||
| 9b04bcefba | |||
| e9b2436ce0 | |||
| 8903e480cf | |||
| d27cc5dddc | |||
| 943f459b91 | |||
| 5106538934 | |||
| 2669543e56 | |||
| 0262ed3d97 |
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
from app.api.deps import MailerDep, SessionDep, extract_session_token
|
from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
PasswordChange,
|
||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
|
|||||||
await auth_service.reset_password(
|
await auth_service.reset_password(
|
||||||
session, raw_token=data.token, new_password=data.new_password
|
session, raw_token=data.token, new_password=data.new_password
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def change_password(
|
||||||
|
data: PasswordChange, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
await auth_service.change_password(
|
||||||
|
session,
|
||||||
|
user=current,
|
||||||
|
current_password=data.current_password,
|
||||||
|
new_password=data.new_password,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
|||||||
default=TreeVisibility.private,
|
default=TreeVisibility.private,
|
||||||
server_default=TreeVisibility.private.value,
|
server_default=TreeVisibility.private.value,
|
||||||
)
|
)
|
||||||
|
# The person a tree opens focused on (its "home"/root person). Cleared if
|
||||||
|
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
|
||||||
|
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"persons.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_trees_home_person_id",
|
||||||
|
use_alter=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
|
|||||||
new_password: str = Field(min_length=8)
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class SessionRead(BaseModel):
|
class SessionRead(BaseModel):
|
||||||
user: UserRead
|
user: UserRead
|
||||||
token: str
|
token: str
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class TreeUpdate(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
visibility: TreeVisibility | None = None
|
visibility: TreeVisibility | None = None
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class TreeRead(BaseModel):
|
class TreeRead(BaseModel):
|
||||||
@@ -26,4 +27,5 @@ class TreeRead(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
visibility: TreeVisibility
|
visibility: TreeVisibility
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,6 +3,7 @@ the change to a User (or the assistant principal acting for a User). Staged on
|
|||||||
the session; the caller commits as part of its unit of work.
|
the session; the caller commits as part of its unit of work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
|
|||||||
from app.models.enums import AuditActorType
|
from app.models.enums import AuditActorType
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(d: dict | None) -> dict | None:
|
||||||
|
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
|
||||||
|
it lands in the JSON audit column regardless of what the caller passed."""
|
||||||
|
if d is None:
|
||||||
|
return None
|
||||||
|
return json.loads(json.dumps(d, default=str))
|
||||||
|
|
||||||
|
|
||||||
def record_audit(
|
def record_audit(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -30,8 +39,8 @@ def record_audit(
|
|||||||
tree_id=tree_id,
|
tree_id=tree_id,
|
||||||
actor_user_id=actor_user_id,
|
actor_user_id=actor_user_id,
|
||||||
actor_type=actor_type,
|
actor_type=actor_type,
|
||||||
before=before,
|
before=_json_safe(before),
|
||||||
after=after,
|
after=_json_safe(after),
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import generate_token, hash_password, hash_token
|
from app.core.security import generate_token, hash_password, hash_token, verify_password
|
||||||
from app.integrations.auth.local import LocalAuthProvider
|
from app.integrations.auth.local import LocalAuthProvider
|
||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
from app.models.auth import Session as SessionModel
|
from app.models.auth import Session as SessionModel
|
||||||
@@ -17,7 +17,7 @@ from app.models.auth import UserToken
|
|||||||
from app.models.enums import TokenPurpose
|
from app.models.enums import TokenPurpose
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit import record_audit
|
from app.services.audit import record_audit
|
||||||
from app.services.exceptions import Conflict, NotFound
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
_local_provider = LocalAuthProvider()
|
_local_provider = LocalAuthProvider()
|
||||||
|
|
||||||
@@ -178,6 +178,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
|
|||||||
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
||||||
|
|
||||||
|
|
||||||
|
async def change_password(
|
||||||
|
session: AsyncSession, *, user: User, current_password: str, new_password: str
|
||||||
|
) -> None:
|
||||||
|
"""Change a logged-in user's password after re-verifying the current one.
|
||||||
|
Revokes other sessions so a changed password takes effect everywhere."""
|
||||||
|
if not user.hashed_password or not verify_password(
|
||||||
|
user.hashed_password, current_password
|
||||||
|
):
|
||||||
|
raise Forbidden("current password is incorrect")
|
||||||
|
user.hashed_password = hash_password(new_password)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="change_password",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
||||||
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -282,11 +282,14 @@ async def delete_person(
|
|||||||
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
|
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
|
||||||
|
|
||||||
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
|
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
|
||||||
# never fires — clear any account's self-person link to a deleted person.
|
# never fires — clear any links (account self-person, tree home person) to a
|
||||||
|
# deleted person.
|
||||||
|
deleted_ids = [p.id for p in to_delete]
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(User)
|
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
|
||||||
.where(User.self_person_id.in_([p.id for p in to_delete]))
|
)
|
||||||
.values(self_person_id=None)
|
await session.execute(
|
||||||
|
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def update_tree(
|
|||||||
raise NotFound("tree not found")
|
raise NotFound("tree not found")
|
||||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
raise Forbidden("not an editor of this tree")
|
raise Forbidden("not an editor of this tree")
|
||||||
for key in {"name", "description", "visibility"} & changes.keys():
|
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
|
||||||
setattr(tree, key, changes[key])
|
setattr(tree, key, changes[key])
|
||||||
record_audit(
|
record_audit(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""tree.home_person_id (per-tree default/home person)
|
||||||
|
|
||||||
|
Revision ID: c7e1a4f2d3b8
|
||||||
|
Revises: b3d5f8a1c920
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "c7e1a4f2d3b8"
|
||||||
|
down_revision: str | None = "b3d5f8a1c920"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("trees", sa.Column("home_person_id", sa.Uuid(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_trees_home_person_id",
|
||||||
|
"trees",
|
||||||
|
"persons",
|
||||||
|
["home_person_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_trees_home_person_id", "trees", type_="foreignkey")
|
||||||
|
op.drop_column("trees", "home_person_id")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Change password and per-tree home person."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_change_password(client):
|
||||||
|
token = await register(client, "cp@example.com", password="password123")
|
||||||
|
h = auth(token)
|
||||||
|
|
||||||
|
# Wrong current password is rejected.
|
||||||
|
bad = await client.post(
|
||||||
|
"/api/v1/auth/change-password",
|
||||||
|
json={"current_password": "nope", "new_password": "newpass123"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert bad.status_code == 403
|
||||||
|
|
||||||
|
ok = await client.post(
|
||||||
|
"/api/v1/auth/change-password",
|
||||||
|
json={"current_password": "password123", "new_password": "newpass123"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert ok.status_code == 204
|
||||||
|
|
||||||
|
# The new password logs in; the old one does not.
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "newpass123"}
|
||||||
|
)
|
||||||
|
).status_code == 200
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_home_person(client):
|
||||||
|
h = auth(await register(client, "home@example.com"))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
pid = (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Root"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}", json={"home_person_id": pid}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["home_person_id"] == pid
|
||||||
|
|
||||||
|
# Deleting the home person clears the link.
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
|
||||||
|
tree = (await client.get(f"/api/v1/trees/{tid}", headers=h)).json()
|
||||||
|
assert tree["home_person_id"] is None
|
||||||
@@ -11,23 +11,28 @@
|
|||||||
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
|
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
|
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant.
|
||||||
|
Theme is class-based (.dark on <html>) so it can be toggled manually; an inline
|
||||||
|
script in the root layout sets it pre-paint from the saved choice or the OS. */
|
||||||
:root {
|
:root {
|
||||||
--background: #f7f3ec;
|
--background: #f7f3ec;
|
||||||
--foreground: #1a1a17;
|
--foreground: #1a1a17;
|
||||||
--muted: #6b6862;
|
--muted: #6b6862;
|
||||||
--surface: #fffdf9;
|
--surface: #fffdf9;
|
||||||
--border: #e6ddcc;
|
--border: #e6ddcc;
|
||||||
|
/* Connector "lines between people" (pedigree + tree chart). Derived from Ink
|
||||||
|
(the brand mark color): a dark line on light, light on dark. */
|
||||||
|
--line: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
|
||||||
--background: #161410;
|
--background: #161410;
|
||||||
--foreground: #f2eee6;
|
--foreground: #f2eee6;
|
||||||
--muted: #9a968e;
|
--muted: #9a968e;
|
||||||
--surface: #211d17;
|
--surface: #211d17;
|
||||||
--border: #353029;
|
--border: #353029;
|
||||||
}
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -78,7 +83,7 @@ h3,
|
|||||||
left: -2.5rem;
|
left: -2.5rem;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf {
|
.ped-leaf {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -90,7 +95,7 @@ h3,
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf::after {
|
.ped-leaf::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -98,7 +103,7 @@ h3,
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf:first-child::after {
|
.ped-leaf:first-child::after {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
|
|||||||
icons: { icon: "/favicon.svg" },
|
icons: { icon: "/favicon.svg" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sets the theme class before first paint to avoid a flash; reads the saved
|
||||||
|
// choice ("light"/"dark"/"system") or falls back to the OS preference.
|
||||||
|
const themeScript = `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
<html lang="en" className={`${serif.variable} ${sans.variable}`} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-screen antialiased">{children}</body>
|
<body className="min-h-screen antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
type Media = components["schemas"]["MediaRead"];
|
type Media = components["schemas"]["MediaRead"];
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
|
||||||
|
const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs";
|
||||||
|
|
||||||
function humanSize(bytes: number) {
|
function humanSize(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -22,6 +25,7 @@ export default function MediaPage() {
|
|||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [items, setItems] = useState<Media[]>([]);
|
const [items, setItems] = useState<Media[]>([]);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -34,10 +38,22 @@ export default function MediaPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
setItems(data ?? []);
|
setItems(data ?? []);
|
||||||
|
setPeople(ppl.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
async function linkPerson(mediaId: string, personId: string) {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||||
|
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||||
|
body: { person_id: personId || null },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -124,6 +140,19 @@ export default function MediaPage() {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className={`${fieldCls} mt-2`}
|
||||||
|
value={m.person_id ?? ""}
|
||||||
|
onChange={(e) => linkPerson(m.id, e.target.value)}
|
||||||
|
title="Link to a person"
|
||||||
|
>
|
||||||
|
<option value="">— link to person —</option>
|
||||||
|
{people.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.primary_name ?? "Unnamed"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -50,15 +50,18 @@ export default function FamilyViewPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [r, e] = await Promise.all([
|
const [r, e, t] = await Promise.all([
|
||||||
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
const ppl = p.data ?? [];
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data?.home_person_id ?? null;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
setPeople(ppl);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -83,8 +86,18 @@ export default function FamilyViewPage() {
|
|||||||
}, [search, treeId]);
|
}, [search, treeId]);
|
||||||
|
|
||||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
|
// Order parents deterministically: father (male) on top, mother below, with a
|
||||||
|
// stable fallback when gender is unknown (so it doesn't depend on which link
|
||||||
|
// happened to be created first).
|
||||||
|
const parentRank = (id: string) => {
|
||||||
|
const g = byId.get(id)?.gender;
|
||||||
|
return g === "male" ? 0 : g === "female" ? 1 : 2;
|
||||||
|
};
|
||||||
const parentsOf = (id: string) =>
|
const parentsOf = (id: string) =>
|
||||||
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
rels
|
||||||
|
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
|
||||||
|
.map((r) => r.person_from_id)
|
||||||
|
.sort((a, b) => parentRank(a) - parentRank(b));
|
||||||
const childrenOf = (id: string) =>
|
const childrenOf = (id: string) =>
|
||||||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||||
const partnersOf = (id: string) =>
|
const partnersOf = (id: string) =>
|
||||||
@@ -113,6 +126,12 @@ export default function FamilyViewPage() {
|
|||||||
return data?.id ?? null;
|
return data?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new (blank) person and open their page to fill in details.
|
||||||
|
async function newPersonAndGo() {
|
||||||
|
const id = await addPerson("");
|
||||||
|
if (id) router.push(`/trees/${treeId}/persons/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function createFirst(e: React.FormEvent) {
|
async function createFirst(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!firstName.trim()) return;
|
if (!firstName.trim()) return;
|
||||||
@@ -339,6 +358,10 @@ export default function FamilyViewPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold">Family view</h1>
|
<h1 className="text-2xl font-semibold">Family view</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" onClick={newPersonAndGo}>
|
||||||
|
+ Add person
|
||||||
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href={`/trees/${treeId}/persons/${focus.id}`}
|
href={`/trees/${treeId}/persons/${focus.id}`}
|
||||||
className="text-sm text-bronze hover:underline"
|
className="text-sm text-bronze hover:underline"
|
||||||
@@ -346,6 +369,7 @@ export default function FamilyViewPage() {
|
|||||||
Open {focus.primary_name ?? "person"} →
|
Open {focus.primary_name ?? "person"} →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
@@ -46,6 +46,9 @@ const EVENT_TYPES = [
|
|||||||
"residence", "census", "immigration", "emigration", "occupation", "education",
|
"residence", "census", "immigration", "emigration", "occupation", "education",
|
||||||
"military service", "naturalization", "other",
|
"military service", "naturalization", "other",
|
||||||
];
|
];
|
||||||
|
// These belong to a couple, not a person — they attach to the partnership and
|
||||||
|
// show on both partners' pages, so they're only entered once.
|
||||||
|
const PARTNERSHIP_EVENTS = ["marriage", "divorce", "engagement"];
|
||||||
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||||
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||||
@@ -108,14 +111,20 @@ export default function PersonDetailPage() {
|
|||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [names, setNames] = useState<Name[]>([]);
|
const [names, setNames] = useState<Name[]>([]);
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
const [citations, setCitations] = useState<Citation[]>([]);
|
const [citations, setCitations] = useState<Citation[]>([]);
|
||||||
|
const [media, setMedia] = useState<components["schemas"]["MediaRead"][]>([]);
|
||||||
|
const [uploadingMedia, setUploadingMedia] = useState(false);
|
||||||
|
const mediaFileRef = useRef<HTMLInputElement>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const [evType, setEvType] = useState("birth");
|
const [evType, setEvType] = useState("birth");
|
||||||
const [evTypeOther, setEvTypeOther] = useState("");
|
const [evTypeOther, setEvTypeOther] = useState("");
|
||||||
|
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
|
||||||
|
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
|
||||||
const [dateQual, setDateQual] = useState("exact");
|
const [dateQual, setDateQual] = useState("exact");
|
||||||
const [dateDay, setDateDay] = useState("");
|
const [dateDay, setDateDay] = useState("");
|
||||||
const [dateMonth, setDateMonth] = useState("");
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
@@ -168,12 +177,13 @@ export default function PersonDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPerson(p.data ?? null);
|
setPerson(p.data ?? null);
|
||||||
const [all, nm, mine, ev, rl, src, cit] = await Promise.all([
|
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
}),
|
}),
|
||||||
api.GET("/api/v1/users/me"),
|
api.GET("/api/v1/users/me"),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
|
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
}),
|
}),
|
||||||
@@ -182,11 +192,16 @@ export default function PersonDetailPage() {
|
|||||||
}),
|
}),
|
||||||
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
setPeople(all.data ?? []);
|
setPeople(all.data ?? []);
|
||||||
setNames(nm.data ?? []);
|
setNames(nm.data ?? []);
|
||||||
setMe(mine.data ?? null);
|
setMe(mine.data ?? null);
|
||||||
|
setTree(tr.data ?? null);
|
||||||
setEvents(ev.data ?? []);
|
setEvents(ev.data ?? []);
|
||||||
|
setAllEvents(evAll.data ?? []);
|
||||||
|
setMedia(med.data ?? []);
|
||||||
setRels(rl.data ?? []);
|
setRels(rl.data ?? []);
|
||||||
setSources(src.data ?? []);
|
setSources(src.data ?? []);
|
||||||
setCitations(cit.data ?? []);
|
setCitations(cit.data ?? []);
|
||||||
@@ -214,6 +229,23 @@ export default function PersonDetailPage() {
|
|||||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
const personCites = citations.filter((c) => c.person_id === personId);
|
||||||
|
|
||||||
|
// Partnership events live on the relationship and show on both partners.
|
||||||
|
const myPartnerRels = rels.filter(
|
||||||
|
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||||
|
);
|
||||||
|
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
|
||||||
|
const relEvents = allEvents.filter(
|
||||||
|
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
|
||||||
|
);
|
||||||
|
const spouseOfRelEvent = (relId: string | null | undefined) => {
|
||||||
|
const r = myPartnerRels.find((x) => x.id === relId);
|
||||||
|
if (!r) return null;
|
||||||
|
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||||
|
};
|
||||||
|
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
|
||||||
|
// Personal events + this person's partnership events, shown together.
|
||||||
|
const shownEvents = [...events, ...relEvents];
|
||||||
|
|
||||||
async function addEvent(e: React.FormEvent) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||||
@@ -224,9 +256,47 @@ export default function PersonDetailPage() {
|
|||||||
dateMonth,
|
dateMonth,
|
||||||
dateYear,
|
dateYear,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let body: components["schemas"]["EventCreate"] = {
|
||||||
|
event_type,
|
||||||
|
person_id: personId,
|
||||||
|
date_value,
|
||||||
|
date_start,
|
||||||
|
date_precision,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A partnership event belongs to the couple: attach it to the partnership
|
||||||
|
// relationship (creating it if needed) so it's entered once and shows on
|
||||||
|
// both partners.
|
||||||
|
if (isPartnershipType(event_type)) {
|
||||||
|
if (!evSpouse) return;
|
||||||
|
let relId = myPartnerRels.find(
|
||||||
|
(r) => r.person_from_id === evSpouse || r.person_to_id === evSpouse,
|
||||||
|
)?.id;
|
||||||
|
if (!relId) {
|
||||||
|
const { data, error: relErr } = await api.POST(
|
||||||
|
"/api/v1/trees/{tree_id}/relationships",
|
||||||
|
{
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { type: "partnership", person_from_id: personId, person_to_id: evSpouse },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (relErr || !data) return;
|
||||||
|
relId = data.id;
|
||||||
|
}
|
||||||
|
body = {
|
||||||
|
event_type,
|
||||||
|
relationship_id: relId,
|
||||||
|
person_id: null,
|
||||||
|
date_value,
|
||||||
|
date_start,
|
||||||
|
date_precision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
body,
|
||||||
});
|
});
|
||||||
if (!error) {
|
if (!error) {
|
||||||
setDateDay("");
|
setDateDay("");
|
||||||
@@ -234,6 +304,7 @@ export default function PersonDetailPage() {
|
|||||||
setDateYear("");
|
setDateYear("");
|
||||||
setDateQual("exact");
|
setDateQual("exact");
|
||||||
setEvTypeOther("");
|
setEvTypeOther("");
|
||||||
|
setEvSpouse("");
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,28 +342,47 @@ export default function PersonDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRel(e: React.FormEvent) {
|
async function linkRelative(otherId: string): Promise<boolean> {
|
||||||
e.preventDefault();
|
|
||||||
if (!relOther) return;
|
|
||||||
let body: RelCreate;
|
let body: RelCreate;
|
||||||
if (relKind === "parent") {
|
if (relKind === "parent") {
|
||||||
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
|
body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
|
||||||
} else if (relKind === "child") {
|
} else if (relKind === "child") {
|
||||||
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
|
body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
|
||||||
} else if (relKind === "partner") {
|
} else if (relKind === "partner") {
|
||||||
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
|
body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
|
||||||
} else {
|
} else {
|
||||||
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
|
body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
|
||||||
}
|
}
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
if (!error) {
|
return !error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRel(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!relOther) return;
|
||||||
|
if (await linkRelative(relOther)) {
|
||||||
setRelOther("");
|
setRelOther("");
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a brand-new person, link them in the chosen role, then jump to their
|
||||||
|
// page so the user can fill in details immediately.
|
||||||
|
async function createRelativeAndGo(name: string) {
|
||||||
|
const toks = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
|
||||||
|
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { given, surname },
|
||||||
|
});
|
||||||
|
if (!data) return;
|
||||||
|
await linkRelative(data.id);
|
||||||
|
router.push(`/trees/${treeId}/persons/${data.id}`);
|
||||||
|
}
|
||||||
async function removeRel(id: string) {
|
async function removeRel(id: string) {
|
||||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
||||||
params: { path: { tree_id: treeId, relationship_id: id } },
|
params: { path: { tree_id: treeId, relationship_id: id } },
|
||||||
@@ -387,6 +477,39 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setDefaultPerson(make: boolean) {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { home_person_id: make ? personId : null },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMediaForPerson(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (mediaFileRef.current) mediaFileRef.current.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingMedia(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("person_id", personId); // link on upload
|
||||||
|
await fetch(`/api/v1/trees/${treeId}/media`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
setUploadingMedia(false);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkMedia(mediaId: string, link: boolean) {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||||
|
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||||
|
body: { person_id: link ? personId : null },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
function startEditPerson(current: Person) {
|
function startEditPerson(current: Person) {
|
||||||
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
||||||
@@ -418,6 +541,7 @@ export default function PersonDetailPage() {
|
|||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
const isSelf = me?.self_person_id === personId;
|
const isSelf = me?.self_person_id === personId;
|
||||||
|
const isDefault = tree?.home_person_id === personId;
|
||||||
|
|
||||||
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
||||||
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
||||||
@@ -561,15 +685,20 @@ export default function PersonDetailPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="flex items-center gap-3 text-3xl font-semibold">
|
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
|
||||||
{person.primary_name ?? "Unnamed person"}
|
{person.primary_name ?? "Unnamed person"}
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
|
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
|
||||||
This is you
|
This is you
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isDefault && (
|
||||||
|
<span className="rounded-full border border-bronze/40 px-2.5 py-1 text-xs font-medium text-bronze">
|
||||||
|
Default person
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{citeControl("p", { person_id: personId }, personCites)}
|
{citeControl("p", { person_id: personId }, personCites)}
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
|
||||||
@@ -580,6 +709,11 @@ export default function PersonDetailPage() {
|
|||||||
This is me
|
This is me
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isDefault && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
|
||||||
|
Set as default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
|
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -755,11 +889,11 @@ export default function PersonDetailPage() {
|
|||||||
<CardTitle className="text-base">Life events</CardTitle>
|
<CardTitle className="text-base">Life events</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{events.length === 0 ? (
|
{shownEvents.length === 0 ? (
|
||||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{events.map((ev) =>
|
{shownEvents.map((ev) =>
|
||||||
editId === ev.id ? (
|
editId === ev.id ? (
|
||||||
<li key={ev.id}>
|
<li key={ev.id}>
|
||||||
<form
|
<form
|
||||||
@@ -828,9 +962,18 @@ export default function PersonDetailPage() {
|
|||||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
<span className="font-medium capitalize">{ev.event_type}</span>
|
||||||
|
{ev.relationship_id ? (
|
||||||
|
<span className="text-[var(--muted)]">
|
||||||
|
{" "}
|
||||||
|
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{ev.date_value ? (
|
{ev.date_value ? (
|
||||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{ev.detail ? (
|
||||||
|
<span className="text-[var(--muted)]"> — {ev.detail}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
||||||
@@ -879,6 +1022,23 @@ export default function PersonDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{isPartnershipType(evType) && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
|
||||||
|
<select
|
||||||
|
className={fieldCls}
|
||||||
|
value={evSpouse}
|
||||||
|
onChange={(e) => setEvSpouse(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
{others.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.primary_name ?? "Unnamed"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-[var(--muted)]">When</span>
|
<span className="text-xs text-[var(--muted)]">When</span>
|
||||||
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
||||||
@@ -955,7 +1115,8 @@ export default function PersonDetailPage() {
|
|||||||
people={others}
|
people={others}
|
||||||
value={relOther}
|
value={relOther}
|
||||||
onChange={setRelOther}
|
onChange={setRelOther}
|
||||||
placeholder="Search for a person…"
|
onCreate={createRelativeAndGo}
|
||||||
|
placeholder="Search, or type a new name…"
|
||||||
/>
|
/>
|
||||||
{(relKind === "parent" || relKind === "child") && (
|
{(relKind === "parent" || relKind === "child") && (
|
||||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||||
@@ -971,6 +1132,87 @@ export default function PersonDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Media</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{(() => {
|
||||||
|
const personMedia = media.filter((m) => m.person_id === personId);
|
||||||
|
const unlinked = media.filter((m) => !m.person_id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{personMedia.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No media linked to this person yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
{personMedia.map((m) => (
|
||||||
|
<div key={m.id} className="overflow-hidden rounded-lg border border-[var(--border)]">
|
||||||
|
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
|
||||||
|
{m.content_type.startsWith("image/") ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={m.url ?? ""}
|
||||||
|
alt={m.title ?? m.original_filename}
|
||||||
|
className="aspect-square w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] font-serif text-2xl text-bronze">
|
||||||
|
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<span className="truncate text-xs" title={m.original_filename}>
|
||||||
|
{m.title ?? m.original_filename}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => linkMedia(m.id, false)}
|
||||||
|
className="shrink-0 text-xs text-[var(--muted)] hover:text-bronze"
|
||||||
|
>
|
||||||
|
unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={mediaFileRef}
|
||||||
|
type="file"
|
||||||
|
onChange={uploadMediaForPerson}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => mediaFileRef.current?.click()}
|
||||||
|
disabled={uploadingMedia}
|
||||||
|
>
|
||||||
|
{uploadingMedia ? "Uploading…" : "Upload & link"}
|
||||||
|
</Button>
|
||||||
|
{unlinked.length > 0 && (
|
||||||
|
<select
|
||||||
|
className={fieldCls}
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => e.target.value && linkMedia(e.target.value, true)}
|
||||||
|
>
|
||||||
|
<option value="">Link existing media…</option>
|
||||||
|
{unlinked.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title ?? m.original_filename}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,6 +530,11 @@
|
|||||||
|
|
||||||
.f3 .link {
|
.f3 .link {
|
||||||
transition: stroke-width 0.2s ease-in-out;
|
transition: stroke-width 0.2s ease-in-out;
|
||||||
|
/* Brand-aware connector lines: dark on the light paper, light on dark. The
|
||||||
|
library's default white stroke is invisible on the light theme. */
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line);
|
||||||
|
stroke-width: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f3 .link.f3-path-to-main {
|
.f3 .link.f3-path-to-main {
|
||||||
|
|||||||
@@ -50,16 +50,20 @@ export default function TreePage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [r, e] = await Promise.all([
|
const [r, e, t] = await Promise.all([
|
||||||
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const ppl = p.data ?? [];
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data?.home_person_id ?? null;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
setPeople(ppl);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
// Open on the tree's default/home person when set, else the first person.
|
||||||
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setStatus(ppl.length ? "ready" : "empty");
|
setStatus(ppl.length ? "ready" : "empty");
|
||||||
})().catch(() => !cancelled && setStatus("error"));
|
})().catch(() => !cancelled && setStatus("error"));
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
@@ -12,6 +13,10 @@ import { AppSidebar } from "@/components/app-sidebar";
|
|||||||
*/
|
*/
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
// The tree visualization wants the whole canvas; everything else reads better
|
||||||
|
// in a centered, max-width column.
|
||||||
|
const fullWidth = /^\/trees\/[^/]+\/tree$/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
@@ -49,7 +54,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
|
<div
|
||||||
|
className={
|
||||||
|
fullWidth
|
||||||
|
? "w-full px-4 py-6 md:px-6"
|
||||||
|
: "mx-auto w-full max-w-4xl px-6 py-10 md:px-10"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
LogOut,
|
LogOut,
|
||||||
Network,
|
Network,
|
||||||
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -23,6 +25,9 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
||||||
const [treeName, setTreeName] = useState<string | null>(null);
|
const [treeName, setTreeName] = useState<string | null>(null);
|
||||||
|
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!treeId) {
|
if (!treeId) {
|
||||||
@@ -34,6 +39,18 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
.then((r) => setTreeName(r.data?.name ?? null));
|
.then((r) => setTreeName(r.data?.name ?? null));
|
||||||
}, [treeId]);
|
}, [treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
await api.POST("/api/v1/auth/logout");
|
await api.POST("/api/v1/auth/logout");
|
||||||
@@ -120,13 +137,45 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 shrink-0" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
|
className="flex w-full items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-bronze"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 shrink-0" />
|
<LogOut className="h-4 w-4 shrink-0" />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-[var(--foreground)] transition-colors hover:bg-bronze/[0.07]"
|
||||||
|
>
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-bronze/15 text-xs font-semibold uppercase text-bronze">
|
||||||
|
{(me?.display_name || me?.email || "?").slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{me?.display_name || me?.email || "Account"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function FanChart({
|
|||||||
<path
|
<path
|
||||||
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
||||||
fill={id ? "var(--surface)" : "transparent"}
|
fill={id ? "var(--surface)" : "transparent"}
|
||||||
stroke="var(--border)"
|
stroke="var(--line)"
|
||||||
/>
|
/>
|
||||||
{id && (
|
{id && (
|
||||||
<text
|
<text
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ export function PersonCombobox({
|
|||||||
people,
|
people,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCreate,
|
||||||
placeholder = "Search for a person…",
|
placeholder = "Search for a person…",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
people: Person[];
|
people: Person[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
|
/** When set, the dropdown offers a "Create '<typed name>'" action. */
|
||||||
|
onCreate?: (name: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -77,7 +80,7 @@ export function PersonCombobox({
|
|||||||
if (value) onChange(""); // typing invalidates the prior pick
|
if (value) onChange(""); // typing invalidates the prior pick
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{open && matches.length > 0 && (
|
{open && (matches.length > 0 || (onCreate && query.trim())) && (
|
||||||
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||||
{matches.map((p) => (
|
{matches.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
@@ -96,6 +99,21 @@ export function PersonCombobox({
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{onCreate && query.trim() && (
|
||||||
|
<li className={matches.length > 0 ? "border-t border-[var(--border)]" : ""}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const name = query.trim();
|
||||||
|
setOpen(false);
|
||||||
|
onCreate(name);
|
||||||
|
}}
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm text-bronze hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
|
||||||
|
>
|
||||||
|
+ Create “{query.trim()}”
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
const ORDER: Theme[] = ["system", "light", "dark"];
|
||||||
|
const ICON = { system: Monitor, light: Sun, dark: Moon };
|
||||||
|
const LABEL = { system: "System", light: "Light", dark: "Dark" };
|
||||||
|
|
||||||
|
function apply(theme: Theme) {
|
||||||
|
const dark =
|
||||||
|
theme === "dark" ||
|
||||||
|
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycles theme System → Light → Dark, persisting the choice in localStorage. */
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>("system");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = (localStorage.getItem("theme") as Theme) || "system";
|
||||||
|
setTheme(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep "system" in sync if the OS preference changes while selected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onChange = () => apply("system");
|
||||||
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
function cycle() {
|
||||||
|
const next = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length];
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
setTheme(next);
|
||||||
|
apply(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = ICON[theme];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={cycle}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
|
||||||
|
title={`Theme: ${LABEL[theme]} (click to change)`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
Theme: {LABEL[theme]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+201
-1
@@ -140,6 +140,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/auth/change-password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Change Password */
|
||||||
|
post: operations["change_password_api_v1_auth_change_password_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/users/me": {
|
"/api/v1/users/me": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -151,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;
|
||||||
@@ -177,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;
|
||||||
@@ -621,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 */
|
||||||
@@ -998,6 +1070,13 @@ export interface components {
|
|||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
||||||
|
/** PasswordChange */
|
||||||
|
PasswordChange: {
|
||||||
|
/** Current Password */
|
||||||
|
current_password: string;
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
/** PasswordResetConfirm */
|
/** PasswordResetConfirm */
|
||||||
PasswordResetConfirm: {
|
PasswordResetConfirm: {
|
||||||
/** Token */
|
/** Token */
|
||||||
@@ -1253,6 +1332,8 @@ export interface components {
|
|||||||
* Format: uuid
|
* Format: uuid
|
||||||
*/
|
*/
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
|
/** Home Person Id */
|
||||||
|
home_person_id?: string | null;
|
||||||
/**
|
/**
|
||||||
* Created At
|
* Created At
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
@@ -1266,6 +1347,8 @@ export interface components {
|
|||||||
/** Description */
|
/** Description */
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
visibility?: components["schemas"]["TreeVisibility"] | null;
|
visibility?: components["schemas"]["TreeVisibility"] | null;
|
||||||
|
/** Home Person Id */
|
||||||
|
home_person_id?: string | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* TreeVisibility
|
* TreeVisibility
|
||||||
@@ -1545,6 +1628,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
change_password_api_v1_auth_change_password_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordChange"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
read_me_api_v1_users_me_get: {
|
read_me_api_v1_users_me_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1565,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;
|
||||||
@@ -1598,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?: {
|
||||||
|
|||||||
@@ -259,6 +259,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/auth/change-password": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"summary": "Change Password",
|
||||||
|
"operationId": "change_password_api_v1_auth_change_password_post",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasswordChange"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/users/me": {
|
"/api/v1/users/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -278,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": {
|
||||||
@@ -322,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": [
|
||||||
@@ -2574,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": {
|
||||||
@@ -3890,6 +4048,25 @@
|
|||||||
"title": "ParentChildQualifier",
|
"title": "ParentChildQualifier",
|
||||||
"description": "Qualifies a parent_child edge so adoption/donor/blended families are\nfirst-class rather than edge cases (ARCHITECTURE \u00a75)."
|
"description": "Qualifies a parent_child edge so adoption/donor/blended families are\nfirst-class rather than edge cases (ARCHITECTURE \u00a75)."
|
||||||
},
|
},
|
||||||
|
"PasswordChange": {
|
||||||
|
"properties": {
|
||||||
|
"current_password": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Current Password"
|
||||||
|
},
|
||||||
|
"new_password": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 8,
|
||||||
|
"title": "New Password"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"current_password",
|
||||||
|
"new_password"
|
||||||
|
],
|
||||||
|
"title": "PasswordChange"
|
||||||
|
},
|
||||||
"PasswordResetConfirm": {
|
"PasswordResetConfirm": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"token": {
|
||||||
@@ -4702,6 +4879,18 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Owner Id"
|
"title": "Owner Id"
|
||||||
},
|
},
|
||||||
|
"home_person_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Home Person Id"
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
@@ -4752,6 +4941,18 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"home_person_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Home Person Id"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
Reference in New Issue
Block a user