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:
@@ -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");
|
||||
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}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
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);
|
||||
setRels(r.data ?? []);
|
||||
setEvents(e.data ?? []);
|
||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function PersonDetailPage() {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [names, setNames] = useState<Name[]>([]);
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
@@ -168,12 +169,13 @@ export default function PersonDetailPage() {
|
||||
return;
|
||||
}
|
||||
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/{person_id}/names", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
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", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
@@ -186,6 +188,7 @@ export default function PersonDetailPage() {
|
||||
setPeople(all.data ?? []);
|
||||
setNames(nm.data ?? []);
|
||||
setMe(mine.data ?? null);
|
||||
setTree(tr.data ?? null);
|
||||
setEvents(ev.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setSources(src.data ?? []);
|
||||
@@ -387,6 +390,14 @@ export default function PersonDetailPage() {
|
||||
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) {
|
||||
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
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>;
|
||||
|
||||
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.
|
||||
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
||||
@@ -561,15 +573,20 @@ export default function PersonDetailPage() {
|
||||
</form>
|
||||
) : (
|
||||
<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"}
|
||||
{isSelf && (
|
||||
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
|
||||
This is you
|
||||
</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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{citeControl("p", { person_id: personId }, personCites)}
|
||||
{isSelf ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
|
||||
@@ -580,6 +597,11 @@ export default function PersonDetailPage() {
|
||||
This is me
|
||||
</Button>
|
||||
)}
|
||||
{!isDefault && (
|
||||
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
|
||||
Set as default
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
@@ -50,16 +50,20 @@ export default function TreePage() {
|
||||
router.push("/login");
|
||||
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}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
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);
|
||||
setRels(r.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");
|
||||
})().catch(() => !cancelled && setStatus("error"));
|
||||
return () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
@@ -12,6 +13,10 @@ import { AppSidebar } from "@/components/app-sidebar";
|
||||
*/
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
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 (
|
||||
<div className="flex min-h-screen">
|
||||
@@ -49,7 +54,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
Image as ImageIcon,
|
||||
LogOut,
|
||||
Network,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -23,6 +24,9 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : 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(() => {
|
||||
if (!treeId) {
|
||||
@@ -34,6 +38,18 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
.then((r) => setTreeName(r.data?.name ?? null));
|
||||
}, [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() {
|
||||
onNavigate?.();
|
||||
await api.POST("/api/v1/auth/logout");
|
||||
@@ -120,13 +136,41 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-4 w-4 shrink-0" />
|
||||
Sign out
|
||||
</button>
|
||||
<div ref={menuRef} className="relative mt-auto">
|
||||
{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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+59
@@ -140,6 +140,23 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -998,6 +1015,13 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
||||
/** PasswordChange */
|
||||
PasswordChange: {
|
||||
/** Current Password */
|
||||
current_password: string;
|
||||
/** New Password */
|
||||
new_password: string;
|
||||
};
|
||||
/** PasswordResetConfirm */
|
||||
PasswordResetConfirm: {
|
||||
/** Token */
|
||||
@@ -1253,6 +1277,8 @@ export interface components {
|
||||
* Format: uuid
|
||||
*/
|
||||
owner_id: string;
|
||||
/** Home Person Id */
|
||||
home_person_id?: string | null;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
@@ -1266,6 +1292,8 @@ export interface components {
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
visibility?: components["schemas"]["TreeVisibility"] | null;
|
||||
/** Home Person Id */
|
||||
home_person_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* 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: {
|
||||
parameters: {
|
||||
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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3890,6 +3924,25 @@
|
||||
"title": "ParentChildQualifier",
|
||||
"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": {
|
||||
"properties": {
|
||||
"token": {
|
||||
@@ -4702,6 +4755,18 @@
|
||||
"format": "uuid",
|
||||
"title": "Owner Id"
|
||||
},
|
||||
"home_person_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Home Person Id"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
@@ -4752,6 +4817,18 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"home_person_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Home Person Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user