From 2e9c2f32cb1db30d1c27b99650a0c436185fa20e Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 5 Nov 2026 15:37:50 -0800 Subject: [PATCH] feat(web): group the token permission picker by resource Each resource (Products, Members, Imports, ...) is a collapsed section with a select-all checkbox and a count of what is chosen, so the list stays short as permissions are added. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/account/token-form.tsx | 96 ++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/apps/web/app/account/token-form.tsx b/apps/web/app/account/token-form.tsx index 11122a3..90d4103 100644 --- a/apps/web/app/account/token-form.tsx +++ b/apps/web/app/account/token-form.tsx @@ -1,6 +1,8 @@ "use client"; // Making an API token: a name, how long it lasts, and what it may do where (some of the person's own permissions). +import { ChevronRightIcon } from "lucide-react"; import { useActionState, useState } from "react"; +import { CopyButton } from "@/components/copy-button"; import { ErrorText } from "@/components/forms"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -13,10 +15,40 @@ const kindOf = { hub: "Everything", catalog: "Catalog", product: "Product" } as type Row = { scope: string; permissions: string[] }; +// Permissions are `resource:action`; they're grouped by resource, in this order (new resources go last, by name). +const groupNames: Record = { + product: "Products", + revision: "Revisions", + catalog: "Catalog", + team: "Team", + member: "Members", + hosting: "Hosting", + import: "Imports", + case: "Cases", + audit: "Audit Log", + stats: "Stats", + person: "People", + room: "Rooms", + settings: "Settings", +}; +const order = Object.keys(groupNames); + +/** A scope's permissions, grouped by resource: [group name, permissions]. */ +function groupsOf(permissions: string[]): [string, string[]][] { + const by = new Map(); + for (const p of permissions) { + const resource = p.split(":")[0]!; + by.set(resource, [...(by.get(resource) ?? []), p]); + } + const rank = (r: string) => (order.includes(r) ? order.indexOf(r) : order.length); + return [...by.entries()] + .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)) + .map(([r, list]) => [groupNames[r] ?? r.charAt(0).toUpperCase() + r.slice(1), list]); +} + export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: number }) { const [state, formAction, pending] = useActionState(createToken, null); const [rows, setRows] = useState([{ scope: keyOf(scopes[0]!.scope), permissions: [] }]); - const [copied, setCopied] = useState(false); const byKey = new Map(scopes.map((s) => [keyOf(s.scope), s])); const grants = rows.flatMap((r) => r.permissions.map((permission) => ({ permission, scope: byKey.get(r.scope)!.scope }))); const set = (i: number, row: Row) => setRows(rows.map((r, j) => (j === i ? row : r))); @@ -62,19 +94,46 @@ export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: )} -
- {scope.permissions.map((p) => ( - - ))} +
+ {groupsOf(scope.permissions).map(([group, list]) => { + const chosen = list.filter((p) => row.permissions.includes(p)).length; + const toggleAll = (on: boolean) => + set(i, { ...row, permissions: on ? [...new Set([...row.permissions, ...list])] : row.permissions.filter((x) => !list.includes(x)) }); + return ( +
+ + + { + if (el) el.indeterminate = chosen > 0 && chosen < list.length; + }} + onChange={(e) => toggleAll(e.target.checked)} + /> + {group} + + {chosen > 0 ? `${chosen} of ${list.length}` : list.length === 1 ? "1 permission" : `${list.length} permissions`} + + +
+ {list.map((p) => ( + + ))} +
+
+ ); + })}
); @@ -97,14 +156,7 @@ export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays:

{state.token} - +

Send it as Authorization: Bearer <token>.