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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-08-21 18:01:28 +02:00
1 parent dd534d229b
commit c7e8373bce
6 files changed
+150 -138

No files matched your search

@@ -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<ResourceAccessPillItem[]>(() => {
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 = ({
<dt className="shrink-0 text-sm text-foreground-lighter">Resource access</dt>
<dd className="w-full min-w-0 text-sm text-foreground sm:w-auto sm:flex-1">
<div ref={pillsRef} className="flex flex-wrap justify-start gap-1.5 sm:justify-end">
<ResourceAccessPills resourceAccess={values.resourceAccess} items={resourceItems} />
{values.resourceAccess === 'organization'
? values.organizationSlugs.map((orgSlug) => (
<OrganizationAccessPill
key={orgSlug}
slug={orgSlug}
organization={organizations.find((org) => org.slug === orgSlug)}
/>
))
: null}
{values.resourceAccess === 'project'
? values.projectRefs.map((projectRef) => (
<ProjectAccessPill key={projectRef} projectRef={projectRef} />
))
: null}
</div>
</dd>
</div>
@@ -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 = ({
/>
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search projects" showResetIcon />
<MultiSelectorList>
{projectsForOrg.map((project) => (
<MultiSelectorItem key={project.ref} value={project.ref}>
{project.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
<ProjectMultiSelectList
projects={projectsForOrg}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
/>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
@@ -303,3 +317,32 @@ export const ResourceAccessStep = ({
</section>
)
}
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 (
<MultiSelectorList onScroll={handleScroll}>
{projects.map((project) => (
<MultiSelectorItem key={project.ref} value={project.ref}>
{project.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
)
}
@@ -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 <span className="text-xs text-foreground-lighter">{emptyText}</span>
}
export const OrganizationAccessPill = ({
slug,
organization,
isInaccessible = false,
}: {
slug: string
organization: OrganizationsData[number] | undefined
isInaccessible?: boolean
}) => (
<div
className={cn(
'flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border bg-surface-75 text-foreground-light px-3 py-1 text-xs',
isInaccessible ? 'border-destructive-500 text-destructive' : 'border-strong text-foreground'
)}
>
<Boxes size={14} strokeWidth={1.5} className="shrink-0 text-foreground-lighter" />
{organization?.name ?? slug}
</div>
)
export const ProjectAccessPill = ({
projectRef,
isInaccessible = false,
}: {
projectRef: string
isInaccessible?: boolean
}) => {
const { data } = useProjectDetailQuery({ ref: projectRef })
return (
<>
{items.map((item) => (
<div
key={item.key}
className={cn(
'flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border bg-surface-75 text-foreground-light px-3 py-1 text-xs',
item.isInaccessible
? 'border-destructive-500 text-destructive'
: 'border-strong text-foreground'
)}
>
{resourceAccess === 'organization' ? (
<Boxes size={14} strokeWidth={1.5} className="shrink-0 text-foreground-lighter" />
) : resourceAccess === 'project' ? (
<Box size={14} strokeWidth={1.5} className="shrink-0 text-foreground-lighter" />
) : null}
{item.label}
</div>
))}
</>
<div
className={cn(
'flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border bg-surface-75 text-foreground-light px-3 py-1 text-xs',
isInaccessible ? 'border-destructive-500 text-destructive' : 'border-strong text-foreground'
)}
>
<Box size={14} strokeWidth={1.5} className="shrink-0 text-foreground-lighter" />
{data?.name ?? projectRef}
</div>
)
}
@@ -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()
})
@@ -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<CapabilityLevelFilter>('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<ResourceAccessPillItem[]>(() => {
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 && (
<Admonition
type="destructive"
title="This token no longer has access"
@@ -299,11 +266,20 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
ref={pillsRef}
className="flex flex-wrap justify-start gap-1.5 sm:justify-end"
>
<ResourceAccessPills
resourceAccess={resourceAccess}
items={resourceItems}
emptyText={hasNoBoundResources ? boundResourcesDeletedText : '-'}
/>
{resourceAccess === 'organization'
? tokenOrganizationSlugs.map((orgSlug) => (
<OrganizationAccessPill
key={orgSlug}
slug={orgSlug}
organization={organizations.find((org) => org.slug === orgSlug)}
/>
))
: null}
{resourceAccess === 'project'
? tokenProjectRefs.map((projectRef) => (
<ProjectAccessPill key={projectRef} projectRef={projectRef} />
))
: null}
</div>
</dd>
</div>
@@ -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<typeof CommandList> & {
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 ? (