feat: permissions, a console for everyone who manages something, and catalog management
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m1s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-04 23:19:15 -08:00
1 parent 76497c9b41
commit 370ae0646a
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.";
});
}