feat: age verification with an ID document (Didit), and closed registration
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

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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-03 04:15:47 -08:00
1 parent 86ebe976ba
commit c75a4c9afc
28 files changed
+3260 -89

No files matched your search

+13
View File
@@ -1,5 +1,6 @@
"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";
@@ -58,3 +59,15 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
return "Signed out everywhere.";
});
}
/** Starts an age check with a provider that checks the person, and sends them to its page. */
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
let url: string | undefined;
const state = await attempt(async () => {
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
if (started.kind === "redirect") url = started.url;
done();
});
if (url) redirect(url);
return state;
}
+68
View File
@@ -0,0 +1,68 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AgeInfo } from "@/lib/api";
import { startAgeCheck } from "./actions";
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
const ways = age.providers.filter((p) => p.scope === "person");
if (ways.length === 0) return null;
const now = Date.now();
const verified = age.checks.find(
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
);
const latest = age.checks[0];
const pending = latest?.status === "pending" ? latest : undefined;
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
return (
<Card>
<CardHeader>
<CardTitle>Age Verification</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{verified ? (
<p className="flex flex-wrap items-center gap-2">
<Tag tone="adult">Age verified</Tag>
<span className="text-muted-foreground">
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
IMVU accounts count as adult.
</span>
</p>
) : (
<>
{pending ? (
<p>
{back
? "Thanks. Your check is being processed; this page shows the result when it's done."
: "Your check isn't finished yet."}
</p>
) : latest?.status === "complete" && !latest.overEighteen ? (
<p>Your last check found you're under 18.</p>
) : latest?.status === "failed" || latest?.status === "revoked" ? (
<p>Your last check didn&apos;t pass. You can try again.</p>
) : (
<p className="text-muted-foreground">
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
</p>
)}
{ways.map((p) => (
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
<div className="space-y-2">
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
</SubmitButton>
<p className="text-xs text-muted-foreground">
Done by {p.vendor}. T5DE only keeps whether you&apos;re over 18, never your ID, photo or birth date.
</p>
</div>
</ActionForm>
))}
</>
)}
</CardContent>
</Card>
);
}
+9 -5
View File
@@ -8,17 +8,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card";
export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) {
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
const me = await getMe();
if (!me) redirect("/signin?next=/account");
const account = unwrap(await api.account.get());
const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]);
const { person, links, challenges, computers } = account;
const welcome = (await searchParams).welcome !== undefined;
const params = await searchParams;
const welcome = params.welcome !== undefined;
return (
<div className="space-y-6">
@@ -78,12 +80,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
</p>
)}
<p className="text-sm text-muted-foreground">
Other people can&apos;t see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
can see adult community products; each account is checked on its own.
Other people can&apos;t see which IMVU accounts are yours. Adult IMVU accounts can see adult community
products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
</p>
</CardContent>
</Card>
<AgeCard age={age} back={params.age === "back"} />
<Card>
<CardHeader>
<CardTitle>T5DE on your computers</CardTitle>
+23
View File
@@ -1,5 +1,6 @@
"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";
@@ -52,3 +53,25 @@ export async function decideCatalog(_: ActionState, form: FormData): Promise<Act
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.";
});
}
+41 -4
View File
@@ -1,19 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type AuditEntry } from "@/lib/api";
import { setRegistration } from "./actions";
import { ConsoleNav, requireModerator } from "./console-nav";
export const metadata: Metadata = { title: "Console" };
export default async function Console() {
await requireModerator("/console");
const data = unwrap(await api.console.get());
const me = await requireModerator("/console");
const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
const stats = [
{ label: "People", value: data.counts.people, note: "accounts" },
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" },
];
@@ -23,18 +28,50 @@ export default async function Console() {
<PageHeader title="Console">Moderation.</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
<CardContent className="text-sm text-muted-foreground">
{s.href ? (
<Link href={s.href} className="underline-offset-4 hover:underline">
{s.note}
</Link>
) : (
s.note
)}
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
{me.role === "admin" && (
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="closed">Closed</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Online now</CardTitle>
+31 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, ApiError, unwrap } from "@/lib/api";
import { ban, setLinkCap, setRole, unban } from "../../actions";
import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions";
import { ConsoleNav, requireModerator } from "../../console-nav";
export const metadata: Metadata = { title: "Person" };
@@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str
joined <DateTime iso={person.createdAt} dateOnly />
</span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
{!person.approved && (
<Card>
<CardHeader>
<CardTitle>Waiting for Approval</CardTitle>
<CardDescription>
{person.email}. Until approved, they can only sign in. Declining deletes the account.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decideAccount} fields={{ id: person.id }}>
<div className="flex gap-2">
<SubmitButton name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
<SubmitButton
name="decision"
value="decline"
variant="outline"
pendingLabel="Declining…"
confirm={`Decline ${person.username}? Their account is deleted.`}
>
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>IMVU accounts</CardTitle>
+30 -13
View File
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
export default async function People({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
}) {
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.slice(0, 64) : "";
const waiting = params.waiting === "true";
const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString();
await requireModerator(`/console/people${query ? `?${query}` : ""}`);
const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } }));
return (
<>
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<ConsoleNav />
<Card>
<CardContent className="space-y-6">
<form action="/console/people" className="flex max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
<Button type="submit" variant="outline">
Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-4">
<form action="/console/people" className="flex max-w-md flex-1 gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
{waiting && <input type="hidden" name="waiting" value="true" />}
<Button type="submit" variant="outline">
Search
</Button>
</form>
<Link
href={waiting ? "/console/people" : "/console/people?waiting=true"}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
{waiting ? "Show everyone" : "Waiting for approval"}
</Link>
</div>
{people.length === 0 ? (
<p className="text-sm text-muted-foreground">Nobody found.</p>
<p className="text-sm text-muted-foreground">{waiting ? "Nobody is waiting." : "Nobody found."}</p>
) : (
<Table>
<TableHeader>
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly />
</TableCell>
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
<TableCell className="text-right">
{p.banned ? <Tag tone="danger">banned</Tag> : !p.approved && <Tag tone="strong">waiting</Tag>}
</TableCell>
</TableRow>
))}
</TableBody>
+14 -2
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header";
import { NarrowCard } from "@/components/narrow-card";
import { StagingBanner } from "@/components/staging-banner";
import { getMe } from "@/lib/api";
import { cn } from "@/lib/utils";
import "./globals.css";
@@ -13,13 +15,23 @@ export const metadata: Metadata = {
description: "One account for all your IMVU avatars.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const me = await getMe();
return (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col">
<StagingBanner />
<Header />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">
{me && !me.approved ? (
<NarrowCard
title="Waiting for Approval"
description={`Thanks for signing up, ${me.username}. A moderator will approve your account soon; until then you can't use the hub.`}
/>
) : (
children
)}
</main>
<footer className="border-t">
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
T5DE is unofficial: not made by or affiliated with IMVU.
+7 -2
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api";
import { api, getMe, unwrap } from "@/lib/api";
import { SignUpForm } from "./signup-form";
export const metadata: Metadata = { title: "Create account" };
export default async function SignUp() {
if (await getMe()) redirect("/account");
const { registration } = unwrap(await api.hub.get());
const description =
registration === "closed"
? "One T5DE account for all your IMVU accounts. New accounts need a moderator's approval."
: "One T5DE account for all your IMVU accounts.";
return (
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
<NarrowCard title="Create account" description={description}>
<SignUpForm />
</NarrowCard>
);
+2 -2
View File
@@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr
}
/** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>;
}
+1
View File
@@ -37,6 +37,7 @@ export type Person = Data<V1["me"]["get"]>["person"];
export type Account = Data<V1["account"]["get"]>;
export type Link = Account["links"][number];
export type Challenge = Account["challenges"][number];
export type AgeInfo = Data<V1["age"]["get"]>;
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number];