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 <[email protected]>
This commit is contained in:
1 parent
2b88a52bcb
commit
2e9c2f32cb
1 file changed
+64
-12
@@ -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<string, string> = {
|
||||
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<string, string[]>();
|
||||
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<Row[]>([{ 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,8 +94,31 @@ export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays:
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{scope.permissions.map((p) => (
|
||||
<div className="divide-y rounded-md border">
|
||||
{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 (
|
||||
<details key={group} className="group/perm">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm select-none [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-open/perm:rotate-90" aria-hidden />
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`All ${group}`}
|
||||
checked={chosen === list.length}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = chosen > 0 && chosen < list.length;
|
||||
}}
|
||||
onChange={(e) => toggleAll(e.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">{group}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chosen > 0 ? `${chosen} of ${list.length}` : list.length === 1 ? "1 permission" : `${list.length} permissions`}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="grid gap-x-4 gap-y-1 px-3 pt-1 pb-3 pl-10 sm:grid-cols-2">
|
||||
{list.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 font-mono text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -72,10 +127,14 @@ export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays:
|
||||
set(i, { ...row, permissions: e.target.checked ? [...row.permissions, p] : row.permissions.filter((x) => x !== p) })
|
||||
}
|
||||
/>
|
||||
{p}
|
||||
{p.slice(p.indexOf(":") + 1)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
@@ -97,14 +156,7 @@ export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays:
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 rounded bg-muted px-2 py-1 font-mono text-xs break-all">{state.token}</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(state.token!).then(() => setCopied(true))}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<CopyButton text={state.token} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Send it as <code className="font-mono text-xs">Authorization: Bearer <token></code>.
|
||||
|
||||
Reference in new issue
Block a user