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,80 @@
|
||||
import Link from "next/link";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AuditEntry } from "@/lib/api";
|
||||
|
||||
const tagLinks: Record<string, (id: string) => string> = {
|
||||
catalog: (id) => `/console/catalogs/${id}`,
|
||||
product: (id) => `/products/${id}`,
|
||||
room: (id) => `/rooms/${id}/edit`,
|
||||
};
|
||||
|
||||
/** Audit entries: when, who, what, which resources (linked), and the details. */
|
||||
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>About</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
|
||||
{e.actor}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">hub</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell className="space-x-2 text-sm">
|
||||
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
|
||||
if (!id) return null;
|
||||
const name = (e.tagNames as Record<string, string>)[id] ?? id;
|
||||
const href = tagLinks[kind]?.(id);
|
||||
return (
|
||||
<span key={kind}>
|
||||
<span className="text-muted-foreground">{kind} </span>
|
||||
{href ? (
|
||||
<Link href={href} className="underline-offset-4 hover:underline">
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{Object.keys(e.tags).length === 0 && e.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">{detailText(e.detail)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function detailText(detail: unknown): string {
|
||||
return Object.entries((detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => {
|
||||
if (k === "changed" && v && typeof v === "object")
|
||||
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
|
||||
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
|
||||
.join(", ");
|
||||
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { AuditTable } from "../audit-table";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit Log" };
|
||||
|
||||
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
|
||||
type Filters = Partial<Record<(typeof filters)[number], string>>;
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
const { console: data } = await requireConsole("/console/audit");
|
||||
const params = await searchParams;
|
||||
const f: Filters = {};
|
||||
for (const k of filters) {
|
||||
const v = params[k];
|
||||
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
|
||||
}
|
||||
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
|
||||
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
|
||||
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
|
||||
const moderator = data.moderation !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit Log">
|
||||
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
|
||||
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
|
||||
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
|
||||
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
|
||||
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
|
||||
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
|
||||
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
|
||||
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">Filter</Button>
|
||||
{Object.keys(f).length > 0 && (
|
||||
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
|
||||
Clear
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{next && entries.length === 100 && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CatalogStatus } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, RatingTag } from "@/components/products";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
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, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
|
||||
import { AuditTable } from "../../audit-table";
|
||||
import { ConsoleNav, requireConsole } from "../../console-nav";
|
||||
import {
|
||||
addOperator,
|
||||
createInvite,
|
||||
decideMember,
|
||||
delist,
|
||||
removeFromTeam,
|
||||
revokeInvite,
|
||||
rotateKey,
|
||||
saveCatalog,
|
||||
setSuspended,
|
||||
} from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalog" };
|
||||
|
||||
type Perm = CatalogPage["permissions"][number];
|
||||
|
||||
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { me } = await requireConsole(`/console/catalogs/${id}`);
|
||||
const answer = await api.catalogs({ id }).get();
|
||||
if (answer.error) notFound();
|
||||
const data = unwrap(answer);
|
||||
const has = (p: Perm) => data.permissions.includes(p);
|
||||
if (!has("audit:read")) notFound();
|
||||
const c = data.catalog;
|
||||
|
||||
const [team, members, invites, hosted, audit] = await Promise.all([
|
||||
api.catalogs({ id }).team.get().then(unwrap),
|
||||
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
|
||||
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
|
||||
api.catalogs({ id }).products.get().then(unwrap),
|
||||
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
|
||||
]);
|
||||
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
|
||||
const onTeam = team.roles.some((r) => r.personId === me.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={c.name}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<CatalogStatus status={c.status} />
|
||||
<span className="font-mono text-xs">{c.url}</span>
|
||||
{c.status === "approved" && (
|
||||
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
|
||||
Public page
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
{(c.status === "suspended" || c.status === "rejected") && (
|
||||
<Alert>
|
||||
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{c.statusReason || "No reason given."}{" "}
|
||||
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
|
||||
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
|
||||
{data.members && (
|
||||
<>
|
||||
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
|
||||
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveCatalog} fields={{ id }}>
|
||||
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="url">Address</FieldLabel>
|
||||
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
|
||||
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
|
||||
</Field>
|
||||
<Choice
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
value={c.visibility}
|
||||
options={[
|
||||
["public", "Public (listed)"],
|
||||
["private", "Private (by link)"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="membership"
|
||||
label="Membership"
|
||||
value={c.membership}
|
||||
options={[
|
||||
["open", "Open"],
|
||||
["approval", "By approval"],
|
||||
["invite", "Invite only"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="maxRating"
|
||||
label="Highest rating"
|
||||
value={c.maxRating}
|
||||
options={[
|
||||
["general", "General only"],
|
||||
["adult", "Adult too"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="uploads"
|
||||
label="Who may upload"
|
||||
value={c.uploads}
|
||||
options={[
|
||||
["anyone", "Anyone"],
|
||||
["members", "Members"],
|
||||
["team", "Only the team"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="mirrors"
|
||||
label="Mirroring others' products"
|
||||
value={c.mirrors}
|
||||
options={[
|
||||
["auto", "Accept"],
|
||||
["approve", "Team approves"],
|
||||
["off", "Off"],
|
||||
]}
|
||||
/>
|
||||
</fieldset>
|
||||
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:rotate-key") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key</CardTitle>
|
||||
<CardDescription>
|
||||
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={rotateKey} fields={{ id }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
|
||||
New key
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team</CardTitle>
|
||||
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="divide-y text-sm">
|
||||
{team.owner && (
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span>{team.owner.username}</span>
|
||||
<Tag tone="strong">owner</Tag>
|
||||
</li>
|
||||
)}
|
||||
{team.roles.map((r) => (
|
||||
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>{r.username}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>operator</Tag>
|
||||
{(has("team:manage") || r.personId === me.id) && (
|
||||
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pendingLabel="Removing…"
|
||||
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
|
||||
>
|
||||
{r.personId === me.id ? "Leave" : "Remove"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{has("team:manage") && (
|
||||
<ActionForm action={addOperator} fields={{ id }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
{!onTeam && team.owner?.personId !== me.id && (
|
||||
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{members && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
{c.membership === "open"
|
||||
? "Open: anyone can get its products. Bans still apply."
|
||||
: c.membership === "approval"
|
||||
? "By approval: people ask, the team decides."
|
||||
: "Invite only: people join with an invitation."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
|
||||
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
|
||||
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
|
||||
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton variant="outline" pendingLabel="Adding…">
|
||||
Add member
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invites && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitations</CardTitle>
|
||||
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invites.invites.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Used</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.invites.map((i) => (
|
||||
<TableRow key={i.code}>
|
||||
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{i.uses}
|
||||
{i.maxUses !== null && ` of ${i.maxUses}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
|
||||
Revoke
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<ActionForm action={createInvite} fields={{ id }}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
|
||||
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
|
||||
</Field>
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
|
||||
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
|
||||
</Field>
|
||||
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Products</CardTitle>
|
||||
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hosted.products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Creator</TableHead>
|
||||
<TableHead>Revisions</TableHead>
|
||||
<TableHead>Since</TableHead>
|
||||
{has("hosting:delist") && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hosted.products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
|
||||
{p.removed && <Tag tone="danger">removed</Tag>}
|
||||
{p.deleted && <Tag>deleted</Tag>}
|
||||
</TableCell>
|
||||
<TableCell>{p.creator}</TableCell>
|
||||
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={p.since} dateOnly />
|
||||
</TableCell>
|
||||
{has("hosting:delist") && (
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={delist} fields={{ id, product: p.id }}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
|
||||
Delist
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moderation</CardTitle>
|
||||
<CardDescription>
|
||||
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={setSuspended} fields={{ id }}>
|
||||
{c.status === "suspended" ? (
|
||||
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
|
||||
Reinstate
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<div className="flex max-w-xl gap-2">
|
||||
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
|
||||
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
|
||||
Suspend
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
|
||||
All changes to this catalog
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditTable entries={audit.entries} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
||||
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
|
||||
{options.map(([v, l]) => (
|
||||
<NativeSelectOption key={v} value={v}>
|
||||
{l}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const memberActions = {
|
||||
approve: { label: "Approve", variant: "default" as const },
|
||||
decline: { label: "Decline", variant: "outline" as const },
|
||||
ban: { label: "Ban", variant: "outline" as const },
|
||||
remove: { label: "Remove", variant: "outline" as const },
|
||||
unban: { label: "Unban", variant: "outline" as const },
|
||||
};
|
||||
|
||||
function MemberList({
|
||||
id,
|
||||
title,
|
||||
members,
|
||||
actions,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
members: CatalogMember[];
|
||||
actions: (keyof typeof memberActions)[];
|
||||
}) {
|
||||
if (members.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
|
||||
</h3>
|
||||
<ul className="divide-y text-sm">
|
||||
{members.map((m) => (
|
||||
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>
|
||||
{m.username}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{m.invited ? "invited, " : ""}
|
||||
<DateTime iso={m.since} dateOnly />
|
||||
</span>
|
||||
</span>
|
||||
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
|
||||
<div className="flex gap-2">
|
||||
{actions.map((a) => (
|
||||
<SubmitButton
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={memberActions[a].variant}
|
||||
name="decision"
|
||||
value={a}
|
||||
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
|
||||
>
|
||||
{memberActions[a].label}
|
||||
</SubmitButton>
|
||||
))}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.";
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { decideCatalog } from "../actions";
|
||||
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalogs" };
|
||||
|
||||
export default async function ConsoleCatalogs() {
|
||||
await requireModerator("/console/catalogs");
|
||||
const { catalogs } = unwrap(await api.console.catalogs.get());
|
||||
await requireConsole("/console/catalogs");
|
||||
const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Catalogs" />
|
||||
<PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
|
||||
<ConsoleNav />
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() {
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Takedown contact</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
{moderator && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>{" "}
|
||||
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.url}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
|
||||
</TableCell>
|
||||
@@ -51,29 +55,31 @@ export default async function ConsoleCatalogs() {
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status !== "approved" && (
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
)}
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
{moderator && (
|
||||
<TableCell className="text-right">
|
||||
{(c.status === "pending" || c.status === "rejected") && (
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { NavLink } from "@/components/header-client";
|
||||
import { getMe, isModerator, type Person } from "@/lib/api";
|
||||
import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
|
||||
|
||||
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
|
||||
/** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
|
||||
export async function requireModerator(path: string): Promise<Person> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
@@ -10,14 +11,47 @@ export async function requireModerator(path: string): Promise<Person> {
|
||||
return me;
|
||||
}
|
||||
|
||||
export function ConsoleNav() {
|
||||
/** The console as this person sees it (once per request); undefined if it isn't for them. */
|
||||
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
|
||||
const answer = await api.console.get();
|
||||
if (answer.error) return undefined;
|
||||
return unwrap(answer);
|
||||
});
|
||||
|
||||
/**
|
||||
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
|
||||
* is for those controlling a product, catalog or room.
|
||||
*/
|
||||
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
const data = await loadConsole();
|
||||
if (!data) notFound();
|
||||
return { me, console: data };
|
||||
}
|
||||
|
||||
type Section = ConsoleData["sections"][number];
|
||||
|
||||
// Products are managed on their own pages for now; settings are on the overview.
|
||||
const tabs: { section: Section; href: string; label: string }[] = [
|
||||
{ section: "overview", href: "/console", label: "Overview" },
|
||||
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
|
||||
{ section: "people", href: "/console/people", label: "People" },
|
||||
{ section: "audit", href: "/console/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
/** The console's sections this person may see. */
|
||||
export async function ConsoleNav() {
|
||||
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
|
||||
return (
|
||||
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||
<NavLink href="/console" exact>
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
{tabs
|
||||
.filter((t) => sections.has(t.section))
|
||||
.map((t) => (
|
||||
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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