mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
|
||||
+62
@@ -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()
|
||||
})
|
||||
})
|
||||
+19
-21
@@ -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>
|
||||
))}
|
||||
|
||||
+25
@@ -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()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user