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]>
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
"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.";
|
|
});
|
|
}
|