feat(studio): lifecycle and role-aware scoped token view sheet (#48848)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Extracts the token view sheet slice of #48742
(w3b6x9/scoped-pat-access-feedback, commit 56ef87a). The role-evaluation
logic (estimateRoleLevel, computeTokenRoleContext,
applySelectionToRoleContext, groupFailingResources) already landed on
master via #48805 and #48809 — this PR only wires the view sheet up to
it:

- Bindings whose project/org was deleted (FGA bindings erased) render a
"resources no longer exist" state; bindings the user can no longer reach
render an anonymous count with a "No longer accessible" badge and a
"removed from" admonition.
- Accessible resources list their name plus ref/slug; capabilities show
"Exceeds your role" pills and the risk badge reflects what the owner's
current role actually allows.
- Header split into separate "Access control" and "API docs" buttons.
- Everything recomputes from live org/project/permission queries (no
stored state) and degrades to no warnings while loading or on
self-hosted.

Also gives DocsButton an optional `label` prop (defaults preserve
existing behavior for every other consumer) so the two header buttons
can carry distinct text.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced access-token details with permission categories, risk
summaries, endpoint, and MCP information.
  * Added warnings for permissions exceeding the token’s role.
* Clearly identifies inaccessible, deleted, or unavailable organizations
and projects.
  * Added resource details and remediation guidance for unusable tokens.
  * Documentation links can now display custom labels.

* **Bug Fixes**
* Improved access evaluation when organization or project data is
incomplete or access has changed.
  * Deferred resource loading until token details are opened.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kemal.earthandClaude Sonnet 5 authored and GitHub committed 2026-08-07 17:19:30 +01:00
1 parent 86854671e9
commit 33482bdc88
10 files changed
+699 -122

No files matched your search

@@ -6,6 +6,10 @@ export type ScopedAccessTokenPermission =
export const CUSTOM_EXPIRY_VALUE = 'custom'
/** Shared tail for every "this token can no longer be used" message. */
export const TOKEN_DENIED_REMEDIATION =
'Requests with this token will be denied. Delete this token and create a new one with the resources and permissions you need.'
/** Warning shown on both entry points that create a classic (account-wide) token. */
export const CLASSIC_TOKEN_WARNING = {
title: 'Access tokens can be used to control your whole account',
@@ -1,3 +1,8 @@
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
import { addAPIMock } from '@/tests/lib/msw'
import type { Permission } from '@/types'
/**
@@ -8,6 +13,10 @@ import type { Permission } from '@/types'
* lower roles' rows. Keep the rows in lockstep with ROLE_PROBES in AccessToken.roles.ts.
*/
type AccessControlPermission = components['schemas']['AccessControlPermission']
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
/** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */
export type PermissionRowFixture = Permission & {
organization_id: number | null
@@ -62,3 +71,52 @@ export const ownerRows = (slug: string, refs: string[] = []) => [
permissionRow(slug, ['write:Update'], ['organizations'], refs),
permissionRow(slug, ['write:Create', 'write:Delete'], ['auth.subject_roles'], refs),
]
export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' }
export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' }
/**
* Registers the GET mocks every scoped-token surface fires on mount: one organization
* ({@link MOCK_ORG}), one project ({@link MOCK_PROJECT}), and the permission scope map.
*/
export const mockScopedTokenEnvironment = () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
]),
})
addAPIMock({
method: 'get',
path: '/platform/projects',
response: () =>
HttpResponse.json<ProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [
{
...createMockProject({ id: 1, ref: MOCK_PROJECT.ref, name: MOCK_PROJECT.name }),
organization_slug: MOCK_ORG.slug,
preview_branch_refs: [],
},
],
}),
})
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
path: '/scoped-access-token-permissions',
response: () => HttpResponse.json({ scopes: {}, endpoints: {}, mcp_tools: {} }),
})
}
export const mockPermissionsApi = (rows: PermissionRowFixture[]) =>
addAPIMock({
method: 'get',
path: '/platform/profile/permissions',
// Permission['condition'] (jsonLogic operator interfaces) has no index signature, so TS won't
// match it against the API row's `{ [key: string]: unknown }` — the runtime shape is fine.
response: () =>
HttpResponse.json<AccessControlPermission[]>(rows as unknown as AccessControlPermission[]),
})
@@ -623,6 +623,18 @@ export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
high: 'High risk',
}
export const PERMISSION_MODE_LABEL: Record<PermissionMode, string> = {
none: 'None',
read: 'Read',
readwrite: 'Read-write',
}
export const RISK_DOT_CLASS: Record<RiskLevel, string> = {
low: 'bg-brand-600',
medium: 'bg-warning-600',
high: 'bg-destructive-600',
}
export type ResourceAccessMode = 'project' | 'organization' | 'account'
export interface OverallRisk {
@@ -632,6 +644,16 @@ export interface OverallRisk {
tone: 'default' | 'low' | 'medium' | 'high'
}
export const RISK_TONE_VARIANT: Record<
OverallRisk['tone'],
'default' | 'success' | 'warning' | 'destructive'
> = {
default: 'default',
low: 'success',
medium: 'warning',
high: 'destructive',
}
/**
* Computes the overall token risk from the selected capabilities and the resource-access breadth.
* Account-level tokens are never below "Elevated", even when read-only.
@@ -0,0 +1,74 @@
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
PERMISSION_MODE_LABEL,
type PermissionCatalogEntry,
type PermissionMode,
} from '../AccessToken.permissions'
import { TOKEN_ROLE_LABEL, type EntryAccess, type FailingResource } from '../AccessToken.roles'
const MAX_LISTED_RESOURCES = 5
const MAX_LISTED_PROJECT_ROLES = 3
/**
* One line per failing resource. Members invited to projects (not the org) get their real
* per-project role spelled out.
*/
export const failingResourceLine = (resource: FailingResource): string => {
if (resource.projectScopedRoles !== undefined && resource.projectScopedRoles.length > 0) {
const listed = resource.projectScopedRoles
.slice(0, MAX_LISTED_PROJECT_ROLES)
.map((project) => `${TOKEN_ROLE_LABEL[project.role]} on the project ${project.label}`)
.join(', ')
const overflow = resource.projectScopedRoles.length - MAX_LISTED_PROJECT_ROLES
const roles = overflow > 0 ? `${listed}, and ${overflow} more` : listed
return `${resource.label} — your role is ${roles}`
}
if (resource.role === 'member' || resource.role === 'none') {
return resource.type === 'organization'
? `${resource.label} — you don't have an organization-level role`
: `${resource.label} — you don't have a role on this project`
}
return `${resource.label} — your role is ${TOKEN_ROLE_LABEL[resource.role]}`
}
interface ExceedsRoleBadgeProps {
entry: PermissionCatalogEntry
mode: PermissionMode
access: EntryAccess
}
/**
* "Exceeds your role" pill with a tooltip naming exactly which resources deny the permission and
* why.
*/
export const ExceedsRoleBadge = ({ entry, mode, access }: ExceedsRoleBadgeProps) => {
const failingResources = access.failingResources
const overflowCount = failingResources.length - MAX_LISTED_RESOURCES
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Badge variant="destructive" className="cursor-help">
Exceeds your role
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-80 space-y-1.5">
<p className="text-xs">
{entry.name} ({PERMISSION_MODE_LABEL[mode]}) requires the{' '}
{TOKEN_ROLE_LABEL[access.requiredRole]} role or above
{entry.level === 'organization' && ' at the organization level'}. Requests will be denied
on:
</p>
<ul className="text-xs text-foreground-light space-y-0.5">
{failingResources.slice(0, MAX_LISTED_RESOURCES).map((resource) => (
<li key={resource.id}>{failingResourceLine(resource)}</li>
))}
{overflowCount > 0 && <li>and {overflowCount} more</li>}
</ul>
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,113 @@
import { Badge, cn } from 'ui'
import {
PERMISSION_MODE_LABEL,
RISK_DOT_CLASS,
RISK_TONE_VARIANT,
type OverallRisk,
type PermissionCatalogEntry,
type PermissionMode,
} from '../AccessToken.permissions'
import type { EntryAccess } from '../AccessToken.roles'
import { ExceedsRoleBadge } from './ExceedsRoleBadge'
/**
* Presentational pieces of the token view sheet's summary section.
*/
interface CapabilityCategoryListProps {
categories: {
key: string
name: string
entries: { entry: PermissionCatalogEntry; mode: PermissionMode }[]
}[]
/** Per-entry access evaluation; entries flagged 'exceeds-role' get the warning pill. */
accessEntries: Record<string, EntryAccess>
}
export const CapabilityCategoryList = ({
categories,
accessEntries,
}: CapabilityCategoryListProps) => (
<div className="space-y-4">
{categories.map((category) => (
<div key={category.key} className="space-y-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
{category.name}
</p>
<div className="divide-y">
{category.entries.map(({ entry, mode }) => {
const entryAccess = accessEntries[entry.key]
return (
<div key={entry.key} className="flex items-center justify-between gap-2 text-sm py-2">
<span className="flex flex-wrap items-center gap-2">
<span
className={cn('h-1.5 w-1.5 shrink-0 rounded-full', RISK_DOT_CLASS[entry.risk])}
/>
<span className="text-foreground text-wrap">{entry.name}</span>
{entryAccess?.status === 'exceeds-role' && (
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
)}
</span>
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
)
})}
</div>
</div>
))}
</div>
)
interface RiskLevelSummaryProps {
risk: OverallRisk
/** True when some selected permissions exceed the owner's role, so the risk is role-capped. */
showRoleCaveat: boolean
}
export const RiskLevelSummary = ({ risk, showRoleCaveat }: RiskLevelSummaryProps) => (
<div className="space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="flex">
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
</span>
<span className="text-sm text-foreground leading-px">
{risk.text.replace(`${risk.level} — `, '')}
</span>
</span>
{showRoleCaveat && (
<p className="text-xs text-foreground-lighter">
Based on what your current role allows this token to do.
</p>
)}
</div>
)
interface ResourceSummaryItemProps {
label: string
/** Mono-rendered identifier under the name — the org slug or project ref. */
sublabel?: string
isInaccessible?: boolean
}
export const ResourceSummaryItem = ({
label,
sublabel,
isInaccessible = false,
}: ResourceSummaryItemProps) => (
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="flex flex-col gap-0.5">
<span
className={cn('text-sm', isInaccessible ? 'text-foreground-lighter' : 'text-foreground')}
>
{label}
</span>
{sublabel !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">{sublabel}</span>
)}
</span>
{isInaccessible && <Badge variant="destructive">No longer accessible</Badge>}
</div>
)
@@ -0,0 +1,143 @@
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
mockPermissionsApi,
mockScopedTokenEnvironment,
ownerRows,
readonlyRows,
} from '../AccessToken.fixtures'
import { ViewTokenSheet } from './ViewTokenSheet'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type TokenResponse = components['schemas']['GetScopedAccessTokenResponse']
mockAnimationsApi()
// The role evaluation reads /platform/profile/permissions, which only fires on the platform for a
// logged-in user — neither is true in the default test environment.
vi.mock('common', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('common')
return { ...actual, useIsLoggedIn: () => true }
})
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, IS_PLATFORM: true }
})
const TOKEN_BASE = {
created_at: '2026-08-01T00:00:00.000Z',
expires_at: null,
id: 'token-1',
last_used_at: null,
name: 'CI token',
token_alias: 'sbp_test123',
} satisfies Partial<TokenResponse>
const mockToken = (token: TokenResponse) =>
addAPIMock({
method: 'get',
path: '/platform/profile/scoped-access-tokens/:id',
response: () => HttpResponse.json<TokenResponse>(token),
})
describe('ViewTokenSheet', () => {
beforeEach(() => {
mockScopedTokenEnvironment()
})
const renderSheet = () =>
customRender(<ViewTokenSheet visible tokenId="token-1" onClose={() => {}} />, {
profileContext: createMockProfileContext(),
})
test('shows no access warnings when the role covers every permission', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
organization_slugs: [MOCK_ORG.slug],
permissions: ['database_read', 'database_write'],
})
renderSheet()
// Bound org resolves with its name and slug, meaning evaluation completed without warnings.
expect(await screen.findByText(MOCK_ORG.name)).toBeInTheDocument()
expect(screen.getByText(MOCK_ORG.slug)).toBeInTheDocument()
expect(screen.queryByText('Exceeds your role')).toBeNull()
expect(
screen.queryByText('Some permissions exceed your current role for the selected resources')
).toBeNull()
expect(screen.queryByText('This token no longer has access')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('marks permissions above the current role without blocking the rest', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
organization_slugs: [MOCK_ORG.slug],
// database_write requires Developer; the owner of this token is Read-only.
permissions: ['database_read', 'database_write'],
})
renderSheet()
expect(
await screen.findByText(
'Some permissions exceed your current role for the selected resources'
)
).toBeInTheDocument()
expect(await screen.findByText('Exceeds your role')).toBeInTheDocument()
// Advisory only — the other (destructive) states must not fire.
expect(screen.queryByText('This token no longer has access')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('reports lost access when the user was removed from every bound resource', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
// Bound to an org the user can no longer see.
organization_slugs: ['departed-org'],
permissions: ['members_read'],
})
renderSheet()
expect(await screen.findByText('This token no longer has access')).toBeInTheDocument()
expect(
await screen.findByText(/You were removed from the organizations this token is bound to/)
).toBeInTheDocument()
// The lost resource renders as an anonymous count, never its slug.
expect(await screen.findByText('1 organization')).toBeInTheDocument()
expect(await screen.findByText('No longer accessible')).toBeInTheDocument()
expect(screen.queryByText('departed-org')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('reports deleted resources when a token has no bindings left', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'project',
// Deleting a project erases the token's binding to it.
project_refs: [],
permissions: ['database_read'],
})
renderSheet()
expect(await screen.findByText("This token's resources no longer exist")).toBeInTheDocument()
expect(
(await screen.findAllByText(/Every project this token was bound to has been deleted/)).length
).toBeGreaterThan(0)
expect(screen.queryByText('This token no longer has access')).toBeNull()
})
})
@@ -1,27 +1,25 @@
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { Badge, cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui'
import { cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants'
import {
computeOverallRisk,
PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_MODE_LABEL,
scopesToSelection,
type OverallRisk,
type PermissionCatalogEntry,
type PermissionMode,
type ResourceAccessMode,
type RiskLevel,
} 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 { DocsButton } from '@/components/ui/DocsButton'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
useGetEnabledEndpointsForCapability,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
import { DOCS_URL } from '@/lib/constants'
import { pluralize } from '@/lib/helpers'
interface ViewTokenSheetProps {
visible: boolean
@@ -29,33 +27,15 @@ interface ViewTokenSheetProps {
onClose: () => void
}
const RISK_TONE_VARIANT: Record<
OverallRisk['tone'],
'default' | 'success' | 'warning' | 'destructive'
> = {
default: 'default',
low: 'success',
medium: 'warning',
high: 'destructive',
}
const RISK_DOT_CLASS: Record<RiskLevel, string> = {
low: 'bg-brand-600',
medium: 'bg-warning-600',
high: 'bg-destructive-600',
}
const modeLabel = (mode: PermissionMode) =>
mode === 'readwrite' ? 'Read-write' : mode === 'read' ? 'Read' : 'None'
const SCOPE_TO_RESOURCE_ACCESS: Record<'user' | 'organization' | 'project', ResourceAccessMode> = {
user: 'account',
organization: 'organization',
project: 'project',
}
const EMPTY_BINDINGS: string[] = []
export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProps) {
const { organizations, projects } = useOrgAndProjectData()
const { data: permissionScopeMap } = useGetEnabledEndpointsForCapability()
const {
@@ -71,72 +51,114 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
}
)
// The sheet stays mounted (hidden) on the tokens page; don't fetch org/project data until it's
// actually opened on a token.
const { organizations, projects } = useOrgAndProjectData({ enabled: visible && !!token })
const resourceAccess = token ? SCOPE_TO_RESOURCE_ACCESS[token.scope] : 'project'
const grantedScopes = useMemo(() => token?.permissions ?? [], [token?.permissions])
const selection = useMemo(() => scopesToSelection(grantedScopes), [grantedScopes])
const tokenOrganizationSlugs = token?.organization_slugs ?? EMPTY_BINDINGS
const tokenProjectRefs = token?.project_refs ?? EMPTY_BINDINGS
const access = useTokenAccessEvaluation({
selection,
resourceAccess,
organizationSlugs: tokenOrganizationSlugs,
projectRefs: tokenProjectRefs,
enabled: visible && !!token,
})
const hasExceedingCapabilities = access.exceedingEntryKeys.length > 0
// Deleting a project/org erases the token's binding to it, so a resource-scoped token with no
// bindings left means everything it was bound to has been deleted.
const hasNoBoundResources = token !== undefined && access.hasNoBoundResources
const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project'
// Deleted bindings are erased from the token, so the original count is unknowable — the
// phrasing has to work for any number of resources.
const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted`
const risk = useMemo(
() => computeOverallRisk(selection, resourceAccess),
[selection, resourceAccess]
() => computeOverallRisk(access.effectiveSelection, resourceAccess),
[access.effectiveSelection, resourceAccess]
)
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 hasCapabilities = grantedScopes.length > 0
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 =
mode === 'readwrite' ? [...entry.readScopes, ...entry.writeScopes] : entry.readScopes
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
if (endpoints.length > 0) {
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
}
}
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
})
// Accessible resources render with their name and ref/slug. Resources the user has lost access
// to are aggregated into an anonymous count — their identifiers aren't shown.
const resourceSummary = useMemo(() => {
const inaccessibleCountItem = (lostCount: number, noun: string) =>
lostCount === 0
? []
: [
{
key: 'inaccessible',
label: `${lostCount} ${pluralize(lostCount, noun)}`,
sublabel: undefined,
isInaccessible: true,
},
]
if (resourceAccess === 'project') {
const selectedProjects = projects.filter((p) => (token?.project_refs ?? []).includes(p.ref))
const projectsByRef = new Map(projects.map((project) => [project.ref, project]))
const accessible = tokenProjectRefs.flatMap((ref) => {
const name = projectsByRef.get(ref)?.name
if (name === undefined) return []
return [{ key: ref, label: name, sublabel: ref, isInaccessible: false }]
})
return {
title: 'Project',
items: selectedProjects.length > 0 ? selectedProjects.map((p) => p.name) : ['-'],
title: 'Projects',
items: [
...accessible,
...inaccessibleCountItem(access.inaccessibleProjectRefs.length, 'project'),
],
}
}
if (resourceAccess === 'organization') {
const selectedOrganizations = organizations.filter((o) =>
(token?.organization_slugs ?? []).includes(o.slug)
)
const organizationsBySlug = new Map(organizations.map((org) => [org.slug, org]))
const accessible = tokenOrganizationSlugs.flatMap((slug) => {
const name = organizationsBySlug.get(slug)?.name
if (name === undefined) return []
return [{ key: slug, label: name, sublabel: slug, isInaccessible: false }]
})
return {
title: 'Organization',
items: selectedOrganizations.length > 0 ? selectedOrganizations.map((o) => o.name) : ['-'],
title: 'Organizations',
items: [
...accessible,
...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'),
],
}
}
return { title: 'Account', items: ['Account-level access'] }
}, [resourceAccess, token, projects, organizations])
return {
title: 'Account',
items: [
{
key: 'account',
label: 'Account-level access',
sublabel: undefined,
isInaccessible: false,
},
],
}
}, [
resourceAccess,
tokenProjectRefs,
tokenOrganizationSlugs,
projects,
organizations,
access.inaccessibleProjectRefs,
access.inaccessibleOrgSlugs,
])
const rows: [string, React.ReactNode][] = token
? [
@@ -183,10 +205,19 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{resourceSummary.title}
</p>
<div className="divide-y">
{resourceSummary.items.length === 0 && hasNoBoundResources && (
<p className="py-2 text-sm text-foreground-lighter">{boundResourcesDeletedText}</p>
)}
{resourceSummary.items.length === 0 && !hasNoBoundResources && (
<p className="py-2 text-sm text-foreground-lighter">-</p>
)}
{resourceSummary.items.map((item) => (
<p key={item} className="py-2 text-sm text-foreground">
{item}
</p>
<ResourceSummaryItem
key={item.key}
label={item.label}
sublabel={item.sublabel}
isInaccessible={item.isInaccessible}
/>
))}
</div>
</div>,
@@ -194,50 +225,14 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
[
'Capabilities',
hasCapabilities ? (
<div className="space-y-4">
{activeByCategory.map((category) => (
<div key={category.key} className="space-y-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
{category.name}
</p>
<div className="divide-y">
{category.entries.map(({ entry, mode }) => (
<div
key={entry.key}
className="flex items-center justify-between gap-2 text-sm py-2"
>
<span className="flex items-center gap-2">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
RISK_DOT_CLASS[entry.risk]
)}
/>
<span className="text-foreground text-wrap">{entry.name}</span>
</span>
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
{modeLabel(mode)}
</span>
</div>
))}
</div>
</div>
))}
</div>
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
) : (
<span className="text-foreground-lighter">No capabilities selected</span>
),
],
[
'Risk level',
<span key="risk" className="flex flex-wrap items-center gap-2">
<span className="flex">
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
</span>
<span className="text-sm text-foreground leading-px">
{risk.text.replace(`${risk.level} — `, '')}
</span>
</span>,
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
],
]
: []
@@ -253,7 +248,18 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
<p className="truncate" title={`View access for ${token?.name}`}>
View access for {token?.name}
</p>
<DocsButton href={`${DOCS_URL}/reference/api/introduction`} />
<div className="flex items-center gap-2">
<DocsButton
href={`${DOCS_URL}/guides/platform/access-control`}
topic="Access control"
label="Access control docs"
/>
<DocsButton
href={`${DOCS_URL}/reference/api/introduction`}
topic="Management API"
label="API docs"
/>
</div>
</SheetHeader>
<ScrollArea className="flex-1">
<div className="space-y-6 px-5 sm:px-6 py-6">
@@ -273,6 +279,27 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{token && (
<>
{hasNoBoundResources && (
<Admonition
type="destructive"
title="This token's resources no longer exist"
description={`${boundResourcesDeletedText}. ${TOKEN_DENIED_REMEDIATION}`}
/>
)}
{access.hasNoAccessibleResource && (
<Admonition
type="destructive"
title="This token no longer has access"
description={`You were removed from the ${resourceNoun}s this token is bound to. ${TOKEN_DENIED_REMEDIATION}`}
/>
)}
{hasExceedingCapabilities && !access.hasNoAccessibleResource && (
<Admonition
type="warning"
title="Some permissions exceed your current role for the selected resources"
description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them."
/>
)}
<div className="flex flex-col gap-3">
<h3 className="text-sm">Token summary</h3>
<dl className="divide-y rounded-md border bg-surface-300">
@@ -299,7 +326,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
<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">
{mode === 'readwrite' ? 'Read-write' : 'Read'}
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
<div className="divide-y">
@@ -0,0 +1,67 @@
import { useMemo } from 'react'
import {
getEntryScopes,
PERMISSION_CATALOG_BY_CATEGORY,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from '../AccessToken.permissions'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
interface UseCapabilitySummaryArgs {
selection: PermissionSelection
grantedScopes: string[]
permissionScopeMap: PermissionScopeMap | undefined
}
/**
* 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.
*/
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 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]) })
}
}
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
return { activeByCategory, mcpTools, capabilityGroups }
}
@@ -0,0 +1,67 @@
import { useMemo } from 'react'
import type { PermissionSelection, ResourceAccessMode } from '../AccessToken.permissions'
import {
applySelectionToRoleContext,
computeTokenRoleContext,
type TokenAccessEvaluation,
} from '../AccessToken.roles'
import { useOrgAndProjectData } from './useOrgAndProjectData'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
interface UseTokenAccessEvaluationArgs {
selection: PermissionSelection
resourceAccess: ResourceAccessMode
organizationSlugs: string[]
projectRefs: string[]
enabled?: boolean
}
/**
* Evaluates a token's scope selection and bound resources against the current user's live access.
* Advisory only — actual enforcement is the per-request intersection on the API side. While the
* underlying queries load (or on self-hosted), the evaluation reports `status: 'unknown'` and
* callers must show no warnings rather than flash false ones.
*
* Role resolution (the expensive part) is memoized separately from the selection, so toggling
* permissions in the form only re-runs the cheap selection pass.
*/
export const useTokenAccessEvaluation = ({
selection,
resourceAccess,
organizationSlugs,
projectRefs,
enabled = true,
}: UseTokenAccessEvaluationArgs): TokenAccessEvaluation => {
const { data: permissions } = usePermissionsQuery({ enabled })
const { organizations, projects, isLoadingOrgs, isLoadingProjects } = useOrgAndProjectData({
enabled,
})
// Org/project lists still loading: resources the user *does* have access to would read as
// inaccessible, so report unknown instead.
const hasCompleteResourceLists = !isLoadingOrgs && !isLoadingProjects
const context = useMemo(
() =>
computeTokenRoleContext({
resourceAccess,
organizationSlugs,
projectRefs,
permissions: hasCompleteResourceLists ? permissions : undefined,
organizations,
projects,
}),
[
resourceAccess,
organizationSlugs,
projectRefs,
permissions,
organizations,
projects,
hasCompleteResourceLists,
]
)
return useMemo(() => applySelectionToRoleContext(context, selection), [context, selection])
}
+4 -2
View File
@@ -6,9 +6,11 @@ interface DocsButtonProps {
abbrev?: boolean
className?: string
topic?: string
/** Custom button text, e.g. to distinguish multiple docs buttons side by side. */
label?: string
}
export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButtonProps) => {
export const DocsButton = ({ href, abbrev = true, className, topic, label }: DocsButtonProps) => {
return (
<Button
asChild
@@ -23,7 +25,7 @@ export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButton
href={href}
aria-label={topic ? `${topic} documentation (opens in new tab)` : undefined}
>
{abbrev ? 'Docs' : 'Documentation'}
{label ?? (abbrev ? 'Docs' : 'Documentation')}
</a>
</Button>
)