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]>
This commit was merged in pull request #7.
This commit is contained in:
1 parent
ed3a188b82
commit
2cbedd26fc
29 files changed
+5657
-181
No files matched your search
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in new issue
Block a user