feat: permissions, a scoped console, and catalog management (#7)
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

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:
dhkatz authored and dhkatz committed 2026-11-05 13:13:42 +00:00
1 parent ed3a188b82
commit 2cbedd26fc
29 files changed
+5657 -181

No files matched your search

+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.";
});
}