Files
t5de-hub/apps/web/app/console/actions.ts
dhkatzandClaude Opus 5.5 c75a4c9afc
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 58s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: age verification with an ID document (Didit), and closed registration
Age: a Didit provider behind the vendor-neutral age interface. A person
verifies once (ID document, passive liveness, face match) and all their
linked IMVU accounts count as adult. Webhooks are checked against Didit's
signatures; only "over 18" is kept. A later answer replaces an earlier one
(an approval taken back is revoked). Checks are limited: one unfinished
check per person, 3 in 30 days, none once verified, and a monthly cap
(DIDIT_MONTHLY_LIMIT, default 30: Didit's free plan is $10 of credit a
month and a check costs $0.30). The Account page gets an Age Verification
card.

Registration: open or closed, an admin's setting in the console
(default from REGISTRATION). Closed: new accounts can only sign in until
a moderator approves them (or declines, deleting the account); the
website shows them a waiting page and the API treats them as signed out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:15:47 -08:00

78 lines
3.0 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const roles = ["user", "moderator", "admin"] as const;
/** Runs a change to a person from the console, then shows their page again. */
async function onPerson(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/people/${id}`);
return notice;
});
}
export async function setLinkCap(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
const linkCap = Number(field(form, "linkCap"));
const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap }));
return `Link cap set to ${person.linkCap}.`;
});
}
export async function ban(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") }));
});
}
export async function unban(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
unwrap(await api.console.people({ id }).unban.post());
});
}
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
const role = roles.find((r) => r === field(form, "role")) ?? "user";
const { person } = unwrap(await api.console.people({ id }).role.post({ role }));
return `Role set to ${person.role}.`;
});
}
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
return attempt(async () => {
unwrap(await api.console.catalogs({ id })({ decision }).post());
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
});
}
/** An account waiting for approval: approve it, or decline it (deleting it). */
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decline = field(form, "decision") === "decline";
const state = await attempt(async () => {
if (decline) unwrap(await api.console.people({ id }).decline.post());
else unwrap(await api.console.people({ id }).approve.post());
revalidatePath("/console", "layout");
});
if (decline && !state?.error) redirect("/console/people?waiting=true");
return state;
}
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
const registration = field(form, "registration") === "closed" ? "closed" : "open";
unwrap(await api.console.settings.put({ registration }));
revalidatePath("/console");
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
});
}