Account menu + Settings (change password); per-tree home person; full-width tree
- Sidebar bottom-left now shows the signed-in user; clicking opens a menu with Settings and Sign out. New /settings page: account info + change password (POST /auth/change-password, re-verifies current password). Export/restore/ delete are stubbed there for the next pass. - Per-tree default/home person: tree.home_person_id (migration) + TreeUpdate/ Read; the tree and family views open focused on it; the person page gets a "Set as default" control and "Default person" badge. Cleared if that person is deleted. Complements the account-level "this is me" link. - Tree visualization now fills the content area (AppShell drops the max-width column on the /tree route); other pages stay centered. - Audit records are coerced JSON-safe (UUIDs/enums), so PATCHing UUID fields like home_person_id audits cleanly. 50 backend tests pass; migration up/down verified; frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
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 [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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Full-account export, restore, and account deletion are coming next.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ 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[]>([]);
|
||||||
@@ -168,12 +169,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] = 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 } },
|
||||||
}),
|
}),
|
||||||
@@ -186,6 +188,7 @@ export default function PersonDetailPage() {
|
|||||||
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 ?? []);
|
||||||
setRels(rl.data ?? []);
|
setRels(rl.data ?? []);
|
||||||
setSources(src.data ?? []);
|
setSources(src.data ?? []);
|
||||||
@@ -387,6 +390,14 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
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 +429,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 +573,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 +597,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>
|
||||||
|
|||||||
@@ -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,11 +8,12 @@ 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";
|
||||||
@@ -23,6 +24,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 +38,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 +136,41 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div ref={menuRef} className="relative mt-auto">
|
||||||
onClick={logout}
|
{menuOpen && (
|
||||||
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"
|
<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
|
||||||
<LogOut className="h-4 w-4 shrink-0" />
|
href="/settings"
|
||||||
Sign out
|
onClick={() => {
|
||||||
</button>
|
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
|
||||||
|
onClick={logout}
|
||||||
|
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" />
|
||||||
|
Sign out
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+59
@@ -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;
|
||||||
@@ -998,6 +1015,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 +1277,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 +1292,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 +1573,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;
|
||||||
|
|||||||
@@ -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": [
|
||||||
@@ -3890,6 +3924,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 +4755,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 +4817,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