Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first). **API** - `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`. - Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs. - Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership. - Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them. - Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries). **Website** - Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes). - Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs. **Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser. Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first). **API** - `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`. - Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs. - Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership. - Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them. - Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries). **Website** - Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes). - Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs. **Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser. Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: dhkatz <[email protected]>
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
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(", ");
|
|
}
|