diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index a643ea5a2c5b..3340c028da74 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -198,6 +198,8 @@ const Constants = { 'React Native': 'javascript', }, }, + cohortSyncKeyPermissions: + 'To manage cohort synchronisation keys you need the Manage segment overrides permission for this environment and the Manage segments permission for this project.
Please contact an administrator.', colours: { primary: '#6837fc', white: '#ffffff', diff --git a/frontend/common/services/useCohort.ts b/frontend/common/services/useCohort.ts index 0bc89daca4ad..5b96e8235bfc 100644 --- a/frontend/common/services/useCohort.ts +++ b/frontend/common/services/useCohort.ts @@ -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({ @@ -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({ invalidatesTags: (q, e, arg) => [ { id: 'LIST', type: 'Cohort' }, @@ -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({ + 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'] @@ -95,8 +122,11 @@ export async function deleteCohort( export const { useCreateCohortMutation, + useCreateCohortSyncKeyMutation, useDeleteCohortMutation, useGetCohortQuery, + useGetCohortSyncKeysQuery, + useRevokeCohortSyncKeyMutation, useSyncCohortCsvMutation, useUpdateCohortMutation, // END OF EXPORTS diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 211df3f3738d..48441cafb20b 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -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 diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 1bf767750333..7b3dd1a35187 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -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 @@ -1379,6 +1389,8 @@ export type Res = { segments: PagedResponse segment: Segment cohort: Cohort + cohortSyncKeys: CohortSyncKey[] + cohortSyncKeyCreated: CohortSyncKeyCreated cohortCsvSync: CohortCsvSyncResult segmentMembers: SegmentMembersResponse auditLogs: PagedResponse diff --git a/frontend/e2e/tests/cohort-sync-keys-test.pw.ts b/frontend/e2e/tests/cohort-sync-keys-test.pw.ts new file mode 100644 index 000000000000..4488baf9ead7 --- /dev/null +++ b/frontend/e2e/tests/cohort-sync-keys-test.pw.ts @@ -0,0 +1,174 @@ +import { test, expect } from '../test-setup' +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) + } + }) +}) diff --git a/frontend/web/components/PanelSearch.tsx b/frontend/web/components/PanelSearch.tsx index d435b684cfd4..ba8f5bb5e19e 100644 --- a/frontend/web/components/PanelSearch.tsx +++ b/frontend/web/components/PanelSearch.tsx @@ -319,7 +319,12 @@ const PanelSearch = (props: PanelSearchProps): ReactElement => { )} {onRefresh && ( - )} diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 102fcec50d55..d117f9f4c550 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -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) diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/CohortSyncKeyStep.tsx b/frontend/web/components/modals/ConnectCohortProviderModal/CohortSyncKeyStep.tsx new file mode 100644 index 000000000000..09f35e863019 --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/CohortSyncKeyStep.tsx @@ -0,0 +1,212 @@ +import React, { FC, useState } from 'react' +import moment from 'moment' +import Constants from 'common/constants' +import { useHasPermission } from 'common/providers/Permission' +import { + useCreateCohortSyncKeyMutation, + useGetCohortSyncKeysQuery, +} from 'common/services/useCohort' +import { + EnvironmentPermission, + ProjectPermission, +} from 'common/types/permissions.types' +import { CohortSyncKey } from 'common/types/responses' +import Button from 'components/base/forms/Button' +import InputGroup from 'components/base/forms/InputGroup' +import Link from 'components/base/link/Link' +import CopyField from 'components/CopyField' +import ErrorMessage from 'components/ErrorMessage' +import Tooltip from 'components/Tooltip' +import WarningMessage from 'components/WarningMessage' +import ConnectCohortProviderStep from './ConnectCohortProviderStep' + +const NAME_MAX_LENGTH = 50 + +type CohortSyncKeyStepProps = { + environmentApiKey: string + index: number + projectId: number | string + providerLabel: string +} + +const CohortSyncKeyStep: FC = ({ + environmentApiKey, + index, + projectId, + providerLabel, +}) => { + const [name, setName] = useState('') + const [createdKey, setCreatedKey] = useState(null) + const [isCreateFormOpen, setIsCreateFormOpen] = useState(false) + + // Key writes need both permissions; mirrors the API's CohortPermission. + const { isLoading: isLoadingEnvPermission, permission: canManageOverrides } = + useHasPermission({ + id: environmentApiKey, + level: 'environment', + permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + }) + const { + isLoading: isLoadingProjectPermission, + permission: canManageSegments, + } = useHasPermission({ + id: `${projectId}`, + level: 'project', + permission: ProjectPermission.MANAGE_SEGMENTS, + }) + const isLoadingPermission = + isLoadingEnvPermission || isLoadingProjectPermission + const canManage = !!canManageOverrides && !!canManageSegments + + const { data: syncKeys, isLoading } = useGetCohortSyncKeysQuery( + { environmentApiKey }, + { skip: !environmentApiKey }, + ) + const [createCohortSyncKey, { error, isLoading: isCreating }] = + useCreateCohortSyncKeyMutation() + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + createCohortSyncKey({ environmentApiKey, name: name.trim() }) + .unwrap() + .then((syncKey) => { + setCreatedKey(syncKey.key) + setIsCreateFormOpen(false) + setName('') + }) + .catch(() => {}) + } + + const hasKeys = !!syncKeys?.length + const getTitle = () => { + if (createdKey) return 'Synchronisation key' + if (hasKeys && !isCreateFormOpen) return 'Use your existing key' + if (!canManage) return 'Synchronisation key' + return 'Generate a synchronisation key' + } + + const renderBody = () => { + if ((isLoading && !syncKeys) || isLoadingPermission) { + return + } + + if (createdKey) { + return ( + <> + +
+ +
+ + ) + } + + if (!hasKeys && !canManage) { + return ( +
+ Creating synchronisation keys needs the Manage segment overrides + permission for this environment and the Manage segments permission for + this project. +
+ ) + } + + if (!hasKeys || isCreateFormOpen) { + return ( +
+ ) => + setName(event.target.value) + } + isValid={!!name.trim().length} + type='text' + placeholder={`e.g. ${providerLabel} production`} + /> + + + + ) + } + + return ( + <> +
+ {syncKeys?.map((syncKey: CohortSyncKey) => ( +
+ {syncKey.name} + + {syncKey.prefix} + {moment(syncKey.created).format('D MMM YYYY')} + +
+ ))} +
+
+ Key values are shown only at creation. + {canManage && ' Lost it? Create a new key.'} +
+
+ You can revoke keys in{' '} + {/* href, not to: this renders in the modal root, outside the Router. */} + closeModal()} + > + Environment Settings + + . +
+ {canManage ? ( + + ) : ( + + Create a new key + + } + place='right' + > + {Constants.cohortSyncKeyPermissions} + + )} + + ) + } + + return ( + + {renderBody()} + + ) +} + +export default CohortSyncKeyStep diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.scss b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.scss new file mode 100644 index 000000000000..2f3f3d24fd4d --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.scss @@ -0,0 +1,11 @@ +.connect-cohort-provider { + &__step-number { + width: 24px; + height: 24px; + } + + &__auth-label { + width: 120px; + flex-shrink: 0; + } +} diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.tsx b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.tsx new file mode 100644 index 000000000000..834409f72451 --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderModal.tsx @@ -0,0 +1,141 @@ +import React, { FC, useMemo, useState } from 'react' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import Button from 'components/base/forms/Button' +import FieldLabel from 'components/base/forms/FieldLabel' +import Link from 'components/base/link/Link' +import CopyField from 'components/CopyField' +import EnvironmentSelect from 'components/EnvironmentSelect' +import ModalHR from 'components/modals/ModalHR' +import CohortSyncKeyStep from './CohortSyncKeyStep' +import ConnectCohortProviderStep from './ConnectCohortProviderStep' +import { + COHORT_PROVIDERS, + CohortProviderKey, + getCohortProviderEndpoint, +} from './providers' +import './ConnectCohortProviderModal.scss' + +const SEGMENTS_DOCS_URL = 'https://docs.flagsmith.com/basic-features/segments' + +type ConnectCohortProviderModalProps = { + projectId: number | string + provider: CohortProviderKey +} + +const ConnectCohortProviderModal: FC = ({ + projectId, + provider, +}) => { + const config = COHORT_PROVIDERS[provider] + const endpointUrl = getCohortProviderEndpoint(provider) + const [selectedEnvironment, setSelectedEnvironment] = useState('') + + const { data: environments, isLoading } = useGetEnvironmentsQuery({ + projectId: Number(projectId), + }) + + // EnvironmentSelect orders alphabetically, so the default matches its first option. + const defaultEnvironment = useMemo( + () => + [...(environments?.results || [])].sort((a, b) => + a.name.localeCompare(b.name), + )[0]?.api_key, + [environments?.results], + ) + const environmentApiKey = selectedEnvironment || defaultEnvironment || '' + + return ( +
+
+ {isLoading && !environments ? ( + + ) : ( +
+
+ + Environment + + setSelectedEnvironment(`${value}`)} + /> +
+ Cohort members are synchronised into this environment only. +
+
+ + + {!!config.endpointStepBody && ( +

+ {config.endpointStepBody} +

+ )} + {!!endpointUrl && ( + + )} +
+ {config.authRows.map((row) => ( +
+
+ {row.label} +
+ {row.mono ? ( + + {row.value} + + ) : ( + {row.value} + )} +
+ ))} +
+ {!!endpointUrl && ( +
+ This URL is the same for every environment — your key decides + where cohort members land. +
+ )} +
+ +

+ {config.exportStepBody} +

+
+
+ )} +
+ +
+ + Learn about Segments + + +
+
+ ) +} + +export default ConnectCohortProviderModal diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderStep.tsx b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderStep.tsx new file mode 100644 index 000000000000..3022baaad21a --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/ConnectCohortProviderStep.tsx @@ -0,0 +1,25 @@ +import React, { FC, ReactNode } from 'react' + +type ConnectCohortProviderStepProps = { + index: number + title: string + children: ReactNode +} + +const ConnectCohortProviderStep: FC = ({ + children, + index, + title, +}) => ( +
+ + {index} + +
+
{title}
+ {children} +
+
+) + +export default ConnectCohortProviderStep diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/index.ts b/frontend/web/components/modals/ConnectCohortProviderModal/index.ts new file mode 100644 index 000000000000..89564e0ea6ab --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/index.ts @@ -0,0 +1,3 @@ +export { default } from './ConnectCohortProviderModal' +export { COHORT_PROVIDERS } from './providers' +export type { CohortProviderConfig, CohortProviderKey } from './providers' diff --git a/frontend/web/components/modals/ConnectCohortProviderModal/providers.ts b/frontend/web/components/modals/ConnectCohortProviderModal/providers.ts new file mode 100644 index 000000000000..d7df10ffcdcc --- /dev/null +++ b/frontend/web/components/modals/ConnectCohortProviderModal/providers.ts @@ -0,0 +1,71 @@ +import Project from 'common/project' + +export type CohortProviderKey = 'amplitude' | 'mixpanel' + +export type CohortProviderAuthRow = { + label: string + value: string + mono?: boolean +} + +export type CohortProviderConfig = { + label: string + authRows: CohortProviderAuthRow[] + // Providers where the user pastes a URL themselves carry an endpoint; + // Amplitude calls Flagsmith's portal-registered endpoints instead. + endpoint?: { + fieldTitle: string + path: string + } + endpointStepTitle: string + endpointStepBody?: string + exportStepTitle: string + exportStepBody: string +} + +export const COHORT_PROVIDERS: Record = + { + amplitude: { + authRows: [ + { label: 'API key', mono: true, value: '{YOUR_SYNCHRONISATION_KEY}' }, + ], + endpointStepBody: + 'In Amplitude, open Data → Destinations and add Flagsmith as a cohort destination. Paste your synchronisation key when asked for the API key.', + endpointStepTitle: 'Add Flagsmith as a destination in Amplitude', + exportStepBody: + 'In Amplitude, open the cohort you want to target and synchronise it to the Flagsmith destination. Flagsmith creates the managed segment automatically on the first synchronisation, then keeps its members up to date as people enter and leave the cohort.', + exportStepTitle: 'Synchronise your cohort to Flagsmith', + label: 'Amplitude', + }, + mixpanel: { + authRows: [ + { label: 'Authentication', value: 'Basic auth' }, + { label: 'Username', value: 'Any value' }, + { label: 'Password', mono: true, value: '{YOUR_SYNCHRONISATION_KEY}' }, + ], + endpoint: { + fieldTitle: 'Webhook URL', + path: 'cohort-sync/mixpanel/webhook/', + }, + endpointStepTitle: 'Create a webhook in Mixpanel', + exportStepBody: + 'In Mixpanel, open the cohort you want to target and export it to the webhook you just created. Flagsmith creates the managed segment automatically on the first synchronisation, then keeps its members up to date as people enter and leave the cohort.', + exportStepTitle: 'Export your cohort to the webhook', + label: 'Mixpanel', + }, + } + +// Proxied self-hosted deployments configure a relative Project.api ('/api/v1/'); +// providers need an absolute callback URL, so resolve against the page origin. +export const getCohortProviderEndpoint = ( + provider: CohortProviderKey, +): string | null => { + const endpoint = COHORT_PROVIDERS[provider].endpoint + if (!endpoint) { + return null + } + return new URL( + endpoint.path, + new URL(Project.api, window.location.origin), + ).toString() +} diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index cc9e7d1a5ab5..d5089ae6c479 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -827,6 +827,9 @@ const LoadingCreateSegment: FC = (props) => { useEffect(() => { if (segmentData) { props.onSegmentRetrieved?.(segmentData) + if (segmentData.cohort?.environment_api_key) { + setEnvironmentId(segmentData.cohort.environment_api_key) + } } //eslint-disable-next-line }, [segmentData]) diff --git a/frontend/web/components/modals/CreateSegmentSourcesModal/CreateSegmentSourcesModal.tsx b/frontend/web/components/modals/CreateSegmentSourcesModal/CreateSegmentSourcesModal.tsx index 90570e0f36c5..1fcbe57d4ab3 100644 --- a/frontend/web/components/modals/CreateSegmentSourcesModal/CreateSegmentSourcesModal.tsx +++ b/frontend/web/components/modals/CreateSegmentSourcesModal/CreateSegmentSourcesModal.tsx @@ -76,20 +76,26 @@ export function getSegmentSources(): SegmentSource[] { type CreateSegmentSourcesModalType = { onManual: () => void + onAmplitude?: () => void onCsv?: () => void + onMixpanel?: () => void sources: SegmentSource[] } const CreateSegmentSourcesModal: FC = ({ + onAmplitude, onCsv, onManual, + onMixpanel, sources, }) => { const [selected, setSelected] = useState(null) const [requested, setRequested] = useState([]) const sourceHandlers: Partial void>> = { + amplitude: onAmplitude, csv: onCsv, + mixpanel: onMixpanel, } const trackSourceEvent = (event: string, source: SegmentSource) => { diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 4c9377562043..2fe170b7b848 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -18,6 +18,10 @@ import CreateSegmentFromCsv from 'components/modals/CreateSegmentFromCsv' import CreateSegmentSourcesModal, { getSegmentSources, } from 'components/modals/CreateSegmentSourcesModal' +import ConnectCohortProviderModal, { + COHORT_PROVIDERS, + CohortProviderKey, +} from 'components/modals/ConnectCohortProviderModal' import PanelSearch from 'components/PanelSearch' import JSONReference from 'components/JSONReference' @@ -117,6 +121,14 @@ const SegmentsPage: FC = () => { ) } + const openConnectCohortProviderDrawer = (provider: CohortProviderKey) => { + openModal( + `Connect ${COHORT_PROVIDERS[provider].label}`, + , + 'p-0 modal--wide', + ) + } + const newSegment = () => { const sources = getSegmentSources() if ( @@ -129,6 +141,8 @@ const SegmentsPage: FC = () => { sources={sources} onManual={openCreateSegmentDrawer} onCsv={openCreateSegmentFromCsvDrawer} + onMixpanel={() => openConnectCohortProviderDrawer('mixpanel')} + onAmplitude={() => openConnectCohortProviderDrawer('amplitude')} />, 'p-0 modal--wide', ) diff --git a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx index 1ae87d1c1b87..6c8693b16191 100644 --- a/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx +++ b/frontend/web/components/pages/environment-settings/EnvironmentSettingsPage.tsx @@ -47,6 +47,8 @@ import { useRouteContext } from 'components/providers/RouteContext' import SettingTitle from 'components/SettingTitle' import ChangeRequestsSetting from 'components/ChangeRequestsSetting' import PlanBasedBanner from 'components/PlanBasedAccess' +import { getSegmentSources } from 'components/modals/CreateSegmentSourcesModal' +import CohortSyncTab from './tabs/cohort-sync-tab' import WarehouseTab from './tabs/warehouse-tab' const showDisabledFlagOptions: { label: string; value: boolean | null }[] = [ @@ -119,6 +121,11 @@ const EnvironmentSettingsPage: React.FC = () => { const warehouseEnabled = Utils.getFlagsmithHasFeature( 'experimentation_warehouse_connection', ) + const cohortSyncEnabled = getSegmentSources().some( + (source) => + (source.key === 'amplitude' || source.key === 'mixpanel') && + source.active, + ) const getEnvironment = useCallback(async () => { if (!env) return @@ -792,7 +799,6 @@ const EnvironmentSettingsPage: React.FC = () => { -
Feature Webhooks
@@ -920,6 +926,16 @@ const EnvironmentSettingsPage: React.FC = () => { )} + {cohortSyncEnabled && ( + +
+ +
+
+ )} {metadataEnable && environmentContentType?.id && ( diff --git a/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CohortSyncTab.tsx b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CohortSyncTab.tsx new file mode 100644 index 000000000000..7438e9d4b020 --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CohortSyncTab.tsx @@ -0,0 +1,185 @@ +import React, { FC } from 'react' +import moment from 'moment' +import Constants from 'common/constants' +import { useHasPermission } from 'common/providers/Permission' +import { + useGetCohortSyncKeysQuery, + useRevokeCohortSyncKeyMutation, +} from 'common/services/useCohort' +import { + EnvironmentPermission, + ProjectPermission, +} from 'common/types/permissions.types' +import { CohortSyncKey } from 'common/types/responses' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import Link from 'components/base/link/Link' +import Flex from 'components/base/grid/Flex' +import FormGroup from 'components/base/grid/FormGroup' +import Row from 'components/base/grid/Row' +import Icon from 'components/icons/Icon' +import PanelSearch from 'components/PanelSearch' +import Tooltip from 'components/Tooltip' +import CreateCohortSyncKeyModal from './CreateCohortSyncKeyModal' + +type CohortSyncTabProps = { + environmentApiKey: string + projectId: number | string +} + +const CohortSyncTab: FC = ({ + environmentApiKey, + projectId, +}) => { + // Key writes need both permissions; mirrors the API's CohortPermission. + const { permission: canManageOverrides } = useHasPermission({ + id: environmentApiKey, + level: 'environment', + permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + }) + const { permission: canManageSegments } = useHasPermission({ + id: `${projectId}`, + level: 'project', + permission: ProjectPermission.MANAGE_SEGMENTS, + }) + const canManage = !!canManageOverrides && !!canManageSegments + + const { + data: syncKeys, + error, + isLoading, + } = useGetCohortSyncKeysQuery( + { environmentApiKey }, + { skip: !environmentApiKey }, + ) + const [revokeCohortSyncKey] = useRevokeCohortSyncKeyMutation() + + const handleCreate = () => { + openModal( + 'Create Cohort Synchronisation Key', + , + 'p-0', + ) + } + + const handleRevoke = (syncKey: CohortSyncKey) => { + openConfirm({ + body: ( +
+ Any provider using {syncKey.name} will stop + synchronising cohorts into this environment immediately. This cannot + be undone. +
+ ), + destructive: true, + onYes: () => { + revokeCohortSyncKey({ environmentApiKey, prefix: syncKey.prefix }) + .unwrap() + .then(() => toast('Cohort synchronisation key revoked')) + .catch(() => + toast('Failed to revoke cohort synchronisation key', 'danger'), + ) + }, + title: 'Revoke Cohort Synchronisation Key', + yesText: 'Revoke', + }) + } + + const filterByName = (syncKey: CohortSyncKey, search: string) => + syncKey.name.toLowerCase().includes(search.toLowerCase()) + + const renderRow = (syncKey: CohortSyncKey) => ( + + +
{syncKey.name}
+
+ Created {moment(syncKey.created).format('D MMM YYYY')} +
+
+
+ + {syncKey.prefix} + +
+
+ {canManage && ( + + )} +
+
+ ) + + return ( + +
+
Cohort Synchronisation Keys
+

+ Cohort synchronisation keys authenticate cohort synchronisation from + providers such as Mixpanel and Amplitude into this environment.{' '} + + Learn about Segments. + +

+

+ Key values are shown only once at creation and cannot be recovered + afterwards. +

+
+ {!!error && } + {isLoading && !syncKeys && !error && } + {!error && (!isLoading || !!syncKeys) && ( + + Create Cohort Key + + ) : ( + + Create Cohort Key + + } + place='left' + > + {Constants.cohortSyncKeyPermissions} + + ) + } + renderNoResults={ + + You currently have no cohort synchronisation keys for this + environment. + + } + isLoading={isLoading} + /> + )} +
+ ) +} + +CohortSyncTab.displayName = 'CohortSyncTab' + +export default CohortSyncTab diff --git a/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CreateCohortSyncKeyModal.tsx b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CreateCohortSyncKeyModal.tsx new file mode 100644 index 000000000000..3329043d385a --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/CreateCohortSyncKeyModal.tsx @@ -0,0 +1,94 @@ +import React, { FC, useState } from 'react' +import { useCreateCohortSyncKeyMutation } from 'common/services/useCohort' +import Button from 'components/base/forms/Button' +import InputGroup from 'components/base/forms/InputGroup' +import CopyField from 'components/CopyField' +import ErrorMessage from 'components/ErrorMessage' +import ModalHR from 'components/modals/ModalHR' +import WarningMessage from 'components/WarningMessage' + +const NAME_MAX_LENGTH = 50 + +type CreateCohortSyncKeyModalProps = { + environmentApiKey: string +} + +const CreateCohortSyncKeyModal: FC = ({ + environmentApiKey, +}) => { + const [name, setName] = useState('') + const [createdKey, setCreatedKey] = useState(null) + const [createCohortSyncKey, { error, isLoading }] = + useCreateCohortSyncKeyMutation() + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + createCohortSyncKey({ environmentApiKey, name: name.trim() }) + .unwrap() + .then((syncKey) => setCreatedKey(syncKey.key)) + .catch(() => {}) + } + + if (createdKey) { + return ( +
+
+ +
+ +
+
+ +
+ +
+
+ ) + } + + return ( +
+
+ ) => + setName(event.target.value) + } + isValid={!!name.trim().length} + type='text' + placeholder='e.g. Mixpanel production' + /> + +
+ +
+ + +
+ + ) +} + +export default CreateCohortSyncKeyModal diff --git a/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/index.ts b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/index.ts new file mode 100644 index 000000000000..bf99ba7c855c --- /dev/null +++ b/frontend/web/components/pages/environment-settings/tabs/cohort-sync-tab/index.ts @@ -0,0 +1 @@ +export { default } from './CohortSyncTab'