From 318fb85e471a96b10274c4f502cfac7971169acf Mon Sep 17 00:00:00 2001 From: Novak Zaballa <41410593+novakzaballa@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:20:42 -0400 Subject: [PATCH] feat: Add UI for SAML attribute mapping (#4184) Co-authored-by: Matthew Elwell --- .../services/useSamlAttributeMapping.ts | 124 ++++++++++++ frontend/common/types/requests.ts | 18 ++ frontend/common/types/responses.ts | 11 ++ .../components/SAMLAttributeMappingTable.tsx | 135 +++++++++++++ frontend/web/components/SamlTab.tsx | 4 +- frontend/web/components/Tooltip.tsx | 5 +- frontend/web/components/modals/CreateSAML.tsx | 185 +++++++++++++++--- 7 files changed, 448 insertions(+), 34 deletions(-) create mode 100644 frontend/common/services/useSamlAttributeMapping.ts create mode 100644 frontend/web/components/SAMLAttributeMappingTable.tsx diff --git a/frontend/common/services/useSamlAttributeMapping.ts b/frontend/common/services/useSamlAttributeMapping.ts new file mode 100644 index 000000000000..f271b12140c0 --- /dev/null +++ b/frontend/common/services/useSamlAttributeMapping.ts @@ -0,0 +1,124 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const samlAttributeMappingService = service + .enhanceEndpoints({ addTagTypes: ['SamlAttributeMapping'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createSamlAttributeMapping: builder.mutation< + Res['samlAttributeMapping'], + Req['createSamlAttributeMapping'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'SamlAttributeMapping' }], + query: (query: Req['createSamlAttributeMapping']) => ({ + body: query.body, + method: 'POST', + url: `auth/saml/attribute-mapping/`, + }), + }), + deleteSamlAttributeMapping: builder.mutation< + Res['samlAttributeMapping'], + Req['deleteSamlAttributeMapping'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'SamlAttributeMapping' }], + query: (query: Req['deleteSamlAttributeMapping']) => ({ + method: 'DELETE', + url: `auth/saml/attribute-mapping/${query.attribute_id}`, + }), + }), + getSamlAttributeMapping: builder.query< + Res['samlAttributeMapping'], + Req['getSamlAttributeMapping'] + >({ + providesTags: () => [{ id: 'LIST', type: 'SamlAttributeMapping' }], + query: (query: Req['getSamlAttributeMapping']) => ({ + url: `auth/saml/attribute-mapping/?saml_configuration=${query.saml_configuration_id}`, + }), + }), + updateSamlAttributeMapping: builder.mutation< + Res['samlAttributeMapping'], + Req['updateSamlAttributeMapping'] + >({ + invalidatesTags: () => [{ id: 'LIST', type: 'SamlAttributeMapping' }], + query: (query: Req['updateSamlAttributeMapping']) => ({ + body: query.body, + method: 'PUT', + url: `auth/saml/attribute-mapping/${query.attribute_id}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createSamlAttributeMapping( + store: any, + data: Req['createSamlAttributeMapping'], + options?: Parameters< + typeof samlAttributeMappingService.endpoints.createSamlAttributeMapping.initiate + >[1], +) { + return store.dispatch( + samlAttributeMappingService.endpoints.createSamlAttributeMapping.initiate( + data, + options, + ), + ) +} +export async function deleteSamlAttributeMapping( + store: any, + data: Req['deleteSamlAttributeMapping'], + options?: Parameters< + typeof samlAttributeMappingService.endpoints.deleteSamlAttributeMapping.initiate + >[1], +) { + return store.dispatch( + samlAttributeMappingService.endpoints.deleteSamlAttributeMapping.initiate( + data, + options, + ), + ) +} +export async function getSamlAttributeMapping( + store: any, + data: Req['getSamlAttributeMapping'], + options?: Parameters< + typeof samlAttributeMappingService.endpoints.getSamlAttributeMapping.initiate + >[1], +) { + return store.dispatch( + samlAttributeMappingService.endpoints.getSamlAttributeMapping.initiate( + data, + options, + ), + ) +} +export async function updateSamlAttributeMapping( + store: any, + data: Req['updateSamlAttributeMapping'], + options?: Parameters< + typeof samlAttributeMappingService.endpoints.updateSamlAttributeMapping.initiate + >[1], +) { + return store.dispatch( + samlAttributeMappingService.endpoints.updateSamlAttributeMapping.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateSamlAttributeMappingMutation, + useDeleteSamlAttributeMappingMutation, + useGetSamlAttributeMappingQuery, + useUpdateSamlAttributeMappingMutation, + // END OF EXPORTS +} = samlAttributeMappingService + +/* Usage examples: +const { data, isLoading } = useGetSamlAttributeMappingQuery({ id: 2 }, {}) //get hook +const [createSamlAttributeMapping, { isLoading, data, isSuccess }] = useCreateSamlAttributeMappingMutation() //create hook +samlAttributeMappingService.endpoints.getSamlAttributeMapping.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index f5ac0efa5828..7fd0aab64f99 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -12,6 +12,7 @@ import { ProjectFlag, Environment, UserGroup, + AttributeName, } from './responses' export type PagedRequest = T & { @@ -486,5 +487,22 @@ export type Req = { updateSamlConfiguration: { name: string; body: SAMLConfiguration } deleteSamlConfiguration: { name: string } createSamlConfiguration: SAMLConfiguration + getSamlAttributeMapping: { saml_configuration_id: number } + updateSamlAttributeMapping: { + attribute_id: number + body: { + saml_configuration: number + django_attribute_name: AttributeName + idp_attribute_name: string + } + } + deleteSamlAttributeMapping: { attribute_id: number } + createSamlAttributeMapping: { + body: { + saml_configuration: number + django_attribute_name: AttributeName + idp_attribute_name: string + } + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 9093d36c526f..95e13fd623a5 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -429,6 +429,8 @@ export type AuthType = 'EMAIL' | 'GITHUB' | 'GOOGLE' export type SignupType = 'NO_INVITE' | 'INVITE_EMAIL' | 'INVITE_LINK' +export type AttributeName = 'email' | 'first_name' | 'last_name' | 'groups' + export type Invite = { id: number email: string @@ -554,6 +556,7 @@ export type MetadataModelField = { } export type SAMLConfiguration = { + id: number organisation: number name: string frontend_url: string @@ -561,6 +564,13 @@ export type SAMLConfiguration = { allow_idp_initiated?: boolean } +export type SAMLAttributeMapping = { + id: number + saml_configuration: number + django_attribute_name: AttributeName + idp_attribute_name: string +} + export type Res = { segments: PagedResponse segment: Segment @@ -679,5 +689,6 @@ export type Res = { response_url: string metadata_xml: string } + samlAttributeMapping: PagedResponse // END OF TYPES } diff --git a/frontend/web/components/SAMLAttributeMappingTable.tsx b/frontend/web/components/SAMLAttributeMappingTable.tsx new file mode 100644 index 000000000000..14151eb89caf --- /dev/null +++ b/frontend/web/components/SAMLAttributeMappingTable.tsx @@ -0,0 +1,135 @@ +import React, { FC } from 'react' + +import { + useDeleteSamlAttributeMappingMutation, + useGetSamlAttributeMappingQuery, +} from 'common/services/useSamlAttributeMapping' +import PanelSearch from './PanelSearch' +import Button from './base/forms/Button' +import Icon from './Icon' +import { SAMLAttributeMapping } from 'common/types/responses' +import Format from 'common/utils/format' +import Tooltip from './Tooltip' + +type SAMLAttributeMappingTableType = { + samlConfigurationId: number +} +const SAMLAttributeMappingTable: FC = ({ + samlConfigurationId, +}) => { + const { data } = useGetSamlAttributeMappingQuery( + { + saml_configuration_id: samlConfigurationId, + }, + { skip: !samlConfigurationId }, + ) + + const [deleteSamlAttribute] = useDeleteSamlAttributeMappingMutation() + + return ( +
+ + +
SAML Attribute Name
+
+ +
+ IDP Attribute Name +
+
+ + } + items={data?.results || []} + renderRow={(attribute: SAMLAttributeMapping) => ( + + +
+ {Format.camelCase( + attribute.django_attribute_name.replace(/_/g, ' '), + )} +
+
+ + + {attribute.idp_attribute_name} +
+ } + > + {attribute.idp_attribute_name} + + +
+ + +
+ , + ) + e.stopPropagation() + e.preventDefault() + }} + className='btn btn-with-icon' + > + + + + + )} + /> + + ) +} +export default SAMLAttributeMappingTable diff --git a/frontend/web/components/SamlTab.tsx b/frontend/web/components/SamlTab.tsx index 39354aa829f2..bb8f47bcbbe0 100644 --- a/frontend/web/components/SamlTab.tsx +++ b/frontend/web/components/SamlTab.tsx @@ -95,7 +95,7 @@ const SamlTab: FC = ({ organisationId }) => { type='button' onClick={(e) => { openModal( - 'Delete Github Integration', + 'Delete SAML configuration',
Are you sure you want to delete the SAML @@ -105,7 +105,7 @@ const SamlTab: FC = ({ organisationId }) => { + {isEdit && ( + + )}
- {!!createError || - (!!updateError && ( +
+ ) + + const Tab2: FC = () => { + const [ipdAttributeName, setIdpAttributeName] = useState('') + const [djangoAttributeName, setDjangoAttributeName] = + useState(samlAttributes[0]) + const [CreateSamlAttributeMapping, { error: errorAttributeCreation }] = + useCreateSamlAttributeMappingMutation() + return ( +
+ { + setDjangoAttributeName(m) + }} + className='mb-4 react-select' + /> + } + /> + ) => { + setIdpAttributeName(Utils.safeParseEventValue(event)) + }} + inputProps={{ + className: 'full-width', + }} + type='text' + name='Name*' + /> +
+ +
+ {errorAttributeCreation && (
- +
- ))} -
+ )} + + + ) + } + + return ( + <> + {!isEdit ? ( + Tab1 + ) : ( + + Basic Configuration + } + > + {Tab1} + + Attribute Mapping + } + > +
+ +
+
+
+ )} + ) } export default CreateSAML