mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
)
|
||||
+143
@@ -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()
|
||||
})
|
||||
})
|
||||
+147
-120
@@ -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 }
|
||||
}
|
||||
+67
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user