c5631d3eab
Provenance had no system-level owner: ownership was only per-tree (TreeMembership), so a self-hosted instance had no operator account and no instance-admin surface. This adds one, declared by environment per the project's twelve-factor rule. - OWNER_EMAIL (comma-separated): the account(s) named here are instance owners. Derived at request time — no DB column, no migration, can't drift from the env, survives DB resets. is_instance_owner()/InstanceOwner dependency in api/deps.py. - Ownership requires a VERIFIED email (independent of REQUIRE_EMAIL_VERIFICATION). Registration is open, so without this an attacker could seize the role by registering the owner address first; verification ties it to inbox control. - GET /api/v1/admin/instance (owner-only): operational status — version, env, user/tree counts, configured AI providers. Deliberately exposes no tree data or PII: instance ownership is an operator role, NOT a privacy-engine bypass. - /users/me reports is_instance_owner; frontend gains an owner-only /admin page and a conditional sidebar link (server-enforced, not just client-hidden). Found-and-fixed by an adversarial security review before merge: the verified-email land-grab (above) and a frontend null-deref where the admin page crashed on 401/5xx instead of failing closed. Docs: .env.example + ARCHITECTURE (notes the not-a-privacy-bypass boundary and the verified-email requirement). Tests: owner matching, the land-grab guard, /users/me, and owner-only /admin. Suite 96 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import { api } from "@/lib/api/client";
|
|
import type { components } from "@/lib/api/schema";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
type Instance = components["schemas"]["InstanceStatus"];
|
|
|
|
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs uppercase tracking-wider text-[var(--muted)]">{label}</div>
|
|
<div className="text-sm font-medium">{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const [instance, setInstance] = useState<Instance | null>(null);
|
|
const [forbidden, setForbidden] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
const { data, response } = await api.GET("/api/v1/admin/instance");
|
|
if (response.status === 403) setForbidden(true);
|
|
else if (data) setInstance(data);
|
|
setReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
if (!ready) return <div className="p-6 text-sm text-[var(--muted)]">Loading…</div>;
|
|
|
|
// Fail closed on anything that isn't a successful owner load: 403 (not owner),
|
|
// 401 (not signed in), or any 5xx all land here rather than dereferencing null.
|
|
if (forbidden || !instance) {
|
|
return (
|
|
<div className="mx-auto max-w-2xl p-6">
|
|
<h1 className="text-xl font-semibold">Instance admin</h1>
|
|
<p className="mt-2 text-sm text-[var(--muted)]">
|
|
{forbidden
|
|
? "This area is for the instance owner only. Set OWNER_EMAIL in the server environment to your account email (and verify that email) to claim it."
|
|
: "Instance status is unavailable right now. Make sure you're signed in as the instance owner."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const i = instance;
|
|
return (
|
|
<div className="mx-auto max-w-2xl p-6">
|
|
<h1 className="text-xl font-semibold">Instance admin</h1>
|
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
|
Operational status for this deployment. You see this because your account is
|
|
named in <code>OWNER_EMAIL</code>. Instance ownership is an operator role — it
|
|
does not grant access to other people's private tree data.
|
|
</p>
|
|
|
|
<Card className="mt-6">
|
|
<CardContent className="grid grid-cols-2 gap-5 py-6 sm:grid-cols-3">
|
|
<Stat label="Version" value={i.version} />
|
|
<Stat label="Environment" value={i.env} />
|
|
<Stat label="Users" value={i.user_count} />
|
|
<Stat label="Trees" value={i.tree_count} />
|
|
<Stat
|
|
label="Email verification"
|
|
value={i.require_email_verification ? "required" : "off"}
|
|
/>
|
|
<Stat label="Owner(s)" value={i.owner_emails.join(", ") || "—"} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="mt-4">
|
|
<CardContent className="flex flex-col gap-3 py-6">
|
|
<div className="text-sm font-medium">AI providers (instance-wide)</div>
|
|
{i.ai_providers.length === 0 ? (
|
|
<div className="text-sm text-[var(--muted)]">
|
|
None configured. Set provider credentials (Anthropic, OpenAI, x.AI, or
|
|
Ollama) in the server environment.
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-1 text-sm">
|
|
{i.ai_providers.map((p) => (
|
|
<li key={p.name} className="flex items-center justify-between">
|
|
<span className="font-medium">{p.name}</span>
|
|
<span className="text-[var(--muted)]">{p.model}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="text-xs text-[var(--muted)]">
|
|
Default provider: {i.default_llm_provider}. Per-tree AI policy is set on
|
|
each tree's AI page.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|