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(", ");
|
||||
}
|
||||
Reference in new issue
Block a user