0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): add SAML IdP settings page (#6853)

* feat(console): add SAML IdP settings page

add SAML IdP application settings page

* feat(console): add download link

add download link
This commit is contained in:
simeng-li 2024-12-05 15:35:52 +08:00 committed by GitHub
parent cd0d3577ee
commit d18422397e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 640 additions and 10 deletions

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5001 11.6667C17.2791 11.6667 17.0671 11.7545 16.9108 11.9108C16.7545 12.0671 16.6667 12.2791 16.6667 12.5001V15.8334C16.6667 16.0544 16.579 16.2664 16.4227 16.4227C16.2664 16.579 16.0544 16.6667 15.8334 16.6667H4.16675C3.94573 16.6667 3.73377 16.579 3.57749 16.4227C3.42121 16.2664 3.33341 16.0544 3.33341 15.8334V12.5001C3.33341 12.2791 3.24562 12.0671 3.08934 11.9108C2.93306 11.7545 2.7211 11.6667 2.50008 11.6667C2.27907 11.6667 2.06711 11.7545 1.91083 11.9108C1.75455 12.0671 1.66675 12.2791 1.66675 12.5001V15.8334C1.66675 16.4965 1.93014 17.1323 2.39898 17.6012C2.86782 18.07 3.50371 18.3334 4.16675 18.3334H15.8334C16.4965 18.3334 17.1323 18.07 17.6012 17.6012C18.07 17.1323 18.3334 16.4965 18.3334 15.8334V12.5001C18.3334 12.2791 18.2456 12.0671 18.0893 11.9108C17.9331 11.7545 17.7211 11.6667 17.5001 11.6667ZM9.40841 13.0917C9.48767 13.1676 9.58112 13.2271 9.68342 13.2667C9.78316 13.3108 9.89102 13.3336 10.0001 13.3336C10.1091 13.3336 10.217 13.3108 10.3167 13.2667C10.419 13.2271 10.5125 13.1676 10.5917 13.0917L13.9251 9.75842C14.082 9.6015 14.1702 9.38867 14.1702 9.16675C14.1702 8.94483 14.082 8.732 13.9251 8.57508C13.7682 8.41816 13.5553 8.33001 13.3334 8.33001C13.1115 8.33001 12.8987 8.41816 12.7417 8.57508L10.8334 10.4917V2.50008C10.8334 2.27907 10.7456 2.06711 10.5893 1.91083C10.4331 1.75455 10.2211 1.66675 10.0001 1.66675C9.77907 1.66675 9.56711 1.75455 9.41083 1.91083C9.25455 2.06711 9.16675 2.27907 9.16675 2.50008V10.4917L7.25842 8.57508C7.18072 8.49738 7.08847 8.43575 6.98696 8.3937C6.88544 8.35165 6.77663 8.33001 6.66675 8.33001C6.55687 8.33001 6.44806 8.35165 6.34654 8.3937C6.24502 8.43575 6.15278 8.49738 6.07508 8.57508C5.99738 8.65278 5.93575 8.74502 5.8937 8.84654C5.85165 8.94806 5.83001 9.05687 5.83001 9.16675C5.83001 9.27663 5.85165 9.38544 5.8937 9.48696C5.93575 9.58847 5.99738 9.68072 6.07508 9.75842L9.40841 13.0917Z" fill="#747778"/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -30,13 +30,14 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import Branding from '../components/Branding';
import Permissions from '../components/Permissions';
import BackchannelLogout from './BackchannelLogout';
import Branding from './Branding';
import EndpointsAndCredentials, { type ApplicationSecretRow } from './EndpointsAndCredentials';
import GuideDrawer from './GuideDrawer';
import MachineLogs from './MachineLogs';
import MachineToMachineApplicationRoles from './MachineToMachineApplicationRoles';
import Permissions from './Permissions';
import RefreshTokenSettings from './RefreshTokenSettings';
import Settings from './Settings';
import styles from './index.module.scss';

View file

@ -0,0 +1,3 @@
.icon {
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,39 @@
import { type SamlApplicationSecretResponse } from '@logto/schemas';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Download from '@/assets/icons/download.svg?react';
import More from '@/assets/icons/more.svg?react';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
import { downloadText } from '@/utils/downloader';
import { buildSamlSigningCertificateFilename } from '../utils';
import styles from './index.module.scss';
type Props = {
readonly appId: string;
readonly secret: SamlApplicationSecretResponse;
};
function CertificateActionMenu({ secret: { id, certificate }, appId }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const onDownload = useCallback(() => {
downloadText(
certificate,
buildSamlSigningCertificateFilename(appId, id),
'application/x-x509-ca-cert'
);
}, [appId, certificate, id]);
return (
<ActionMenu icon={<More className={styles.icon} />} title={t('general.more_options')}>
<ActionMenuItem iconClassName={styles.icon} icon={<Download />} onClick={onDownload}>
{t('general.download')}
</ActionMenuItem>
</ActionMenu>
);
}
export default CertificateActionMenu;

View file

@ -0,0 +1,206 @@
import { type SamlApplicationSecretResponse, type SamlApplicationResponse } from '@logto/schemas';
import { appendPath, type Nullable } from '@silverhand/essentials';
import { useContext } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR, { type KeyedMutator } from 'swr';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { AppDataContext } from '@/contexts/AppDataProvider';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import Table from '@/ds-components/Table';
import TextInput from '@/ds-components/TextInput';
import useApi, { type RequestError } from '@/hooks/use-api';
import useCustomDomain from '@/hooks/use-custom-domain';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import { useSecretTableColumns } from './use-secret-table-columns';
import {
parseFormDataToSamlApplicationRequest,
parseSamlApplicationResponseToFormData,
samlApplicationEndpointPrefix,
samlApplicationManagementApiPrefix,
samlApplicationMetadataEndpointSuffix,
samlApplicationSingleSignOnEndpointSuffix,
} from './utils';
export type SamlApplicationFormData = Pick<
SamlApplicationResponse,
'id' | 'description' | 'name' | 'entityId'
> & {
// Currently we only support HTTP-POST binding
// Keep the acsUrl as a string in the form data instead of the object
acsUrl: Nullable<string>;
};
type Props = {
readonly data: SamlApplicationResponse;
readonly mutateApplication: KeyedMutator<SamlApplicationResponse>;
readonly isDeleted: boolean;
};
function Settings({ data, mutateApplication, isDeleted }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tenantEndpoint } = useContext(AppDataContext);
const { applyDomain: applyCustomDomain } = useCustomDomain();
const secrets = useSWR<SamlApplicationSecretResponse[], RequestError>(
`api/saml-applications/${data.id}/secrets`
);
const {
register,
handleSubmit,
reset,
formState: { isDirty, isSubmitting, errors },
} = useForm<SamlApplicationFormData>({
defaultValues: parseSamlApplicationResponseToFormData(data),
mode: 'onBlur',
});
const api = useApi();
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
if (isSubmitting) {
return;
}
const { id, payload } = parseFormDataToSamlApplicationRequest(formData);
const updated = await api
.patch(`api/saml-applications/${id}`, { json: payload })
.json<SamlApplicationResponse>();
reset(parseSamlApplicationResponseToFormData(updated));
void mutateApplication(updated);
toast.success(t('general.saved'));
})
);
const secretTableColumns = useSecretTableColumns({ appId: data.id });
return (
<>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<FormCard
title="application_details.settings"
description="application_details.settings_description"
>
<FormField isRequired title="application_details.application_name">
<TextInput
{...register('name', {
required: t('errors.required_field_missing', {
field: t('application_details.application_name'),
}),
})}
error={errors.name?.message}
placeholder={t('application_details.application_name_placeholder')}
/>
</FormField>
<FormField title="application_details.description">
<TextInput
{...register('description')}
placeholder={t('application_details.description_placeholder')}
/>
</FormField>
<FormField isRequired title="enterprise_sso.basic_info.saml.acs_url_field_name">
<TextInput
{...register('acsUrl', {
required: t('errors.required_field_missing', {
field: t('enterprise_sso.basic_info.saml.acs_url_field_name'),
}),
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={Boolean(errors.acsUrl)}
placeholder={t('enterprise_sso.basic_info.saml.acs_url_field_name')}
/>
</FormField>
<FormField isRequired title="enterprise_sso.basic_info.saml.audience_uri_field_name">
<TextInput
{...register('entityId', {
required: t('errors.required_field_missing', {
field: t('enterprise_sso.basic_info.saml.audience_uri_field_name'),
}),
})}
error={Boolean(errors.entityId)}
placeholder={t('enterprise_sso.basic_info.saml.audience_uri_field_name')}
/>
</FormField>
</FormCard>
<FormCard
title="application_details.saml_idp_config.title"
description="application_details.saml_idp_config.description"
>
{tenantEndpoint && (
<>
<FormField title="application_details.saml_idp_config.metadata_url_label">
<CopyToClipboard
displayType="block"
value={applyCustomDomain(
appendPath(
tenantEndpoint,
samlApplicationManagementApiPrefix,
data.id,
samlApplicationMetadataEndpointSuffix
).href
)}
variant="border"
/>
</FormField>
<FormField title="application_details.saml_idp_config.single_sign_on_service_url_label">
<CopyToClipboard
displayType="block"
value={applyCustomDomain(
appendPath(
tenantEndpoint,
samlApplicationEndpointPrefix,
data.id,
samlApplicationSingleSignOnEndpointSuffix
).href
)}
variant="border"
/>
</FormField>
<FormField title="application_details.saml_idp_config.idp_entity_id_label">
<CopyToClipboard
displayType="block"
value={applyCustomDomain(
appendPath(tenantEndpoint, samlApplicationEndpointPrefix, data.id).href
)}
variant="border"
/>
</FormField>
</>
)}
<FormField title="application_details.saml_idp_certificates.title">
<Table
hasBorder
isRowHoverEffectDisabled
rowIndexKey="id"
isLoading={!secrets.data && !secrets.error}
errorMessage={secrets.error?.body?.message ?? secrets.error?.message}
rowGroups={[{ key: 'application_secrets', data: secrets.data ?? [] }]}
columns={secretTableColumns}
/>
</FormField>
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
</>
);
}
export default Settings;

View file

@ -0,0 +1,32 @@
@use '@/scss/underscore' as _;
.deleteConfirm {
> :not(:first-child) {
margin-top: _.unit(6);
}
.description {
font: var(--font-body-2);
}
.highlight {
color: var(--color-primary-50);
}
}
.tabContainer {
flex-direction: column;
flex-grow: 1;
&[data-active='true'] {
display: flex;
}
}
.expired {
color: var(--color-placeholder);
}
.fingerPrint {
word-break: break-all;
}

View file

@ -0,0 +1,171 @@
import {
type ApplicationResponse,
ApplicationType,
type SamlApplicationResponse,
} from '@logto/schemas';
import { useCallback, useState } from 'react';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import Delete from '@/assets/icons/delete.svg?react';
import ApplicationIcon from '@/components/ApplicationIcon';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Skeleton from '@/components/FormCard/Skeleton';
import RequestDataError from '@/components/RequestDataError';
import { ApplicationDetailsTabs } from '@/consts';
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import TabWrapper from '@/ds-components/TabWrapper';
import useApi, { type RequestError } from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import Branding from '../components/Branding';
import Permissions from '../components/Permissions';
import Settings from './Settings';
import styles from './index.module.scss';
type SamlApplication = Omit<ApplicationResponse, 'type'> & {
type: ApplicationType.SAML;
};
export const isSamlApplication = (data: ApplicationResponse): data is SamlApplication =>
data.type === ApplicationType.SAML;
type Props = {
readonly data: SamlApplication;
};
function SamlApplicationDetailsContent({ data }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tab } = useParams();
const { navigate } = useTenantPathname();
const {
data: samlApplicationData,
error: samlApplicationError,
mutate: mutateSamlApplication,
} = useSWR<SamlApplicationResponse, RequestError>(`api/saml-applications/${data.id}`);
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const isLoading = !samlApplicationData && !samlApplicationError;
const onDelete = useCallback(async () => {
setIsDeleting(true);
try {
await api.delete(`api/saml-applications/${data.id}`);
setIsDeleted(true);
setIsDeleteFormOpen(false);
toast.success(
t('application_details.application_deleted', { name: samlApplicationData?.name })
);
navigate(
samlApplicationData?.isThirdParty
? '/applications/third-party-applications'
: '/applications'
);
} finally {
setIsDeleting(false);
}
}, [api, data.id, navigate, samlApplicationData?.isThirdParty, samlApplicationData?.name, t]);
if (isLoading) {
return <Skeleton />;
}
if (samlApplicationError) {
return (
<RequestDataError
error={samlApplicationError}
onRetry={() => {
void mutateSamlApplication();
}}
/>
);
}
return (
<>
<DetailsPageHeader
icon={<ApplicationIcon type={data.type} isThirdParty={data.isThirdParty} />}
title={data.name}
primaryTag={t(`${applicationTypeI18nKey.thirdParty}.title`)}
identifier={{ name: 'App ID', value: data.id }}
actionMenuItems={[
{
type: 'danger',
title: 'general.delete',
icon: <Delete />,
onClick: () => {
setIsDeleteFormOpen(true);
},
},
]}
/>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Settings}`}>
{t('application_details.settings')}
</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>
</TabNav>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Settings}
className={styles.tabContainer}
>
{samlApplicationData && (
<Settings
data={samlApplicationData}
mutateApplication={mutateSamlApplication}
isDeleted={isDeleted}
/>
)}
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Permissions}
className={styles.tabContainer}
>
<Permissions application={data} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Branding}
className={styles.tabContainer}
>
{/* isActive is needed to support conditional render UnsavedChangesAlertModal */}
<Branding application={data} isActive={tab === ApplicationDetailsTabs.Branding} />
</TabWrapper>
</>
);
}
export default SamlApplicationDetailsContent;

View file

@ -0,0 +1,85 @@
import { type SamlApplicationSecretResponse } from '@logto/schemas';
import { compareDesc } from 'date-fns';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { LocaleDateTime } from '@/components/DateTime';
import { type Column } from '@/ds-components/Table/types';
import Tag from '@/ds-components/Tag';
import { Tooltip } from '@/ds-components/Tip';
import CertificateActionMenu from './CertificateActionMenu';
import styles from './index.module.scss';
const isExpired = (expiresAt: Date | number) => compareDesc(expiresAt, new Date()) === 1;
function Expired({ expiresAt }: { readonly expiresAt: Date }) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<Tooltip
content={t('application_details.secrets.expired_tooltip', {
date: expiresAt.toLocaleString(),
})}
>
<span className={styles.expired}>{t('application_details.secrets.expired')}</span>
</Tooltip>
);
}
type UseSecretTableColumns = {
appId: string;
};
export const useSecretTableColumns = ({ appId }: UseSecretTableColumns) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tableColumns: Array<Column<SamlApplicationSecretResponse>> = useMemo(
() => [
{
title: t('application_details.saml_idp_certificates.expires_at'),
dataIndex: 'expiresAt',
colSpan: 3,
render: ({ expiresAt }) => (
<span>
{isExpired(expiresAt) ? (
<Expired expiresAt={new Date(expiresAt)} />
) : (
<LocaleDateTime>{expiresAt}</LocaleDateTime>
)}
</span>
),
},
{
title: t('application_details.saml_idp_certificates.finger_print'),
dataIndex: 'fingerPrint',
colSpan: 5,
render: ({ fingerprints }) => (
<span className={styles.fingerPrint}>{fingerprints.sha256.unformatted}</span>
),
},
{
title: t('application_details.saml_idp_certificates.status'),
dataIndex: 'status',
render: ({ active }) => (
<Tag type="state" status={active ? 'success' : 'info'} variant="plain">
{t(
active
? 'application_details.saml_idp_certificates.active'
: 'application_details.saml_idp_certificates.inactive'
)}
</Tag>
),
},
{
title: '',
dataIndex: 'actions',
render: (secret) => {
return <CertificateActionMenu secret={secret} appId={appId} />;
},
},
],
[appId, t]
);
return tableColumns;
};

View file

@ -0,0 +1,52 @@
import {
BindingType,
type PatchSamlApplication,
type SamlApplicationResponse,
} from '@logto/schemas';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { type SamlApplicationFormData } from './Settings';
export const parseSamlApplicationResponseToFormData = (
data: SamlApplicationResponse
): SamlApplicationFormData => {
const { id, description, name, entityId, acsUrl } = data;
return {
id,
description,
name,
entityId,
acsUrl: acsUrl?.url ?? null,
};
};
export const parseFormDataToSamlApplicationRequest = (
data: SamlApplicationFormData
): {
id: string;
payload: PatchSamlApplication;
} => {
const { id, description, name, entityId, acsUrl } = data;
// If acsUrl value is empty string, it should be removed. Convert it to null.
const acsUrlData = acsUrl ? { url: acsUrl, binding: BindingType.Post } : null;
return {
id,
payload: removeUndefinedKeys({
description,
name,
entityId,
acsUrl: acsUrlData,
}),
};
};
export const buildSamlSigningCertificateFilename = (appId: string, id: string) =>
`${appId}-saml-certificate-${id}`;
export const samlApplicationManagementApiPrefix = '/api/saml-applications';
export const samlApplicationEndpointPrefix = '/saml';
export const samlApplicationMetadataEndpointSuffix = 'metadata';
export const samlApplicationSingleSignOnEndpointSuffix = 'authn';

View file

@ -12,6 +12,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import ApplicationDetailsContent from './ApplicationDetailsContent';
import { type ApplicationSecretRow } from './ApplicationDetailsContent/EndpointsAndCredentials';
import GuideModal from './GuideModal';
import SamlApplicationDetailsContent, { isSamlApplication } from './SamlApplicationDetailsContent';
function ApplicationDetails() {
const { id, guideId } = useParams();
@ -60,14 +61,19 @@ function ApplicationDetails() {
}}
>
<PageMeta titleKey="application_details.page_title" />
{data && oidcConfig.data && secrets.data && (
<ApplicationDetailsContent
data={data}
oidcConfig={oidcConfig.data}
secrets={secrets.data}
onApplicationUpdated={mutate}
/>
)}
{data &&
oidcConfig.data &&
secrets.data &&
(isSamlApplication(data) ? (
<SamlApplicationDetailsContent data={data} />
) : (
<ApplicationDetailsContent
data={data}
oidcConfig={oidcConfig.data}
secrets={secrets.data}
onApplicationUpdated={mutate}
/>
))}
</DetailsPage>
);
}

View file

@ -0,0 +1,15 @@
export const downloadText = (
text: string,
filename: string,
type: BlobPropertyBag['type'] = 'text/plain'
) => {
const blob = new Blob([text], { type });
const downloadLink = document.createElement('a');
// eslint-disable-next-line @silverhand/fp/no-mutation
downloadLink.href = URL.createObjectURL(blob);
// eslint-disable-next-line @silverhand/fp/no-mutation
downloadLink.download = filename;
downloadLink.click();
downloadLink.remove();
window.URL.revokeObjectURL(downloadLink.href);
};

View file

@ -198,6 +198,22 @@ const application_details = {
edited: 'The secret {{name}} has been successfully edited.',
},
},
saml_idp_config: {
title: 'SAML IdP metadata',
description:
'Use the following metadata and certificate to configure the SAML IdP in your application.',
metadata_url_label: 'IdP metadata URL',
single_sign_on_service_url_label: 'Single sign-on service URL',
idp_entity_id_label: 'IdP entity ID',
},
saml_idp_certificates: {
title: 'SAML signing certificate',
expires_at: 'Expires at',
finger_print: 'Fingerprint',
status: 'Status',
active: 'Active',
inactive: 'Inactive',
},
};
export default Object.freeze(application_details);

View file

@ -13,6 +13,7 @@ const general = {
save_changes: 'Save changes',
saved: 'Saved',
discard: 'Discard',
download: 'Download',
loading: 'Loading...',
redirecting: 'Redirecting...',
add: 'Add',