feat: permissions, a console for everyone who manages something, and catalog management
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m1s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

Phase 1 of docs/console.md: permissions as resource:action strings in role
bundles at a scope; the console for anyone controlling a catalog, product
or room, with a tagged audit log scoped by permission and filters;
catalog settings (rating ceiling, upload policy), key rotation, operators,
membership (approval, invitations, bans) enforced when getting and
wearing products, delisting, suspension, and the catalogs' changes feed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-04 23:19:15 -08:00
1 parent 76497c9b41
commit 370ae0646a
29 files changed
+5657 -181

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { membership as membershipLabel } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api, getMe, unwrap } from "@/lib/api";
import { membership } from "../../console/catalogs/actions";
export const metadata: Metadata = { title: "Catalog" };
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ invite?: string | string[] }>;
}) {
const { id } = await params;
const raw = (await searchParams).invite;
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
if (answer.error) notFound();
const data = unwrap(answer);
const c = data.catalog;
const manage = data.permissions.includes("audit:read");
return (
<div className="space-y-6">
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<span>{membershipLabel[c.membership] ?? c.membership}</span>
{c.maxRating === "general" && <Tag>General audiences</Tag>}
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
{data.membership === "requested" && <Tag>Asked to join</Tag>}
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
</span>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Products</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Takedown Contact</CardDescription>
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Membership</CardTitle>
<CardDescription>
{c.membership === "open"
? "Anyone on T5DE can get its products."
: c.membership === "approval"
? "Ask to join; its team decides. Anyone can see its products on others."
: "Join with an invitation. Anyone can see its products on others."}
</CardDescription>
</CardHeader>
<CardContent>
{!me ? (
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
Sign in to join
</Link>
) : data.membership === "banned" ? (
<p className="text-sm text-muted-foreground">This catalog&apos;s team banned you from it.</p>
) : data.membership === "member" || data.membership === "requested" ? (
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
{data.membership === "member" ? "Leave" : "Withdraw request"}
</SubmitButton>
</ActionForm>
) : c.membership === "open" ? (
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
) : c.membership === "invite" && !invite ? (
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
) : (
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
{manage && (
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
Manage
</Link>
)}
</div>
);
}
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -71,7 +72,11 @@ function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; s
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-medium">
<Link href={showStatus ? `/console/catalogs/${c.id}` : `/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { DateTime } from "@/components/time";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AuditEntry } from "@/lib/api";
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/console/catalogs/${id}`,
product: (id) => `/products/${id}`,
room: (id) => `/rooms/${id}/edit`,
};
/** Audit entries: when, who, what, which resources (linked), and the details. */
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Who</TableHead>
<TableHead>What</TableHead>
<TableHead>About</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-muted-foreground">
<DateTime iso={e.at} />
</TableCell>
<TableCell>
{e.actor ? (
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
{e.actor}
</Link>
) : (
<span className="text-muted-foreground">hub</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{e.action}</TableCell>
<TableCell className="space-x-2 text-sm">
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
if (!id) return null;
const name = (e.tagNames as Record<string, string>)[id] ?? id;
const href = tagLinks[kind]?.(id);
return (
<span key={kind}>
<span className="text-muted-foreground">{kind} </span>
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{name}
</Link>
) : (
name
)}
</span>
);
})}
{Object.keys(e.tags).length === 0 && e.target}
</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">{detailText(e.detail)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function detailText(detail: unknown): string {
return Object.entries((detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => {
if (k === "changed" && v && typeof v === "object")
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
.join(", ");
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
})
.join(", ");
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/status";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api, unwrap } from "@/lib/api";
import { AuditTable } from "../audit-table";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Audit Log" };
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
type Filters = Partial<Record<(typeof filters)[number], string>>;
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
const { console: data } = await requireConsole("/console/audit");
const params = await searchParams;
const f: Filters = {};
for (const k of filters) {
const v = params[k];
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
}
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
const moderator = data.moderation !== null;
return (
<>
<PageHeader title="Audit Log">
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
</PageHeader>
<ConsoleNav />
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
<div className="flex gap-2">
<Button type="submit">Filter</Button>
{Object.keys(f).length > 0 && (
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</div>
</form>
<Card>
<CardContent>
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
</CardContent>
</Card>
{next && entries.length === 100 && (
<div className="mt-4">
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
+498
View File
@@ -0,0 +1,498 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CatalogStatus } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, RatingTag } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
import { AuditTable } from "../../audit-table";
import { ConsoleNav, requireConsole } from "../../console-nav";
import {
addOperator,
createInvite,
decideMember,
delist,
removeFromTeam,
revokeInvite,
rotateKey,
saveCatalog,
setSuspended,
} from "../actions";
export const metadata: Metadata = { title: "Catalog" };
type Perm = CatalogPage["permissions"][number];
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { me } = await requireConsole(`/console/catalogs/${id}`);
const answer = await api.catalogs({ id }).get();
if (answer.error) notFound();
const data = unwrap(answer);
const has = (p: Perm) => data.permissions.includes(p);
if (!has("audit:read")) notFound();
const c = data.catalog;
const [team, members, invites, hosted, audit] = await Promise.all([
api.catalogs({ id }).team.get().then(unwrap),
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
api.catalogs({ id }).products.get().then(unwrap),
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
]);
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
const onTeam = team.roles.some((r) => r.personId === me.id);
return (
<>
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<CatalogStatus status={c.status} />
<span className="font-mono text-xs">{c.url}</span>
{c.status === "approved" && (
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
Public page
</Link>
)}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
{(c.status === "suspended" || c.status === "rejected") && (
<Alert>
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
<AlertDescription>
{c.statusReason || "No reason given."}{" "}
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
{data.members && (
<>
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
</>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
</CardHeader>
<CardContent>
<ActionForm action={saveCatalog} fields={{ id }}>
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
</Field>
<Choice
name="visibility"
label="Visibility"
value={c.visibility}
options={[
["public", "Public (listed)"],
["private", "Private (by link)"],
]}
/>
<Choice
name="membership"
label="Membership"
value={c.membership}
options={[
["open", "Open"],
["approval", "By approval"],
["invite", "Invite only"],
]}
/>
<Choice
name="maxRating"
label="Highest rating"
value={c.maxRating}
options={[
["general", "General only"],
["adult", "Adult too"],
]}
/>
<Choice
name="uploads"
label="Who may upload"
value={c.uploads}
options={[
["anyone", "Anyone"],
["members", "Members"],
["team", "Only the team"],
]}
/>
<Choice
name="mirrors"
label="Mirroring others' products"
value={c.mirrors}
options={[
["auto", "Accept"],
["approve", "Team approves"],
["off", "Off"],
]}
/>
</fieldset>
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
</ActionForm>
</CardContent>
</Card>
{has("catalog:rotate-key") && (
<Card>
<CardHeader>
<CardTitle>Key</CardTitle>
<CardDescription>
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={rotateKey} fields={{ id }}>
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
New key
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Team</CardTitle>
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="divide-y text-sm">
{team.owner && (
<li className="flex items-center justify-between py-2">
<span>{team.owner.username}</span>
<Tag tone="strong">owner</Tag>
</li>
)}
{team.roles.map((r) => (
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
<span>{r.username}</span>
<span className="flex items-center gap-2">
<Tag>operator</Tag>
{(has("team:manage") || r.personId === me.id) && (
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Removing…"
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
>
{r.personId === me.id ? "Leave" : "Remove"}
</SubmitButton>
</ActionForm>
)}
</span>
</li>
))}
</ul>
{has("team:manage") && (
<ActionForm action={addOperator} fields={{ id }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
</div>
</ActionForm>
)}
{!onTeam && team.owner?.personId !== me.id && (
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
)}
</CardContent>
</Card>
{members && (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
{c.membership === "open"
? "Open: anyone can get its products. Bans still apply."
: c.membership === "approval"
? "By approval: people ask, the team decides."
: "Invite only: people join with an invitation."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton variant="outline" pendingLabel="Adding…">
Add member
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
{invites && (
<Card>
<CardHeader>
<CardTitle>Invitations</CardTitle>
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{invites.invites.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Link</TableHead>
<TableHead>Used</TableHead>
<TableHead>Expires</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{invites.invites.map((i) => (
<TableRow key={i.code}>
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
<TableCell className="tabular-nums">
{i.uses}
{i.maxUses !== null && ` of ${i.maxUses}`}
</TableCell>
<TableCell className="text-muted-foreground">
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
</TableCell>
<TableCell className="text-right">
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
Revoke
</SubmitButton>
</ActionForm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ActionForm action={createInvite} fields={{ id }}>
<div className="flex flex-wrap items-end gap-2">
<Field className="w-36">
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
</Field>
<Field className="w-36">
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
</Field>
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Products</CardTitle>
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
</CardHeader>
<CardContent>
{hosted.products.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Revisions</TableHead>
<TableHead>Since</TableHead>
{has("hosting:delist") && <TableHead />}
</TableRow>
</TableHeader>
<TableBody>
{hosted.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>{" "}
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
{p.removed && <Tag tone="danger">removed</Tag>}
{p.deleted && <Tag>deleted</Tag>}
</TableCell>
<TableCell>{p.creator}</TableCell>
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.since} dateOnly />
</TableCell>
{has("hosting:delist") && (
<TableCell className="text-right">
<ActionForm action={delist} fields={{ id, product: p.id }}>
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
Delist
</SubmitButton>
</div>
</ActionForm>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
<CardDescription>
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setSuspended} fields={{ id }}>
{c.status === "suspended" ? (
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
Reinstate
</SubmitButton>
) : (
<div className="flex max-w-xl gap-2">
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
Suspend
</SubmitButton>
</div>
)}
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Recent Changes</CardTitle>
<CardDescription>
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
All changes to this catalog
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<AuditTable entries={audit.entries} />
</CardContent>
</Card>
</div>
</>
);
}
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
</Card>
);
}
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
return (
<Field>
<FieldLabel htmlFor={name}>{label}</FieldLabel>
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
{options.map(([v, l]) => (
<NativeSelectOption key={v} value={v}>
{l}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
);
}
const memberActions = {
approve: { label: "Approve", variant: "default" as const },
decline: { label: "Decline", variant: "outline" as const },
ban: { label: "Ban", variant: "outline" as const },
remove: { label: "Remove", variant: "outline" as const },
unban: { label: "Unban", variant: "outline" as const },
};
function MemberList({
id,
title,
members,
actions,
}: {
id: string;
title: string;
members: CatalogMember[];
actions: (keyof typeof memberActions)[];
}) {
if (members.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-medium">
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
</h3>
<ul className="divide-y text-sm">
{members.map((m) => (
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
<span>
{m.username}{" "}
<span className="text-muted-foreground">
{m.invited ? "invited, " : ""}
<DateTime iso={m.since} dateOnly />
</span>
</span>
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
<div className="flex gap-2">
{actions.map((a) => (
<SubmitButton
key={a}
size="sm"
variant={memberActions[a].variant}
name="decision"
value={a}
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
>
{memberActions[a].label}
</SubmitButton>
))}
</div>
</ActionForm>
</li>
))}
</ul>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension.
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Runs a change to a catalog, then shows its pages again. */
async function onCatalog(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const notice = await change(id);
revalidatePath(`/console/catalogs/${id}`);
revalidatePath(`/catalogs/${id}`);
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
return notice;
});
}
const pick = <T extends string>(v: string, options: readonly T[]): T | undefined => options.find((o) => o === v);
export async function saveCatalog(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(
await api.catalogs({ id }).patch({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: pick(field(form, "visibility"), ["public", "private"] as const),
membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const),
maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const),
uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const),
mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const),
}),
);
return "Saved.";
});
}
export async function rotateKey(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const { key } = unwrap(await api.catalogs({ id }).key.post());
return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`;
});
}
export async function setSuspended(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post());
else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") }));
});
}
export async function addOperator(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const username = field(form, "username");
unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" }));
return `${username} is an operator now.`;
});
}
export async function removeFromTeam(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } }));
});
}
/** A member or request: approve, decline, add, ban, unban or remove. */
export async function decideMember(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const member = api.catalogs({ id }).members({ username: field(form, "username") });
switch (field(form, "decision")) {
case "approve":
case "add":
unwrap(await member.put({ status: "member" }));
break;
case "ban":
unwrap(await member.put({ status: "banned" }));
break;
default: // decline, unban, remove
unwrap(await member.delete());
}
});
}
export async function createInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const maxUses = Number(field(form, "maxUses")) || undefined;
const expiresInDays = Number(field(form, "expiresInDays")) || undefined;
const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays }));
return `Invitation link: ${invite.url}`;
});
}
export async function revokeInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete());
});
}
export async function delist(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const product = field(form, "product");
unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } }));
revalidatePath(`/products/${product}`);
});
}
/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */
export async function membership(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "leave") {
unwrap(await api.catalogs({ id }).join.delete());
revalidatePath("/library");
return "You left.";
}
const invite = field(form, "invite");
const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {}));
revalidatePath("/library");
return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide.";
});
}
+38 -32
View File
@@ -1,23 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { decideCatalog } from "../actions";
import { ConsoleNav, requireModerator } from "../console-nav";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Catalogs" };
export default async function ConsoleCatalogs() {
await requireModerator("/console/catalogs");
const { catalogs } = unwrap(await api.console.catalogs.get());
await requireConsole("/console/catalogs");
const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
return (
<>
<PageHeader title="Catalogs" />
<PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
<ConsoleNav />
<Card>
<CardContent>
@@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() {
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Takedown contact</TableHead>
<TableHead>Access</TableHead>
<TableHead>Registered</TableHead>
<TableHead>Status</TableHead>
<TableHead />
{moderator && <TableHead />}
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="font-medium">
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>{" "}
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
</TableCell>
<TableCell className="font-mono text-xs">{c.url}</TableCell>
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
<TableCell className="text-muted-foreground">
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
</TableCell>
@@ -51,29 +55,31 @@ export default async function ConsoleCatalogs() {
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
<TableCell className="text-right">
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<div className="flex justify-end gap-2">
{c.status !== "approved" && (
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
)}
{c.status !== "rejected" && (
<SubmitButton
size="sm"
variant="outline"
name="decision"
value="reject"
pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`}
>
Reject
</SubmitButton>
)}
</div>
</ActionForm>
</TableCell>
{moderator && (
<TableCell className="text-right">
{(c.status === "pending" || c.status === "rejected") && (
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<div className="flex justify-end gap-2">
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
{c.status !== "rejected" && (
<SubmitButton
size="sm"
variant="outline"
name="decision"
value="reject"
pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`}
>
Reject
</SubmitButton>
)}
</div>
</ActionForm>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
+42 -8
View File
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { NavLink } from "@/components/header-client";
import { getMe, isModerator, type Person } from "@/lib/api";
import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
/** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
export async function requireModerator(path: string): Promise<Person> {
const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
@@ -10,14 +11,47 @@ export async function requireModerator(path: string): Promise<Person> {
return me;
}
export function ConsoleNav() {
/** The console as this person sees it (once per request); undefined if it isn't for them. */
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
const answer = await api.console.get();
if (answer.error) return undefined;
return unwrap(answer);
});
/**
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
* is for those controlling a product, catalog or room.
*/
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
const data = await loadConsole();
if (!data) notFound();
return { me, console: data };
}
type Section = ConsoleData["sections"][number];
// Products are managed on their own pages for now; settings are on the overview.
const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "people", href: "/console/people", label: "People" },
{ section: "audit", href: "/console/audit", label: "Audit" },
];
/** The console's sections this person may see. */
export async function ConsoleNav() {
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
return (
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
<NavLink href="/console" exact>
Overview
</NavLink>
<NavLink href="/console/people">People</NavLink>
<NavLink href="/console/catalogs">Catalogs</NavLink>
{tabs
.filter((t) => sections.has(t.section))
.map((t) => (
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
{t.label}
</NavLink>
))}
</nav>
);
}
+86 -86
View File
@@ -3,61 +3,77 @@ import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type AuditEntry } from "@/lib/api";
import { api, unwrap } from "@/lib/api";
import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav";
import { AuditTable } from "./audit-table";
import { ConsoleNav, requireConsole } from "./console-nav";
export const metadata: Metadata = { title: "Console" };
type Stat = { label: string; value: number; note: string; href?: string };
export default async function Console() {
const me = await requireModerator("/console");
const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
const stats = [
{ label: "People", value: data.counts.people, note: "accounts" },
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" },
const { me, console: data } = await requireConsole("/console");
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const todo: Stat[] = [
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
];
if (data.moderation) {
todo.push(
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
);
}
const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
];
const hub: Stat[] = data.moderation
? [
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
]
: [];
return (
<>
<PageHeader title="Console">Moderation.</PageHeader>
<PageHeader title="Console">What you manage on T5DE.</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card>
))}
</div>
<StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
<StatGrid stats={mine.filter((s) => s.value > 0)} />
{hub.length > 0 && <StatGrid stats={hub} />}
<Card>
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
{me.role === "admin" && (
{data.moderation && (
<Card>
<CardHeader>
<CardTitle>Online Now</CardTitle>
<CardDescription>
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
the page loaded
</CardDescription>
</CardHeader>
<CardContent>
<OnlineList initial={data.moderation.online} moderator />
</CardContent>
</Card>
)}
{settings && (
<Card id="settings">
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
@@ -69,28 +85,21 @@ export default async function Console() {
</div>
</ActionForm>
</CardContent>
)}
</Card>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Online now</CardTitle>
<CardTitle>Recent Changes</CardTitle>
<CardDescription>
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
<Link href="/console/audit" className="underline-offset-4 hover:underline">
Full audit log
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<OnlineList initial={data.online} moderator />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>The latest 50 changes.</CardDescription>
</CardHeader>
<CardContent>
<AuditLog entries={data.audit} />
<AuditTable entries={data.audit} />
</CardContent>
</Card>
</div>
@@ -98,36 +107,27 @@ export default async function Console() {
);
}
function AuditLog({ entries }: { entries: AuditEntry[] }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
function StatGrid({ stats }: { stats: Stat[] }) {
if (stats.length === 0) return null;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Who</TableHead>
<TableHead>What</TableHead>
<TableHead>Target</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e, i) => (
<TableRow key={i}>
<TableCell className="text-muted-foreground">
<DateTime iso={e.at} />
</TableCell>
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
<TableCell className="font-mono text-xs">{e.action}</TableCell>
<TableCell>{e.target}</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(", ")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card>
))}
</div>
);
}
+16 -2
View File
@@ -19,7 +19,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine } = data;
const { product, revisions, inLibrary, stayOn, mine, available, catalogs } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
return (
@@ -62,10 +62,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
Remove
</SubmitButton>
</>
) : (
) : available ? (
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
Get
</SubmitButton>
) : (
<span className="text-sm text-muted-foreground">
Members only. Join{" "}
{catalogs.map((c, i) => (
<span key={c.id}>
{i > 0 && (i === catalogs.length - 1 ? " or " : ", ")}
<Link href={`/catalogs/${c.id}`} className="font-medium text-foreground underline-offset-4 hover:underline">
{c.name}
</Link>
{c.joined === "requested" && " (asked)"}
</span>
))}{" "}
to get it.
</span>
)}
</div>
</ActionForm>
+15 -5
View File
@@ -8,15 +8,24 @@ import { api, getMe, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Products" };
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]);
export default async function Products({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>;
}) {
const params = await searchParams;
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100);
const creator = one(params.creator, 64);
const [me, { products }] = await Promise.all([
getMe(),
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap),
]);
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">Community products for T5DE.</PageHeader>
<PageHeader title="Products">{creator ? `By ${creator}.` : "Community products for T5DE."}</PageHeader>
{me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
@@ -25,6 +34,7 @@ export default async function Products({ searchParams }: { searchParams: Promise
</div>
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
{creator && <input type="hidden" name="creator" value={creator} />}
<Button type="submit" variant="outline">
Search
</Button>