diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 940b6da11ea11..3c79bb06fd879 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -3365,7 +3365,7 @@ export const reference = { }, { name: 'Management API', - url: '/reference/javascript', + url: '/reference/api/introduction', icon: '/img/icons/menu/reference-api' as `/${string}`, }, ], diff --git a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx index 3820b37c0ce64..855391dca71f5 100644 --- a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx +++ b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx @@ -158,7 +158,7 @@ https://.supabase.co/auth/v1/oauth/authorize? | `client_id` | The client ID from registration | | `redirect_uri` | Must exactly match a registered redirect URI | | `code_challenge` | The generated code challenge | -| `code_challenge_method` | Must be `S256` (SHA-256) | +| `code_challenge_method` | `S256` (SHA-256, recommended) or `plain` | #### Optional parameters diff --git a/apps/docs/content/guides/functions/limits.mdx b/apps/docs/content/guides/functions/limits.mdx index a7db02f15fca9..6049d907497a0 100644 --- a/apps/docs/content/guides/functions/limits.mdx +++ b/apps/docs/content/guides/functions/limits.mdx @@ -20,8 +20,8 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform." - Maximum Function Size: 20MB (bundled locally via the CLI) or 5MB (bundled server-side, e.g. via the Management API or Dashboard) - Maximum no. of Functions per project: - Free: 100 - - Pro: 500 - - Team: 1000 + - Pro: 1000 + - Team: 2000 - Enterprise: Unlimited - Maximum log message length: 10,000 characters - Log event threshold: 100 events per 10 seconds diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index 3682cb505b3ef..d3becfcb67c36 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -51,6 +51,18 @@ export async function fetchJsLibReferenceSource() { }) } +export async function fetchServerLibReferenceSource() { + // Server SDK is driven by the new reference pipeline. Ingest search sources + // from the generated `content/reference/server/v1/` outputs so embeddings + // never drift from what the renderer shows. + return loadClientLibReferenceFromNewPipeline({ + source: 'server-lib', + path: '/reference/server', + meta: { title: 'Server Reference', language: 'TypeScript' }, + contentDir: 'content/reference/server/v1', + }) +} + export async function fetchDartLibReferenceSource() { // Dart v2 is driven by the new reference pipeline. Ingest search sources from // the generated `content/reference/dart/v2/` outputs so embeddings never @@ -132,6 +144,7 @@ export async function fetchAllSources(fullIndex: boolean) { const lintWarningsGuideSources = fetchLintWarningsGuideSources() const openApiReferenceSource = fetchOpenApiReferenceSource() const jsLibReferenceSource = fetchJsLibReferenceSource() + const serverLibReferenceSource = fullIndex ? fetchServerLibReferenceSource() : [] const dartLibReferenceSource = fullIndex ? fetchDartLibReferenceSource() : [] const pythonLibReferenceSource = fullIndex ? fetchPythonLibReferenceSource() : [] const cSharpLibReferenceSource = fullIndex ? fetchCSharpLibReferenceSource() : [] @@ -165,6 +178,7 @@ export async function fetchAllSources(fullIndex: boolean) { lintWarningsGuideSources, openApiReferenceSource, jsLibReferenceSource, + serverLibReferenceSource, dartLibReferenceSource, pythonLibReferenceSource, cSharpLibReferenceSource, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts index 4aab626e393c9..b40d3550382af 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { - computeOverallRisk, countConfigured, getCatalogEntry, scopesToSelection, @@ -145,37 +144,6 @@ describe('selectionToScopes', () => { }) }) -describe('computeOverallRisk', () => { - it('is Minimal with no capabilities', () => { - expect(computeOverallRisk({}, 'project').level).toBe('Minimal') - }) - - it('account-level read-only is still Elevated', () => { - const risk = computeOverallRisk({ 'project:advisors': 'read' }, 'account') - expect(risk.level).toBe('Elevated') - expect(risk.tone).toBe('medium') - }) - - it('account-level with any write is High', () => { - const risk = computeOverallRisk({ 'project:realtime_config': 'readwrite' }, 'account') - expect(risk.level).toBe('High') - }) - - it('project high-risk write is High', () => { - expect(computeOverallRisk({ 'project:database': 'readwrite' }, 'project').level).toBe('High') - }) - - it('project medium write is Medium', () => { - expect(computeOverallRisk({ 'project:realtime_config': 'readwrite' }, 'project').level).toBe( - 'Medium' - ) - }) - - it('read-only project is Low', () => { - expect(computeOverallRisk({ 'project:database': 'read' }, 'project').level).toBe('Low') - }) -}) - describe('countConfigured', () => { it('counts only non-none modes', () => { expect(countConfigured({ a: 'read', b: 'none', c: 'readwrite' })).toBe(2) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts index 4b74ad622daf5..405b96e3d61cc 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -629,72 +629,10 @@ export const PERMISSION_MODE_LABEL: Record = { 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 { - /** Minimal | Low | Medium | Elevated | High */ - level: string - text: string - tone: 'default' | 'low' | 'medium' | 'high' -} - -export const RISK_TONE_VARIANT: Record< - OverallRisk['tone'], - 'default' | 'success' | 'warning' | 'destructive' -> = { - default: 'default', +export const RISK_TONE_VARIANT: Record = { 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. - */ -export const computeOverallRisk = ( - selection: PermissionSelection, - resourceAccess: ResourceAccessMode -): OverallRisk => { - const active = Object.entries(selection).filter(([, mode]) => mode !== 'none') - if (active.length === 0) { - return { level: 'Minimal', text: 'Minimal — No capabilities', tone: 'default' } - } - - const anyWrite = active.some(([, mode]) => mode === 'readwrite') - const anyHighWrite = active.some( - ([key, mode]) => mode === 'readwrite' && CATALOG_BY_KEY.get(key)?.risk === 'high' - ) - - const scopeWord = - resourceAccess === 'account' - ? 'Account-wide' - : resourceAccess === 'organization' - ? 'Organization-wide' - : 'Single-project' - const accessWord = anyWrite ? 'read-write' : 'read-only' - - let level: string - let tone: OverallRisk['tone'] - if (resourceAccess === 'account') { - level = anyWrite ? 'High' : 'Elevated' - tone = anyWrite ? 'high' : 'medium' - } else if (anyHighWrite) { - level = 'High' - tone = 'high' - } else if (anyWrite) { - level = 'Medium' - tone = 'medium' - } else { - level = 'Low' - tone = 'low' - } - - return { level, text: `${level} — ${scopeWord} ${accessWord} access`, tone } -} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index 25f572efe1b87..7c18d08c332b0 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -1,9 +1,20 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { ChevronRight } from 'lucide-react' +import { ChevronRight, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' -import { Button, Form, ScrollArea, Separator, SheetClose, SheetFooter } from 'ui' +import { + Button, + Form, + InfoIcon, + Popover, + PopoverAnchor, + PopoverContent, + ScrollArea, + Separator, + SheetClose, + SheetFooter, +} from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants' @@ -46,6 +57,8 @@ export const NewScopedTokenForm = ({ }) const [step, setStep] = useState<'form' | 'review'>('form') const [formValues, setFormValues] = useState(DEFAULT_VALUES) + // Dismissal sticks for the sheet's lifetime, so bouncing between steps doesn't resurface it. + const [isCreateHintDismissed, setIsCreateHintDismissed] = useState(false) const [showMissingPermissionsWarning, setShowMissingPermissionsWarning] = useState(false) const resourceSectionRef = useRef(null) const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' }) @@ -111,7 +124,10 @@ export const NewScopedTokenForm = ({ return ( <> - + {/* Radix wraps viewport children in an inline-styled display:table div that grows to fit + the widest child, which would let one long endpoint path expand the sheet instead of + clipping — force it back to block so widths are bounded and rows can truncate. */} + {step === 'form' ? (
@@ -154,7 +170,6 @@ export const NewScopedTokenForm = ({ {showMissingPermissionsWarning && ( @@ -187,16 +202,11 @@ export const NewScopedTokenForm = ({ ) : ( )} -
+
{step === 'review' && ( - <> - - Access can't be changed after creation - - - + )} @@ -212,9 +222,35 @@ export const NewScopedTokenForm = ({ )} {step === 'review' && ( - + + + + + event.preventDefault()} + onInteractOutside={() => setIsCreateHintDismissed(true)} + onEscapeKeyDown={() => setIsCreateHintDismissed(true)} + > + +

+ Access can't be changed after creation +

+
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx index 3f71e882fb75b..6ef729763e966 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -1,12 +1,9 @@ import dayjs from 'dayjs' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import { Badge, cn } from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import { - computeOverallRisk, - PERMISSION_MODE_LABEL, - selectionToScopes, -} from '../../AccessToken.permissions' +import { PERMISSION_MODE_LABEL, selectionToScopes } from '../../AccessToken.permissions' import { groupFailingResources, TOKEN_ROLE_LABEL, @@ -15,9 +12,24 @@ import { import { useCapabilitySummary } from '../../hooks/useCapabilitySummary' import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData' import { failingResourceLine } from '../ExceedsRoleBadge' -import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from '../TokenSummaryRows' +import { + ResourceAccessPills, + useResourceAccessWrap, + type ResourceAccessPillItem, +} from '../ResourceAccessPills' +import { CapabilitiesSection } from '../TokenCapabilities/CapabilitiesSection' +import { CapabilityLevelToggle } from '../TokenCapabilities/CapabilityLevelToggle' +import { RiskBanner } from '../TokenCapabilities/RiskBanner' +import { + computeRiskBanner, + getCapabilityDensityTier, + type CapabilityLevelFilter, +} from '../TokenCapabilities/TokenCapabilities.utils' import { EXPIRY_OPTIONS, type TokenFormValues } from './NewScopedTokenForm.utils' -import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' +import { + getEnabledMcpTools, + PermissionScopeMap, +} from '@/data/scoped-access-tokens/permission-scope-map-query' interface ReviewStepProps { values: TokenFormValues @@ -25,6 +37,10 @@ interface ReviewStepProps { permissionScopeMap: PermissionScopeMap | undefined } +/** + * Mirrors the token view sheet (risk banner, summary, capability cards) so reviewing a token + * before creating it looks identical to viewing it afterwards. + */ export const NewScopedTokenFormReview = ({ values, access, @@ -44,37 +60,27 @@ export const NewScopedTokenFormReview = ({ ) const risk = useMemo( - () => computeOverallRisk(access.effectiveSelection, values.resourceAccess), - [access.effectiveSelection, values.resourceAccess] + () => + computeRiskBanner({ + effectiveSelection: access.effectiveSelection, + resourceAccess: values.resourceAccess, + organizationSlugs: values.organizationSlugs, + projectRefs: values.projectRefs, + }), + [access.effectiveSelection, values.resourceAccess, values.organizationSlugs, values.projectRefs] ) - const resourceSummary = useMemo(() => { - if (values.resourceAccess === 'project') { - const selectedProjects = projects.filter((p) => values.projectRefs.includes(p.ref)) - return { - title: 'Projects', - items: - selectedProjects.length > 0 - ? selectedProjects.map((p) => ({ key: p.ref, label: p.name, sublabel: p.ref })) - : [{ key: 'none', label: '-', sublabel: undefined }], - } - } + // The classic (account) flow skips review entirely, so only org- and project-bound tokens land + // here. + const resourceItems = useMemo(() => { if (values.resourceAccess === 'organization') { - const selectedOrganizations = organizations.filter((o) => - values.organizationSlugs.includes(o.slug) - ) - return { - title: 'Organizations', - items: - selectedOrganizations.length > 0 - ? selectedOrganizations.map((o) => ({ key: o.slug, label: o.name, sublabel: o.slug })) - : [{ key: 'none', label: '-', sublabel: undefined }], - } - } - return { - title: 'Account', - items: [{ key: 'account', label: 'Account-level access', sublabel: undefined }], + return organizations + .filter((org) => values.organizationSlugs.includes(org.slug)) + .map((org) => ({ key: org.slug, label: org.name })) } + return projects + .filter((project) => values.projectRefs.includes(project.ref)) + .map((project) => ({ key: project.ref, label: project.name })) }, [values, projects, organizations]) const expiresSummary = useMemo(() => { @@ -86,53 +92,23 @@ export const NewScopedTokenFormReview = ({ return EXPIRY_OPTIONS.find((o) => o.value === values.expiresAt)?.label ?? values.expiresAt }, [values]) - const hasCapabilities = grantedScopes.length > 0 - - const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({ + const { capabilities } = useCapabilitySummary({ selection, grantedScopes, permissionScopeMap, }) + const capabilityTier = getCapabilityDensityTier(capabilities.length) + const [levelFilter, setLevelFilter] = useState('all') - const rows: [string, React.ReactNode][] = [ - ['Name', values.tokenName || Untitled token], - ['Expires', expiresSummary], - [ - 'Resource access', -
-

- {resourceSummary.title} -

-
- {resourceSummary.items.map((item) => ( - - ))} -
-
, - ], - [ - 'Capabilities', - hasCapabilities ? ( - - ) : ( - No capabilities selected - ), - ], - [ - 'Risk level', - , - ], - ] + const enabledMcpTools = useMemo( + () => getEnabledMcpTools({ grantedScopes, permissionScopeMap }).sort(), + [grantedScopes, permissionScopeMap] + ) + + const { containerRef: pillsRef, isWrapped: isResourceAccessWrapped } = useResourceAccessWrap() return (
- {!hasCapabilities && ( - - )} {hasExceedingCapabilities && ( )} + +
+

Risk assessment

+ +
+

Token summary

- {rows.map(([key, value]) => ( -
-
{key}
-
{value}
-
- ))} +
+
Name
+
+ {values.tokenName || Untitled token} +
+
+
+
Expires
+
{expiresSummary}
+
+
+
Resource access
+
+
+ +
+
+
- {hasCapabilities && ( - <> -
-

Management API endpoints enabled

- {capabilityGroups.length === 0 ? ( -

- No Management API endpoints are enabled by the selected capabilities. -

- ) : ( - capabilityGroups.map(({ entry, mode, endpoints }) => ( -
-
- {entry.name} - - {PERMISSION_MODE_LABEL[mode]} - -
-
- {endpoints.map(([method, path]) => ( -
- {method} - {path} -
- ))} -
-
- )) - )} -
+
+
+

Capabilities

+ {capabilityTier === 'dense' && ( + + )} +
+ +
-
-

MCP tools

- {mcpTools.length === 0 ? ( -

- No MCP tools are enabled by the selected capabilities. -

- ) : ( -
- {mcpTools.map((tool) => ( - - {tool} - - ))} -
- )} +
+

Available MCP tools

+ {enabledMcpTools.length === 0 ? ( + No MCP tools enabled + ) : ( +
+ {enabledMcpTools.map((tool) => ( + + {tool} + + ))}
- - )} + )} +
) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx index 5bc8a18575ade..4eb1535e109dc 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx @@ -4,23 +4,15 @@ import type { PermissionCatalogEntry, PermissionMode } from '../../AccessToken.p import type { EntryAccess } from '../../AccessToken.roles' import { ExceedsRoleBadge } from '../ExceedsRoleBadge' import { RiskMarker } from './RiskMarker' -import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' interface PermissionRowProps { entry: PermissionCatalogEntry mode: PermissionMode onChange: (mode: PermissionMode) => void - permissionScopeMap: PermissionScopeMap | undefined entryAccess?: EntryAccess } -export const PermissionRow = ({ - entry, - mode, - onChange, - permissionScopeMap, - entryAccess, -}: PermissionRowProps) => { +export const PermissionRow = ({ entry, mode, onChange, entryAccess }: PermissionRowProps) => { return (
@@ -30,7 +22,7 @@ export const PermissionRow = ({ {entry.name} permissions - + {entryAccess?.status === 'exceeds-role' && ( )} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx index 5bbb47e0a1aa4..23112f36d8763 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx @@ -10,20 +10,17 @@ import { import type { TokenAccessEvaluation } from '../../AccessToken.roles' import { PermissionRow } from './PermissionRow' import { InlineLink } from '@/components/ui/InlineLink' -import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' import { DOCS_URL } from '@/lib/constants' interface PermissionsAccordionProps { selection: PermissionSelection onChange: (key: string, mode: PermissionMode) => void - permissionScopeMap: PermissionScopeMap | undefined access?: TokenAccessEvaluation } export const PermissionsAccordion = ({ selection, onChange, - permissionScopeMap, access, }: PermissionsAccordionProps) => { const [openCategories, setOpenCategories] = useState([]) @@ -81,7 +78,6 @@ export const PermissionsAccordion = ({ entry={entry} mode={selection[entry.key] ?? 'none'} onChange={(mode) => onChange(entry.key, mode)} - permissionScopeMap={permissionScopeMap} entryAccess={access?.entries[entry.key]} />
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx index 92c99c051ae0c..2366b1ff68268 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx @@ -2,37 +2,21 @@ import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { RISK_LEVEL_LABEL, + RISK_TONE_VARIANT, type PermissionCatalogEntry, - type RiskLevel, } from '../../AccessToken.permissions' -import { - getMcpToolsForScopes, - PermissionScopeMap, -} from '@/data/scoped-access-tokens/permission-scope-map-query' - -const RISK_VARIANT: Record = { - low: 'success', - medium: 'warning', - high: 'destructive', -} interface RiskMarkerProps { entry: PermissionCatalogEntry /** When false, renders the dot + label without the explanatory tooltip (used in the review list). */ withTooltip?: boolean className?: string - permissionScopeMap: PermissionScopeMap | undefined } -export const RiskMarker = ({ - entry, - withTooltip = true, - className, - permissionScopeMap, -}: RiskMarkerProps) => { +export const RiskMarker = ({ entry, withTooltip = true, className }: RiskMarkerProps) => { const marker = ( {RISK_LEVEL_LABEL[entry.risk]} @@ -41,18 +25,13 @@ export const RiskMarker = ({ if (!withTooltip) return marker - const mcpTools = getMcpToolsForScopes({ - scopeIds: [...entry.readScopes, ...entry.writeScopes], - permissionScopeMap, - }) - return ( {marker} - {RISK_LEVEL_LABEL[entry.risk]} + {RISK_LEVEL_LABEL[entry.risk]}

{entry.riskReason}

{(entry.allowsRead.length > 0 || entry.allowsWrite.length > 0) && (
@@ -74,18 +53,6 @@ export const RiskMarker = ({ )}
)} - {mcpTools.length > 0 && ( -
- {/* getMcpToolsForScopes is associative, not conjunctive: these scopes contribute to - the listed tools, but a tool may need scopes from other capabilities too — the - review step's enabled-tools list is the authoritative view. Keep this heading - distinct from the review step's "MCP tools". */} -

- Related MCP tools -

-

{mcpTools.join(', ')}

-
- )}
) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index a1c401f7170b7..550295bac60e2 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -189,9 +189,9 @@ describe('NewScopedTokenSheet', () => { fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) fireEvent.click(await screen.findByRole('option', { name: 'Read' })) fireEvent.click(await screen.findByRole('button', { name: 'Review access' })) - // Review screen - await screen.findByText('Low Risk') - await screen.findByText('Single-project read-only access') + // Review screen — Project Settings is a high-risk entry, downgraded one tier for read-only + await screen.findByText('Medium risk') + await screen.findByText('Read on 1 capability, across 1 project.') fireEvent.click(await screen.findByRole('button', { name: 'Create token' })) // If we can click this checkbox, the token was created // Must be a real click, which focuses the button: nothing holds focus once the form @@ -239,9 +239,9 @@ describe('NewScopedTokenSheet', () => { fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) fireEvent.click(await screen.findByRole('option', { name: 'Read' })) fireEvent.click(await screen.findByRole('button', { name: 'Review access' })) - // Review screen - await screen.findByText('Low Risk') - await screen.findByText('Organization-wide read-only access') + // Review screen — Project Settings is a high-risk entry, downgraded one tier for read-only + await screen.findByText('Medium risk') + await screen.findByText('Read on 1 capability, across 1 organization.') fireEvent.click(await screen.findByRole('button', { name: 'Create token' })) // If we can click this checkbox, the token was created // Must be a real click, which focuses the button: nothing holds focus once the form diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx new file mode 100644 index 0000000000000..568fb8af78bc9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx @@ -0,0 +1,80 @@ +import { Box, Boxes } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { cn } from 'ui' + +import type { ResourceAccessMode } from '../AccessToken.permissions' + +export interface ResourceAccessPillItem { + key: string + label: string + isInaccessible?: boolean +} + +interface ResourceAccessPillsProps { + resourceAccess: ResourceAccessMode + items: ResourceAccessPillItem[] + /** Shown when there are no items — only the caller knows why the list is empty. */ + emptyText?: string +} + +/** The org/project badges in a token summary's "Resource access" row. */ +export const ResourceAccessPills = ({ + resourceAccess, + items, + emptyText = '-', +}: ResourceAccessPillsProps) => { + if (items.length === 0) { + return {emptyText} + } + + return ( + <> + {items.map((item) => ( +
+ {resourceAccess === 'organization' ? ( + + ) : resourceAccess === 'project' ? ( + + ) : null} + {item.label} +
+ ))} + + ) +} + +/** + * Whether the pill container has wrapped onto multiple lines. Vertically centering the row only + * reads right when its badges fit on a single line — with wrapped badges the label should sit at + * the top instead. Measured, not guessed from item count, since wrapping depends on the sheet's + * width and each badge's label length. + */ +export const useResourceAccessWrap = () => { + const containerRef = useRef(null) + const [isWrapped, setIsWrapped] = useState(false) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const checkWrapped = () => { + const firstBadge = container.firstElementChild as HTMLElement | null + setIsWrapped(firstBadge !== null && container.clientHeight > firstBadge.clientHeight + 2) + } + + checkWrapped() + const observer = new ResizeObserver(checkWrapped) + observer.observe(container) + return () => observer.disconnect() + }, []) + + return { containerRef, isWrapped } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx new file mode 100644 index 0000000000000..5b34c84c2919c --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx @@ -0,0 +1,54 @@ +import { Accordion } from 'ui' + +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { CapabilityCard } from './CapabilityCard' +import { DenseCapabilities } from './DenseCapabilities' +import { getCapabilityDensityTier, type CapabilityLevelFilter } from './TokenCapabilities.utils' + +interface CapabilitiesSectionProps { + capabilities: CapabilitySummaryEntry[] + accessEntries: Record + /** Dense tier's All/Read/Read-write control, rendered by the caller next to the section title. */ + levelFilter: CapabilityLevelFilter +} + +/** + * Switches capability presentation on granted count: a moderate number render as a single + * closed-by-default accordion, and a large grant switches to the dense, filterable view. + */ +export const CapabilitiesSection = ({ + capabilities, + accessEntries, + levelFilter, +}: CapabilitiesSectionProps) => { + if (capabilities.length === 0) { + return No capabilities selected + } + + const tier = getCapabilityDensityTier(capabilities.length) + + if (tier === 'accordion') { + return ( + + {capabilities.map((capability, index) => ( + + ))} + + ) + } + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx new file mode 100644 index 0000000000000..ea69970757c9c --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx @@ -0,0 +1,69 @@ +import { AccordionContent, AccordionItem, AccordionTrigger, Badge, cn } from 'ui' + +import { PERMISSION_MODE_LABEL } from '../../AccessToken.permissions' +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { ExceedsRoleBadge } from '../ExceedsRoleBadge' +import { CapabilityCardBody } from './CapabilityCardBody' +import { pluralize } from '@/lib/helpers' + +interface CapabilityCardProps { + capability: CapabilitySummaryEntry + accessEntries: Record + /** Position within the continuous bordered list — controls corner rounding and shared edges. */ + isFirst?: boolean + isLast?: boolean +} + +const CapabilityCardHeader = ({ + capability, + accessEntries, +}: Pick) => { + const { entry, mode, endpoints } = capability + const entryAccess = accessEntries[entry.key] + + return ( +
+
+ + {entry.name} + {entryAccess?.status === 'exceeds-role' && ( + + )} + + + {`${endpoints.length} ${pluralize(endpoints.length, 'endpoint')}`} + +
+ + {PERMISSION_MODE_LABEL[mode]} + +
+ ) +} + +export const CapabilityCard = ({ + capability, + accessEntries, + isFirst = true, + isLast = true, +}: CapabilityCardProps) => { + const body = + const positionClassName = cn( + 'border', + !isLast && 'border-b-0', + isFirst && 'rounded-t-md', + isLast && 'rounded-b-md' + ) + + return ( + + + + + +
{body}
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx new file mode 100644 index 0000000000000..dae5dc274e3ad --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx @@ -0,0 +1,56 @@ +import { Badge } from 'ui' + +import { + RISK_LEVEL_LABEL, + RISK_TONE_VARIANT, + type PermissionCatalogEntry, +} from '../../AccessToken.permissions' +import { EndpointRow } from './EndpointRow' +import { getSharedPathPrefix } from './TokenCapabilities.utils' +import type { EnabledEndpoint } from '@/data/scoped-access-tokens/permission-scope-map-query' + +interface CapabilityCardBodyProps { + entry: PermissionCatalogEntry + endpoints: EnabledEndpoint[] +} + +export const CapabilityCardBody = ({ entry, endpoints }: CapabilityCardBodyProps) => { + const sharedPrefix = getSharedPathPrefix(endpoints.map((endpoint) => endpoint.path)) + const methodColumnWidth = `${Math.max(0, ...endpoints.map((endpoint) => endpoint.method.length)) + 2}ch` + + return ( +
+
+

Description

+

{entry.description}

+
+ +
+

Risk Level

+ {RISK_LEVEL_LABEL[entry.risk]} +
+ + {endpoints.length > 0 && ( +
+

API endpoints

+
+ {endpoints.map((endpoint) => ( + + ))} +
+
+ )} + {endpoints.length === 0 && ( +

+ No API endpoints are enabled by this capability yet. +

+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx new file mode 100644 index 0000000000000..e2306e9cd04f1 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx @@ -0,0 +1,43 @@ +import { Button, cn } from 'ui' + +import type { CapabilityLevelFilter } from './TokenCapabilities.utils' + +interface CapabilityLevelToggleProps { + value: CapabilityLevelFilter + onChange: (value: CapabilityLevelFilter) => void +} + +const OPTIONS: { value: CapabilityLevelFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'read', label: 'Read' }, + { value: 'readwrite', label: 'Read-write' }, +] + +/** + * Segmented control matching the "Show all / Error only" pattern from Auth's UserLogs: adjacent + * Buttons with their variant swapped on selection, glued together via rounding/border removal + * and a thin divider, rather than a dedicated segmented-control primitive. + */ +export const CapabilityLevelToggle = ({ value, onChange }: CapabilityLevelToggleProps) => ( +
+ {OPTIONS.map(({ value: optionValue, label }, index) => ( +
+ {index > 0 &&
} + +
+ ))} +
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx new file mode 100644 index 0000000000000..ff5e8408772dd --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { Accordion } from 'ui' + +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { CapabilityCard } from './CapabilityCard' +import { groupCapabilitiesByLevel, type CapabilityLevelFilter } from './TokenCapabilities.utils' + +interface DenseCapabilitiesProps { + capabilities: CapabilitySummaryEntry[] + accessEntries: Record + /** Which level group(s) to show — the All/Read/Read-write control lives next to the section title. */ + levelFilter: CapabilityLevelFilter +} + +/** 9+ capabilities: a level-grouped list, read-write pinned first. */ +export const DenseCapabilities = ({ + capabilities, + accessEntries, + levelFilter, +}: DenseCapabilitiesProps) => { + const [openKeys, setOpenKeys] = useState([]) + + const { readwrite, read } = groupCapabilitiesByLevel(capabilities) + + const shown = + levelFilter === 'readwrite' + ? readwrite + : levelFilter === 'read' + ? read + : [...readwrite, ...read] + + return ( +
+ {shown.length === 0 && ( +

No capabilities match this filter.

+ )} + + + {shown.map((capability, index) => ( + + ))} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.test.tsx new file mode 100644 index 0000000000000..00ccd311b1df8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.test.tsx @@ -0,0 +1,29 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test } from 'vitest' + +import { EndpointRow } from './EndpointRow' +import { customRender } from '@/tests/lib/custom-render' + +const user = userEvent.setup({ writeToClipboard: true }) + +describe('EndpointRow', () => { + test('copies the endpoint path on click', async () => { + customRender( + + ) + + // Must be a real click, which focuses the button: copyToClipboard bails out when the + // document has no focus + await user.click(screen.getByRole('button', { name: 'Copy GET /v1/projects/{ref}/config' })) + + await waitFor(async () => + expect(await window.navigator.clipboard.readText()).toBe('/v1/projects/{ref}/config') + ) + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx new file mode 100644 index 0000000000000..bec181ee5175d --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx @@ -0,0 +1,121 @@ +import { Check, Copy } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { cn, copyToClipboard } from 'ui' + +import { splitEndpointPath } from './TokenCapabilities.utils' + +interface EndpointRowProps { + method: string + path: string + /** Shared leading segments across the group, rendered muted ahead of the distinguishing part. */ + sharedPrefix: string + /** Sized by the caller for the longest method present in the group. */ + methodColumnWidth: string +} + +/** Pan slowly enough to read while revealing (~70px/s), but never snap for short distances. */ +const panDurationMs = (distance: number) => Math.max(300, Math.round(distance * 14)) + +/** + * One copyable endpoint. The muted prefix span shrinks with an end-ellipsis while the + * distinguishing segment stays fixed-width — visually equivalent to truncating the full path in + * its middle, without needing to measure pixel widths. A distinguishing segment too long for the + * row clips at the right instead, so hovering (or focusing) pans the path sideways to bring the + * clipped tail into view. Clicking copies the path — the pasteable part; the method is visible + * context. + */ +export const EndpointRow = ({ + method, + path, + sharedPrefix, + methodColumnWidth, +}: EndpointRowProps) => { + const { prefix, distinguishing } = splitEndpointPath(path, sharedPrefix) + + const pathContainerRef = useRef(null) + const [panDistance, setPanDistance] = useState(0) + const [isRevealed, setIsRevealed] = useState(false) + const [isCopied, setIsCopied] = useState(false) + const copiedTimerRef = useRef | undefined>(undefined) + const [showCopiedIcon, setShowCopiedIcon] = useState(false) + + useEffect(() => () => clearTimeout(copiedTimerRef.current), []) + + // Measured when the reveal starts, not on mount: rows live inside accordion content that mounts + // collapsed, so resting measurements are taken before the row has its real width. + const handleRevealStart = () => { + const container = pathContainerRef.current + if (container) setPanDistance(Math.max(0, container.scrollWidth - container.clientWidth)) + setIsRevealed(true) + } + const handleRevealEnd = () => setIsRevealed(false) + + const handleCopy = () => { + copyToClipboard(path, () => { + setIsCopied(true) + setShowCopiedIcon(true) + clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => setShowCopiedIcon(false), 2000) + }) + } + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx new file mode 100644 index 0000000000000..c4f50328f1a78 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx @@ -0,0 +1,27 @@ +import { Admonition } from 'ui-patterns/Admonition' +import type { AdmonitionType } from 'ui-patterns/Admonition' + +import type { RiskBannerResult } from './TokenCapabilities.utils' + +const TONE_TO_ADMONITION_TYPE: Record = { + default: 'default', + low: 'success', + medium: 'warning', + high: 'destructive', +} + +interface RiskBannerProps { + risk: RiskBannerResult + /** True when some selected permissions exceed the owner's role, so the risk is role-capped. */ + showRoleCaveat: boolean +} + +export const RiskBanner = ({ risk, showRoleCaveat }: RiskBannerProps) => ( + + {showRoleCaveat && 'Based on what your current role allows this token to do.'} + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts new file mode 100644 index 0000000000000..d6da62c72b3df --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts @@ -0,0 +1,5 @@ +/** + * Density-tier threshold for the capabilities section, keyed to the number of granted + * capabilities. Provisional — revisit with product/design once real tokens exercise it. + */ +export const CAPABILITY_DENSITY_ACCORDION_MAX = 8 diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts new file mode 100644 index 0000000000000..55c807be2172a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' + +import { getCatalogEntry } from '../../AccessToken.permissions' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { + computeRiskBanner, + getCapabilityDensityTier, + getSharedPathPrefix, + groupCapabilitiesByLevel, + splitEndpointPath, +} from './TokenCapabilities.utils' + +// 'project:database' is catalog-high and writable; 'project:advisors' is catalog-low and read-only. +const databaseCapability = ( + mode: CapabilitySummaryEntry['mode'], + endpoints: CapabilitySummaryEntry['endpoints'] = [] +): CapabilitySummaryEntry => ({ + entry: getCatalogEntry('project:database')!, + mode, + endpoints, +}) + +const advisorsCapability = (mode: CapabilitySummaryEntry['mode']): CapabilitySummaryEntry => ({ + entry: getCatalogEntry('project:advisors')!, + mode, + endpoints: [], +}) + +describe('getCapabilityDensityTier', () => { + it('is accordion at 8 or fewer capabilities', () => { + expect(getCapabilityDensityTier(0)).toBe('accordion') + expect(getCapabilityDensityTier(8)).toBe('accordion') + }) + + it('is dense at 9 or more capabilities', () => { + expect(getCapabilityDensityTier(9)).toBe('dense') + expect(getCapabilityDensityTier(50)).toBe('dense') + }) +}) + +describe('getSharedPathPrefix', () => { + it('returns nothing for a single endpoint — there is nothing to share', () => { + expect(getSharedPathPrefix(['/v1/projects/{ref}'])).toBe('') + }) + + it('finds the longest shared leading segments across paths', () => { + expect( + getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions/{slug}']) + ).toBe('/v1/projects/{ref}/functions/') + }) + + it('never cuts a shared prefix mid-segment', () => { + // "functions" and "functions-secrets" share characters but not a path segment. + expect( + getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions-secrets']) + ).toBe('/v1/projects/{ref}/') + }) + + it('shares only the common leading segments, not any further', () => { + expect(getSharedPathPrefix(['/v1/branches', '/v1/organizations'])).toBe('/v1/') + }) + + it('returns nothing when paths share no leading segment at all', () => { + expect(getSharedPathPrefix(['/v1/branches', '/v2/organizations'])).toBe('') + }) + + it('keeps at least the last segment distinguishing when every path is identical', () => { + expect(getSharedPathPrefix(['/v1/projects/{ref}', '/v1/projects/{ref}'])).toBe('/v1/projects/') + }) +}) + +describe('splitEndpointPath', () => { + it('splits off the shared prefix when the path starts with it', () => { + expect(splitEndpointPath('/v1/projects/{ref}/functions', '/v1/projects/{ref}/')).toEqual({ + prefix: '/v1/projects/{ref}/', + distinguishing: 'functions', + }) + }) + + it('treats the whole path as distinguishing when there is no shared prefix', () => { + expect(splitEndpointPath('/v1/projects/{ref}', '')).toEqual({ + prefix: '', + distinguishing: '/v1/projects/{ref}', + }) + }) +}) + +describe('groupCapabilitiesByLevel', () => { + it('splits granted capabilities into read-write and read-only', () => { + const capabilities = [databaseCapability('readwrite'), advisorsCapability('read')] + + const { readwrite, read } = groupCapabilitiesByLevel(capabilities) + expect(readwrite.map((c) => c.entry.key)).toEqual(['project:database']) + expect(read.map((c) => c.entry.key)).toEqual(['project:advisors']) + }) +}) + +describe('computeRiskBanner', () => { + it('reports Minimal with no active capabilities', () => { + const risk = computeRiskBanner({ + effectiveSelection: {}, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk).toEqual({ level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' }) + }) + + it('downgrades a read-only grant so it never outranks read-write on a lower-risk resource', () => { + // project:database is catalog-high; read-only downgrades it to medium. + const risk = computeRiskBanner({ + effectiveSelection: { 'project:database': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk.level).toBe('Medium') + }) + + it('takes the max risk across capabilities, not just a write flag', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:database': 'readwrite', 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk.level).toBe('High') + }) + + it('escalates account-wide read-write access to High', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'readwrite' }, + resourceAccess: 'account', + organizationSlugs: [], + projectRefs: [], + }) + expect(risk.level).toBe('High') + expect(risk.summary).toContain('across your entire account') + }) + + it('escalates project scope spanning many bound projects', () => { + const narrow = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a'], + }) + const broad = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a', 'b', 'c', 'd', 'e', 'f'], + }) + expect(narrow.level).toBe('Low') + expect(broad.level).toBe('Medium') + }) + + it('summarizes mixed read and read-write grants with explicit scope breadth', () => { + const risk = computeRiskBanner({ + effectiveSelection: { + 'project:database': 'readwrite', + 'project:advisors': 'read', + 'project:storage': 'read', + }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a', 'b', 'c'], + }) + expect(risk.summary).toBe('Read-write on 1 capability, read on 2, across 3 projects.') + }) + + it('states scope breadth even when nothing is bound', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: [], + }) + expect(risk.summary).toContain('with no projects bound') + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts new file mode 100644 index 0000000000000..8df712e589cc1 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts @@ -0,0 +1,136 @@ +import { + getCatalogEntry, + type PermissionSelection, + type ResourceAccessMode, + type RiskLevel, +} from '../../AccessToken.permissions' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { CAPABILITY_DENSITY_ACCORDION_MAX } from './TokenCapabilities.constants' +import { pluralize } from '@/lib/helpers' + +export type CapabilityDensityTier = 'accordion' | 'dense' + +export const getCapabilityDensityTier = (count: number): CapabilityDensityTier => + count <= CAPABILITY_DENSITY_ACCORDION_MAX ? 'accordion' : 'dense' + +/** + * Longest shared leading path segments across a group of endpoint paths, so the UI can mute the + * boilerplate prefix and highlight only the segment that distinguishes each row. Matching is + * segment-aware (split on "/") so a shared prefix never cuts a path mid-segment. A single-endpoint + * group has nothing to share, so the whole path is treated as the distinguishing part. + */ +export const getSharedPathPrefix = (paths: string[]): string => { + if (paths.length < 2) return '' + + const segmentLists = paths.map((path) => path.split('/')) + const [first, ...rest] = segmentLists + let matched = 0 + while (matched < first.length && rest.every((segments) => segments[matched] === first[matched])) { + matched++ + } + // Every path is identical (not just the shortest one fully consumed as a prefix of a longer + // one) — back off one segment so each path keeps at least the last as its distinguishing part. + if (segmentLists.every((segments) => segments.length === matched)) matched -= 1 + // matched === 1 only captures the empty segment before the leading "/", shared trivially by + // every absolute path — not a meaningful prefix. + if (matched <= 1) return '' + return first.slice(0, matched).join('/') + '/' +} + +export const splitEndpointPath = (path: string, sharedPrefix: string) => + sharedPrefix !== '' && path.startsWith(sharedPrefix) + ? { prefix: sharedPrefix, distinguishing: path.slice(sharedPrefix.length) } + : { prefix: '', distinguishing: path } + +export const groupCapabilitiesByLevel = (capabilities: CapabilitySummaryEntry[]) => ({ + readwrite: capabilities.filter((capability) => capability.mode === 'readwrite'), + read: capabilities.filter((capability) => capability.mode === 'read'), +}) + +export type CapabilityLevelFilter = 'all' | 'read' | 'readwrite' + +const RISK_RANK: Record = { low: 1, medium: 2, high: 3 } +const RANK_TO_RISK: Record = { 1: 'low', 2: 'medium', 3: 'high' } + +/** One severity tier down, floored at 'low' — a read-only grant never outranks a read-write one. */ +const downgradeRisk = (risk: RiskLevel): RiskLevel => RANK_TO_RISK[Math.max(1, RISK_RANK[risk] - 1)] + +export interface RiskBannerResult { + level: 'Minimal' | 'Low' | 'Medium' | 'High' + tone: 'default' | 'low' | 'medium' | 'high' + summary: string +} + +/** + * Computes the risk banner from the grant itself, never from a stored string. Severity is a max() + * over every granted capability's catalog risk — downgraded a tier for read-only grants so a + * read-only high-risk resource never outranks read-write on a medium one — then escalated for + * account-wide tokens and resource bindings spanning many orgs/projects. + */ +export const computeRiskBanner = ({ + effectiveSelection, + resourceAccess, + organizationSlugs, + projectRefs, +}: { + effectiveSelection: PermissionSelection + resourceAccess: ResourceAccessMode + organizationSlugs: string[] + projectRefs: string[] +}): RiskBannerResult => { + const active = Object.entries(effectiveSelection).filter(([, mode]) => mode !== 'none') + + if (active.length === 0) { + return { level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' } + } + + const readWriteCount = active.filter(([, mode]) => mode === 'readwrite').length + const readCount = active.length - readWriteCount + + const maxRisk = active.reduce((max, [key, mode]) => { + const entry = getCatalogEntry(key) + if (!entry) return max + const effectiveRisk: RiskLevel = mode === 'readwrite' ? entry.risk : downgradeRisk(entry.risk) + return RISK_RANK[effectiveRisk] > RISK_RANK[max] ? effectiveRisk : max + }, 'low') + + let rank = RISK_RANK[maxRisk] + if (resourceAccess === 'account') { + rank = Math.max(rank, RISK_RANK.medium) + (readWriteCount > 0 ? 1 : 0) + } else if (resourceAccess === 'organization' && organizationSlugs.length > 3) { + rank += 1 + } else if (resourceAccess === 'project' && projectRefs.length > 5) { + rank += 1 + } + rank = Math.min(rank, RISK_RANK.high) + + const level = rank === RISK_RANK.high ? 'High' : rank === RISK_RANK.medium ? 'Medium' : 'Low' + const tone = rank === RISK_RANK.high ? 'high' : rank === RISK_RANK.medium ? 'medium' : 'low' + + const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project' + const boundCount = + resourceAccess === 'organization' ? organizationSlugs.length : projectRefs.length + const scopeText = + resourceAccess === 'account' + ? 'across your entire account' + : boundCount === 0 + ? `with no ${resourceNoun}s bound` + : `across ${boundCount} ${pluralize(boundCount, resourceNoun)}` + + const segments: string[] = [] + if (readWriteCount > 0) { + segments.push( + `read-write on ${readWriteCount} ${pluralize(readWriteCount, 'capability', 'capabilities')}` + ) + } + if (readCount > 0) { + segments.push( + segments.length === 0 + ? `read on ${readCount} ${pluralize(readCount, 'capability', 'capabilities')}` + : `read on ${readCount}` + ) + } + const sentence = `${segments.join(', ')}, ${scopeText}.` + + return { level, tone, summary: sentence.charAt(0).toUpperCase() + sentence.slice(1) } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx deleted file mode 100644 index 140e38cf8ff49..0000000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 index 97e970b6c51c6..145ded7054ef6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { platformComponents as components } from 'api-types' import { mockAnimationsApi } from 'jsdom-testing-mocks' import { HttpResponse } from 'msw' @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { MOCK_ORG, + MOCK_PROJECT, mockPermissionsApi, mockScopedTokenEnvironment, ownerRows, @@ -68,9 +69,8 @@ describe('ViewTokenSheet', () => { }) renderSheet() - // Bound org resolves with its name and slug, meaning evaluation completed without warnings. + // Bound org resolves with its name, 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') @@ -118,7 +118,6 @@ describe('ViewTokenSheet', () => { ).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() }) @@ -140,4 +139,101 @@ describe('ViewTokenSheet', () => { ).toBeGreaterThan(0) expect(screen.queryByText('This token no longer has access')).toBeNull() }) + + test('renders capability cards with endpoints, a token-wide MCP tools row, and a risk banner', async () => { + mockPermissionsApi(ownerRows(MOCK_ORG.slug)) + mockToken({ + ...TOKEN_BASE, + scope: 'project', + project_refs: [MOCK_PROJECT.ref], + permissions: ['advisors_read', 'database_read', 'database_write'], + }) + addAPIMock({ + method: 'get', + // @ts-expect-error Studio API is missing from types + path: '/scoped-access-token-permissions', + response: () => + HttpResponse.json({ + scopes: {}, + endpoints: { + 'GET /v1/projects/{ref}/advisors/security': [['advisors_read']], + 'GET /v1/projects/{ref}/database': [['database_read']], + 'POST /v1/projects/{ref}/database/query': [['database_write']], + }, + mcp_tools: { + get_advisors: [['advisors_read']], + execute_sql: [['database_write']], + }, + }), + }) + renderSheet() + + expect(await screen.findByText('Advisors')).toBeInTheDocument() + expect(screen.getByText('Database')).toBeInTheDocument() + expect(screen.getByText('Read-write')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + + // Enabled tools are token-wide summary rows, visible without expanding any capability card. + expect(screen.getByText('get_advisors')).toBeInTheDocument() + expect(screen.getByText('execute_sql')).toBeInTheDocument() + + // Capability cards are closed by default — expand both to see their endpoints. + fireEvent.click(screen.getByText('Advisors')) + fireEvent.click(screen.getByText('Database')) + + // Endpoint rows are copy buttons, so their accessible name carries the method and path. + expect( + screen.getByRole('button', { name: 'Copy GET /v1/projects/{ref}/advisors/security' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Copy GET /v1/projects/{ref}/database' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Copy POST /v1/projects/{ref}/database/query' }) + ).toBeInTheDocument() + expect(screen.getByText('POST')).toBeInTheDocument() + + // project:database is catalog-high risk and granted read-write — max() over capabilities. + // "High risk" appears twice: the risk banner title, and Database's own Risk Level badge. + expect(screen.getAllByText('High risk').length).toBe(2) + expect( + screen.getByText('Read-write on 1 capability, read on 1, across 1 project.') + ).toBeInTheDocument() + }) + + test('switches to the dense, filterable view at 9+ granted capabilities', async () => { + mockPermissionsApi(ownerRows(MOCK_ORG.slug)) + mockToken({ + ...TOKEN_BASE, + scope: 'project', + project_refs: [MOCK_PROJECT.ref], + permissions: [ + 'advisors_read', + 'database_read', + 'database_write', + 'backups_read', + 'custom_domain_read', + 'edge_functions_read', + 'storage_read', + 'realtime_config_read', + 'vanity_subdomain_read', + 'infra_add_ons_read', + ], + }) + renderSheet() + + expect(await screen.findByText('Database')).toBeInTheDocument() + // All granted capabilities render immediately — no truncation. + expect(screen.getByText('Storage')).toBeInTheDocument() + expect(screen.getByText('Backups')).toBeInTheDocument() + + // The All/Read/Read-write toggle sits next to the "Capabilities" title, not a text filter. + fireEvent.click(screen.getByRole('button', { name: 'Read-write' })) + expect(screen.getByText('Database')).toBeInTheDocument() + expect(screen.queryByText('Backups')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Read' })) + expect(screen.queryByText('Database')).toBeNull() + expect(screen.getByText('Backups')).toBeInTheDocument() + }) }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx index edb9183e4567b..2b8b39ab309d7 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -1,22 +1,32 @@ import dayjs from 'dayjs' -import { useMemo } from 'react' -import { cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui' +import { useMemo, useState } from 'react' +import { Badge, 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_MODE_LABEL, - scopesToSelection, - type ResourceAccessMode, -} from '../AccessToken.permissions' +import { scopesToSelection, type ResourceAccessMode } from '../AccessToken.permissions' import { useCapabilitySummary } from '../hooks/useCapabilitySummary' import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData' import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation' -import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from './TokenSummaryRows' +import { + ResourceAccessPills, + useResourceAccessWrap, + type ResourceAccessPillItem, +} from './ResourceAccessPills' +import { CapabilitiesSection } from './TokenCapabilities/CapabilitiesSection' +import { CapabilityLevelToggle } from './TokenCapabilities/CapabilityLevelToggle' +import { RiskBanner } from './TokenCapabilities/RiskBanner' +import { + computeRiskBanner, + getCapabilityDensityTier, + type CapabilityLevelFilter, +} from './TokenCapabilities/TokenCapabilities.utils' import { DocsButton } from '@/components/ui/DocsButton' -import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query' +import { + getEnabledMcpTools, + 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' @@ -82,21 +92,27 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted` const risk = useMemo( - () => computeOverallRisk(access.effectiveSelection, resourceAccess), - [access.effectiveSelection, resourceAccess] + () => + computeRiskBanner({ + effectiveSelection: access.effectiveSelection, + resourceAccess, + organizationSlugs: tokenOrganizationSlugs, + projectRefs: tokenProjectRefs, + }), + [access.effectiveSelection, resourceAccess, tokenOrganizationSlugs, tokenProjectRefs] ) - const hasCapabilities = grantedScopes.length > 0 - - const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({ + const { capabilities } = useCapabilitySummary({ selection, grantedScopes, permissionScopeMap, }) + const capabilityTier = getCapabilityDensityTier(capabilities.length) + const [levelFilter, setLevelFilter] = useState('all') - // 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(() => { + // Accessible resources render with their name. Resources the user has lost access to are + // aggregated into an anonymous count — their identifiers aren't shown. + const resourceItems = useMemo(() => { const inaccessibleCountItem = (lostCount: number, noun: string) => lostCount === 0 ? [] @@ -104,7 +120,6 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp { key: 'inaccessible', label: `${lostCount} ${pluralize(lostCount, noun)}`, - sublabel: undefined, isInaccessible: true, }, ] @@ -114,42 +129,26 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp 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 [{ key: ref, label: name }] }) - return { - title: 'Projects', - items: [ - ...accessible, - ...inaccessibleCountItem(access.inaccessibleProjectRefs.length, 'project'), - ], - } + return [ + ...accessible, + ...inaccessibleCountItem(access.inaccessibleProjectRefs.length, 'project'), + ] } if (resourceAccess === 'organization') { 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 [{ key: slug, label: name }] }) - return { - title: 'Organizations', - items: [ - ...accessible, - ...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'), - ], - } - } - return { - title: 'Account', - items: [ - { - key: 'account', - label: 'Account-level access', - sublabel: undefined, - isInaccessible: false, - }, - ], + return [ + ...accessible, + ...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'), + ] } + return [{ key: 'account', label: 'Account-level access' }] }, [ resourceAccess, tokenProjectRefs, @@ -160,82 +159,12 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp access.inaccessibleOrgSlugs, ]) - const rows: [string, React.ReactNode][] = token - ? [ - [ - 'Created', - token.created_at ? ( - - ) : ( - Unknown - ), - ], - [ - 'Last used', - token.last_used_at ? ( - - ) : ( - Never - ), - ], - [ - 'Expires', - token.expires_at ? ( - - ) : ( - Never - ), - ], - [ - 'Resource access', -
-

- {resourceSummary.title} -

-
- {resourceSummary.items.length === 0 && hasNoBoundResources && ( -

{boundResourcesDeletedText}

- )} - {resourceSummary.items.length === 0 && !hasNoBoundResources && ( -

-

- )} - {resourceSummary.items.map((item) => ( - - ))} -
-
, - ], - [ - 'Capabilities', - hasCapabilities ? ( - - ) : ( - No capabilities selected - ), - ], - [ - 'Risk level', - , - ], - ] - : [] + const enabledMcpTools = useMemo( + () => getEnabledMcpTools({ grantedScopes, permissionScopeMap }).sort(), + [grantedScopes, permissionScopeMap] + ) + + const { containerRef: pillsRef, isWrapped: isResourceAccessWrapped } = useResourceAccessWrap() return ( onClose()}> @@ -244,7 +173,11 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp size="default" className="flex h-full flex-col gap-0 sm:w-[656px] lg:w-[800px]" > - +

View access for {token?.name}

@@ -261,7 +194,10 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp />
- + {/* Radix wraps viewport children in an inline-styled display:table div that grows to fit + the widest child, which would let one long endpoint path expand the sheet instead of + clipping — force it back to block so widths are bounded and rows can truncate. */} +
{isTokenLoading && (
@@ -300,74 +236,112 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them." /> )} + +
+

Risk assessment

+ +
+

Token summary

- {rows.map(([key, value]) => ( -
-
{key}
-
{value}
-
- ))} +
+
Created
+
+ {token.created_at ? ( + + ) : ( + Unknown + )} +
+
+
+
Last used
+
+ {token.last_used_at ? ( + + ) : ( + Never + )} +
+
+
+
Expires
+
+ {token.expires_at ? ( + + ) : ( + Never + )} +
+
+
+
Resource access
+
+
+ +
+
+
- {hasCapabilities && ( - <> -
-

Management API endpoints enabled

- {capabilityGroups.length === 0 ? ( -

- No Management API endpoints are enabled by the selected capabilities. -

- ) : ( - capabilityGroups.map(({ entry, mode, endpoints }) => ( -
-
- {entry.name} - - {PERMISSION_MODE_LABEL[mode]} - -
-
- {endpoints.map(([method, path]) => ( -
- - {method} - - {path} -
- ))} -
-
- )) - )} -
+
+
+

Capabilities

+ {capabilityTier === 'dense' && ( + + )} +
+ +
-
-

MCP tools

- {mcpTools.length === 0 ? ( -

- No MCP tools are enabled by the selected capabilities. -

- ) : ( -
- {mcpTools.map((tool) => ( - - {tool} - - ))} -
- )} +
+

Available MCP tools

+ {enabledMcpTools.length === 0 ? ( + No MCP tools enabled + ) : ( +
+ {enabledMcpTools.map((tool) => ( + + {tool} + + ))}
- - )} + )} +
)}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts index 76b78959d4a75..28e3dcccad7d4 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts @@ -2,15 +2,15 @@ import { useMemo } from 'react' import { getEntryScopes, - PERMISSION_CATALOG_BY_CATEGORY, + PERMISSION_CATALOG, type PermissionCatalogEntry, type PermissionMode, type PermissionSelection, } from '../AccessToken.permissions' import { getEnabledEndpointsForCapability, - getEnabledMcpTools, - PermissionScopeMap, + type EnabledEndpoint, + type PermissionScopeMap, } from '@/data/scoped-access-tokens/permission-scope-map-query' interface UseCapabilitySummaryArgs { @@ -19,49 +19,37 @@ interface UseCapabilitySummaryArgs { permissionScopeMap: PermissionScopeMap | undefined } +export interface CapabilitySummaryEntry { + entry: PermissionCatalogEntry + mode: PermissionMode + endpoints: EnabledEndpoint[] +} + /** - * Selection-derived summary data for the token view sheet: selected entries grouped by catalog - * category, the Management API endpoints each capability enables, and the enabled MCP tools. + * Selection-derived summary data for the token view sheet: every granted catalog entry paired with + * the Management API endpoints it enables. */ export const useCapabilitySummary = ({ selection, grantedScopes, permissionScopeMap, }: UseCapabilitySummaryArgs) => { - const activeByCategory = useMemo( - () => - PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({ - ...category, - entries: category.entries - .map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' })) - .filter(({ mode }) => mode !== 'none'), - })).filter((category) => category.entries.length > 0), - [selection] - ) - - const mcpTools = useMemo( - () => getEnabledMcpTools({ grantedScopes, permissionScopeMap }), - [grantedScopes, permissionScopeMap] - ) + const capabilities = useMemo(() => { + const result: CapabilitySummaryEntry[] = [] + for (const entry of PERMISSION_CATALOG) { + const mode = selection[entry.key] ?? 'none' + if (mode === 'none') continue - const capabilityGroups = useMemo(() => { - const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] = - [] - for (const category of activeByCategory) { - for (const { entry, mode } of category.entries) { - const capabilityScopes = getEntryScopes(entry, mode) - const endpoints = getEnabledEndpointsForCapability({ - capabilityScopes, - allGrantedScopes: grantedScopes, - permissionScopeMap, - }) - if (endpoints.length > 0) { - groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) }) - } - } + const capabilityScopes = getEntryScopes(entry, mode) + const endpoints = getEnabledEndpointsForCapability({ + capabilityScopes, + allGrantedScopes: grantedScopes, + permissionScopeMap, + }) + result.push({ entry, mode, endpoints }) } - return groups - }, [activeByCategory, grantedScopes, permissionScopeMap]) + return result + }, [selection, grantedScopes, permissionScopeMap]) - return { activeByCategory, mcpTools, capabilityGroups } + return { capabilities } } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx index 3c6fd80b98ef7..2d891eacbc1af 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx @@ -30,9 +30,8 @@ const connectModeButtonVariants = cva( { variants: { selected: { - true: 'z-1 border-foreground-muted bg-surface-300 ring-1 ring-border', - false: - 'hover:z-1 hover:border-foreground-muted hover:bg-background dark:hover:bg-surface-200', + true: 'z-1 border-control-hover bg-surface-300', + false: 'hover:z-1 hover:border-control-hover hover:bg-background dark:hover:bg-surface-200', }, // Narrow 2-col outer corners topLeft: { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx index 629633df830ad..798e9e25ddb5b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx @@ -82,10 +82,8 @@ const DuckLakeModeSelector = ({ onClick={() => onChange(option.value)} className={cn( 'relative flex flex-col gap-y-3 rounded-md border p-4 text-left transition', - 'hover:border-foreground-muted', - selected - ? 'border-foreground-muted bg-surface-300 ring-1 ring-border' - : 'border-default bg-surface-100' + 'hover:border-control-hover', + selected ? 'border-control-hover bg-surface-300' : 'border-default bg-surface-100' )} >
diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index d0568f8dfa4c2..2ca4c01d0e65c 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -288,7 +288,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
{ +export const ExplorerChatTab = () => { const { id, ref } = useParams() const router = useRouter() const tabs = useTabsStateSnapshot() diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx similarity index 99% rename from apps/studio/components/interfaces/Explorer/ExplorerHome.tsx rename to apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx index ed15f4b7f7e5e..583d4a7e3b870 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerHomeTab.tsx @@ -6,7 +6,7 @@ import { ActionCard } from '@/components/layouts/Tabs/ActionCard' import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm' import type { AssistantModel } from '@/state/ai-assistant-state' -export const ExplorerHome = () => { +export const ExplorerHomeTab = () => { const { createNotebook } = useCreateNotebook() const { createQuery } = useCreateQuery() const { createChat } = useCreateChat() diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx similarity index 80% rename from apps/studio/components/interfaces/Explorer/NotebookEditor.tsx rename to apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index 4ded5d995f773..adc39e12076bd 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -13,6 +13,7 @@ import { } from '@dnd-kit/sortable' import { useParams } from 'common' import { FileText, Notebook, NotebookText, Play, Save, SquareCode } from 'lucide-react' +import { useRef, useState } from 'react' import { AiIconAnimation, Button } from 'ui' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' @@ -25,13 +26,14 @@ import { } from './ExplorerToolbar' import { MarkdownCell } from './MarkdownCell' import { QueryCell } from './QueryCell' +import { type QueryEditorHandle } from './QueryEditor' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { isQueryCell } from '@/data/content/notebooks/notebook-schema' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' -export const NotebookEditor = () => { +export const ExplorerNotebookTab = () => { const { id } = useParams() const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() @@ -39,6 +41,10 @@ export const NotebookEditor = () => { const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} const cells = content?.cells ?? [] + const queryCellIds = cells.filter(isQueryCell).map((cell) => cell.id) + + const [isRunningNotebook, setIsRunningNotebook] = useState(false) + const queryCellRefs = useRef(new Map()) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -53,6 +59,17 @@ export const NotebookEditor = () => { } } + const handleRunNotebook = async () => { + setIsRunningNotebook(true) + try { + await Promise.allSettled( + queryCellIds.map((cellId) => queryCellRefs.current.get(cellId)?.run()) + ) + } finally { + setIsRunningNotebook(false) + } + } + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (!id || !over || active.id === over.id) return @@ -81,7 +98,14 @@ export const NotebookEditor = () => { }> Analyze - } tooltip="Run notebook" /> + } + tooltip="Run notebook" + loading={isRunningNotebook} + disabled={isRunningNotebook || queryCellIds.length === 0} + onClick={handleRunNotebook} + /> } tooltip="Save changes" /> @@ -115,7 +139,14 @@ export const NotebookEditor = () => {
{cells.map((cell) => isQueryCell(cell) ? ( - + { + if (instance) queryCellRefs.current.set(cell.id, instance) + else queryCellRefs.current.delete(cell.id) + }} + /> ) : ( ) diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx similarity index 99% rename from apps/studio/components/interfaces/Explorer/QueryTab.tsx rename to apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx index 306b03b41712b..69b05ad8388fe 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx @@ -12,7 +12,7 @@ import { useControlledRoleImpersonationState } from '@/state/role-impersonation- import { createTabId, TabsStateContext } from '@/state/tabs' /** Query-tab lifecycle adapter around the shared QueryEditor. */ -export const QueryTab = () => { +export const ExplorerQueryTab = () => { const { id, ref } = useParams() const router = useRouter() const tabs = useContext(TabsStateContext) diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx new file mode 100644 index 0000000000000..e12d35d52b173 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx @@ -0,0 +1,86 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { NotebookPreview } from './NotebookPreview' +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' +import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' +import { customRender as render } from '@/tests/lib/custom-render' + +const wireMarkdownCell = (id: string, text: string): CellWire => ({ + _tag: 'markdown_cell', + id, + text, +}) + +const agentMarkdownCell = (text: string): AgentCell => ({ _tag: 'markdown_cell', text }) + +const wireDatabaseCell = (id: string, database_identifier?: string): CellWire => ({ + _tag: 'database_cell', + id, + sql: 'select 1', + row_limit: 100, + database_identifier, +}) + +const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier, +}) + +describe('NotebookPreview', () => { + // The whole safety argument for this feature reduces to this: agent-authored markdown text + // is rendered as literal source (via CodeBlock), never interpreted into real DOM nodes. A + // future refactor that swaps in would break this silently. + it('never renders agent-authored cell content as real img/link/src DOM nodes', () => { + const adversarialText = + '![x](https://evil.example/) [y](https://evil.example/) ' + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'added', cell: agentMarkdownCell(adversarialText), operationIndex: 0 }, + ] + + const { container } = render() + + expect(container.querySelectorAll('img')).toHaveLength(0) + expect(container.querySelectorAll('[href]')).toHaveLength(0) + expect(container.querySelectorAll('[src]')).toHaveLength(0) + }) + + it('renders the create-mode header summary', () => { + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'unchanged', cell: wireMarkdownCell('a', 'one') }, + { _tag: 'unchanged', cell: wireMarkdownCell('b', 'two') }, + ] + + render() + + expect(screen.getByText('2 cells')).toBeInTheDocument() + }) + + it('surfaces a metadata-only change on a replaced cell even when the sql is unchanged', () => { + const entries: NotebookCellDiffEntry[] = [ + { + _tag: 'replaced', + before: wireDatabaseCell('cell-1', 'primary'), + after: agentDatabaseCell('replica-3'), + operationIndex: 0, + }, + ] + + render() + + expect(screen.getByText('Database: primary → Database: replica-3')).toBeInTheDocument() + }) + + it('hides entries past the limit behind a "Show N more" button', async () => { + const entries: NotebookCellDiffEntry[] = Array.from({ length: 7 }, (_, index) => ({ + _tag: 'unchanged' as const, + cell: wireMarkdownCell(`cell-${index}`, `text-${index}`), + })) + + render() + + expect(screen.getByText('Show 2 more cells')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx new file mode 100644 index 0000000000000..62b8754e1e1ad --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { Button } from 'ui' + +import { + formatNotebookDiffSummary, + getEntryKey, + summarizeNotebookDiff, +} from './NotebookPreview.utils' +import { NotebookPreviewCell } from './NotebookPreviewCell' +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' + +export interface NotebookPreviewProps { + entries: NotebookCellDiffEntry[] + mode: 'create' | 'update' +} + +const VISIBLE_ENTRY_LIMIT = 5 + +/** + * Read-only preview of a proposed notebook create/update, rendered from a pre-computed diff. + * Pure presentational component: no data fetching, no approval or notebook-editor state — see + * `deriveNotebookDiff` for how `entries` is produced. + */ +export const NotebookPreview = ({ entries, mode }: NotebookPreviewProps) => { + const [isExpanded, setIsExpanded] = useState(false) + + const summary = summarizeNotebookDiff(entries, mode) + const visibleEntries = isExpanded ? entries : entries.slice(0, VISIBLE_ENTRY_LIMIT) + const hiddenCount = entries.length - visibleEntries.length + + return ( +
+

+ {formatNotebookDiffSummary(summary)} +

+
+ {visibleEntries.map((entry) => ( + + ))} +
+ {hiddenCount > 0 && ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts new file mode 100644 index 0000000000000..5f4523daea56a --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts @@ -0,0 +1,182 @@ +import dayjs from 'dayjs' +import { describe, expect, it } from 'vitest' + +import { + formatNotebookDiffSummary, + formatTimeRange, + getCellLabel, + getCellMetadataLine, + getEntryKey, + summarizeNotebookDiff, +} from './NotebookPreview.utils' +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' +import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' +import { isoDateTimeString } from '@/lib/iso-datetime' + +const wireMarkdownCell = (id: string, text = 'hello'): CellWire => ({ + _tag: 'markdown_cell', + id, + text, +}) + +const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell', text }) + +const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({ + _tag: 'database_cell', + id, + title, + sql: 'select 1', + row_limit: 100, + database_identifier, +}) + +const wireLogCell = (id: string): CellWire => ({ + _tag: 'log_cell', + id, + sql: 'select 1', + time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 }, +}) + +describe('getEntryKey', () => { + it('keys unchanged, removed, and moved entries off the cell id', () => { + expect(getEntryKey({ _tag: 'unchanged', cell: wireMarkdownCell('cell-1') })).toBe('cell-1') + expect( + getEntryKey({ _tag: 'removed', cell: wireMarkdownCell('cell-2'), operationIndex: 0 }) + ).toBe('cell-2') + expect( + getEntryKey({ + _tag: 'moved', + cell: wireMarkdownCell('cell-3'), + fromIndex: 1, + operationIndex: 0, + }) + ).toBe('cell-3') + }) + + it('keys added and replaced entries off the operation index', () => { + expect(getEntryKey({ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 2 })).toBe( + 'op-2' + ) + expect( + getEntryKey({ + _tag: 'replaced', + before: wireMarkdownCell('cell-4'), + after: agentMarkdownCell(), + operationIndex: 3, + }) + ).toBe('op-3') + }) +}) + +describe('getCellLabel', () => { + it('labels markdown cells', () => { + expect(getCellLabel(wireMarkdownCell('cell-1'))).toBe('Markdown cell') + }) + + it('labels query cells with their title', () => { + expect(getCellLabel(wireDatabaseCell('cell-1', 'Signups'))).toBe('Query: Signups') + }) + + it('falls back to "Untitled query" when a query cell has no title', () => { + expect(getCellLabel(wireDatabaseCell('cell-1'))).toBe('Query: Untitled query') + }) +}) + +describe('formatTimeRange', () => { + it('formats a relative range, pluralizing the unit', () => { + expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'day', amount: 7 })).toBe( + 'Last 7 days' + ) + }) + + it('does not pluralize a relative range with amount 1', () => { + expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'hour', amount: 1 })).toBe( + 'Last 1 hour' + ) + }) + + it('formats an absolute range as a start → end pair', () => { + const start = isoDateTimeString('2026-01-01T13:00:00.000Z')! + const end = isoDateTimeString('2026-01-02T09:30:00.000Z')! + + const formatted = formatTimeRange({ _tag: 'absolute_time_range', start, end }) + + // Bounds are asserted via dayjs rather than a hardcoded string so this doesn't depend on + // the test runner's local timezone (formatTimeRange formats in local time). + const expectedBound = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A') + expect(formatted).toBe(`${expectedBound(start)} → ${expectedBound(end)}`) + }) +}) + +describe('getCellMetadataLine', () => { + it('returns null for markdown cells', () => { + expect(getCellMetadataLine(wireMarkdownCell('cell-1'))).toBeNull() + }) + + it('returns null for a database cell with no database_identifier', () => { + expect(getCellMetadataLine(wireDatabaseCell('cell-1'))).toBeNull() + }) + + it('labels a database cell with a database_identifier', () => { + expect(getCellMetadataLine(wireDatabaseCell('cell-1', 'Signups', 'replica-3'))).toBe( + 'Database: replica-3' + ) + }) + + it('labels a log cell with its formatted time range', () => { + expect(getCellMetadataLine(wireLogCell('cell-1'))).toBe('Time range: Last 7 days') + }) +}) + +describe('summarizeNotebookDiff / formatNotebookDiffSummary', () => { + it('formats create mode with pluralized cell count', () => { + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'unchanged', cell: wireMarkdownCell('a') }, + { _tag: 'unchanged', cell: wireMarkdownCell('b') }, + ] + + expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('2 cells') + }) + + it('formats create mode singular for a single cell', () => { + const entries: NotebookCellDiffEntry[] = [{ _tag: 'unchanged', cell: wireMarkdownCell('a') }] + + expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('1 cell') + }) + + it('formats update mode with a mix of categories, ignoring unchanged', () => { + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'unchanged', cell: wireMarkdownCell('a') }, + { _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 }, + { _tag: 'added', cell: agentMarkdownCell(), operationIndex: 1 }, + { _tag: 'removed', cell: wireMarkdownCell('d'), operationIndex: 2 }, + { + _tag: 'replaced', + before: wireMarkdownCell('e'), + after: agentMarkdownCell(), + operationIndex: 3, + }, + { _tag: 'moved', cell: wireMarkdownCell('f'), fromIndex: 0, operationIndex: 4 }, + ] + + expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+2 −1 ~1 ↕1') + }) + + it('omits zero categories from the update summary', () => { + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'unchanged', cell: wireMarkdownCell('a') }, + { _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 }, + ] + + expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+1') + }) + + it('falls back to "No changes" when every entry is unchanged', () => { + const entries: NotebookCellDiffEntry[] = [ + { _tag: 'unchanged', cell: wireMarkdownCell('a') }, + { _tag: 'unchanged', cell: wireMarkdownCell('b') }, + ] + + expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('No changes') + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts new file mode 100644 index 0000000000000..4dd3087ffc8e0 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts @@ -0,0 +1,141 @@ +import dayjs from 'dayjs' +import type { CodeBlockLang } from 'ui-patterns/CodeBlock' + +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' +import type { AgentCell, CellWire, TimeRange } from '@/data/content/notebooks/notebook-schema' + +/** React key for a diff entry. Added/replaced cells have no `id`, so they key off the operation. */ +export function getEntryKey(entry: NotebookCellDiffEntry): string { + switch (entry._tag) { + case 'unchanged': + case 'removed': + case 'moved': + return entry.cell.id + case 'added': + case 'replaced': + return `op-${entry.operationIndex}` + } +} + +/** Human label for a collapsed/badge row. */ +export function getCellLabel(cell: CellWire | AgentCell): string { + switch (cell._tag) { + case 'markdown_cell': + return 'Markdown cell' + case 'database_cell': + case 'log_cell': + return `Query: ${cell.title ?? 'Untitled query'}` + } +} + +/** The cell's underlying source text, regardless of backend. */ +export function getCellSourceText(cell: CellWire | AgentCell): string { + switch (cell._tag) { + case 'markdown_cell': + return cell.text + case 'database_cell': + case 'log_cell': + return cell.sql + } +} + +/** Language for rendering the cell's source via `CodeBlock`. */ +export function getCellCodeBlockLanguage(cell: CellWire | AgentCell): CodeBlockLang { + switch (cell._tag) { + case 'markdown_cell': + return 'markdown' + case 'database_cell': + case 'log_cell': + return 'sql' + } +} + +/** Monaco language id for rendering the cell's source via `DiffEditor`. */ +export function getCellMonacoLanguage(cell: CellWire | AgentCell): string { + switch (cell._tag) { + case 'markdown_cell': + return 'markdown' + case 'database_cell': + case 'log_cell': + return 'pgsql' + } +} + +/** Formats a `TimeRange` as plain text, e.g. "Last 7 days" or an absolute bound pair. */ +export function formatTimeRange(range: TimeRange): string { + if (range._tag === 'relative_time_range') { + return `Last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}` + } + + const format = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A') + return `${format(range.start)} → ${format(range.end)}` +} + +/** + * Plain-text metadata line for a query cell (its source parameters, not its SQL) — `null` + * when the cell has none. A `replace_cell` can change only this and leave `sql` identical, + * so it's compared independently of the source text rather than folded into it. + */ +export function getCellMetadataLine(cell: CellWire | AgentCell): string | null { + switch (cell._tag) { + case 'markdown_cell': + return null + case 'database_cell': + return cell.database_identifier ? `Database: ${cell.database_identifier}` : null + case 'log_cell': + return `Time range: ${formatTimeRange(cell.time_range)}` + } +} + +export type NotebookDiffSummary = + | { mode: 'create'; cellCount: number } + | { mode: 'update'; counts: { added: number; removed: number; replaced: number; moved: number } } + +/** Summarizes a set of diff entries into counts suitable for a header line. */ +export function summarizeNotebookDiff( + entries: NotebookCellDiffEntry[], + mode: 'create' | 'update' +): NotebookDiffSummary { + if (mode === 'create') { + return { mode: 'create', cellCount: entries.length } + } + + const counts = { added: 0, removed: 0, replaced: 0, moved: 0 } + for (const entry of entries) { + switch (entry._tag) { + case 'added': + counts.added++ + break + case 'removed': + counts.removed++ + break + case 'replaced': + counts.replaced++ + break + case 'moved': + counts.moved++ + break + case 'unchanged': + break + } + } + + return { mode: 'update', counts } +} + +/** Formats a `NotebookDiffSummary` into the header string. */ +export function formatNotebookDiffSummary(summary: NotebookDiffSummary): string { + if (summary.mode === 'create') { + const { cellCount } = summary + return `${cellCount} cell${cellCount === 1 ? '' : 's'}` + } + + const { added, removed, replaced, moved } = summary.counts + const parts: string[] = [] + if (added > 0) parts.push(`+${added}`) + if (removed > 0) parts.push(`−${removed}`) + if (replaced > 0) parts.push(`~${replaced}`) + if (moved > 0) parts.push(`↕${moved}`) + + return parts.length > 0 ? parts.join(' ') : 'No changes' +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx new file mode 100644 index 0000000000000..ddac9ae9e1cb6 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx @@ -0,0 +1,154 @@ +import { useState, type ReactNode } from 'react' +import { Badge, Button, cn } from 'ui' +import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock' + +import { + getCellCodeBlockLanguage, + getCellLabel, + getCellMetadataLine, + getCellMonacoLanguage, + getCellSourceText, +} from './NotebookPreview.utils' +import { DiffEditor } from '@/components/ui/DiffEditor' +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' +import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' + +export interface NotebookPreviewCellProps { + entry: NotebookCellDiffEntry +} + +/** Renders a single diff entry, dispatching on its tag. */ +export const NotebookPreviewCell = ({ entry }: NotebookPreviewCellProps) => { + switch (entry._tag) { + case 'unchanged': + return + case 'removed': + return ( + + ) + case 'moved': + return ( + + ) + case 'added': + return + case 'replaced': + return + } +} + +interface CollapsedRowProps { + label: string + strikethrough?: boolean + badge?: { variant: 'destructive' | 'secondary'; label: string } +} + +/** A single muted row with no content — used for unchanged, removed, and moved entries. */ +const CollapsedRow = ({ label, strikethrough, badge }: CollapsedRowProps) => ( +
+ {badge && {badge.label}} + + {label} + +
+) + +interface ContentCellProps { + badge: { variant: 'success' | 'warning'; label: string } + label: string + children: ReactNode +} + +/** Shared frame for entries that show full cell content — added and replaced cells. */ +const ContentCell = ({ badge, label, children }: ContentCellProps) => ( +
+
+ {badge.label} + {label} +
+ {children} +
+) + +const AddedCell = ({ cell }: { cell: AgentCell }) => ( + + + + +) + +/** + * A `replace_cell` can change only the source parameters (`database_identifier`, + * `time_range`) and leave `sql`/`text` identical — the `DiffEditor` above would then show no + * change at all, so the metadata is compared independently and rendered as its own + * before → after line whenever it differs. + */ +const ReplacedCell = ({ before, after }: { before: CellWire; after: AgentCell }) => { + const beforeMetadata = getCellMetadataLine(before) + const afterMetadata = getCellMetadataLine(after) + + return ( + + + {beforeMetadata !== afterMetadata ? ( + + ) : ( + + )} + + ) +} + +/** A plain-text metadata line for a query cell — never rendered as a link or attribute. */ +const MetadataLine = ({ text }: { text: string | null }) => + text ?

{text}

: null + +interface ExpandableCodeBlockProps { + language: CodeBlockLang + value: string +} + +/** + * `CodeBlock` clipped to a fixed height with a "Show more/less" toggle. `CodeBlock`'s + * wrapper already scrolls (`overflow-auto`), so clipping just changes what's visible. + */ +const ExpandableCodeBlock = ({ language, value }: ExpandableCodeBlockProps) => { + const [isExpanded, setIsExpanded] = useState(false) + + return ( +
+ + +
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 80adb841a0396..dec953401334f 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react' +import { forwardRef, useState } from 'react' import { type Snapshot } from 'valtio' import { AddCellDropdown } from '../AddCellDropdown' import { MoveCellDropdownContent } from '../MoveCellDropdownContent' -import { QueryEditor } from '../QueryEditor' +import { QueryEditor, type QueryEditorHandle } from '../QueryEditor' import { type QueryDisplay, type QueryResult } from '../types' import { changeCellSource, @@ -28,7 +28,10 @@ interface QueryCellProps { } /** Notebook adapter around the shared QueryEditor. */ -export const QueryCell = ({ cell }: QueryCellProps) => { +export const QueryCell = forwardRef(function QueryCell( + { cell }, + ref +) { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() @@ -92,6 +95,7 @@ export const QueryCell = ({ cell }: QueryCellProps) => { gripClassName="mt-2 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" > { /> ) -} +}) diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/DisplaySettingsButton.tsx similarity index 100% rename from apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/DisplaySettingsButton.tsx diff --git a/apps/studio/components/interfaces/Explorer/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx similarity index 98% rename from apps/studio/components/interfaces/Explorer/QueryResultChart.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx index 3d92ec07b8ee1..7028ca3116815 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { Chart, ChartBar, ChartCard, ChartContent, ChartLine } from 'ui-patterns/Chart' -import { type QueryResult } from './types' +import { type QueryResult } from '../types' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { formatLogTick, getCumulativeResults } from '@/components/ui/QueryBlock/QueryBlock.utils' import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' diff --git a/apps/studio/components/interfaces/Explorer/QueryResultError.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx similarity index 97% rename from apps/studio/components/interfaces/Explorer/QueryResultError.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx index 3e06ecd10e02e..83cc108148964 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultError.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultError.tsx @@ -3,8 +3,8 @@ import { ExternalLink } from 'lucide-react' import { parseAsBoolean, useQueryState } from 'nuqs' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import { type QueryResult } from './types' +import { subscriptionHasHipaaAddon } from '../../Billing/Subscription/Subscription.utils' +import { type QueryResult } from '../types' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultRenderer.tsx similarity index 78% rename from apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultRenderer.tsx index 1915d3c7328c0..79fbc5a407f5c 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultRenderer.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultRenderer.tsx @@ -1,6 +1,6 @@ +import { type QueryResult } from '../types' import { QueryResultChart } from './QueryResultChart' import { QueryResultError } from './QueryResultError' -import { type QueryResult } from './types' import { DataGridResults } from '@/components/ui/DataGridResults' import { type ChartConfig } from '@/data/content/notebooks/notebook-schema' @@ -14,7 +14,7 @@ export const QueryResultRenderer = ({ result, view, chart }: QueryResultRenderer const { rows, error, autoLimit } = result ?? {} if (!result) { - return

Run the query to see results

+ return

Run the query to see results

} if (error) { @@ -22,7 +22,7 @@ export const QueryResultRenderer = ({ result, view, chart }: QueryResultRenderer } if ((rows ?? []).length === 0) { - return

Success. No rows returned

+ return

Success. No rows returned

} if (rows && rows.length > 0) { diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QuerySourceMenu.tsx similarity index 94% rename from apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/QuerySourceMenu.tsx index fb46469c4a682..450df7cec6f2c 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QuerySourceMenu.tsx @@ -9,8 +9,8 @@ import { DropdownMenuTrigger, } from 'ui' -import { RowLimitSubMenu } from '../SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu' -import { RunAsSubMenu } from '../SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu' +import { RowLimitSubMenu } from '../../SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu' +import { RunAsSubMenu } from '../../SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu' import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' @@ -25,7 +25,7 @@ import { } from '@/data/query-sources/query-source-registry' import { type RoleImpersonationController } from '@/state/role-impersonation-state' -export type ExplorerQuerySourceMenuProps = { +export type QuerySourceMenuProps = { rowLimit?: number onRowLimitChange?: (val: number) => void roleImpersonationState?: RoleImpersonationController @@ -42,13 +42,13 @@ export type ExplorerQuerySourceMenuProps = { * what happens to the query body is the consumer's call, since a notebook cell * has SQL to preserve or discard and a fresh draft does not. */ -export const ExplorerQuerySourceMenu = ({ +export const QuerySourceMenu = ({ rowLimit = 100, onRowLimitChange, roleImpersonationState, source, onSourceChange, -}: ExplorerQuerySourceMenuProps) => { +}: QuerySourceMenuProps) => { const { ref } = useParams() const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') const isOtelLogsEnabled = useFlag('otelLegacyLogs') diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx similarity index 77% rename from apps/studio/components/interfaces/Explorer/QueryEditor.tsx rename to apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 1259b8714ebcd..29b970fa242be 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -1,28 +1,29 @@ import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' -import { useState, type ReactNode } from 'react' +import { forwardRef, useImperativeHandle, useState, type ReactNode } from 'react' import { cn } from 'ui' -import { resolveLogTimeRange } from '../QuerySources/LogTimeRange.utils' +import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils' import { ExplorerQuery, ExplorerQueryEditor, ExplorerQueryFooter, ExplorerQueryResults, ExplorerQueryViewport, -} from './ExplorerQuery' -import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu' +} from '../ExplorerQuery' import { ExplorerToolbar, ExplorerToolbarAction, ExplorerToolbarActions, ExplorerToolbarIcon, ExplorerToolbarTitle, -} from './ExplorerToolbar' -import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' +} from '../ExplorerToolbar' +import { type QueryDisplay, type QueryResult } from '../types' +import { DisplaySettingsButton } from './DisplaySettingsButton' import { QueryResultRenderer } from './QueryResultRenderer' -import { type QueryDisplay, type QueryResult } from './types' +import { QuerySourceMenu } from './QuerySourceMenu' +import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { type DatabaseSourceParameters, @@ -86,28 +87,35 @@ export type QueryEditorProps = { onDisplayChange?: (display: QueryDisplay) => void } +export type QueryEditorHandle = { + run: () => Promise +} + /** * Shared query editor used by query tabs, notebook cells, and other Explorer surfaces. * The consuming surface owns persistence and surrounding chrome; this component owns * query-level UI and execution behavior. */ -export const QueryEditor = ({ - id, - variant, - title, - query, - result, - roleImpersonationState, - display, - toolbarActions, - onTitleChange, - onSqlChange, - onSqlCommit, - onSourceChange, - onResultChange, - onRowLimitChange, - onDisplayChange, -}: QueryEditorProps) => { +export const QueryEditor = forwardRef(function QueryEditor( + { + id, + variant, + title, + query, + result, + roleImpersonationState, + display, + toolbarActions, + onTitleChange, + onSqlChange, + onSqlCommit, + onSourceChange, + onResultChange, + onRowLimitChange, + onDisplayChange, + }: QueryEditorProps, + ref +) { const sql = query.uncheckedSql const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) @@ -132,12 +140,12 @@ export const QueryEditor = ({ } ) - const { mutate: executeSql, isPending: isExecutingSql } = useExecuteSqlMutation({ + const { mutateAsync: executeSql, isPending: isExecutingSql } = useExecuteSqlMutation({ onSuccess: (data) => onResultChange({ rows: data.result }), onError: (error) => onResultChange({ error }), }) - const { mutate: executeLogsSql, isPending: isExecutingLogs } = useExecuteLogsSqlMutation({ + const { mutateAsync: executeLogsSql, isPending: isExecutingLogs } = useExecuteLogsSqlMutation({ onSuccess: (data) => onResultChange({ rows: data.rows as readonly Record[] }), onError: (error) => onResultChange({ error }), }) @@ -154,7 +162,7 @@ export const QueryEditor = ({ * decided by `query._tag`, the same discriminant that picks the execution endpoint, so * Postgres SQL cannot reach the analytics wire or vice versa. */ - const handleRunQuery = (rawSql: string = sql) => { + const handleRunQuery = async (rawSql: string = sql) => { if (!project || isBusy || rawSql.trim().length === 0) return onSqlCommit?.(rawSql) @@ -167,12 +175,12 @@ export const QueryEditor = ({ return } - executeLogsSql({ + await executeLogsSql({ projectRef: project.ref, sql: acceptUntrustedLogsSql(untrustedLogSql(rawSql)), range: resolveLogTimeRange(query.time_range), endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, - }) + }).catch(() => {}) return } @@ -189,7 +197,7 @@ export const QueryEditor = ({ return } - executeSql({ + await executeSql({ projectRef: project.ref, connectionString, sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState), @@ -197,9 +205,11 @@ export const QueryEditor = ({ contextualInvalidation: true, isStatementTimeoutDisabled: true, isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role), - }) + }).catch(() => {}) } + useImperativeHandle(ref, () => ({ run: () => handleRunQuery() })) + const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery return ( @@ -212,7 +222,7 @@ export const QueryEditor = ({ {toolbarActions} {onSourceChange && ( - {showQuery && ( - - onSqlChange(value ?? '')} - onMount={(editor) => { - editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + <> + sqlRef.current} + onProposal={({ modified }) => { + onSqlChange(modified) + onSqlCommit?.(modified) }} /> - + + onSqlChange(value ?? '')} + onMount={(editor) => { + editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + }} + /> + + )} ) -} +}) diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerChatTab.test.tsx similarity index 94% rename from apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx rename to apps/studio/components/interfaces/Explorer/__tests__/ExplorerChatTab.test.tsx index 1aa2d95956d47..6db1c54e004c5 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ChatEditor.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerChatTab.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ChatEditor } from '../ChatEditor' +import { ExplorerChatTab } from '../ExplorerChatTab' import { customRender } from '@/tests/lib/custom-render' const mocks = vi.hoisted(() => ({ @@ -69,7 +69,7 @@ vi.mock('@/components/ui/AIAssistantPanel/AssistantChat', () => ({ ), })) -describe('ChatEditor', () => { +describe('ExplorerChatTab', () => { beforeEach(() => { vi.clearAllMocks() mocks.useParams.mockReturnValue({ ref: 'default', id: 'chat-1' }) @@ -81,7 +81,7 @@ describe('ChatEditor', () => { }) it('renders and registers the routed chat without changing sidebar selection', () => { - customRender() + customRender() expect(mocks.ensureChatInstance).toHaveBeenCalledWith('chat-1') expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute( @@ -98,7 +98,7 @@ describe('ChatEditor', () => { }) it('routes shared chat navigation through Explorer', () => { - customRender() + customRender() fireEvent.click(screen.getByRole('button', { name: 'Assistant' })) @@ -112,7 +112,7 @@ describe('ChatEditor', () => { chatInstances: {}, }) - customRender() + customRender() expect(screen.getByRole('heading', { name: 'Chat not found' })).toBeVisible() expect(mocks.handleTabClose).toHaveBeenCalledWith( diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx new file mode 100644 index 0000000000000..882d79dfb94d6 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -0,0 +1,133 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ExplorerNotebookTab } from '../ExplorerNotebookTab' +import { createMarkdownCellSkeleton, createQueryCellSkeleton } from '../utils' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { notebooksState } from '@/state/notebooks/notebooks-state' +import type { Notebook, StateNotebook } from '@/state/notebooks/types' +import { createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' +import type { Notebooks } from '@/types' + +const testContext = vi.hoisted(() => ({ + flags: { otelLegacyLogs: true } as Record, +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + useParams: () => ({ ref: 'default', id: 'notebook-test' }), + useFlag: (flag: string) => testContext.flags[flag] ?? false, + } +}) + +vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({ + CodeEditor: ({ value }: { value: string }) => ( +