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