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 ? (