feat: permissions, a console for everyone who manages something, and catalog management
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:
1 parent
76497c9b41
commit
370ae0646a
29 files changed
+5657
-181
No files matched your search
@@ -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.";
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user