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
@@ -3,61 +3,77 @@ 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 { api, unwrap } from "@/lib/api";
|
||||
import { setRegistration } from "./actions";
|
||||
import { ConsoleNav, requireModerator } from "./console-nav";
|
||||
import { AuditTable } from "./audit-table";
|
||||
import { ConsoleNav, requireConsole } from "./console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Console" };
|
||||
|
||||
type Stat = { label: string; value: number; note: string; href?: string };
|
||||
|
||||
export default async function Console() {
|
||||
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" },
|
||||
const { me, console: data } = await requireConsole("/console");
|
||||
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
|
||||
|
||||
const todo: Stat[] = [
|
||||
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
|
||||
];
|
||||
if (data.moderation) {
|
||||
todo.push(
|
||||
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
|
||||
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
|
||||
);
|
||||
}
|
||||
const mine: Stat[] = [
|
||||
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` },
|
||||
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
|
||||
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
|
||||
];
|
||||
const hub: Stat[] = data.moderation
|
||||
? [
|
||||
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
|
||||
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
|
||||
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Console">Moderation.</PageHeader>
|
||||
<PageHeader title="Console">What you manage on T5DE.</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
<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.href ? (
|
||||
<Link href={s.href} className="underline-offset-4 hover:underline">
|
||||
{s.note}
|
||||
</Link>
|
||||
) : (
|
||||
s.note
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
|
||||
<StatGrid stats={mine.filter((s) => s.value > 0)} />
|
||||
{hub.length > 0 && <StatGrid stats={hub} />}
|
||||
|
||||
<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" && (
|
||||
{data.moderation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Online Now</CardTitle>
|
||||
<CardDescription>
|
||||
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
|
||||
the page loaded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OnlineList initial={data.moderation.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<Card id="settings">
|
||||
<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>
|
||||
<CardContent>
|
||||
<ActionForm action={setRegistration}>
|
||||
<div className="flex gap-2">
|
||||
@@ -69,28 +85,21 @@ export default async function Console() {
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Online now</CardTitle>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
|
||||
<Link href="/console/audit" className="underline-offset-4 hover:underline">
|
||||
Full audit log
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OnlineList initial={data.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit log</CardTitle>
|
||||
<CardDescription>The latest 50 changes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditLog entries={data.audit} />
|
||||
<AuditTable entries={data.audit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -98,36 +107,27 @@ export default async function Console() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||
function StatGrid({ stats }: { stats: Stat[] }) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell>{e.target}</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<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.href ? (
|
||||
<Link href={s.href} className="underline-offset-4 hover:underline">
|
||||
{s.note}
|
||||
</Link>
|
||||
) : (
|
||||
s.note
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user