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:
parent
cd0d3577ee
commit
d18422397e
32 changed files with 640 additions and 10 deletions
3
packages/console/src/assets/icons/download.svg
Normal file
3
packages/console/src/assets/icons/download.svg
Normal 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 |
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
15
packages/console/src/utils/downloader.ts
Normal file
15
packages/console/src/utils/downloader.ts
Normal 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);
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -13,6 +13,7 @@ const general = {
|
|||
save_changes: 'Save changes',
|
||||
saved: 'Saved',
|
||||
discard: 'Discard',
|
||||
download: 'Download',
|
||||
loading: 'Loading...',
|
||||
redirecting: 'Redirecting...',
|
||||
add: 'Add',
|
||||
|
|
Loading…
Reference in a new issue