diff --git a/packages/console/src/containers/ConsoleContent/index.tsx b/packages/console/src/containers/ConsoleContent/index.tsx index 238f992d5..f319a691b 100644 --- a/packages/console/src/containers/ConsoleContent/index.tsx +++ b/packages/console/src/containers/ConsoleContent/index.tsx @@ -119,6 +119,8 @@ function ConsoleContent() { } /> } /> + } /> + } /> )} diff --git a/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.module.scss b/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.module.scss new file mode 100644 index 000000000..43b86c820 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.copyToClipboard { + display: block; +} diff --git a/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.tsx b/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.tsx new file mode 100644 index 000000000..6b04ff1e4 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/BasicInfo/index.tsx @@ -0,0 +1,79 @@ +import { SsoProviderName, type SsoConnectorWithProviderConfig } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import { useContext } from 'react'; +import { z } from 'zod'; + +import { AppDataContext } from '@/contexts/AppDataProvider'; +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import FormField from '@/ds-components/FormField'; +import useCustomDomain from '@/hooks/use-custom-domain'; + +import * as styles from './index.module.scss'; + +type Props = { + ssoConnectorId: string; +} & Pick; + +/** + * TODO: Should align this with the guard `samlServiceProviderMetadataGuard` defined in {@link logto/core/src/sso/types/saml.ts}. + * This only applies to SAML SSO connectors. + */ +const providerPropertiesGuard = z.object({ + assertionConsumerServiceUrl: z.string().min(1), + entityId: z.string().min(1), +}); + +function BasicInfo({ ssoConnectorId, providerName, providerProperties }: Props) { + const { tenantEndpoint } = useContext(AppDataContext); + const { applyDomain: applyCustomDomain } = useCustomDomain(); + + if (providerName === SsoProviderName.OIDC) { + return ( +
+ + {/* Generated and passed in by Admin console. */} + + +
+ ); + } + + const result = providerPropertiesGuard.safeParse(providerProperties); + + /** + * Used fallback to show the default value anyways but the cases should not happen. + * TODO: @darcyYe refactor to remove the manual guard. + */ + return ( +
+ + + + + + +
+ ); +} + +export default BasicInfo; diff --git a/packages/console/src/pages/EnterpriseSso/Guide/OidcMetadataForm/index.tsx b/packages/console/src/pages/EnterpriseSso/Guide/OidcMetadataForm/index.tsx new file mode 100644 index 000000000..f3898baf4 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/OidcMetadataForm/index.tsx @@ -0,0 +1,29 @@ +import { useFormContext } from 'react-hook-form'; + +import FormField from '@/ds-components/FormField'; +import TextInput from '@/ds-components/TextInput'; + +import { type OidcGuideFormType } from '../../types.js'; + +function OidcMetadataForm() { + const { register } = useFormContext(); + + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export default OidcMetadataForm; diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.module.scss b/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.module.scss new file mode 100644 index 000000000..957cdf7d7 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.module.scss @@ -0,0 +1,39 @@ +@use '@/scss/underscore' as _; + +.copyToClipboard { + display: block; +} + +.table { + width: 100%; +} + +.row { + width: 100%; + display: flex; + flex-direction: row; + gap: _.unit(4); + padding-bottom: _.unit(3); + + > td, + th { + width: calc((100% - _.unit(4)) / 2); + } +} + +.header { + width: 100%; + font: var(--font-title-3); + + > tr { + padding-bottom: _.unit(3); + } + + * > th { + text-align: left; + } +} + +.body { + width: 100%; +} diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.tsx b/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.tsx new file mode 100644 index 000000000..a1996f92e --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlAttributeMapping/index.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import DynamicT from '@/ds-components/DynamicT'; +import TextInput from '@/ds-components/TextInput'; + +import { attributeKeys, type SamlGuideFormType, type AttributeMapping } from '../../types.js'; + +import * as styles from './index.module.scss'; + +type Props = { + isReadOnly?: boolean; +}; + +const primaryKey = 'attributeMapping'; + +function SamlAttributeMapping({ isReadOnly }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { watch, register } = useFormContext(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const attributeMapping = watch(primaryKey) ?? {}; + const attributeMappingEntries = useMemo< + Array<[keyof AttributeMapping, string | undefined]> + >(() => { + return attributeKeys.map((key) => [key, attributeMapping[key] ?? '']); + }, [attributeMapping]); + + return ( +
+ + + + + + + + + {attributeMappingEntries.map(([key, value]) => { + return ( + + + + + ); + })} + +
+ + + +
+ + + {isReadOnly ? ( + + ) : ( + + )} +
+
+ ); +} + +export default SamlAttributeMapping; diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.module.scss b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.module.scss new file mode 100644 index 000000000..c5543abc0 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.module.scss @@ -0,0 +1,35 @@ +@use '@/scss/underscore' as _; + +.dropdownIcon { + color: var(--color-text-link); +} + +.dropdown { + min-width: unset; +} + +.icon { + width: 20px; + height: 20px; + flex-shrink: 0; + color: transparent; + + &.selected { + color: var(--color-primary-40); + } +} + +.title { + display: flex; + align-items: center; + margin-left: _.unit(1); + + .optionText { + font: var(--font-body-2); + margin-left: _.unit(3); + } + + &.selected { + color: var(--color-primary-40); + } +} diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.tsx b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.tsx new file mode 100644 index 000000000..68b21d842 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/SwitchFormatButton/index.tsx @@ -0,0 +1,72 @@ +import { type AdminConsoleKey } from '@logto/phrases'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import SwitchArrowIcon from '@/assets/icons/switch-arrow.svg'; +import Tick from '@/assets/icons/tick.svg'; +import ActionMenu from '@/ds-components/ActionMenu'; +import { DropdownItem } from '@/ds-components/Dropdown'; +import DynamicT from '@/ds-components/DynamicT'; + +import * as styles from './index.module.scss'; + +type Props = { + value: FormFormat; + onChange: (formFormat: FormFormat) => void; +}; + +export enum FormFormat { + Url = 'url', + Xml = 'xml', + Manual = 'manual', +} + +type Options = { + value: FormFormat; + title: AdminConsoleKey; +}; + +const options: Options[] = [ + { value: FormFormat.Url, title: 'enterprise_sso.metadata.metadata_format_url' }, + { value: FormFormat.Xml, title: 'enterprise_sso.metadata.metadata_format_xml' }, + { value: FormFormat.Manual, title: 'enterprise_sso.metadata.metadata_format_manual' }, +]; + +function SwitchFormatButton({ value, onChange }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + return ( + , + }} + dropdownHorizontalAlign="start" + dropdownClassName={styles.dropdown} + title={t('enterprise_sso.metadata.dropdown_title')} + > + {options.map(({ value: optionValue, title }) => ( + { + onChange(optionValue); + }} + > + +
+ +
+
+ ))} +
+ ); +} + +export default SwitchFormatButton; diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.module.scss b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.module.scss new file mode 100644 index 000000000..c1044d7e7 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.module.scss @@ -0,0 +1,13 @@ +@use '@/scss/underscore' as _; + +.description { + color: var(--color-text-secondary); + font: var(--font-body-2); + margin-top: _.unit(0.5); +} + +.samlMetadataForm { + > div:not(:first-child) { + margin-top: _.unit(6); + } +} diff --git a/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.tsx b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.tsx new file mode 100644 index 000000000..b10071e61 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSso/Guide/SamlMetadataForm/index.tsx @@ -0,0 +1,108 @@ +import { conditional, pick } from '@silverhand/essentials'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import FormField from '@/ds-components/FormField'; +import TextInput from '@/ds-components/TextInput'; +import Textarea from '@/ds-components/Textarea'; + +import { type SamlGuideFormType } from '../../types.js'; + +import SwitchFormatButton, { FormFormat } from './SwitchFormatButton'; +import * as styles from './index.module.scss'; + +/** + * Since we need to reset some of the form fields when we switch the form format + * for SAML configuration form, here we define this object for convenience. + */ +const completeResetObject: Omit = { + metadata: undefined, + metadataUrl: undefined, + signInEndpoint: undefined, + entityId: undefined, + x509Certificate: undefined, +}; + +type Props = { + formFormat: FormFormat; +}; + +function SamlMetadataFormFields({ formFormat }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { register } = useFormContext(); + + switch (formFormat) { + case FormFormat.Manual: { + return ( + <> + + + + + + + +