Skip to content

Commit 396d871

Browse files
feat(SCIM): SCIM configuration management UI (#7528)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 24f4d1a commit 396d871

17 files changed

Lines changed: 815 additions & 184 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Res } from 'common/types/responses'
2+
import { Req } from 'common/types/requests'
3+
import { service } from 'common/service'
4+
5+
export const scimConfigurationService = service
6+
.enhanceEndpoints({
7+
addTagTypes: ['ScimConfiguration'],
8+
})
9+
.injectEndpoints({
10+
endpoints: (builder) => ({
11+
createScimConfiguration: builder.mutation<
12+
Res['scimConfigurationWithToken'],
13+
Req['createScimConfiguration']
14+
>({
15+
invalidatesTags: (_res, _err, query) => [
16+
{ id: query.organisation_id, type: 'ScimConfiguration' },
17+
],
18+
query: (query: Req['createScimConfiguration']) => ({
19+
method: 'POST',
20+
url: `organisations/${query.organisation_id}/scim/`,
21+
}),
22+
}),
23+
deleteScimConfiguration: builder.mutation<
24+
void,
25+
Req['deleteScimConfiguration']
26+
>({
27+
invalidatesTags: (_res, _err, query) => [
28+
{ id: query.organisation_id, type: 'ScimConfiguration' },
29+
],
30+
query: (query: Req['deleteScimConfiguration']) => ({
31+
method: 'DELETE',
32+
url: `organisations/${query.organisation_id}/scim/`,
33+
}),
34+
}),
35+
getScimConfiguration: builder.query<
36+
Res['scimConfiguration'],
37+
Req['getScimConfiguration']
38+
>({
39+
providesTags: (_res, _err, query) => [
40+
{ id: query.organisation_id, type: 'ScimConfiguration' },
41+
],
42+
query: (query: Req['getScimConfiguration']) => ({
43+
url: `organisations/${query.organisation_id}/scim/`,
44+
}),
45+
}),
46+
regenerateScimToken: builder.mutation<
47+
Res['scimConfigurationWithToken'],
48+
Req['regenerateScimToken']
49+
>({
50+
invalidatesTags: (_res, _err, query) => [
51+
{ id: query.organisation_id, type: 'ScimConfiguration' },
52+
],
53+
query: (query: Req['regenerateScimToken']) => ({
54+
method: 'POST',
55+
url: `organisations/${query.organisation_id}/scim/regenerate-token/`,
56+
}),
57+
}),
58+
// END OF ENDPOINTS
59+
}),
60+
})
61+
62+
export async function createScimConfiguration(
63+
store: any,
64+
data: Req['createScimConfiguration'],
65+
options?: Parameters<
66+
typeof scimConfigurationService.endpoints.createScimConfiguration.initiate
67+
>[1],
68+
) {
69+
return store.dispatch(
70+
scimConfigurationService.endpoints.createScimConfiguration.initiate(
71+
data,
72+
options,
73+
),
74+
)
75+
}
76+
export async function deleteScimConfiguration(
77+
store: any,
78+
data: Req['deleteScimConfiguration'],
79+
options?: Parameters<
80+
typeof scimConfigurationService.endpoints.deleteScimConfiguration.initiate
81+
>[1],
82+
) {
83+
return store.dispatch(
84+
scimConfigurationService.endpoints.deleteScimConfiguration.initiate(
85+
data,
86+
options,
87+
),
88+
)
89+
}
90+
export async function getScimConfiguration(
91+
store: any,
92+
data: Req['getScimConfiguration'],
93+
options?: Parameters<
94+
typeof scimConfigurationService.endpoints.getScimConfiguration.initiate
95+
>[1],
96+
) {
97+
return store.dispatch(
98+
scimConfigurationService.endpoints.getScimConfiguration.initiate(
99+
data,
100+
options,
101+
),
102+
)
103+
}
104+
export async function regenerateScimToken(
105+
store: any,
106+
data: Req['regenerateScimToken'],
107+
options?: Parameters<
108+
typeof scimConfigurationService.endpoints.regenerateScimToken.initiate
109+
>[1],
110+
) {
111+
return store.dispatch(
112+
scimConfigurationService.endpoints.regenerateScimToken.initiate(
113+
data,
114+
options,
115+
),
116+
)
117+
}
118+
// END OF FUNCTION_EXPORTS
119+
120+
export const {
121+
useCreateScimConfigurationMutation,
122+
useDeleteScimConfigurationMutation,
123+
useGetScimConfigurationQuery,
124+
useRegenerateScimTokenMutation,
125+
// END OF EXPORTS
126+
} = scimConfigurationService
127+
128+
/* Usage examples:
129+
const { data, isLoading } = useGetScimConfigurationQuery({ organisation_id: 2 }) //get hook
130+
const [createScimConfiguration, { isLoading, data, isSuccess }] = useCreateScimConfigurationMutation() //create hook
131+
scimConfigurationService.endpoints.getScimConfiguration.select({organisation_id: 2})(store.getState()) //access data from any function
132+
*/

