diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts index 830692e2b2c..ca320cd6ab6 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts @@ -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 = { + [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({ + projects, + pagination: { count: projects.length, limit: 100, offset: 0 }, + }) + }, + }) addAPIMock({ method: 'get', // @ts-expect-error Studio API is missing from types diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx index d21edc783a8..84f80fef842 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx @@ -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( {}} />, { + 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([ + 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() + }) +}) 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 0743b1c3201..e8678654068 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.tsx @@ -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 + {} as Record ), - [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() @@ -121,11 +124,6 @@ export const ResourceAccessStep = ({ ) }, [permissions, organizations]) - const projectsForOrg = useMemo( - () => projects.filter((project) => organizationSlugs.includes(project.organization_slug)), - [projects, organizationSlugs] - ) - return (
void }) => { @@ -339,7 +337,7 @@ const ProjectMultiSelectList = ({ return ( {projects.map((project) => ( - + {project.name} ))} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 1b4c3072a2c..7f4480086bf 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -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({ + 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() })