From fd0b3223c564585bbbf9ae0d9c34466284115677 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 30 Dec 2024 18:56:54 +0800 Subject: [PATCH] feat: add attribute mapping --- packages/console/src/consts/page-tabs.ts | 1 + .../AttributeMapping.module.scss | 44 ++++ .../AttributeMapping.tsx | 216 ++++++++++++++++++ .../SamlApplicationDetailsContent/index.tsx | 13 +- .../SamlApplicationDetailsContent/utils.ts | 12 + .../libraries/saml-applications.ts | 8 +- .../api/application/saml-application.test.ts | 107 +++++++-- .../admin-console/application-details.ts | 8 + .../jsonb-types/saml-application-configs.ts | 17 +- packages/toolkit/core-kit/src/openid.ts | 75 +++--- 10 files changed, 446 insertions(+), 55 deletions(-) create mode 100644 packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.module.scss create mode 100644 packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.tsx diff --git a/packages/console/src/consts/page-tabs.ts b/packages/console/src/consts/page-tabs.ts index dcf3c524f81..7d5109a4357 100644 --- a/packages/console/src/consts/page-tabs.ts +++ b/packages/console/src/consts/page-tabs.ts @@ -5,6 +5,7 @@ export enum ApplicationDetailsTabs { Branding = 'branding', Permissions = 'permissions', Organizations = 'organizations', + AttributeMapping = 'attribute-mapping', } export enum ApiResourceDetailsTabs { diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.module.scss b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.module.scss new file mode 100644 index 00000000000..651595cfc00 --- /dev/null +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.module.scss @@ -0,0 +1,44 @@ +@use '@/scss/underscore' as _; + +.table { + width: 100%; +} + +.header { + width: 100%; + + > tr { + padding-bottom: _.unit(3); + } + + * > th { + font: var(--font-label-2); + color: var(--color-text); + text-align: start; + } +} + +.row { + width: 100%; + display: flex; + flex-direction: row; + gap: _.unit(4); + padding-bottom: _.unit(3); + align-items: center; + + > td, + th { + &:not(:last-child) { + width: calc((100% - _.unit(4) - _.unit(10)) / 2); + } + + &:last-child { + width: _.unit(10); + flex-shrink: 0; + } + } +} + +.body { + width: 100%; +} diff --git a/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.tsx b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.tsx new file mode 100644 index 00000000000..dea6152b292 --- /dev/null +++ b/packages/console/src/pages/ApplicationDetails/SamlApplicationDetailsContent/AttributeMapping.tsx @@ -0,0 +1,216 @@ +import { type UserClaim, completeUserClaims } from '@logto/core-kit'; +import { type SamlApplicationResponse, samlAttributeMappingKeys } from '@logto/schemas'; +import { useMemo } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import CircleMinus from '@/assets/icons/circle-minus.svg?react'; +import CirclePlus from '@/assets/icons/circle-plus.svg?react'; +import DetailsForm from '@/components/DetailsForm'; +import FormCard from '@/components/FormCard'; +import Button from '@/ds-components/Button'; +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import DynamicT from '@/ds-components/DynamicT'; +import IconButton from '@/ds-components/IconButton'; +import Select from '@/ds-components/Select'; +import TextInput from '@/ds-components/TextInput'; +import useApi from '@/hooks/use-api'; +import useDocumentationUrl from '@/hooks/use-documentation-url'; +import { trySubmitSafe } from '@/utils/form'; + +import styles from './AttributeMapping.module.scss'; +import { camelCaseToSentenceCase } from './utils'; + +const defaultFormValue: Array<[UserClaim | 'id' | '', string]> = [['id', '']]; + +type Props = { + readonly data: SamlApplicationResponse; + readonly mutateApplication: (data?: SamlApplicationResponse) => void; +}; + +/** + * Type for the attribute mapping form data. + * Array of tuples containing key (UserClaim or 'id' or empty string) and value pairs + */ +type FormData = { + attributeMapping: Array<[key: UserClaim | 'id' | '', value: string]>; +}; + +const keyPrefix = 'attributeMapping'; + +const getOrderedAttributeMapping = ( + attributeMapping: SamlApplicationResponse['attributeMapping'] & Partial> +) => { + return ( + samlAttributeMappingKeys + .filter((key) => key in attributeMapping) + // eslint-disable-next-line no-restricted-syntax + .map((key) => [key, attributeMapping[key]] as [UserClaim | 'id', string]) + ); +}; + +function AttributeMapping({ data, mutateApplication }: Props) { + const { id, attributeMapping } = data; + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { getDocumentationUrl } = useDocumentationUrl(); + const api = useApi(); + + const { + watch, + register, + handleSubmit, + reset, + control, + formState: { isSubmitting, isDirty }, + setValue, + } = useForm({ + defaultValues: { + attributeMapping: + Object.keys(attributeMapping).length > 0 + ? getOrderedAttributeMapping(attributeMapping) + : defaultFormValue, + }, + }); + + const formValues = watch('attributeMapping'); + + const onSubmit = handleSubmit( + trySubmitSafe(async (formData) => { + if (isSubmitting) { + return; + } + + const updatedData = await api + .patch(`api/saml-applications/${id}`, { + json: { + attributeMapping: Object.fromEntries( + formData.attributeMapping.filter(([key, value]) => Boolean(key) && Boolean(value)) + ), + }, + }) + .json(); + + mutateApplication(updatedData); + toast.success(t('general.saved')); + reset({ + attributeMapping: + Object.keys(updatedData.attributeMapping).length > 0 + ? getOrderedAttributeMapping(updatedData.attributeMapping) + : defaultFormValue, + }); + }) + ); + + const existingKeys = useMemo(() => formValues.map(([key]) => key).filter(Boolean), [formValues]); + + const availableKeys = useMemo( + () => completeUserClaims.filter((claim) => !existingKeys.includes(claim)), + [existingKeys] + ); + + return ( + + + + + + + + + + + {formValues.map(([key, _], index) => { + return ( + // eslint-disable-next-line react/no-array-index-key + + + + + + ); + })} + +
+ + + + +
+ {key === 'id' ? ( + + ) : ( + ( + + + + {key !== 'id' && ( + { + const currentValues = [...formValues]; + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + currentValues.splice(index, 1); + setValue('attributeMapping', currentValues, { + shouldDirty: true, + }); + }} + > + + + )} +
+