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]>
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
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'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>
|
|
);
|
|
}
|