mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
dd534d229b
commit
c7e8373bce
6 files changed
+150
-138
No files matched your search
+18
-18
@@ -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>
|
||||
|
||||
+53
-10
@@ -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>
|
||||
)
|
||||
}
|
||||
+38
-37
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user