{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scope-picker", "title": "Scope Picker", "author": "MR ", "description": "A controlled OAuth scope picker: checkbox list with descriptions, one-click presets, required scopes pinned on, and an escape hatch for scopes outside the catalog.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "badge", "button", "checkbox", "input", "label", "@cantera/oauth-types", "@cantera/status-tokens" ], "files": [ { "path": "registry/ui/scope-picker.tsx", "content": "'use client'\n\nimport { PlusIcon, XIcon } from 'lucide-react'\nimport type * as React from 'react'\nimport { useId, useState } from 'react'\n\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport type { OAuthScope, OAuthScopePreset } from '@/lib/oauth-types'\nimport { cn } from '@/lib/utils'\n\n/**\n * Union of a picker value with the catalog's required scopes, in catalog order\n * with custom scopes appended.\n *\n * `ScopePicker` keeps `value` pure — it never calls `onChange` on mount to\n * backfill required scopes, because a parent that memoizes or filters would\n * loop. Call this where the value is actually used (submit, or building the\n * authorize URL) so required scopes are never silently dropped.\n */\nfunction withRequiredScopes(scopes: OAuthScope[], value: string[]): string[] {\n const required = scopes.filter((scope) => scope.required).map((scope) => scope.id)\n const merged = new Set([...value, ...required])\n const catalogIds = new Set(scopes.map((scope) => scope.id))\n return [\n ...scopes.filter((scope) => merged.has(scope.id)).map((scope) => scope.id),\n ...[...merged].filter((id) => !catalogIds.has(id)),\n ]\n}\n\ninterface ScopePickerProps extends Omit, 'onChange'> {\n scopes: OAuthScope[]\n /**\n * Selected scope ids. Controlled, and taken literally: required scopes are\n * rendered checked whether or not they appear here. Union them in at submit\n * time with `withRequiredScopes`.\n */\n value: string[]\n onChange: (value: string[]) => void\n /** Named bundles shown as one-click presets above the list. */\n presets?: OAuthScopePreset[]\n /**\n * Let the user enter a scope that is not in the catalog — granular scopes\n * like `data:read:`, or anything the provider added after this catalog\n * was written. Custom scopes round-trip through value / onChange like any\n * other and render distinguishably.\n */\n allowCustomScopes?: boolean\n /** Label for the custom-scope field. */\n customScopeLabel?: string\n disabled?: boolean\n}\n\n/**\n * A controlled picker for OAuth scopes: checkbox list with descriptions,\n * optional one-click presets, and required scopes pinned on. Data-agnostic —\n * pass any scope catalog (see the aps-oauth-preset for Autodesk's).\n */\nfunction ScopePicker({\n scopes,\n value,\n onChange,\n presets,\n allowCustomScopes = false,\n customScopeLabel = 'Add a scope',\n disabled = false,\n className,\n ...props\n}: ScopePickerProps) {\n const baseId = useId()\n const [draft, setDraft] = useState('')\n const catalogIds = new Set(scopes.map((scope) => scope.id))\n const required = scopes.filter((scope) => scope.required).map((scope) => scope.id)\n const selected = new Set([...value, ...required])\n // Anything selected that the catalog does not describe, in the order it was added.\n const customScopes = value.filter((id) => !catalogIds.has(id))\n\n /** Emit in catalog order, with custom scopes trailing in insertion order. */\n function emit(next: Set) {\n onChange([\n ...scopes.filter((scope) => next.has(scope.id)).map((scope) => scope.id),\n ...[...next].filter((id) => !catalogIds.has(id)),\n ])\n }\n\n function toggle(scopeId: string, checked: boolean) {\n const next = new Set(selected)\n if (checked) next.add(scopeId)\n else next.delete(scopeId)\n for (const id of required) next.add(id)\n emit(next)\n }\n\n function applyPreset(preset: OAuthScopePreset) {\n emit(new Set([...preset.scopes, ...required, ...customScopes]))\n }\n\n function presetActive(preset: OAuthScopePreset) {\n const target = new Set([...preset.scopes, ...required, ...customScopes])\n return target.size === selected.size && [...target].every((id) => selected.has(id))\n }\n\n function addCustomScope() {\n const scopeId = draft.trim()\n if (!scopeId || selected.has(scopeId)) {\n setDraft('')\n return\n }\n emit(new Set([...selected, scopeId]))\n setDraft('')\n }\n\n const customFieldId = `${baseId}-custom`\n\n return (\n
\n {presets && presets.length > 0 && (\n
\n Presets\n
\n {presets.map((preset) => {\n const active = presetActive(preset)\n return (\n {\n if (disabled) return\n applyPreset(preset)\n }}\n className={cn(\n 'flex min-h-11 flex-col items-start justify-center gap-0.5 rounded-lg',\n 'border border-border bg-background px-3 py-2 text-left transition-colors',\n 'outline-none focus-visible:border-ring focus-visible:ring-3',\n 'focus-visible:ring-ring/50 hover:bg-muted',\n 'aria-disabled:pointer-events-none aria-disabled:opacity-50',\n active && 'border-primary bg-primary text-primary-foreground hover:bg-primary',\n )}\n >\n {preset.label}\n {preset.description && (\n \n {preset.description}\n \n )}\n \n )\n })}\n
\n
\n )}\n
\n {scopes.map((scope) => {\n const id = `${baseId}-${scope.id}`\n const descriptionId = scope.description ? `${id}-description` : undefined\n // The checkbox renders as a role=\"checkbox\" element next to its own\n // hidden input, so `htmlFor` alone leaves it nameless until the\n // primitive wires the association up on the client. Naming it\n // explicitly means it is named in the server markup too.\n const labelId = `${id}-label`\n const isRequired = Boolean(scope.required)\n return (\n
\n {\n if (isRequired) return\n toggle(scope.id, checked === true)\n }}\n />\n
\n \n {scope.description && (\n

\n {scope.description}\n

\n )}\n
\n
\n )\n })}\n {customScopes.map((scopeId) => (\n
\n toggle(scopeId, false)}\n />\n
\n \n \n {scopeId}\n \n custom\n \n
\n toggle(scopeId, false)}\n >\n \n Remove {scopeId}\n \n
\n ))}\n
\n {allowCustomScopes && (\n
\n \n
\n setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key !== 'Enter') return\n event.preventDefault()\n addCustomScope()\n }}\n />\n \n \n Add\n \n
\n

\n Scopes outside the catalog — granular resource scopes, or anything this provider added\n since. Sent verbatim.\n

\n
\n )}\n
\n )\n}\n\nexport { ScopePicker, type ScopePickerProps, withRequiredScopes }\n", "type": "registry:ui" } ], "categories": [ "authentication", "permissions", "forms" ], "type": "registry:component" }