feat: first draft of view permissions revamp

This commit is contained in:
kemal committed 2026-08-12 17:12:36 +01:00
1 parent ba5f0f57fd
commit 98566d011a
15 files changed
+1137 -124

No files matched your search

@@ -12,6 +12,7 @@ import {
getEnabledEndpoints,
getEnabledEndpointsForCapability,
getEnabledMcpTools,
getEnabledMcpToolsForCapability,
normalizePermissionScopeMap,
type PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
@@ -341,3 +342,64 @@ describe('getEnabledEndpointsForCapability', () => {
).toEqual(['PUT /api/upgrade'])
})
})
describe('getEnabledMcpToolsForCapability', () => {
it('attributes a tool to each capability whose scope is in a fully-granted group', () => {
const permissionScopeMap = scopeMap({
mcp_tools: {
list_branches: [['branching_development_read'], ['branching_production_read']],
},
})
const allGrantedScopes = ['branching_development_read', 'branching_production_read']
expect(
getEnabledMcpToolsForCapability({
capabilityScopes: ['branching_development_read'],
allGrantedScopes,
permissionScopeMap,
})
).toEqual(['list_branches'])
expect(
getEnabledMcpToolsForCapability({
capabilityScopes: ['branching_production_read'],
allGrantedScopes,
permissionScopeMap,
})
).toEqual(['list_branches'])
})
it('does not attribute a tool to a capability whose own group is unsatisfied', () => {
const enabled = getEnabledMcpToolsForCapability({
capabilityScopes: ['branching_production_read'],
allGrantedScopes: ['branching_development_read'],
permissionScopeMap: scopeMap({
mcp_tools: {
list_branches: [['branching_development_read'], ['branching_production_read']],
},
}),
})
expect(enabled).toEqual([])
})
it('requires every scope of the capability group to be granted', () => {
const permissionScopeMap = scopeMap({
mcp_tools: { upgrade_project: [['project_admin_read', 'database_read']] },
})
expect(
getEnabledMcpToolsForCapability({
capabilityScopes: ['database_read'],
allGrantedScopes: ['database_read'],
permissionScopeMap,
})
).toEqual([])
expect(
getEnabledMcpToolsForCapability({
capabilityScopes: ['database_read'],
allGrantedScopes: ['database_read', 'project_admin_read'],
permissionScopeMap,
})
).toEqual(['upgrade_project'])
})
})
@@ -4,6 +4,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import {
computeOverallRisk,
PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_MODE_LABEL,
selectionToScopes,
} from '../../AccessToken.permissions'
@@ -88,12 +89,33 @@ export const NewScopedTokenFormReview = ({
const hasCapabilities = grantedScopes.length > 0
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
const { capabilities } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
})
const activeByCategory = useMemo(
() =>
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
...category,
entries: category.entries
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
.filter(({ mode }) => mode !== 'none'),
})).filter((category) => category.entries.length > 0),
[selection]
)
const mcpTools = useMemo(
() => Array.from(new Set(capabilities.flatMap((capability) => capability.mcpTools))),
[capabilities]
)
const capabilityGroups = useMemo(
() => capabilities.filter((capability) => capability.endpoints.length > 0),
[capabilities]
)
const rows: [string, React.ReactNode][] = [
['Name', values.tokenName || <span className="text-foreground-lighter">Untitled token</span>],
['Expires', expiresSummary],
@@ -190,13 +212,15 @@ export const NewScopedTokenFormReview = ({
</span>
</div>
<div className="divide-y">
{endpoints.map(([method, path]) => (
{endpoints.map((endpoint) => (
<div
key={`${method} ${path}`}
key={endpoint.raw}
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
>
<span className="w-14 shrink-0 text-foreground-light">{method}</span>
<span className="text-foreground">{path}</span>
<span className="w-14 shrink-0 text-foreground-light">
{endpoint.method}
</span>
<span className="text-foreground">{endpoint.path}</span>
</div>
))}
</div>
@@ -0,0 +1,56 @@
import { Accordion } from 'ui'
import type { EntryAccess } from '../../AccessToken.roles'
import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
import { CapabilityCard } from './CapabilityCard'
import { DenseCapabilities } from './DenseCapabilities'
import { getCapabilityDensityTier } from './TokenCapabilities.utils'
interface CapabilitiesSectionProps {
capabilities: CapabilitySummaryEntry[]
accessEntries: Record<string, EntryAccess>
}
/**
* Switches capability presentation on granted count: a handful render fully expanded, a moderate
* number collapse into an accordion, and a large grant switches to the dense, filterable view.
*/
export const CapabilitiesSection = ({ capabilities, accessEntries }: CapabilitiesSectionProps) => {
if (capabilities.length === 0) {
return <span className="text-sm text-foreground-lighter">No capabilities selected</span>
}
const tier = getCapabilityDensityTier(capabilities.length)
if (tier === 'expanded') {
return (
<div className="flex flex-col gap-3">
{capabilities.map((capability) => (
<CapabilityCard
key={capability.entry.key}
capability={capability}
collapsible={false}
accessEntries={accessEntries}
/>
))}
</div>
)
}
if (tier === 'accordion') {
return (
<Accordion type="multiple" className="flex flex-col gap-3">
{capabilities.map((capability) => (
<CapabilityCard
key={capability.entry.key}
capability={capability}
collapsible
accessEntries={accessEntries}
/>
))}
</Accordion>
)
}
return <DenseCapabilities capabilities={capabilities} accessEntries={accessEntries} />
}
@@ -0,0 +1,69 @@
import { AccordionContent, AccordionItem, AccordionTrigger, Badge } from 'ui'
import { PERMISSION_MODE_LABEL } from '../../AccessToken.permissions'
import type { EntryAccess } from '../../AccessToken.roles'
import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
import { ExceedsRoleBadge } from '../ExceedsRoleBadge'
import { CapabilityCardBody } from './CapabilityCardBody'
import { pluralize } from '@/lib/helpers'
interface CapabilityCardProps {
capability: CapabilitySummaryEntry
/** Accordion tiers wrap the header in a trigger button; the ≤2 tier renders it inert. */
collapsible: boolean
accessEntries: Record<string, EntryAccess>
}
const CapabilityCardHeader = ({
capability,
accessEntries,
}: Pick<CapabilityCardProps, 'capability' | 'accessEntries'>) => {
const { entry, mode, endpoints, mcpTools } = capability
const entryAccess = accessEntries[entry.key]
return (
<div className="flex w-full flex-1 items-center justify-between gap-2">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm text-foreground">{entry.name}</span>
<Badge variant={mode === 'readwrite' ? 'warning' : 'default'}>
{PERMISSION_MODE_LABEL[mode]}
</Badge>
{entryAccess?.status === 'exceeds-role' && (
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
)}
</span>
<span className="shrink-0 text-xs text-foreground-lighter">
{endpoints.length} {pluralize(endpoints.length, 'endpoint')}
{mcpTools.length > 0 && ` · ${mcpTools.length} ${pluralize(mcpTools.length, 'tool')}`}
</span>
</div>
)
}
export const CapabilityCard = ({ capability, collapsible, accessEntries }: CapabilityCardProps) => {
const body = (
<CapabilityCardBody endpoints={capability.endpoints} mcpTools={capability.mcpTools} />
)
if (!collapsible) {
return (
<div className="rounded-md border">
<div className="px-3 py-2">
<CapabilityCardHeader capability={capability} accessEntries={accessEntries} />
</div>
<div className="border-t p-3">{body}</div>
</div>
)
}
return (
<AccordionItem value={capability.entry.key} className="rounded-md border">
<AccordionTrigger className="px-3 py-2 font-normal hover:no-underline">
<CapabilityCardHeader capability={capability} accessEntries={accessEntries} />
</AccordionTrigger>
<AccordionContent className="border-t">
<div className="p-3">{body}</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { cn } from 'ui'
import { EndpointRow } from './EndpointRow'
import { MAX_VISIBLE_ENDPOINT_ROWS } from './TokenCapabilities.constants'
import { getSharedPathPrefix } from './TokenCapabilities.utils'
import type { EnabledEndpoint } from '@/data/scoped-access-tokens/permission-scope-map-query'
interface CapabilityCardBodyProps {
endpoints: EnabledEndpoint[]
mcpTools: string[]
}
/**
* One bordered container holding two labelled, independently-tabled groups — endpoints and MCP
* tools — rather than a single table with interleaved header rows, per the two-table a11y pattern.
*/
export const CapabilityCardBody = ({ endpoints, mcpTools }: CapabilityCardBodyProps) => {
const [showAllEndpoints, setShowAllEndpoints] = useState(false)
if (endpoints.length === 0 && mcpTools.length === 0) {
return (
<div className="rounded-md border px-3 py-4 text-center text-xs text-foreground-lighter">
No Management API endpoints or MCP tools are enabled by this capability yet.
</div>
)
}
const sharedPrefix = getSharedPathPrefix(endpoints.map((endpoint) => endpoint.path))
const methodColumnWidth = `${Math.max(0, ...endpoints.map((endpoint) => endpoint.method.length)) + 2}ch`
const visibleEndpoints = endpoints.slice(
0,
showAllEndpoints ? endpoints.length : MAX_VISIBLE_ENDPOINT_ROWS
)
const hiddenEndpointCount = endpoints.length - visibleEndpoints.length
return (
<div className="overflow-hidden rounded-md border">
{endpoints.length > 0 && (
<div>
<div className="flex items-center justify-between border-b bg-surface-200 px-3 py-1.5">
<span className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
Management API endpoints
</span>
<span className="text-[11px] text-foreground-lighter">{endpoints.length}</span>
</div>
<table className="w-full" aria-label="Management API endpoints">
<tbody className="divide-y">
{visibleEndpoints.map((endpoint) => (
<EndpointRow
key={endpoint.raw}
method={endpoint.method}
path={endpoint.path}
sharedPrefix={sharedPrefix}
methodColumnWidth={methodColumnWidth}
/>
))}
</tbody>
</table>
{hiddenEndpointCount > 0 && (
<button
type="button"
tabIndex={0}
onClick={() => setShowAllEndpoints(true)}
className="w-full border-t px-3 py-1.5 text-left text-xs text-foreground-light hover:text-foreground"
>
Show all {endpoints.length}
</button>
)}
</div>
)}
{mcpTools.length > 0 && (
<div className={cn(endpoints.length > 0 && 'border-t border-strong')}>
<div className="flex items-center justify-between border-b bg-surface-200 px-3 py-1.5">
<span className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
MCP tools
</span>
<span className="text-[11px] text-foreground-lighter">{mcpTools.length}</span>
</div>
<table className="w-full" aria-label="MCP tools">
<tbody className="divide-y">
{mcpTools.map((tool) => (
<tr key={tool}>
<td className="w-1/3 px-3 py-1.5 align-top font-mono text-xs text-foreground">
{tool}
</td>
{/* No description field exists yet for MCP tools — stubbed rather than fabricated. */}
<td className="px-3 py-1.5 text-xs text-foreground-lighter">
No description available
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,136 @@
import { useState } from 'react'
import { Accordion, Badge, Input, ToggleGroup, ToggleGroupItem } from 'ui'
import type { EntryAccess } from '../../AccessToken.roles'
import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
import { CapabilityCard } from './CapabilityCard'
import { DENSE_READONLY_PREVIEW_ROWS } from './TokenCapabilities.constants'
import {
filterCapabilities,
getNotGrantedCatalogEntries,
groupCapabilitiesByLevel,
type CapabilityLevelFilter,
} from './TokenCapabilities.utils'
interface DenseCapabilitiesProps {
capabilities: CapabilitySummaryEntry[]
accessEntries: Record<string, EntryAccess>
}
/**
* 9+ capabilities: a text/path filter and read/read-write segmented control narrow a level-grouped
* list. Read-write is pinned first and never truncated; read-only previews a few rows. A path match
* auto-expands its parent capability on top of whatever the user has manually opened.
*/
export const DenseCapabilities = ({ capabilities, accessEntries }: DenseCapabilitiesProps) => {
const [query, setQuery] = useState('')
const [levelFilter, setLevelFilter] = useState<CapabilityLevelFilter>('all')
const [manuallyOpenKeys, setManuallyOpenKeys] = useState<string[]>([])
const [showAllReadOnly, setShowAllReadOnly] = useState(false)
const filtered = filterCapabilities(capabilities, query, levelFilter)
const forcedOpenKeys = filtered
.filter((match) => match.matchedByPath)
.map((match) => match.capability.entry.key)
const openKeys = Array.from(new Set([...manuallyOpenKeys, ...forcedOpenKeys]))
const { readwrite, read } = groupCapabilitiesByLevel(filtered.map((match) => match.capability))
const notGranted = getNotGrantedCatalogEntries(capabilities)
const visibleRead = showAllReadOnly ? read : read.slice(0, DENSE_READONLY_PREVIEW_ROWS)
const hiddenReadCount = read.length - visibleRead.length
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Input
size="tiny"
placeholder="Filter by capability or endpoint..."
value={query}
onChange={(event) => setQuery(event.target.value)}
className="max-w-xs"
/>
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={levelFilter}
onValueChange={(value) => {
if (value) setLevelFilter(value as CapabilityLevelFilter)
}}
>
<ToggleGroupItem value="all">All</ToggleGroupItem>
<ToggleGroupItem value="read">Read</ToggleGroupItem>
<ToggleGroupItem value="readwrite">Read-write</ToggleGroupItem>
</ToggleGroup>
</div>
{readwrite.length === 0 && read.length === 0 && (
<p className="text-xs text-foreground-lighter">No capabilities match your filter.</p>
)}
<Accordion type="multiple" value={openKeys} onValueChange={setManuallyOpenKeys}>
{readwrite.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
Read-write · {readwrite.length}
</p>
<div className="flex flex-col gap-2 rounded-md border border-warning-400 p-2">
{readwrite.map((capability) => (
<CapabilityCard
key={capability.entry.key}
capability={capability}
collapsible
accessEntries={accessEntries}
/>
))}
</div>
</div>
)}
{read.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
Read-only · {read.length}
</p>
<div className="flex flex-col gap-2">
{visibleRead.map((capability) => (
<CapabilityCard
key={capability.entry.key}
capability={capability}
collapsible
accessEntries={accessEntries}
/>
))}
</div>
{hiddenReadCount > 0 && (
<button
type="button"
tabIndex={0}
onClick={() => setShowAllReadOnly(true)}
className="self-start text-xs text-foreground-light hover:text-foreground"
>
Show {hiddenReadCount} more
</button>
)}
</div>
)}
</Accordion>
{notGranted.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
Not granted · {notGranted.length}
</p>
<div className="flex flex-wrap gap-1.5">
{notGranted.map((entry) => (
<Badge key={entry.key} variant="default">
{entry.name}
</Badge>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,47 @@
import { Badge } from 'ui'
import { splitEndpointPath } from './TokenCapabilities.utils'
interface EndpointRowProps {
method: string
path: string
/** Shared leading segments across the group, rendered muted ahead of the distinguishing part. */
sharedPrefix: string
/** Sized by the caller for the longest method present in the group. */
methodColumnWidth: string
}
/**
* The muted prefix span shrinks with an end-ellipsis while the distinguishing segment stays
* fixed-width — visually equivalent to truncating the full path in its middle, without needing to
* measure pixel widths.
*/
export const EndpointRow = ({
method,
path,
sharedPrefix,
methodColumnWidth,
}: EndpointRowProps) => {
const { prefix, distinguishing } = splitEndpointPath(path, sharedPrefix)
const isMutating = method !== 'GET'
return (
<tr>
<td className="px-3 py-1.5 align-top" style={{ width: methodColumnWidth }}>
{isMutating ? (
<Badge variant="warning">{method}</Badge>
) : (
<span className="font-mono text-xs text-foreground-lighter">{method}</span>
)}
</td>
<td className="min-w-0 px-3 py-1.5" title={path}>
<div className="flex min-w-0 overflow-hidden whitespace-nowrap font-mono text-xs">
{prefix !== '' && (
<span className="overflow-hidden text-ellipsis text-foreground-lighter">{prefix}</span>
)}
<span className="shrink-0 text-foreground">{distinguishing}</span>
</div>
</td>
</tr>
)
}
@@ -0,0 +1,27 @@
import { Admonition } from 'ui-patterns/Admonition'
import type { AdmonitionType } from 'ui-patterns/Admonition'
import type { RiskBannerResult } from './TokenCapabilities.utils'
const TONE_TO_ADMONITION_TYPE: Record<RiskBannerResult['tone'], AdmonitionType> = {
default: 'default',
low: 'success',
medium: 'warning',
high: 'destructive',
}
interface RiskBannerProps {
risk: RiskBannerResult
/** True when some selected permissions exceed the owner's role, so the risk is role-capped. */
showRoleCaveat: boolean
}
export const RiskBanner = ({ risk, showRoleCaveat }: RiskBannerProps) => (
<Admonition
type={TONE_TO_ADMONITION_TYPE[risk.tone]}
title={`${risk.level} risk`}
description={risk.summary}
>
{showRoleCaveat && 'Based on what your current role allows this token to do.'}
</Admonition>
)
@@ -0,0 +1,12 @@
/**
* Density-tier thresholds for the capabilities section, keyed to the number of granted
* capabilities. Provisional — revisit with product/design once real tokens exercise them.
*/
export const CAPABILITY_DENSITY_EXPANDED_MAX = 2
export const CAPABILITY_DENSITY_ACCORDION_MAX = 8
/** Endpoint rows shown per capability before collapsing behind "Show all N". */
export const MAX_VISIBLE_ENDPOINT_ROWS = 8
/** Read-only capability rows shown in dense mode before "Show N more". */
export const DENSE_READONLY_PREVIEW_ROWS = 3
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import { getCatalogEntry } from '../../AccessToken.permissions'
import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
import {
computeRiskBanner,
filterCapabilities,
getCapabilityDensityTier,
getNotGrantedCatalogEntries,
getSharedPathPrefix,
groupCapabilitiesByLevel,
splitEndpointPath,
} from './TokenCapabilities.utils'
const endpoint = (method: string, path: string) => ({ method, path, raw: `${method} ${path}` })
// 'project:database' is catalog-high and writable; 'project:advisors' is catalog-low and read-only.
const databaseCapability = (
mode: CapabilitySummaryEntry['mode'],
endpoints: CapabilitySummaryEntry['endpoints'] = []
): CapabilitySummaryEntry => ({
entry: getCatalogEntry('project:database')!,
mode,
endpoints,
mcpTools: [],
})
const advisorsCapability = (mode: CapabilitySummaryEntry['mode']): CapabilitySummaryEntry => ({
entry: getCatalogEntry('project:advisors')!,
mode,
endpoints: [],
mcpTools: [],
})
describe('getCapabilityDensityTier', () => {
it('is expanded at 2 or fewer capabilities', () => {
expect(getCapabilityDensityTier(0)).toBe('expanded')
expect(getCapabilityDensityTier(2)).toBe('expanded')
})
it('is accordion between 3 and 8 capabilities', () => {
expect(getCapabilityDensityTier(3)).toBe('accordion')
expect(getCapabilityDensityTier(8)).toBe('accordion')
})
it('is dense at 9 or more capabilities', () => {
expect(getCapabilityDensityTier(9)).toBe('dense')
expect(getCapabilityDensityTier(50)).toBe('dense')
})
})
describe('getSharedPathPrefix', () => {
it('returns nothing for a single endpoint — there is nothing to share', () => {
expect(getSharedPathPrefix(['/v1/projects/{ref}'])).toBe('')
})
it('finds the longest shared leading segments across paths', () => {
expect(
getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions/{slug}'])
).toBe('/v1/projects/{ref}/functions/')
})
it('never cuts a shared prefix mid-segment', () => {
// "functions" and "functions-secrets" share characters but not a path segment.
expect(
getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions-secrets'])
).toBe('/v1/projects/{ref}/')
})
it('shares only the common leading segments, not any further', () => {
expect(getSharedPathPrefix(['/v1/branches', '/v1/organizations'])).toBe('/v1/')
})
it('returns nothing when paths share no leading segment at all', () => {
expect(getSharedPathPrefix(['/v1/branches', '/v2/organizations'])).toBe('')
})
it('keeps at least the last segment distinguishing when every path is identical', () => {
expect(getSharedPathPrefix(['/v1/projects/{ref}', '/v1/projects/{ref}'])).toBe('/v1/projects/')
})
})
describe('splitEndpointPath', () => {
it('splits off the shared prefix when the path starts with it', () => {
expect(splitEndpointPath('/v1/projects/{ref}/functions', '/v1/projects/{ref}/')).toEqual({
prefix: '/v1/projects/{ref}/',
distinguishing: 'functions',
})
})
it('treats the whole path as distinguishing when there is no shared prefix', () => {
expect(splitEndpointPath('/v1/projects/{ref}', '')).toEqual({
prefix: '',
distinguishing: '/v1/projects/{ref}',
})
})
})
describe('groupCapabilitiesByLevel', () => {
it('splits granted capabilities into read-write and read-only', () => {
const capabilities = [databaseCapability('readwrite'), advisorsCapability('read')]
const { readwrite, read } = groupCapabilitiesByLevel(capabilities)
expect(readwrite.map((c) => c.entry.key)).toEqual(['project:database'])
expect(read.map((c) => c.entry.key)).toEqual(['project:advisors'])
})
})
describe('getNotGrantedCatalogEntries', () => {
it('returns every catalog entry when nothing is granted', () => {
expect(getNotGrantedCatalogEntries([]).map((e) => e.key)).toContain('project:database')
})
it('excludes granted entries', () => {
const notGranted = getNotGrantedCatalogEntries([databaseCapability('read')])
expect(notGranted.map((e) => e.key)).not.toContain('project:database')
})
})
describe('filterCapabilities', () => {
const capabilities = [
databaseCapability('readwrite', [endpoint('GET', '/v1/projects/{ref}/database')]),
advisorsCapability('read'),
]
it('returns everything, unmatched by path, when the query is empty', () => {
const result = filterCapabilities(capabilities, '', 'all')
expect(result.map((r) => r.capability.entry.key)).toEqual([
'project:database',
'project:advisors',
])
expect(result.every((r) => !r.matchedByPath)).toBe(true)
})
it('matches by capability name', () => {
const result = filterCapabilities(capabilities, 'advisors', 'all')
expect(result.map((r) => r.capability.entry.key)).toEqual(['project:advisors'])
expect(result[0].matchedByPath).toBe(false)
})
it('matches by endpoint path and flags it as a path match', () => {
const result = filterCapabilities(capabilities, '/database', 'all')
expect(result.map((r) => r.capability.entry.key)).toEqual(['project:database'])
expect(result[0].matchedByPath).toBe(true)
})
it('applies the level filter before matching', () => {
expect(filterCapabilities(capabilities, '', 'read')).toHaveLength(1)
expect(filterCapabilities(capabilities, '', 'readwrite')).toHaveLength(1)
})
it('drops capabilities matching neither the name nor any endpoint path', () => {
expect(filterCapabilities(capabilities, 'storage', 'all')).toEqual([])
})
})
describe('computeRiskBanner', () => {
it('reports Minimal with no active capabilities', () => {
const risk = computeRiskBanner({
effectiveSelection: {},
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['proj-1'],
})
expect(risk).toEqual({ level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' })
})
it('downgrades a read-only grant so it never outranks read-write on a lower-risk resource', () => {
// project:database is catalog-high; read-only downgrades it to medium.
const risk = computeRiskBanner({
effectiveSelection: { 'project:database': 'read' },
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['proj-1'],
})
expect(risk.level).toBe('Medium')
})
it('takes the max risk across capabilities, not just a write flag', () => {
const risk = computeRiskBanner({
effectiveSelection: { 'project:database': 'readwrite', 'project:advisors': 'read' },
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['proj-1'],
})
expect(risk.level).toBe('High')
})
it('escalates account-wide read-write access to High', () => {
const risk = computeRiskBanner({
effectiveSelection: { 'project:advisors': 'readwrite' },
resourceAccess: 'account',
organizationSlugs: [],
projectRefs: [],
})
expect(risk.level).toBe('High')
expect(risk.summary).toContain('across your entire account')
})
it('escalates project scope spanning many bound projects', () => {
const narrow = computeRiskBanner({
effectiveSelection: { 'project:advisors': 'read' },
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['a'],
})
const broad = computeRiskBanner({
effectiveSelection: { 'project:advisors': 'read' },
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['a', 'b', 'c', 'd', 'e', 'f'],
})
expect(narrow.level).toBe('Low')
expect(broad.level).toBe('Medium')
})
it('summarizes mixed read and read-write grants with explicit scope breadth', () => {
const risk = computeRiskBanner({
effectiveSelection: {
'project:database': 'readwrite',
'project:advisors': 'read',
'project:storage': 'read',
},
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: ['a', 'b', 'c'],
})
expect(risk.summary).toBe('Read-write on 1 capability, read on 2, across 3 projects.')
})
it('states scope breadth even when nothing is bound', () => {
const risk = computeRiskBanner({
effectiveSelection: { 'project:advisors': 'read' },
resourceAccess: 'project',
organizationSlugs: [],
projectRefs: [],
})
expect(risk.summary).toContain('with no projects bound')
})
})
@@ -0,0 +1,180 @@
import {
getCatalogEntry,
PERMISSION_CATALOG,
type PermissionCatalogEntry,
type PermissionSelection,
type ResourceAccessMode,
type RiskLevel,
} from '../../AccessToken.permissions'
import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary'
import {
CAPABILITY_DENSITY_ACCORDION_MAX,
CAPABILITY_DENSITY_EXPANDED_MAX,
} from './TokenCapabilities.constants'
import { pluralize } from '@/lib/helpers'
export type CapabilityDensityTier = 'expanded' | 'accordion' | 'dense'
export const getCapabilityDensityTier = (count: number): CapabilityDensityTier => {
if (count <= CAPABILITY_DENSITY_EXPANDED_MAX) return 'expanded'
if (count <= CAPABILITY_DENSITY_ACCORDION_MAX) return 'accordion'
return 'dense'
}
/**
* Longest shared leading path segments across a group of endpoint paths, so the UI can mute the
* boilerplate prefix and highlight only the segment that distinguishes each row. Matching is
* segment-aware (split on "/") so a shared prefix never cuts a path mid-segment. A single-endpoint
* group has nothing to share, so the whole path is treated as the distinguishing part.
*/
export const getSharedPathPrefix = (paths: string[]): string => {
if (paths.length < 2) return ''
const segmentLists = paths.map((path) => path.split('/'))
const [first, ...rest] = segmentLists
let matched = 0
while (matched < first.length && rest.every((segments) => segments[matched] === first[matched])) {
matched++
}
// Every path is identical (not just the shortest one fully consumed as a prefix of a longer
// one) — back off one segment so each path keeps at least the last as its distinguishing part.
if (segmentLists.every((segments) => segments.length === matched)) matched -= 1
// matched === 1 only captures the empty segment before the leading "/", shared trivially by
// every absolute path — not a meaningful prefix.
if (matched <= 1) return ''
return first.slice(0, matched).join('/') + '/'
}
export const splitEndpointPath = (path: string, sharedPrefix: string) =>
sharedPrefix !== '' && path.startsWith(sharedPrefix)
? { prefix: sharedPrefix, distinguishing: path.slice(sharedPrefix.length) }
: { prefix: '', distinguishing: path }
export const groupCapabilitiesByLevel = (capabilities: CapabilitySummaryEntry[]) => ({
readwrite: capabilities.filter((capability) => capability.mode === 'readwrite'),
read: capabilities.filter((capability) => capability.mode === 'read'),
})
/** Catalog entries the token doesn't grant at all — dense mode's "Not granted" group. */
export const getNotGrantedCatalogEntries = (
capabilities: CapabilitySummaryEntry[]
): PermissionCatalogEntry[] => {
const grantedKeys = new Set(capabilities.map((capability) => capability.entry.key))
return PERMISSION_CATALOG.filter((entry) => !grantedKeys.has(entry.key))
}
export type CapabilityLevelFilter = 'all' | 'read' | 'readwrite'
export interface FilteredCapability {
capability: CapabilitySummaryEntry
/** True when the match came from an endpoint path rather than just the capability name. */
matchedByPath: boolean
}
/** Local, in-memory filter for dense mode: matches capability name or any enabled endpoint path. */
export const filterCapabilities = (
capabilities: CapabilitySummaryEntry[],
query: string,
levelFilter: CapabilityLevelFilter
): FilteredCapability[] => {
const normalizedQuery = query.trim().toLowerCase()
return capabilities
.filter((capability) => levelFilter === 'all' || capability.mode === levelFilter)
.flatMap((capability) => {
if (normalizedQuery === '') return [{ capability, matchedByPath: false }]
const nameMatches = capability.entry.name.toLowerCase().includes(normalizedQuery)
const matchedByPath = capability.endpoints.some((endpoint) =>
endpoint.path.toLowerCase().includes(normalizedQuery)
)
if (!nameMatches && !matchedByPath) return []
return [{ capability, matchedByPath }]
})
}
const RISK_RANK: Record<RiskLevel, number> = { low: 1, medium: 2, high: 3 }
const RANK_TO_RISK: Record<number, RiskLevel> = { 1: 'low', 2: 'medium', 3: 'high' }
/** One severity tier down, floored at 'low' — a read-only grant never outranks a read-write one. */
const downgradeRisk = (risk: RiskLevel): RiskLevel => RANK_TO_RISK[Math.max(1, RISK_RANK[risk] - 1)]
export interface RiskBannerResult {
level: 'Minimal' | 'Low' | 'Medium' | 'High'
tone: 'default' | 'low' | 'medium' | 'high'
summary: string
}
/**
* Computes the risk banner from the grant itself, never from a stored string. Severity is a max()
* over every granted capability's catalog risk — downgraded a tier for read-only grants so a
* read-only high-risk resource never outranks read-write on a medium one — then escalated for
* account-wide tokens and resource bindings spanning many orgs/projects.
*/
export const computeRiskBanner = ({
effectiveSelection,
resourceAccess,
organizationSlugs,
projectRefs,
}: {
effectiveSelection: PermissionSelection
resourceAccess: ResourceAccessMode
organizationSlugs: string[]
projectRefs: string[]
}): RiskBannerResult => {
const active = Object.entries(effectiveSelection).filter(([, mode]) => mode !== 'none')
if (active.length === 0) {
return { level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' }
}
const readWriteCount = active.filter(([, mode]) => mode === 'readwrite').length
const readCount = active.length - readWriteCount
const maxRisk = active.reduce<RiskLevel>((max, [key, mode]) => {
const entry = getCatalogEntry(key)
if (!entry) return max
const effectiveRisk: RiskLevel = mode === 'readwrite' ? entry.risk : downgradeRisk(entry.risk)
return RISK_RANK[effectiveRisk] > RISK_RANK[max] ? effectiveRisk : max
}, 'low')
let rank = RISK_RANK[maxRisk]
if (resourceAccess === 'account') {
rank = Math.max(rank, RISK_RANK.medium) + (readWriteCount > 0 ? 1 : 0)
} else if (resourceAccess === 'organization' && organizationSlugs.length > 3) {
rank += 1
} else if (resourceAccess === 'project' && projectRefs.length > 5) {
rank += 1
}
rank = Math.min(rank, RISK_RANK.high)
const level = rank === RISK_RANK.high ? 'High' : rank === RISK_RANK.medium ? 'Medium' : 'Low'
const tone = rank === RISK_RANK.high ? 'high' : rank === RISK_RANK.medium ? 'medium' : 'low'
const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project'
const boundCount =
resourceAccess === 'organization' ? organizationSlugs.length : projectRefs.length
const scopeText =
resourceAccess === 'account'
? 'across your entire account'
: boundCount === 0
? `with no ${resourceNoun}s bound`
: `across ${boundCount} ${pluralize(boundCount, resourceNoun)}`
const segments: string[] = []
if (readWriteCount > 0) {
segments.push(
`read-write on ${readWriteCount} ${pluralize(readWriteCount, 'capability', 'capabilities')}`
)
}
if (readCount > 0) {
segments.push(
segments.length === 0
? `read on ${readCount} ${pluralize(readCount, 'capability', 'capabilities')}`
: `read on ${readCount}`
)
}
const sentence = `${segments.join(', ')}, ${scopeText}.`
return { level, tone, summary: sentence.charAt(0).toUpperCase() + sentence.slice(1) }
}
@@ -1,4 +1,4 @@
import { screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
MOCK_PROJECT,
mockPermissionsApi,
mockScopedTokenEnvironment,
ownerRows,
@@ -140,4 +141,100 @@ describe('ViewTokenSheet', () => {
).toBeGreaterThan(0)
expect(screen.queryByText('This token no longer has access')).toBeNull()
})
test('renders capability cards with attributed endpoints, MCP tools, and a risk banner', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'project',
project_refs: [MOCK_PROJECT.ref],
permissions: ['advisors_read', 'database_read', 'database_write'],
})
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
path: '/scoped-access-token-permissions',
response: () =>
HttpResponse.json({
scopes: {},
endpoints: {
'GET /v1/projects/{ref}/advisors/security': [['advisors_read']],
'GET /v1/projects/{ref}/database': [['database_read']],
'POST /v1/projects/{ref}/database/query': [['database_write']],
},
mcp_tools: {
get_advisors: [['advisors_read']],
execute_sql: [['database_write']],
},
}),
})
renderSheet()
// ≤2 capabilities render fully expanded — both cards are visible without interaction.
expect(await screen.findByText('Advisors')).toBeInTheDocument()
expect(screen.getByText('Database')).toBeInTheDocument()
expect(screen.getByText('Read-write')).toBeInTheDocument()
expect(screen.getByText('Read')).toBeInTheDocument()
expect(screen.getByTitle('/v1/projects/{ref}/advisors/security')).toBeInTheDocument()
expect(screen.getByTitle('/v1/projects/{ref}/database')).toBeInTheDocument()
expect(screen.getByTitle('/v1/projects/{ref}/database/query')).toBeInTheDocument()
// Non-GET methods get a tinted badge; GET stays plain — both still render as text.
expect(screen.getByText('POST')).toBeInTheDocument()
expect(screen.getByText('get_advisors')).toBeInTheDocument()
expect(screen.getByText('execute_sql')).toBeInTheDocument()
// MCP tools have no description field yet — stubbed visibly rather than fabricated.
expect(screen.getAllByText('No description available').length).toBe(2)
// project:database is catalog-high risk and granted read-write — max() over capabilities.
expect(screen.getByText('High risk')).toBeInTheDocument()
expect(
screen.getByText('Read-write on 1 capability, read on 1, across 1 project.')
).toBeInTheDocument()
})
test('switches to the dense, filterable view at 9+ granted capabilities', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'project',
project_refs: [MOCK_PROJECT.ref],
permissions: [
'advisors_read',
'database_read',
'database_write',
'backups_read',
'custom_domain_read',
'edge_functions_read',
'storage_read',
'realtime_config_read',
'vanity_subdomain_read',
'infra_add_ons_read',
],
})
renderSheet()
expect(
await screen.findByPlaceholderText('Filter by capability or endpoint...')
).toBeInTheDocument()
expect(screen.getByText('Read-write · 1')).toBeInTheDocument()
expect(screen.getByText('Read-only · 8')).toBeInTheDocument()
expect(screen.getByText(/Not granted · \d+/)).toBeInTheDocument()
// Read-only previews only 3 of the 8 rows until "Show N more" is clicked.
expect(screen.getByText('Show 5 more')).toBeInTheDocument()
expect(screen.queryByText('Storage')).toBeNull()
fireEvent.click(screen.getByText('Show 5 more'))
expect(screen.getByText('Storage')).toBeInTheDocument()
expect(screen.getByText('Backups')).toBeInTheDocument()
// Filtering re-derives the read-only bucket, so a narrowed result isn't re-truncated.
fireEvent.change(screen.getByPlaceholderText('Filter by capability or endpoint...'), {
target: { value: 'storage' },
})
expect(screen.getByText('Storage')).toBeInTheDocument()
expect(screen.queryByText('Backups')).toBeNull()
})
})
@@ -5,16 +5,14 @@ import { Admonition } from 'ui-patterns/Admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants'
import {
computeOverallRisk,
PERMISSION_MODE_LABEL,
scopesToSelection,
type ResourceAccessMode,
} from '../AccessToken.permissions'
import { scopesToSelection, type ResourceAccessMode } from '../AccessToken.permissions'
import { useCapabilitySummary } from '../hooks/useCapabilitySummary'
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation'
import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from './TokenSummaryRows'
import { CapabilitiesSection } from './TokenCapabilities/CapabilitiesSection'
import { RiskBanner } from './TokenCapabilities/RiskBanner'
import { computeRiskBanner } from './TokenCapabilities/TokenCapabilities.utils'
import { ResourceSummaryItem } from './TokenSummaryRows'
import { DocsButton } from '@/components/ui/DocsButton'
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
@@ -82,13 +80,17 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted`
const risk = useMemo(
() => computeOverallRisk(access.effectiveSelection, resourceAccess),
[access.effectiveSelection, resourceAccess]
() =>
computeRiskBanner({
effectiveSelection: access.effectiveSelection,
resourceAccess,
organizationSlugs: tokenOrganizationSlugs,
projectRefs: tokenProjectRefs,
}),
[access.effectiveSelection, resourceAccess, tokenOrganizationSlugs, tokenProjectRefs]
)
const hasCapabilities = grantedScopes.length > 0
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
const { capabilities } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
@@ -222,18 +224,6 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
</div>
</div>,
],
[
'Capabilities',
hasCapabilities ? (
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
) : (
<span className="text-foreground-lighter">No capabilities selected</span>
),
],
[
'Risk level',
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
],
]
: []
@@ -300,6 +290,9 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them."
/>
)}
<RiskBanner risk={risk} showRoleCaveat={hasExceedingCapabilities} />
<div className="flex flex-col gap-3">
<h3 className="text-sm">Token summary</h3>
<dl className="divide-y rounded-md border bg-surface-300">
@@ -312,62 +305,10 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
</dl>
</div>
{hasCapabilities && (
<>
<div className="flex flex-col gap-3">
<h3 className="text-sm">Management API endpoints enabled</h3>
{capabilityGroups.length === 0 ? (
<p className="text-xs text-foreground-light">
No Management API endpoints are enabled by the selected capabilities.
</p>
) : (
capabilityGroups.map(({ entry, mode, endpoints }) => (
<div key={entry.key} className="rounded-md border">
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
<span className="text-xs text-foreground">{entry.name}</span>
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
<div className="divide-y">
{endpoints.map(([method, path]) => (
<div
key={`${method} ${path}`}
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
>
<span className="w-14 shrink-0 text-foreground-light">
{method}
</span>
<span className="text-foreground">{path}</span>
</div>
))}
</div>
</div>
))
)}
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm">MCP tools</h3>
{mcpTools.length === 0 ? (
<p className="text-xs text-foreground-light">
No MCP tools are enabled by the selected capabilities.
</p>
) : (
<div className="flex flex-wrap gap-2">
{mcpTools.map((tool) => (
<span
key={tool}
className="rounded border bg-surface-100 px-2 py-1 font-mono text-xs text-foreground-light"
>
{tool}
</span>
))}
</div>
)}
</div>
</>
)}
<div className="flex flex-col gap-3">
<h3 className="text-sm">Capabilities</h3>
<CapabilitiesSection capabilities={capabilities} accessEntries={access.entries} />
</div>
</>
)}
</div>
@@ -2,15 +2,16 @@ import { useMemo } from 'react'
import {
getEntryScopes,
PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_CATALOG,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from '../AccessToken.permissions'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
PermissionScopeMap,
getEnabledMcpToolsForCapability,
type EnabledEndpoint,
type PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
interface UseCapabilitySummaryArgs {
@@ -19,49 +20,43 @@ interface UseCapabilitySummaryArgs {
permissionScopeMap: PermissionScopeMap | undefined
}
export interface CapabilitySummaryEntry {
entry: PermissionCatalogEntry
mode: PermissionMode
endpoints: EnabledEndpoint[]
mcpTools: string[]
}
/**
* Selection-derived summary data for the token view sheet: selected entries grouped by catalog
* category, the Management API endpoints each capability enables, and the enabled MCP tools.
* Selection-derived summary data for the token view sheet: every granted catalog entry paired with
* the Management API endpoints and MCP tools it enables.
*/
export const useCapabilitySummary = ({
selection,
grantedScopes,
permissionScopeMap,
}: UseCapabilitySummaryArgs) => {
const activeByCategory = useMemo(
() =>
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
...category,
entries: category.entries
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
.filter(({ mode }) => mode !== 'none'),
})).filter((category) => category.entries.length > 0),
[selection]
)
const capabilities = useMemo(() => {
const result: CapabilitySummaryEntry[] = []
for (const entry of PERMISSION_CATALOG) {
const mode = selection[entry.key] ?? 'none'
if (mode === 'none') continue
const mcpTools = useMemo(
() => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
[grantedScopes, permissionScopeMap]
)
const capabilityGroups = useMemo(() => {
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
[]
for (const category of activeByCategory) {
for (const { entry, mode } of category.entries) {
const capabilityScopes = getEntryScopes(entry, mode)
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
if (endpoints.length > 0) {
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
}
}
const capabilityScopes = getEntryScopes(entry, mode)
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
const mcpTools = getEnabledMcpToolsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
result.push({ entry, mode, endpoints, mcpTools })
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
return result
}, [selection, grantedScopes, permissionScopeMap])
return { activeByCategory, mcpTools, capabilityGroups }
return { capabilities }
}
@@ -154,6 +154,34 @@ export const getEnabledEndpointsForCapability = ({
.map(([raw]) => splitEndpoint(raw))
}
/**
* MCP-tool counterpart to getEnabledEndpointsForCapability: the MCP tools enabled by the complete
* granted-scope set that owe that to `capabilityScopes`, for grouping enabled tools under the
* capability that contributes them.
*/
export const getEnabledMcpToolsForCapability = ({
capabilityScopes,
allGrantedScopes,
permissionScopeMap,
}: {
capabilityScopes: Iterable<string>
allGrantedScopes: Iterable<string>
permissionScopeMap: PermissionScopeMap | undefined
}): string[] => {
if (permissionScopeMap == null) return []
const granted = new Set(allGrantedScopes)
const capability = new Set(capabilityScopes)
return Object.entries(permissionScopeMap.mcp_tools)
.filter(([, groups]) =>
groups.some(
(group) =>
group.some((scope) => capability.has(scope)) && group.every((scope) => granted.has(scope))
)
)
.map(([tool]) => tool)
}
/**
* Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of
* the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that