Files
t5de-hub/apps/web/app/console/catalogs/[id]/page.tsx
T
dhkatz 2cbedd26fc
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 14s
feat: permissions, a scoped console, and catalog management (#7)
Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 13:13:42 +00:00

499 lines
20 KiB
TypeScript

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>
);
}