diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts
index 4448dfb5f40..c3f765e8bb3 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts
@@ -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',
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
index f19ae68170a..830692e2b2c 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts
@@ -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([
+ createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
+ ]),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects',
+ response: () =>
+ HttpResponse.json({
+ 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(rows as unknown as AccessControlPermission[]),
+ })
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts
index 5e4586d21b6..4b74ad622da 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts
+++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts
@@ -623,6 +623,18 @@ export const RISK_LEVEL_LABEL: Record = {
high: 'High risk',
}
+export const PERMISSION_MODE_LABEL: Record = {
+ none: 'None',
+ read: 'Read',
+ readwrite: 'Read-write',
+}
+
+export const RISK_DOT_CLASS: Record = {
+ 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.
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx
new file mode 100644
index 00000000000..67912517bf2
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx
@@ -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 (
+
+
+
+
+ Exceeds your role
+
+
+
+
+
+ {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:
+
+
+ {failingResources.slice(0, MAX_LISTED_RESOURCES).map((resource) => (
+ {failingResourceLine(resource)}
+ ))}
+ {overflowCount > 0 && and {overflowCount} more }
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx
new file mode 100644
index 00000000000..140e38cf8ff
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx
@@ -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
+}
+
+export const CapabilityCategoryList = ({
+ categories,
+ accessEntries,
+}: CapabilityCategoryListProps) => (
+
+ {categories.map((category) => (
+
+
+ {category.name}
+
+
+ {category.entries.map(({ entry, mode }) => {
+ const entryAccess = accessEntries[entry.key]
+ return (
+
+
+
+ {entry.name}
+ {entryAccess?.status === 'exceeds-role' && (
+
+ )}
+
+
+ {PERMISSION_MODE_LABEL[mode]}
+
+
+ )
+ })}
+
+
+ ))}
+
+)
+
+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) => (
+
+
+
+ {risk.level} Risk
+
+
+ {risk.text.replace(`${risk.level} — `, '')}
+
+
+ {showRoleCaveat && (
+
+ Based on what your current role allows this token to do.
+
+ )}
+
+)
+
+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) => (
+
+
+
+ {label}
+
+ {sublabel !== undefined && (
+ {sublabel}
+ )}
+
+ {isInaccessible && No longer accessible }
+
+)
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
new file mode 100644
index 00000000000..97e970b6c51
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
@@ -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>()
+ 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
+
+const mockToken = (token: TokenResponse) =>
+ addAPIMock({
+ method: 'get',
+ path: '/platform/profile/scoped-access-tokens/:id',
+ response: () => HttpResponse.json(token),
+ })
+
+describe('ViewTokenSheet', () => {
+ beforeEach(() => {
+ mockScopedTokenEnvironment()
+ })
+
+ const renderSheet = () =>
+ customRender( {}} />, {
+ 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()
+ })
+})
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
index 7c2b2f8a5b9..edb9183e456 100644
--- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
+++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx
@@ -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 = {
- 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}
+ {resourceSummary.items.length === 0 && hasNoBoundResources && (
+
{boundResourcesDeletedText}
+ )}
+ {resourceSummary.items.length === 0 && !hasNoBoundResources && (
+
-
+ )}
{resourceSummary.items.map((item) => (
-
- {item}
-
+
))}
,
@@ -194,50 +225,14 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
[
'Capabilities',
hasCapabilities ? (
-
- {activeByCategory.map((category) => (
-
-
- {category.name}
-
-
- {category.entries.map(({ entry, mode }) => (
-
-
-
- {entry.name}
-
-
- {modeLabel(mode)}
-
-
- ))}
-
-
- ))}
-
+
) : (
No capabilities selected
),
],
[
'Risk level',
-
-
- {risk.level} Risk
-
-
- {risk.text.replace(`${risk.level} — `, '')}
-
- ,
+ ,
],
]
: []
@@ -253,7 +248,18 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
View access for {token?.name}
-
+
+
+
+
@@ -273,6 +279,27 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{token && (
<>
+ {hasNoBoundResources && (
+
+ )}
+ {access.hasNoAccessibleResource && (
+
+ )}
+ {hasExceedingCapabilities && !access.hasNoAccessibleResource && (
+
+ )}
Token summary
@@ -299,7 +326,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{entry.name}
- {mode === 'readwrite' ? 'Read-write' : 'Read'}
+ {PERMISSION_MODE_LABEL[mode]}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
new file mode 100644
index 00000000000..76b78959d4a
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
@@ -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 }
+}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts
new file mode 100644
index 00000000000..4d18186d5bc
--- /dev/null
+++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts
@@ -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])
+}
diff --git a/apps/studio/components/ui/DocsButton.tsx b/apps/studio/components/ui/DocsButton.tsx
index 9a3de4e8e9e..45b5588c20e 100644
--- a/apps/studio/components/ui/DocsButton.tsx
+++ b/apps/studio/components/ui/DocsButton.tsx
@@ -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 (
- {abbrev ? 'Docs' : 'Documentation'}
+ {label ?? (abbrev ? 'Docs' : 'Documentation')}
)