Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Deji I
Dennis Senn
Dimitrios Liappis
Div Arora
Dion Zeneli
Divit D
Divya Sharma
Donna Alexandra
Expand Down Expand Up @@ -318,6 +319,7 @@ Tyler Shukert
TzeYiing L
Utkarash Singh
Victor Farazdagi
Warda Bibi
Warwick Mitchell
Wen Bo Xie
Wendie Cheung
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
)
Expand All @@ -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>()
Expand All @@ -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
Expand Down Expand Up @@ -323,7 +321,7 @@ const ProjectMultiSelectList = ({
hasNextPage,
fetchNextPage,
}: {
projects: ProjectsInfiniteData['projects']
projects: OrgProjectsResponse['projects']
hasNextPage: boolean
fetchNextPage: () => void
}) => {
Expand All @@ -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>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -145,6 +169,7 @@ describe('NewScopedTokenSheet', () => {
mockPermissionsMap()
mockOrganizations()
mockProjects()
mockOrgProjects()
mockCreateToken()
mockCreateClassicToken()
})
Expand Down
Loading
Loading