From 33482bdc887c93db0f402af8184ebab887a2d367 Mon Sep 17 00:00:00 2001
From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com>
Date: Fri, 7 Aug 2026 17:19:30 +0100
Subject: [PATCH] feat(studio): lifecycle and role-aware scoped token view
sheet (#48848)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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.
## 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.
Co-authored-by: Claude Sonnet 5
---
.../AccessTokens/AccessToken.constants.ts | 4 +
.../AccessTokens/AccessToken.fixtures.ts | 58 ++++
.../AccessTokens/AccessToken.permissions.ts | 22 ++
.../AccessTokens/Scoped/ExceedsRoleBadge.tsx | 74 +++++
.../AccessTokens/Scoped/TokenSummaryRows.tsx | 113 ++++++++
.../Scoped/ViewTokenSheet.test.tsx | 143 ++++++++++
.../AccessTokens/Scoped/ViewTokenSheet.tsx | 267 ++++++++++--------
.../hooks/useCapabilitySummary.ts | 67 +++++
.../hooks/useTokenAccessEvaluation.ts | 67 +++++
apps/studio/components/ui/DocsButton.tsx | 6 +-
10 files changed, 699 insertions(+), 122 deletions(-)
create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx
create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx
create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx
create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts
create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts
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:
+