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 frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ const Constants = {
'React Native': 'javascript',
},
},
cohortSyncKeyPermissions:
'To manage cohort synchronisation keys you need the <i>Manage segment overrides</i> permission for this environment and the <i>Manage segments</i> permission for this project.<br/>Please contact an administrator.',
colours: {
primary: '#6837fc',
white: '#ffffff',
Expand Down
32 changes: 31 additions & 1 deletion frontend/common/services/useCohort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { service } from 'common/service'
import toFormData from 'common/utils/toFormData'

export const cohortService = service
.enhanceEndpoints({ addTagTypes: ['Cohort', 'Segment'] })
.enhanceEndpoints({ addTagTypes: ['Cohort', 'CohortSyncKey', 'Segment'] })
.injectEndpoints({
endpoints: (builder) => ({
createCohort: builder.mutation<Res['cohort'], Req['createCohort']>({
Expand All @@ -22,6 +22,17 @@ export const cohortService = service
url: `environments/${query.environmentApiKey}/cohorts/`,
}),
}),
createCohortSyncKey: builder.mutation<
Res['cohortSyncKeyCreated'],
Req['createCohortSyncKey']
>({
invalidatesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
query: (query) => ({
body: { name: query.name },
method: 'POST',
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/`,
}),
}),
deleteCohort: builder.mutation<void, Req['deleteCohort']>({
invalidatesTags: (q, e, arg) => [
{ id: 'LIST', type: 'Cohort' },
Expand All @@ -38,6 +49,22 @@ export const cohortService = service
url: `environments/${query.environmentApiKey}/cohorts/${query.cohortId}/`,
}),
}),
getCohortSyncKeys: builder.query<
Res['cohortSyncKeys'],
Req['getCohortSyncKeys']
>({
providesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
query: (query) => ({
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/`,
}),
}),
revokeCohortSyncKey: builder.mutation<void, Req['revokeCohortSyncKey']>({
invalidatesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
query: (query) => ({
method: 'DELETE',
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/${query.prefix}/`,
}),
}),
syncCohortCsv: builder.mutation<
Res['cohortCsvSync'],
Req['syncCohortCsv']
Expand Down Expand Up @@ -95,8 +122,11 @@ export async function deleteCohort(

export const {
useCreateCohortMutation,
useCreateCohortSyncKeyMutation,
useDeleteCohortMutation,
useGetCohortQuery,
useGetCohortSyncKeysQuery,
useRevokeCohortSyncKeyMutation,
useSyncCohortCsvMutation,
useUpdateCohortMutation,
// END OF EXPORTS
Expand Down
11 changes: 11 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ export type Req = {
identifier_column?: number
has_header?: boolean
}
getCohortSyncKeys: {
environmentApiKey: string
}
createCohortSyncKey: {
environmentApiKey: string
name: string
}
revokeCohortSyncKey: {
environmentApiKey: string
prefix: string
}
cloneSegment: {
projectId: number
segmentId: number
Expand Down
12 changes: 12 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,16 @@ export type Cohort = {
membership_counts: CohortMembershipCounts
}

export type CohortSyncKey = {
prefix: string
name: string
created: string
key: string | null
}

// The plaintext key only exists in the create response.
export type CohortSyncKeyCreated = CohortSyncKey & { key: string }

export type CohortCsvSyncResult = {
version: number
added: number
Expand Down Expand Up @@ -1379,6 +1389,8 @@ export type Res = {
segments: PagedResponse<Segment>
segment: Segment
cohort: Cohort
cohortSyncKeys: CohortSyncKey[]
cohortSyncKeyCreated: CohortSyncKeyCreated
cohortCsvSync: CohortCsvSyncResult
segmentMembers: SegmentMembersResponse
auditLogs: PagedResponse<AuditLogItem>
Expand Down
174 changes: 174 additions & 0 deletions frontend/e2e/tests/cohort-sync-keys-test.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { test, expect } from '../test-setup'
Comment thread
Zaimwa9 marked this conversation as resolved.
import {
byId,
log,
createHelpers,
getFlagsmith,
LONG_TIMEOUT,
} from '../helpers'
import { E2E_USER, PASSWORD, E2E_TEST_PROJECT } from '../config'

const COHORT_PROVIDERS = ['amplitude', 'mixpanel'] as const

type CohortProvider = (typeof COHORT_PROVIDERS)[number]

type SegmentSourceFlagEntry = {
active?: boolean
name?: string
visible?: boolean
}

// Mirrors `getSegmentSources` in CreateSegmentSourcesModal: only an entry that
// is visible AND active opens the connect modal (and shows the environment tab).
const getActiveCohortProviders = (config: unknown): CohortProvider[] => {
if (!Array.isArray(config)) {
return []
}
return (config as SegmentSourceFlagEntry[])
.filter(
(entry) =>
COHORT_PROVIDERS.includes(entry?.name as CohortProvider) &&
entry?.visible !== false &&
entry?.active === true,
)
.map((entry) => entry.name as CohortProvider)
}

test.describe('Cohort Synchronisation Keys Tests', () => {
test('Cohort synchronisation keys can be created from a provider connection and revoked in Environment Settings @oss', async ({
page,
}) => {
const {
click,
gotoProject,
gotoSegments,
login,
setText,
waitForElementVisible,
waitForModalToClose,
} = createHelpers(page)

const flagsmith = await getFlagsmith()
const activeProviders = flagsmith.hasFeature(
'create_segment_with_external_sources',
)
? getActiveCohortProviders(
flagsmith.getValue('create_segment_with_external_sources', {
fallback: null,
json: true,
}),
)
: []
test.skip(
activeProviders.length === 0,
'No active Amplitude or Mixpanel entry in `create_segment_with_external_sources`, so the cohort synchronisation UI is unreachable',
)

// With both providers active each key goes through a different provider;
// with one, the second key goes through its "Create a new key" state.
const firstProvider = activeProviders[0]
const secondProvider = activeProviders[1] ?? activeProviders[0]

const runId = Date.now()
const keyOne = `e2e key one ${runId}`
const keyTwo = `e2e key two ${runId}`
const keyThree = `e2e key three ${runId}`

const connectModal = page.locator('.connect-cohort-provider')
const envSelect = connectModal.locator(byId('connect-provider-env-select'))

const openConnectModal = async (provider: CohortProvider) => {
await click(byId('show-create-segment-btn'))
await click(byId(`segment-source-${provider}`))
await waitForElementVisible(byId('connect-provider-done'))
await expect(envSelect).toBeVisible({ timeout: LONG_TIMEOUT })
// The modal defaults to the alphabetically first environment; assert it
// resolved so both keys are created against the same environment.
const label = (await envSelect.innerText()).trim()
expect(label).not.toBe('')
expect(label).not.toBe('Select an Environment')
return label
}

const createKeyInConnectModal = async (name: string) => {
// Step 1 renders either the create form or the existing-keys state,
// depending on whether this environment already has keys.
await connectModal
.locator(
`${byId('connect-provider-key-name')}, ${byId(
'connect-provider-new-key',
)}`,
)
.first()
.waitFor({ state: 'visible', timeout: LONG_TIMEOUT })
const newKeyButton = connectModal.locator(
byId('connect-provider-new-key'),
)
if (await newKeyButton.isVisible()) {
await click(byId('connect-provider-new-key'))
}
await setText(byId('connect-provider-key-name'), name)
await click(byId('connect-provider-create-key'))
await expect(
connectModal.locator(byId('connect-provider-key-value')),
).toHaveValue(/.+/, { timeout: LONG_TIMEOUT })
await click(byId('connect-provider-done'))
await waitForModalToClose()
}

log('Login')
await login(E2E_USER, PASSWORD)
await gotoProject(E2E_TEST_PROJECT)
await gotoSegments()

log(`Create the first key while connecting ${firstProvider}`)
const environmentLabel = await openConnectModal(firstProvider)
await createKeyInConnectModal(keyOne)

log(`Create the second key while connecting ${secondProvider}`)
expect(await openConnectModal(secondProvider)).toBe(environmentLabel)
await waitForElementVisible(byId('connect-provider-new-key'))
await expect(connectModal).toContainText(keyOne)
// This link carries the environment the modal is working against, so
// following it guarantees we inspect the keys we just created.
const settingsLink = connectModal.locator(
'a[href*="tab=cohorts"]',
)
await expect(settingsLink).toBeVisible()
const settingsHref = (await settingsLink.getAttribute('href')) ?? ''
expect(settingsHref).not.toBe('')
await createKeyInConnectModal(keyTwo)

log('Open the Cohort Synchronisation tab in Environment Settings')
await page.goto(settingsHref)
await waitForElementVisible('#cohort-sync-keys-list')
const keysList = page.locator('#cohort-sync-keys-list')
await expect(keysList).toContainText(keyOne)
await expect(keysList).toContainText(keyTwo)

log('Create a third key from Environment Settings')
await click(byId('create-cohort-sync-key'))
await setText(byId('cohort-sync-key-name'), keyThree)
await click(byId('cohort-sync-key-create'))
await expect(page.locator(byId('cohort-sync-key-value'))).toHaveValue(
/.+/,
{ timeout: LONG_TIMEOUT },
)
await click(byId('cohort-sync-key-done'))
await waitForModalToClose()
await expect(keysList).toContainText(keyThree)

log('Revoke the keys created by this test')
for (const name of [keyOne, keyTwo, keyThree]) {
const row = keysList.locator('.list-item').filter({ hasText: name })
await row.locator('[aria-label^="Revoke "]').click()
await click('#confirm-btn-yes')
await expect(row).toHaveCount(0, { timeout: LONG_TIMEOUT })
}

log('Verify the test keys are gone')
for (const name of [keyOne, keyTwo, keyThree]) {
await expect(page.getByText(name, { exact: true })).toHaveCount(0)
}
})
})
7 changes: 6 additions & 1 deletion frontend/web/components/PanelSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,12 @@ const PanelSearch = <T,>(props: PanelSearchProps<T>): ReactElement => {
</Row>
)}
{onRefresh && (
<Button theme='text' size='xSmall' onClick={onRefresh}>
<Button
theme='text'
size='xSmall'
className='mr-2'
onClick={onRefresh}
>
<Icon name='refresh' fill='#6837FC' width={16} /> Refresh
</Button>
)}
Expand Down
6 changes: 6 additions & 0 deletions frontend/web/components/SegmentOverrides.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,12 @@ class TheComponent extends Component {
const filter = (segment) => {
if (segment.feature && segment.feature !== this.props.feature)
return false
// A cohort-managed segment only has members in its own environment.
if (
segment.cohort &&
segment.cohort.environment_api_key !== this.props.environmentId
)
return false
if (this.props.id && this.props.id !== segment.id) return null
const foundSegment = find(value, (v) => v.segment === segment.id)
return !value || !foundSegment || (foundSegment && foundSegment.toRemove)
Expand Down
Loading
Loading