fix(studio): project selector fetch on scoped pat (#49865)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

The scoped-access-token project selector fetched a single page of the
user's projects across all orgs and filtered client-side, so switching
to an org whose projects weren't in that page left the list empty with
no way to load more. Use the org-scoped projects query instead, keyed on
the selected org, and fix project search to match by name.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Project selection now displays projects belonging to the currently
selected organization.
* Switching organizations refreshes the available project list,
preventing projects from another organization from appearing.

* **Tests**
* Added coverage for organization-specific project loading, organization
switching, pagination, and empty project lists.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
kemal.earth authored and GitHub committed 2026-09-02 09:29:58 +01:00
1 parent b04e26872b
commit d088ec6259
4 files changed
+140 -21

No files matched your search

@@ -16,6 +16,8 @@ import type { Permission } from '@/types'
type AccessControlPermission = components['schemas']['AccessControlPermission']
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
type OrganizationProject = OrganizationProjectsResponse['projects'][number]
/** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */
export type PermissionRowFixture = Permission & {
@@ -73,7 +75,27 @@ export const ownerRows = (slug: string, refs: string[] = []) => [
]
export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' }
export const MOCK_ORG_2 = { slug: 'acme-staging', name: 'Acme Staging' }
export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' }
export const MOCK_PROJECT_2 = { ref: 'project-2', name: 'Project 2' }
const toOrganizationProject = (project: { ref: string; name: string }): OrganizationProject => ({
cloud_provider: 'AWS',
databases: [],
inserted_at: new Date().toISOString(),
integration_source: null,
is_branch: false,
name: project.name,
ref: project.ref,
region: 'us-east-1',
status: 'ACTIVE_HEALTHY',
})
/** Per-org project lists backing the `/platform/organizations/{slug}/projects` mock below. */
const PROJECTS_BY_ORG: Record<string, { ref: string; name: string }[]> = {
[MOCK_ORG.slug]: [MOCK_PROJECT],
[MOCK_ORG_2.slug]: [MOCK_PROJECT_2],
}
/**
* Registers the GET mocks every scoped-token surface fires on mount: one organization
@@ -103,6 +125,18 @@ export const mockScopedTokenEnvironment = () => {
],
}),
})
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/projects',
response: ({ params }) => {
const slug = (params as { slug: string }).slug
const projects = (PROJECTS_BY_ORG[slug] ?? []).map(toOrganizationProject)
return HttpResponse.json<OrganizationProjectsResponse>({
projects,
pagination: { count: projects.length, limit: 100, offset: 0 },
})
},
})
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
@@ -1,18 +1,26 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
MOCK_ORG_2,
MOCK_PROJECT,
MOCK_PROJECT_2,
mockPermissionsApi,
mockScopedTokenEnvironment,
readonlyRows,
} from '../../AccessToken.fixtures'
import { NewScopedTokenSheet } from '../NewScopedTokenSheet'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type OrganizationResponse = components['schemas']['OrganizationResponse']
// Disabling orgs for project-scoped members reads /platform/profile/permissions, which only
// fires on the platform for a logged-in user — neither is true in the default test environment.
vi.mock('common', async (importOriginal) => {
@@ -68,3 +76,57 @@ describe('ResourceAccessStep organization selector', () => {
).toBeNull()
})
})
describe('ResourceAccessStep project selector', () => {
beforeEach(() => {
mockScopedTokenEnvironment()
})
const openTokenForm = async () => {
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
profileContext: createMockProfileContext(),
})
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
}
const selectOrganization = async (name: string) => {
fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' }))
fireEvent.click(await screen.findByRole('option', { name }))
}
test('loads projects scoped to the selected organization', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
await openTokenForm()
await selectOrganization(MOCK_ORG.name)
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument()
})
// Regression test: the project list used to be fetched cross-org (a single page of the user's
// first 100 projects, filtered client-side by org), so switching to an org whose projects
// didn't fall in that page left the selector permanently empty.
test('refreshes the project list when switching organizations', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
createMockOrganizationResponse({ slug: MOCK_ORG_2.slug, name: MOCK_ORG_2.name }),
]),
})
mockPermissionsApi([...readonlyRows(MOCK_ORG.slug), ...readonlyRows(MOCK_ORG_2.slug)])
await openTokenForm()
await selectOrganization(MOCK_ORG.name)
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
expect(await screen.findByRole('option', { name: MOCK_PROJECT.name })).toBeInTheDocument()
await selectOrganization(MOCK_ORG_2.name)
fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' }))
expect(await screen.findByRole('option', { name: MOCK_PROJECT_2.name })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: MOCK_PROJECT.name })).toBeNull()
})
})
@@ -30,10 +30,10 @@ import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import {
ProjectInfoInfinite,
ProjectsInfiniteData,
useProjectsInfiniteQuery,
} from '@/data/projects/projects-infinite-query'
OrgProject,
OrgProjectsResponse,
useOrgProjectsInfiniteQuery,
} from '@/data/projects/org-projects-infinite-query'
import { Organization } from '@/types'
interface ResourceAccessStepProps {
@@ -69,15 +69,21 @@ export const ResourceAccessStep = ({
onSelectLegacyToken,
}: ResourceAccessStepProps) => {
const { data: organizations = [] } = useOrganizationsQuery()
const resourceAccess = useWatch({ control, name: 'resourceAccess' })
const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
const selectedOrgSlug = organizationSlugs[0]
const {
data: projectsData,
hasNextPage,
fetchNextPage,
} = useProjectsInfiniteQuery({
} = useOrgProjectsInfiniteQuery({
slug: selectedOrgSlug,
limit: 100,
})
const projects = useMemo(
const projectsForOrg = useMemo(
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
[projectsData]
)
@@ -94,23 +100,20 @@ export const ResourceAccessStep = ({
)
const projectsByRef = useMemo(
() =>
projects.reduce(
projectsForOrg.reduce(
(acc, project) => {
acc[project.ref] = project
return acc
},
{} as Record<string, ProjectInfoInfinite>
{} as Record<string, OrgProject>
),
[projects]
[projectsForOrg]
)
const resourceAccess = useWatch({ control, name: 'resourceAccess' })
const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
// Users invited to specific projects (rather than the whole org) can't select that org for an
// org-wide token. Skipped while permissions are still loading so nothing gets disabled by
// mistake. The project list itself needs no permission filter — /platform/projects is already
// scoped server-side to what the user can access.
// mistake. The project list itself needs no permission filter — the org projects endpoint is
// already scoped server-side to what the user can access.
const { data: permissions } = usePermissionsQuery()
const projectScopedOrgSlugs = useMemo(() => {
if (permissions === undefined) return new Set<string>()
@@ -121,11 +124,6 @@ export const ResourceAccessStep = ({
)
}, [permissions, organizations])
const projectsForOrg = useMemo(
() => projects.filter((project) => organizationSlugs.includes(project.organization_slug)),
[projects, organizationSlugs]
)
return (
<section className="space-y-4 px-5 sm:px-6 py-6">
<FormField
@@ -323,7 +321,7 @@ const ProjectMultiSelectList = ({
hasNextPage,
fetchNextPage,
}: {
projects: ProjectsInfiniteData['projects']
projects: OrgProjectsResponse['projects']
hasNextPage: boolean
fetchNextPage: () => void
}) => {
@@ -339,7 +337,7 @@ const ProjectMultiSelectList = ({
return (
<MultiSelectorList onScroll={handleScroll}>
{projects.map((project) => (
<MultiSelectorItem key={project.ref} value={project.ref}>
<MultiSelectorItem key={project.ref} value={project.ref} keywords={[project.name]}>
{project.name}
</MultiSelectorItem>
))}
@@ -12,6 +12,7 @@ import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']
@@ -81,6 +82,29 @@ const mockProjects = () =>
}),
})
const mockOrgProjects = () =>
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/projects',
response: () =>
HttpResponse.json<OrganizationProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [
{
cloud_provider: 'AWS',
databases: [],
inserted_at: new Date().toISOString(),
integration_source: null,
is_branch: false,
name: 'Project 1',
ref: 'project-1',
region: 'us-east-1',
status: 'ACTIVE_HEALTHY',
},
],
}),
})
const mockPermissionsMap = () =>
addAPIMock({
method: 'get',
@@ -145,6 +169,7 @@ describe('NewScopedTokenSheet', () => {
mockPermissionsMap()
mockOrganizations()
mockProjects()
mockOrgProjects()
mockCreateToken()
mockCreateClassicToken()
})