frontend/common/types/requests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,10 @@ export type Req = {
734734
idp_attribute_name: string
735735
}
736736
}
737+
getScimConfiguration: { organisation_id: number }
738+
createScimConfiguration: { organisation_id: number }
739+
deleteScimConfiguration: { organisation_id: number }
740+
regenerateScimToken: { organisation_id: number }
737741
updateIdentity: {
738742
environmentId: string
739743
data: Identity

frontend/common/types/responses.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,16 @@ export type SAMLAttributeMapping = {
864864
idp_attribute_name: string
865865
}
866866

867+
export type ScimConfiguration = {
868+
created_at: string
869+
token_rotated_at: string
870+
base_url: string
871+
}
872+
873+
export type ScimConfigurationWithToken = ScimConfiguration & {
874+
token: string
875+
}
876+
867877
export type HealthEventType = 'HEALTHY' | 'UNHEALTHY'
868878

869879
export type FeatureHealthEventReasonTextBlock = {
@@ -1215,6 +1225,8 @@ export type Res = {
12151225
metadata_xml: string
12161226
}
12171227
samlAttributeMapping: PagedResponse<SAMLAttributeMapping>
1228+
scimConfiguration: ScimConfiguration
1229+
scimConfigurationWithToken: ScimConfigurationWithToken
12181230
identitySegments: PagedResponse<Segment>
12191231
organisationWebhooks: PagedResponse<Webhook>
12201232
projectChangeRequests: PagedResponse<ChangeRequestSummary>

frontend/common/utils/utils.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type PaidFeature =
5050
| 'METADATA'
5151
| 'REALTIME'
5252
| 'SAML'
53+
| 'SCIM'
5354
| 'SCHEDULE_FLAGS'
5455
| 'CREATE_ADDITIONAL_PROJECT'
5556
| '2FA'
@@ -221,7 +222,11 @@ const Utils = Object.assign({}, BaseUtils, {
221222
flagsmithFeatureExists(flag: string) {
222223
return Object.prototype.hasOwnProperty.call(flagsmith.getAllFlags(), flag)
223224
},
224-
getContentType(contentTypes: ContentType[] | undefined, model: string, type: string) {
225+
getContentType(
226+
contentTypes: ContentType[] | undefined,
227+
model: string,
228+
type: string,
229+
) {
225230
return contentTypes?.find((c: ContentType) => c[model] === type) || null
226231
},
227232
getCreateProjectPermission(organisation: Organisation) {
@@ -522,7 +527,8 @@ const Utils = Object.assign({}, BaseUtils, {
522527
case 'AUDIT':
523528
case '4_EYES_PROJECT':
524529
case '4_EYES':
525-
case 'SAML': {
530+
case 'SAML':
531+
case 'SCIM': {
526532
plan = 'scale-up'
527533
break
528534
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react'
2+
import type { Meta, StoryObj } from 'storybook'
3+
4+
import CopyField from 'components/CopyField'
5+
6+
const meta: Meta = {
7+
parameters: {
8+
docs: {
9+
description: {
10+
component:
11+
'Read-only input paired with an icon-only Copy button. Pass `value` to copy; render any width by wrapping in a sized container — the input flexes to fill the row.',
12+
},
13+
},
14+
layout: 'padded',
15+
},
16+
title: 'Components/Forms/CopyField',
17+
}
18+
export default meta
19+
20+
type Story = StoryObj
21+
22+
const Container: React.FC<React.PropsWithChildren> = ({ children }) => (
23+
<div style={{ width: 480 }}>{children}</div>
24+
)
25+
26+
export const Default: Story = {
27+
render: () => (
28+
<Container>
29+
<CopyField value='https://app.flagsmith.com/api/v1/scim/organisations/42/scim/v2' />
30+
</Container>
31+
),
32+
}
33+
34+
export const Monospace: Story = {
35+
render: () => (
36+
<Container>
37+
<CopyField
38+
value='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzY2ltLXRva2VuIn0'
39+
className='font-monospace'
40+
/>
41+
</Container>
42+
),
43+
}
44+
45+
export const ShortValue: Story = {
46+
render: () => (
47+
<Container>
48+
<CopyField value='org_42' />
49+
</Container>
50+
),
51+
}
52+
53+
export const LongValue: Story = {
54+
render: () => (
55+
<Container>
56+
<CopyField value='https://very-long-subdomain.example.flagsmith-eu.com/api/v1/auth/saml/configurations/marketing-okta/response/redirect?next=/dashboard' />
57+
</Container>
58+
),
59+
}

frontend/e2e/tests/sso-test.pw.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test } from '../test-setup'
2+
import { byId, log, createHelpers, visualSnapshot } from '../helpers'
3+
import { E2E_USER, PASSWORD } from '../config'
4+
5+
test.describe('SCIM Tests', () => {
6+
test('SCIM configuration can be created, regenerated, and deleted @enterprise', async ({ page }, testInfo) => {
7+
const {
8+
click,
9+
login,
10+
waitForElementVisible,
11+
} = createHelpers(page)
12+
13+
log('Login')
14+
await login(E2E_USER, PASSWORD)
15+
16+
log('Navigate to SSO tab in Organisation Settings')
17+
await waitForElementVisible(byId('organisation-link'))
18+
await click(byId('organisation-link'))
19+
await waitForElementVisible(byId('org-settings-link'))
20+
await click(byId('org-settings-link'))
21+
await click(byId('sso'))
22+
23+
log('Ensure clean state')
24+
const createBtn = page.locator(byId('scim-create'))
25+
const deleteBtn = page.locator(byId('scim-delete'))
26+
await createBtn.or(deleteBtn).waitFor({ state: 'visible' })
27+
if (await deleteBtn.isVisible()) {
28+
await click(byId('scim-delete'))
29+
await click('#confirm-btn-yes')
30+
await waitForElementVisible(byId('scim-create'))
31+
}
32+
await visualSnapshot(page, 'scim-empty', testInfo)
33+
34+
log('Create SCIM configuration')
35+
await click(byId('scim-create'))
36+
37+
log('Token modal shows the bearer token')
38+
await waitForElementVisible(byId('scim-token-value'))
39+
await visualSnapshot(page, 'scim-token-modal', testInfo)
40+
41+
log('Close token modal via inline confirmation')
42+
await click(byId('scim-token-done'))
43+
await click(byId('scim-token-confirm-done'))
44+
45+
log('Configured state shows base URL')
46+
await waitForElementVisible(byId('scim-base-url'))
47+
await visualSnapshot(page, 'scim-configured', testInfo)
48+
49+
log('Regenerate token')
50+
await click(byId('scim-regenerate'))
51+
await click('#confirm-btn-yes')
52+
await waitForElementVisible(byId('scim-token-value'))
53+
await click(byId('scim-token-done'))
54+
await click(byId('scim-token-confirm-done'))
55+
await waitForElementVisible(byId('scim-base-url'))
56+
57+
log('Delete SCIM configuration')
58+
await click(byId('scim-delete'))
59+
await click('#confirm-btn-yes')
60+
61+
log('Back to empty state')
62+
await waitForElementVisible(byId('scim-create'))
63+
})
64+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { FC } from 'react'
2+
import Button from './base/forms/Button'
3+
import Flex from './base/grid/Flex'
4+
import Icon from './icons/Icon'
5+
import Input from './base/forms/Input'
6+
import Row from './base/grid/Row'
7+
import Utils from 'common/utils/utils'
8+
9+
// Minimal read-only input + icon-only Copy button pattern. The codebase has
10+
// half a dozen consumers rolling this inline (CreateSAML's ACS URL, SDK
11+
// keys, webhook URLs, etc.) — they should all migrate here in a follow-up.
12+
// Keep the API tight for now to avoid pre-committing to choices that don't
13+
// fit every existing consumer.
14+
type CopyFieldProps = {
15+
value: string
16+
className?: string
17+
'data-test'?: string
18+
}
19+
20+
const CopyField: FC<CopyFieldProps> = ({
21+
className,
22+
'data-test': dataTest,
23+
value,
24+
}) => {
25+
const onCopy = () => Utils.copyToClipboard(value)
26+
27+
return (
28+
<Row className='gap-2 align-items-center'>
29+
<Flex>
30+
<Input
31+
value={value}
32+
readOnly
33+
className={className}
34+
data-test={dataTest}
35+
/>
36+
</Flex>
37+
<Button
38+
theme='secondary'
39+
className='btn-with-icon'
40+
onClick={onCopy}
41+
aria-label='Copy to clipboard'
42+
>
43+
<Icon name='copy' width={20} />
44+
</Button>
45+
</Row>
46+
)
47+
}
48+
49+
export default CopyField

frontend/web/components/PlanBasedAccess.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export const featureDescriptions: Record<PaidFeature, any> = {
9393
docs: 'https://docs.flagsmith.com/advanced-use/scheduled-flags',
9494
title: 'Scheduled Flags',
9595
},
96+
'SCIM': {
97+
description:
98+
'Provision and de-provision users and groups automatically from your identity provider.',
99+
docs: 'https://docs.flagsmith.com/administration-and-security/access-control/scim/',
100+
title: 'SCIM user provisioning',
101+
},
96102
'STALE_FLAGS': {
97103
description:
98104
'Add automatic stale flag detection, prompting your team to clean up old flags.',

0 commit comments

Comments
 (0)