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]>
499 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|