From cc18c9a0650c6e695bba358117b67812c65070a3 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 15 May 2026 16:40:56 -0300 Subject: [PATCH] feat(SCIM): Frontend for SCIM configuration management Adds the frontend UI consuming the backend SCIM management endpoints from #7512. The existing SAML tab in Organisation Settings is renamed to SSO and now hosts both SAML and SCIM as stacked sections gated by the same Enterprise plan check. Closes #7151. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/services/useScimConfiguration.ts | 132 +++++++++++ frontend/common/types/requests.ts | 4 + frontend/common/types/responses.ts | 12 + frontend/common/utils/utils.tsx | 10 +- frontend/web/components/PlanBasedAccess.tsx | 6 + frontend/web/components/SamlTab.tsx | 220 +++++++++--------- frontend/web/components/ScimSection.tsx | 218 +++++++++++++++++ .../web/components/modals/ScimTokenModal.tsx | 58 +++++ .../OrganisationSettingsPage.tsx | 8 +- .../organisation-settings/tabs/SSOTab.tsx | 17 ++ 10 files changed, 567 insertions(+), 118 deletions(-) create mode 100644 frontend/common/services/useScimConfiguration.ts create mode 100644 frontend/web/components/ScimSection.tsx create mode 100644 frontend/web/components/modals/ScimTokenModal.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/SSOTab.tsx diff --git a/frontend/common/services/useScimConfiguration.ts b/frontend/common/services/useScimConfiguration.ts new file mode 100644 index 000000000000..76aac9f0be83 --- /dev/null +++ b/frontend/common/services/useScimConfiguration.ts @@ -0,0 +1,132 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const scimConfigurationService = service + .enhanceEndpoints({ + addTagTypes: ['ScimConfiguration'], + }) + .injectEndpoints({ + endpoints: (builder) => ({ + createScimConfiguration: builder.mutation< + Res['scimConfigurationWithToken'], + Req['createScimConfiguration'] + >({ + invalidatesTags: (_res, _err, query) => [ + { id: query.organisation_id, type: 'ScimConfiguration' }, + ], + query: (query: Req['createScimConfiguration']) => ({ + method: 'POST', + url: `organisations/${query.organisation_id}/scim/`, + }), + }), + deleteScimConfiguration: builder.mutation< + void, + Req['deleteScimConfiguration'] + >({ + invalidatesTags: (_res, _err, query) => [ + { id: query.organisation_id, type: 'ScimConfiguration' }, + ], + query: (query: Req['deleteScimConfiguration']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/scim/`, + }), + }), + getScimConfiguration: builder.query< + Res['scimConfiguration'], + Req['getScimConfiguration'] + >({ + providesTags: (_res, _err, query) => [ + { id: query.organisation_id, type: 'ScimConfiguration' }, + ], + query: (query: Req['getScimConfiguration']) => ({ + url: `organisations/${query.organisation_id}/scim/`, + }), + }), + regenerateScimToken: builder.mutation< + Res['scimConfigurationWithToken'], + Req['regenerateScimToken'] + >({ + invalidatesTags: (_res, _err, query) => [ + { id: query.organisation_id, type: 'ScimConfiguration' }, + ], + query: (query: Req['regenerateScimToken']) => ({ + method: 'POST', + url: `organisations/${query.organisation_id}/scim/regenerate-token/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createScimConfiguration( + store: any, + data: Req['createScimConfiguration'], + options?: Parameters< + typeof scimConfigurationService.endpoints.createScimConfiguration.initiate + >[1], +) { + return store.dispatch( + scimConfigurationService.endpoints.createScimConfiguration.initiate( + data, + options, + ), + ) +} +export async function deleteScimConfiguration( + store: any, + data: Req['deleteScimConfiguration'], + options?: Parameters< + typeof scimConfigurationService.endpoints.deleteScimConfiguration.initiate + >[1], +) { + return store.dispatch( + scimConfigurationService.endpoints.deleteScimConfiguration.initiate( + data, + options, + ), + ) +} +export async function getScimConfiguration( + store: any, + data: Req['getScimConfiguration'], + options?: Parameters< + typeof scimConfigurationService.endpoints.getScimConfiguration.initiate + >[1], +) { + return store.dispatch( + scimConfigurationService.endpoints.getScimConfiguration.initiate( + data, + options, + ), + ) +} +export async function regenerateScimToken( + store: any, + data: Req['regenerateScimToken'], + options?: Parameters< + typeof scimConfigurationService.endpoints.regenerateScimToken.initiate + >[1], +) { + return store.dispatch( + scimConfigurationService.endpoints.regenerateScimToken.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateScimConfigurationMutation, + useDeleteScimConfigurationMutation, + useGetScimConfigurationQuery, + useRegenerateScimTokenMutation, + // END OF EXPORTS +} = scimConfigurationService + +/* Usage examples: +const { data, isLoading } = useGetScimConfigurationQuery({ organisation_id: 2 }) //get hook +const [createScimConfiguration, { isLoading, data, isSuccess }] = useCreateScimConfigurationMutation() //create hook +scimConfigurationService.endpoints.getScimConfiguration.select({organisation_id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 09cc47986326..eabc5773ac2c 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -734,6 +734,10 @@ export type Req = { idp_attribute_name: string } } + getScimConfiguration: { organisation_id: number } + createScimConfiguration: { organisation_id: number } + deleteScimConfiguration: { organisation_id: number } + regenerateScimToken: { organisation_id: number } updateIdentity: { environmentId: string data: Identity diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b765b948b4a1..407feb8f9da2 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -864,6 +864,16 @@ export type SAMLAttributeMapping = { idp_attribute_name: string } +export type ScimConfiguration = { + created_at: string + token_rotated_at: string + base_url: string +} + +export type ScimConfigurationWithToken = ScimConfiguration & { + token: string +} + export type HealthEventType = 'HEALTHY' | 'UNHEALTHY' export type FeatureHealthEventReasonTextBlock = { @@ -1215,6 +1225,8 @@ export type Res = { metadata_xml: string } samlAttributeMapping: PagedResponse + scimConfiguration: ScimConfiguration + scimConfigurationWithToken: ScimConfigurationWithToken identitySegments: PagedResponse organisationWebhooks: PagedResponse projectChangeRequests: PagedResponse diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 7e6df844397a..8d34e6fd7b8f 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -50,6 +50,7 @@ export type PaidFeature = | 'METADATA' | 'REALTIME' | 'SAML' + | 'SCIM' | 'SCHEDULE_FLAGS' | 'CREATE_ADDITIONAL_PROJECT' | '2FA' @@ -221,7 +222,11 @@ const Utils = Object.assign({}, BaseUtils, { flagsmithFeatureExists(flag: string) { return Object.prototype.hasOwnProperty.call(flagsmith.getAllFlags(), flag) }, - getContentType(contentTypes: ContentType[] | undefined, model: string, type: string) { + getContentType( + contentTypes: ContentType[] | undefined, + model: string, + type: string, + ) { return contentTypes?.find((c: ContentType) => c[model] === type) || null }, getCreateProjectPermission(organisation: Organisation) { @@ -522,7 +527,8 @@ const Utils = Object.assign({}, BaseUtils, { case 'AUDIT': case '4_EYES_PROJECT': case '4_EYES': - case 'SAML': { + case 'SAML': + case 'SCIM': { plan = 'scale-up' break } diff --git a/frontend/web/components/PlanBasedAccess.tsx b/frontend/web/components/PlanBasedAccess.tsx index 4e96d4d59401..41b985b8c469 100644 --- a/frontend/web/components/PlanBasedAccess.tsx +++ b/frontend/web/components/PlanBasedAccess.tsx @@ -93,6 +93,12 @@ export const featureDescriptions: Record = { docs: 'https://docs.flagsmith.com/advanced-use/scheduled-flags', title: 'Scheduled Flags', }, + 'SCIM': { + description: + 'Provision and de-provision users and groups automatically from your identity provider.', + docs: 'https://docs.flagsmith.com/system-administration/authentication/', + title: 'SCIM user provisioning', + }, 'STALE_FLAGS': { description: 'Add automatic stale flag detection, prompting your team to clean up old flags.', diff --git a/frontend/web/components/SamlTab.tsx b/frontend/web/components/SamlTab.tsx index 2ec2c42f528d..0f31b680de4a 100644 --- a/frontend/web/components/SamlTab.tsx +++ b/frontend/web/components/SamlTab.tsx @@ -11,8 +11,6 @@ import { } from 'common/services/useSamlConfiguration' import CreateSAML from './modals/CreateSAML' import Switch from './Switch' -import { SAMLConfiguration } from 'common/types/responses' -import PlanBasedBanner from './PlanBasedAccess' type SamlTabType = { organisationId: number @@ -36,122 +34,120 @@ const SamlTab: FC = ({ organisationId }) => { } return ( - -
- + { + openCreateSAML('Create SAML configuration', organisationId) + }} + > + {'Create a SAML configuration'} + + } + /> + + + + samlConf.name.toLowerCase().indexOf(search) > -1 + } + header={ + + +
Configuration name
+
+
+ Allow IdP-initiated +
+
+ Action +
+
+ } + items={data?.results || []} + renderRow={(samlConf) => ( + { - openCreateSAML('Create SAML configuration', organisationId) + openCreateSAML( + 'Update SAML configuration', + organisationId, + samlConf.name, + ) }} + space + className='list-item py-2 py-md-0 clickable cursor-pointer' + key={samlConf.name} > - {'Create a SAML configuration'} - - } - /> - - - - samlConf.name.toLowerCase().indexOf(search) > -1 - } - header={ - - -
Configuration name
-
-
- Allow IdP-initiated -
-
- Action -
-
- } - items={data?.results || []} - renderRow={(samlConf) => ( - { - openCreateSAML( - 'Update SAML configuration', - organisationId, - samlConf.name, - ) - }} - space - className='list-item py-2 py-md-0 clickable cursor-pointer' - key={samlConf.name} + +
{samlConf.name}
+
+
- -
{samlConf.name}
-
-
- -
-
-
+
+
+
+ + - -
-
, - ) - e.stopPropagation() - e.preventDefault() - }} - className='btn btn-with-icon' - > - - -
- - )} - /> - - -
+ }) + }} + > + Delete + + + , + ) + e.stopPropagation() + e.preventDefault() + }} + className='btn btn-with-icon' + > + + + + + )} + /> + + ) } diff --git a/frontend/web/components/ScimSection.tsx b/frontend/web/components/ScimSection.tsx new file mode 100644 index 000000000000..b03470ead48a --- /dev/null +++ b/frontend/web/components/ScimSection.tsx @@ -0,0 +1,218 @@ +import React, { FC } from 'react' +import moment from 'moment' + +import Button from './base/forms/Button' +import EmptyState from './EmptyState' +import Icon from './icons/Icon' +import Loader from './Loader' +import PageTitle from './PageTitle' +import ScimTokenModal from './modals/ScimTokenModal' +import Utils from 'common/utils/utils' +import { + useCreateScimConfigurationMutation, + useDeleteScimConfigurationMutation, + useGetScimConfigurationQuery, + useRegenerateScimTokenMutation, +} from 'common/services/useScimConfiguration' + +type ScimSectionProps = { + organisationId: number +} + +const DATE_FORMAT = 'Do MMM YYYY HH:mma' + +const ScimSection: FC = ({ organisationId }) => { + const { data, error, isLoading } = useGetScimConfigurationQuery({ + organisation_id: organisationId, + }) + const [createScimConfiguration, { isLoading: isCreating }] = + useCreateScimConfigurationMutation() + const [regenerateScimToken, { isLoading: isRegenerating }] = + useRegenerateScimTokenMutation() + const [deleteScimConfiguration, { isLoading: isDeleting }] = + useDeleteScimConfigurationMutation() + + const showTokenModal = (token: string) => { + openModal('Save your SCIM bearer token', ) + } + + const onCreate = () => { + createScimConfiguration({ organisation_id: organisationId }) + .unwrap() + .then((result) => { + showTokenModal(result.token) + toast('SCIM configuration created') + }) + .catch(() => { + toast('Could not create SCIM configuration', 'danger') + }) + } + + const onRegenerate = () => { + openConfirm({ + body: ( +
+ Regenerating will invalidate the existing token. Any identity provider + currently using it will stop syncing until the new token is + configured. +
+ ), + destructive: true, + onYes: () => { + regenerateScimToken({ organisation_id: organisationId }) + .unwrap() + .then((result) => { + showTokenModal(result.token) + toast('SCIM token regenerated') + }) + .catch(() => { + toast('Could not regenerate SCIM token', 'danger') + }) + }, + title: 'Regenerate SCIM token', + yesText: 'Regenerate', + }) + } + + const onDelete = () => { + openConfirm({ + body: ( +
+ Deleting the SCIM configuration will stop automatic user provisioning. + Existing users and groups are not affected. +
+ ), + destructive: true, + onYes: () => { + deleteScimConfiguration({ organisation_id: organisationId }) + .unwrap() + .then(() => { + toast('SCIM configuration deleted') + }) + .catch(() => { + toast('Could not delete SCIM configuration', 'danger') + }) + }, + title: 'Delete SCIM configuration', + yesText: 'Delete', + }) + } + + const onCopyBaseUrl = () => { + if (data?.base_url) { + Utils.copyToClipboard(data.base_url) + } + } + + if (isLoading) { + return ( +
+ +
+ +
+
+ ) + } + + // RTK Query surfaces HTTP errors via `error.status`. 404 = no configuration + // exists for this organisation, which is our empty state. Don't gate on + // `!data` — after a successful fetch followed by a delete, RTK keeps stale + // `data` alongside the new 404 error; trusting the error alone reflects + // the server state correctly. + const isNotFound = + !!error && + typeof error === 'object' && + 'status' in error && + error.status === 404 + + if (isNotFound) { + return ( +
+ + + {isCreating ? 'Creating…' : 'Create SCIM configuration'} + + } + /> +
+ ) + } + + if (!data) { + return null + } + + return ( +
+ + +
+
Created
+
+ {moment(data.created_at).format(DATE_FORMAT)} +
+
+
+
+ Token last rotated +
+
+ {moment(data.token_rotated_at).format(DATE_FORMAT)} +
+
+
+ +
+
+ SCIM base URL +
+ + + + + + +
+ + + + + +
+ ) +} + +ScimSection.displayName = 'ScimSection' + +export default ScimSection diff --git a/frontend/web/components/modals/ScimTokenModal.tsx b/frontend/web/components/modals/ScimTokenModal.tsx new file mode 100644 index 000000000000..ccce2aa58ca9 --- /dev/null +++ b/frontend/web/components/modals/ScimTokenModal.tsx @@ -0,0 +1,58 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import Icon from 'components/icons/Icon' +import Utils from 'common/utils/utils' + +type ScimTokenModalProps = { + token: string +} + +const ScimTokenModal: FC = ({ token }) => { + const onCopy = () => Utils.copyToClipboard(token) + + return ( +
+
+ +
+ Copy this token now. It will not be retrievable later + — store it somewhere secure before closing this dialogue. +
+
+ + + + + + + } + /> +
+ +
+
+ ) +} + +ScimTokenModal.displayName = 'ScimTokenModal' + +export default ScimTokenModal diff --git a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx index 1fc2f313a199..790ddc306f07 100644 --- a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx +++ b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx @@ -12,7 +12,7 @@ import { LicensingTab } from './tabs/LicensingTab' import { CustomFieldsTab } from './tabs/CustomFieldsTab' import { APIKeysTab } from './tabs/APIKeysTab' import { WebhooksTab } from './tabs/WebhooksTab' -import { SAMLTab } from './tabs/SAMLTab' +import { SSOTab } from './tabs/SSOTab' type OrganisationSettingsTab = { component: ReactNode @@ -111,10 +111,10 @@ const OrganisationSettingsPage: FC = () => { label: 'Webhooks', }, { - component: , + component: , isVisible: true, - key: 'saml', - label: 'SAML', + key: 'sso', + label: 'SSO', }, ].filter(({ isVisible }) => isVisible) diff --git a/frontend/web/components/pages/organisation-settings/tabs/SSOTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/SSOTab.tsx new file mode 100644 index 000000000000..1c2008c07218 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/SSOTab.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import PlanBasedBanner from 'components/PlanBasedAccess' +import SamlTab from 'components/SamlTab' +import ScimSection from 'components/ScimSection' + +type SSOTabProps = { + organisationId: number +} + +export const SSOTab = ({ organisationId }: SSOTabProps) => { + return ( + + + + + ) +}