From c7e8373bce501e7f71a09d6fc2ad01816b3bc5ed Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:01:28 +0200 Subject: [PATCH] Scoped PAT: Fix projects handling when user has more than 100 projects (#49393) ## Problem Some users have more than 100 projects and our current UI has the following issues: 1. The project selector only loads the first 100 making it impossible to see more 2. The review step and the token permissions view only loads the first 100 so we may display invalid warnings about missing projects However, we currently don't have an API route to fetch many projects by their refs in a single call. ## Solution 1. Make sure we load more projects when scrolling down in the project selector 2. When below 100 project, show the admonition for missing resources. Anyone above for the time being won't see these message and we display the project refs instead of their names ## Summary by CodeRabbit - **New Features** - Improved scoped access-token setup with paginated project loading and an easier scrolling project selector. - Organization and project access are now displayed as separate, clearer access indicators. - Access details show project information when available, with a fallback reference when details cannot be loaded. - **Bug Fixes** - Updated resource warnings to better reflect deleted resources and large project lists. - Improved multi-select list handling for more reliable interactions. - Preserved the name of inaccessible organizations when displaying lost access. --- .../Scoped/Form/NewScopedTokenFormReview.tsx | 36 +++---- .../Scoped/Form/ResourceAccessStep.tsx | 63 ++++++++++-- .../Scoped/ResourceAccessPills.tsx | 75 ++++++++------- .../Scoped/ViewTokenSheet.test.tsx | 3 +- .../AccessTokens/Scoped/ViewTokenSheet.tsx | 96 +++++++------------ .../src/multi-select/multi-select.tsx | 15 +-- 6 files changed, 150 insertions(+), 138 deletions(-) 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 6ef729763e9..4be22a146ec 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -10,12 +10,11 @@ import { type TokenAccessEvaluation, } from '../../AccessToken.roles' import { useCapabilitySummary } from '../../hooks/useCapabilitySummary' -import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData' import { failingResourceLine } from '../ExceedsRoleBadge' import { - ResourceAccessPills, + OrganizationAccessPill, + ProjectAccessPill, useResourceAccessWrap, - type ResourceAccessPillItem, } from '../ResourceAccessPills' import { CapabilitiesSection } from '../TokenCapabilities/CapabilitiesSection' import { CapabilityLevelToggle } from '../TokenCapabilities/CapabilityLevelToggle' @@ -26,6 +25,7 @@ import { type CapabilityLevelFilter, } from '../TokenCapabilities/TokenCapabilities.utils' import { EXPIRY_OPTIONS, type TokenFormValues } from './NewScopedTokenForm.utils' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { getEnabledMcpTools, PermissionScopeMap, @@ -46,7 +46,7 @@ export const NewScopedTokenFormReview = ({ access, permissionScopeMap, }: ReviewStepProps) => { - const { organizations, projects } = useOrgAndProjectData() + const { data: organizations = [] } = useOrganizationsQuery() const selection = values.permissions const grantedScopes = useMemo(() => selectionToScopes(selection), [selection]) @@ -70,19 +70,6 @@ export const NewScopedTokenFormReview = ({ [access.effectiveSelection, values.resourceAccess, values.organizationSlugs, values.projectRefs] ) - // The classic (account) flow skips review entirely, so only org- and project-bound tokens land - // here. - const resourceItems = useMemo(() => { - if (values.resourceAccess === 'organization') { - 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(() => { if (values.expiresAt === 'custom') { return values.customExpiryDate @@ -164,7 +151,20 @@ export const NewScopedTokenFormReview = ({
Resource access
- + {values.resourceAccess === 'organization' + ? values.organizationSlugs.map((orgSlug) => ( + org.slug === orgSlug)} + /> + )) + : null} + {values.resourceAccess === 'project' + ? values.projectRefs.map((projectRef) => ( + + )) + : null}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx index 10ec083ec56..0743b1c3201 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx @@ -25,11 +25,15 @@ import { import type { ResourceAccessMode } from '../../AccessToken.permissions' import { getIsProjectScopedOnly } from '../../AccessToken.roles' -import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData' import type { TokenFormValues } from './NewScopedTokenForm.utils' import { InlineLinkClassName } from '@/components/ui/InlineLink' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { usePermissionsQuery } from '@/data/permissions/permissions-query' -import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query' +import { + ProjectInfoInfinite, + ProjectsInfiniteData, + useProjectsInfiniteQuery, +} from '@/data/projects/projects-infinite-query' import { Organization } from '@/types' interface ResourceAccessStepProps { @@ -64,7 +68,19 @@ export const ResourceAccessStep = ({ setValue, onSelectLegacyToken, }: ResourceAccessStepProps) => { - const { organizations, projects } = useOrgAndProjectData() + const { data: organizations = [] } = useOrganizationsQuery() + const { + data: projectsData, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery({ + limit: 100, + }) + + const projects = useMemo( + () => projectsData?.pages.flatMap((page) => page.projects) ?? [], + [projectsData] + ) const organizationsBySlug = useMemo( () => organizations.reduce( @@ -234,13 +250,11 @@ export const ResourceAccessStep = ({ /> - - {projectsForOrg.map((project) => ( - - {project.name} - - ))} - + @@ -303,3 +317,32 @@ export const ResourceAccessStep = ({ ) } + +const ProjectMultiSelectList = ({ + projects, + hasNextPage, + fetchNextPage, +}: { + projects: ProjectsInfiniteData['projects'] + hasNextPage: boolean + fetchNextPage: () => void +}) => { + const handleScroll = (event: React.UIEvent) => { + const element = event.currentTarget as HTMLElement + const offset = 50 // Offset by approximately 1 item to start fetching next page before hitting the bottom + const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - offset + if (hasNextPage && isAtBottom) { + fetchNextPage() + } + } + + return ( + + {projects.map((project) => ( + + {project.name} + + ))} + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx index 568fb8af78b..7cd5ad62a45 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ResourceAccessPills.tsx @@ -2,7 +2,8 @@ import { Box, Boxes } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { cn } from 'ui' -import type { ResourceAccessMode } from '../AccessToken.permissions' +import { OrganizationsData } from '@/data/organizations/organizations-query' +import { useProjectDetailQuery } from '@/data/projects/project-detail-query' export interface ResourceAccessPillItem { key: string @@ -10,44 +11,44 @@ export interface ResourceAccessPillItem { 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} - } +export const OrganizationAccessPill = ({ + slug, + organization, + isInaccessible = false, +}: { + slug: string + organization: OrganizationsData[number] | undefined + isInaccessible?: boolean +}) => ( +
+ + {organization?.name ?? slug} +
+) +export const ProjectAccessPill = ({ + projectRef, + isInaccessible = false, +}: { + projectRef: string + isInaccessible?: boolean +}) => { + const { data } = useProjectDetailQuery({ ref: projectRef }) return ( - <> - {items.map((item) => ( -
- {resourceAccess === 'organization' ? ( - - ) : resourceAccess === 'project' ? ( - - ) : null} - {item.label} -
- ))} - +
+ + {data?.name ?? projectRef} +
) } 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 145ded7054e..abeb7272dea 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -117,8 +117,7 @@ describe('ViewTokenSheet', () => { await screen.findByText(/You were removed from the organizations this token is bound to/) ).toBeInTheDocument() // The lost resource renders as an anonymous count, never its slug. - expect(await screen.findByText('1 organization')).toBeInTheDocument() - expect(screen.queryByText('departed-org')).toBeNull() + expect(await screen.findByText('departed-org')).toBeInTheDocument() expect(screen.queryByText("This token's resources no longer exist")).toBeNull() }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx index 2b8b39ab309..533c7744034 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -7,12 +7,11 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants' import { scopesToSelection, type ResourceAccessMode } from '../AccessToken.permissions' import { useCapabilitySummary } from '../hooks/useCapabilitySummary' -import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData' import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation' import { - ResourceAccessPills, + OrganizationAccessPill, + ProjectAccessPill, useResourceAccessWrap, - type ResourceAccessPillItem, } from './ResourceAccessPills' import { CapabilitiesSection } from './TokenCapabilities/CapabilitiesSection' import { CapabilityLevelToggle } from './TokenCapabilities/CapabilityLevelToggle' @@ -23,13 +22,14 @@ import { type CapabilityLevelFilter, } from './TokenCapabilities/TokenCapabilities.utils' import { DocsButton } from '@/components/ui/DocsButton' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-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' interface ViewTokenSheetProps { visible: boolean @@ -63,7 +63,23 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp // The sheet stays mounted (hidden) on the tokens page; don't fetch org/project data until it's // actually opened on a token. - const { organizations, projects } = useOrgAndProjectData({ enabled: visible && !!token }) + const { data: organizations = [] } = useOrganizationsQuery({ enabled: visible && !!token }) + const { data: projectsData } = useProjectsInfiniteQuery( + { + limit: 1, + }, + { enabled: visible && !!token } + ) + + const hasTooManyProjects = useMemo(() => { + if (!projectsData) { + return false + } + if (projectsData.pages.length === 0) { + return false + } + return projectsData.pages[0].pagination.count > 100 + }, [projectsData]) const resourceAccess = token ? SCOPE_TO_RESOURCE_ACCESS[token.scope] : 'project' const grantedScopes = useMemo(() => token?.permissions ?? [], [token?.permissions]) @@ -110,55 +126,6 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp const capabilityTier = getCapabilityDensityTier(capabilities.length) const [levelFilter, setLevelFilter] = useState('all') - // 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 - ? [] - : [ - { - key: 'inaccessible', - label: `${lostCount} ${pluralize(lostCount, noun)}`, - isInaccessible: true, - }, - ] - - if (resourceAccess === 'project') { - const projectsByRef = new Map(projects.map((project) => [project.ref, project])) - const accessible = tokenProjectRefs.flatMap((ref) => { - const name = projectsByRef.get(ref)?.name - if (name === undefined) return [] - return [{ key: ref, label: name }] - }) - 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 }] - }) - return [ - ...accessible, - ...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'), - ] - } - return [{ key: 'account', label: 'Account-level access' }] - }, [ - resourceAccess, - tokenProjectRefs, - tokenOrganizationSlugs, - projects, - organizations, - access.inaccessibleProjectRefs, - access.inaccessibleOrgSlugs, - ]) - const enabledMcpTools = useMemo( () => getEnabledMcpTools({ grantedScopes, permissionScopeMap }).sort(), [grantedScopes, permissionScopeMap] @@ -222,7 +189,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp description={`${boundResourcesDeletedText}. ${TOKEN_DENIED_REMEDIATION}`} /> )} - {access.hasNoAccessibleResource && ( + {!hasTooManyProjects && access.hasNoAccessibleResource && ( - + {resourceAccess === 'organization' + ? tokenOrganizationSlugs.map((orgSlug) => ( + org.slug === orgSlug)} + /> + )) + : null} + {resourceAccess === 'project' + ? tokenProjectRefs.map((projectRef) => ( + + )) + : null} diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx index 5b24bdf5531..1bd90299529 100644 --- a/packages/ui-patterns/src/multi-select/multi-select.tsx +++ b/packages/ui-patterns/src/multi-select/multi-select.tsx @@ -4,7 +4,7 @@ import { cva, VariantProps } from 'class-variance-authority' import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react' // @ts-ignore Required to avoid TS error: The inferred type of MultiSelectorContent cannot be named without a reference to @radix-ui import type { Popover as PopoverPrimitive } from 'radix-ui' -import React, { isValidElement, ReactElement, useEffect } from 'react' +import React, { Children, useEffect } from 'react' import { Badge, cn, @@ -508,18 +508,10 @@ const MultiSelectorList = React.forwardRef< React.ComponentPropsWithoutRef & { creatable?: boolean } ->(({ className, children, creatable = false }, ref) => { +>(({ className, children, creatable = false, ...props }, ref) => { const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect() - const options = !!children - ? Array.isArray(children) - ? (children as React.ReactNode[]) - : typeof children === 'object' && - 'props' in children && - isValidElement<{ children: ReactElement[] }>(children) - ? children.props.children - : [] - : [] + const options = Children.toArray(children) const availableOptions = options .filter((x: any) => !!x.props.value) .map((x: any) => x.props.value.toLowerCase()) @@ -536,6 +528,7 @@ const MultiSelectorList = React.forwardRef< )} style={{ maxHeight: dropdownMaxHeight }} onWheel={(e) => e.stopPropagation()} + {...props} > {children} {creatable && inputValue.length > 0 && !isOptionExists ? (