Skip to content

Commit

Permalink
feat: add attribute mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Dec 31, 2024
1 parent 3fa2b79 commit fd0b322
Show file tree
Hide file tree
Showing 10 changed files with 446 additions and 55 deletions.
1 change: 1 addition & 0 deletions packages/console/src/consts/page-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum ApplicationDetailsTabs {
Branding = 'branding',
Permissions = 'permissions',
Organizations = 'organizations',
AttributeMapping = 'attribute-mapping',
}

export enum ApiResourceDetailsTabs {
Expand Down
Original file line number Diff line number Diff line change
@@ -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%;
}
Original file line number Diff line number Diff line change
@@ -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<Record<'', string>>
) => {
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<FormData>({
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<SamlApplicationResponse>();

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 (
<DetailsForm
isSubmitting={isSubmitting}
isDirty={isDirty}
onSubmit={onSubmit}
onDiscard={reset}
>
<FormCard
title="application_details.saml_app_attribute_mapping.title"
description="application_details.saml_app_attribute_mapping.description"
learnMoreLink={{
// TODO: update this link once docs is ready
href: getDocumentationUrl('/connectors/enterprise-connectors'),
targetBlank: 'noopener',
}}
>
<table className={styles.table}>
<thead className={styles.header}>
<tr className={styles.row}>
<th>
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_logto_claims" />
</th>
<th>
<DynamicT forKey="application_details.saml_app_attribute_mapping.col_sp_claims" />
</th>
<th />
</tr>
</thead>
<tbody className={styles.body}>
{formValues.map(([key, _], index) => {
return (
// eslint-disable-next-line react/no-array-index-key
<tr key={index} className={styles.row}>
<td>
{key === 'id' ? (
<CopyToClipboard displayType="block" variant="border" value={key} />
) : (
<Controller
control={control}
name={`${keyPrefix}.${index}.0` as const}
render={({ field: { onChange, value } }) => (
<Select
isSearchEnabled
value={value}
options={[
...availableKeys.map((claim) => ({
title: camelCaseToSentenceCase(claim),
value: claim,
})),
// If this is not specified, the component will fail to render the current value. The current value has been excluded in `availableKeys`.
{ value, title: camelCaseToSentenceCase(value) },
]}
onChange={(value) => {
onChange(value);
}}
/>
)}
/>
)}
</td>
<td>
<TextInput {...register(`${keyPrefix}.${index}.1`)} />
</td>
<td>
{key !== 'id' && (
<IconButton
onClick={() => {
const currentValues = [...formValues];
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
currentValues.splice(index, 1);
setValue('attributeMapping', currentValues, {
shouldDirty: true,
});
}}
>
<CircleMinus />
</IconButton>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<Button
size="small"
type="text"
disabled={availableKeys.length === 0}
icon={<CirclePlus />}
title="application_details.saml_app_attribute_mapping.add_button"
onClick={() => {
const currentValues = [...formValues];
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
currentValues.push(['', '']);
setValue('attributeMapping', currentValues, {
shouldDirty: true,
});
}}
/>
</FormCard>
</DetailsForm>
);
}

export default AttributeMapping;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { applicationTypeI18nKey } from '@/types/applications';
import Branding from '../components/Branding';
import Permissions from '../components/Permissions';

import AttributeMapping from './AttributeMapping';
import Settings from './Settings';
import styles from './index.module.scss';

Expand Down Expand Up @@ -131,10 +132,12 @@ function SamlApplicationDetailsContent({ data }: Props) {
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.AttributeMapping}`}>
{t('application_details.saml_app_attribute_mapping.name')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Permissions}`}>
{t('application_details.permissions.name')}
</TabNavItem>
{/** TODO: Attribute mapping tab */}
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Branding}`}>
{t('application_details.branding.name')}
</TabNavItem>
Expand All @@ -151,6 +154,14 @@ function SamlApplicationDetailsContent({ data }: Props) {
/>
)}
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.AttributeMapping}
className={styles.tabContainer}
>
{samlApplicationData && (
<AttributeMapping data={samlApplicationData} mutateApplication={mutateSamlApplication} />
)}
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,15 @@ export const samlApplicationManagementApiPrefix = '/api/saml-applications';
export const samlApplicationEndpointPrefix = '/saml';
export const samlApplicationMetadataEndpointSuffix = 'metadata';
export const samlApplicationSingleSignOnEndpointSuffix = 'authn';

export const camelCaseToSentenceCase = (input: string): string => {
const words = input.split('_');

// If the first word is empty, return an empty string.
if (!words[0]) {
return '';
}

const capitalizedFirstWord = words[0].charAt(0).toUpperCase() + words[0].slice(1);
return [capitalizedFirstWord, ...words.slice(1)].join(' ');
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
BindingType,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { removeUndefinedKeys, pick } from '@silverhand/essentials';
import saml from 'samlify';

import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
Expand Down Expand Up @@ -86,7 +86,9 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
): Promise<SamlApplicationResponse> => {
const { name, description, customData, ...config } = patchApplicationObject;
const originalApplication = await findApplicationById(id);
const applicationData = { name, description, customData };
const applicationData = removeUndefinedKeys(
pick(patchApplicationObject, 'name', 'description', 'customData')
);

Check warning on line 91 in packages/core/src/saml-applications/libraries/saml-applications.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/saml-applications.ts#L89-L91

Added lines #L89 - L91 were not covered by tests

assertThat(
originalApplication.type === ApplicationType.SAML,
Expand All @@ -98,7 +100,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {

const [updatedApplication, upToDateSamlConfig] = await Promise.all([
Object.keys(applicationData).length > 0
? updateApplicationById(id, removeUndefinedKeys(applicationData))
? updateApplicationById(id, applicationData)

Check warning on line 103 in packages/core/src/saml-applications/libraries/saml-applications.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/saml-applications.ts#L103

Added line #L103 was not covered by tests
: originalApplication,
Object.keys(config).length > 0
? updateSamlApplicationConfig({
Expand Down
Loading

0 comments on commit fd0b322

Please sign in to comment.