mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(console): add idp-initiated sso config page (#6688)
* feat(console): add idp-initiated SSO auth config page add idp-initiated SSO auth config page * fix(console): fix PR comments fix PR comments
This commit is contained in:
parent
cfc1b5eb0f
commit
64d7b6a4f3
11 changed files with 622 additions and 3 deletions
|
@ -47,6 +47,7 @@ export enum TenantSettingsTabs {
|
|||
export enum EnterpriseSsoDetailsTabs {
|
||||
Connection = 'connection',
|
||||
Experience = 'experience',
|
||||
IdpInitiatedAuth = 'idp-initiated-auth',
|
||||
}
|
||||
|
||||
export enum OrganizationTemplateTabs {
|
||||
|
|
|
@ -167,3 +167,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-error);
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ type Props<T> = {
|
|||
readonly options: Array<Option<T>>;
|
||||
readonly onChange?: (value?: T) => void;
|
||||
readonly isReadOnly?: boolean;
|
||||
readonly error?: string | boolean;
|
||||
readonly error?: ReactNode;
|
||||
readonly placeholder?: ReactNode;
|
||||
readonly isClearable?: boolean;
|
||||
readonly size?: 'small' | 'medium' | 'large';
|
||||
|
@ -150,6 +150,9 @@ function Select<T extends string>({
|
|||
{isOpen ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
|
||||
</div>
|
||||
</div>
|
||||
{Boolean(error) && typeof error !== 'boolean' && (
|
||||
<div className={styles.errorMessage}>{error}</div>
|
||||
)}
|
||||
<Dropdown
|
||||
isFullWidth
|
||||
anchorRef={anchorRef}
|
||||
|
|
|
@ -0,0 +1,310 @@
|
|||
import {
|
||||
type Application,
|
||||
type SsoConnectorWithProviderConfig,
|
||||
ApplicationType,
|
||||
type SsoConnectorIdpInitiatedAuthConfig,
|
||||
} from '@logto/schemas';
|
||||
import { type ReactElement, useEffect, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { type KeyedMutator } from 'swr';
|
||||
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import CodeEditor from '@/ds-components/CodeEditor';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
|
||||
import Select from '@/ds-components/Select';
|
||||
import Switch from '@/ds-components/Switch';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { uriValidator } from '@/utils/validator';
|
||||
|
||||
import styles from './index.module.scss';
|
||||
import {
|
||||
buildIdpInitiatedAuthConfigEndpoint,
|
||||
type IdpInitiatedAuthConfigFormData,
|
||||
parseResponseToFormData,
|
||||
parseFormDataToRequestPayload,
|
||||
} from './utils';
|
||||
|
||||
type FormProps = {
|
||||
readonly ssoConnector: SsoConnectorWithProviderConfig;
|
||||
readonly applications: Application[];
|
||||
readonly idpInitiatedAuthConfig: SsoConnectorIdpInitiatedAuthConfig | undefined;
|
||||
readonly mutateIdpInitiatedConfig: KeyedMutator<SsoConnectorIdpInitiatedAuthConfig>;
|
||||
};
|
||||
|
||||
function ConfigForm({
|
||||
ssoConnector,
|
||||
applications,
|
||||
idpInitiatedAuthConfig,
|
||||
mutateIdpInitiatedConfig,
|
||||
}: FormProps) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { getTo } = useTenantPathname();
|
||||
const api = useApi();
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { isDirty, isSubmitting, errors },
|
||||
reset,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
watch,
|
||||
} = useForm<IdpInitiatedAuthConfigFormData>({
|
||||
defaultValues: parseResponseToFormData(idpInitiatedAuthConfig),
|
||||
});
|
||||
|
||||
const isIdpInitiatedSsoEnabled = watch('isIdpInitiatedSsoEnabled');
|
||||
const defaultApplicationId = watch('config.defaultApplicationId');
|
||||
const autoSendAuthorizationRequest = watch('config.autoSendAuthorizationRequest');
|
||||
|
||||
const defaultApplication = useMemo(() => {
|
||||
return applications.find((application) => application.id === defaultApplicationId);
|
||||
}, [applications, defaultApplicationId]);
|
||||
|
||||
const emptyApplicationsError = useMemo<ReactElement | undefined>(() => {
|
||||
if (applications.length === 0) {
|
||||
return (
|
||||
<Trans
|
||||
components={{
|
||||
a: <Link className={styles.inlineLink} to={getTo('/applications')} />,
|
||||
}}
|
||||
>
|
||||
{t('enterprise_sso_details.idp_initiated_auth_config.empty_applications_error')}
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
}, [applications, getTo, t]);
|
||||
|
||||
const defaultApplicationRedirectUris = useMemo(
|
||||
() => defaultApplication?.oidcClientMetadata.redirectUris ?? [],
|
||||
[defaultApplication]
|
||||
);
|
||||
|
||||
const emptyRedirectUrisError = useMemo(() => {
|
||||
if (defaultApplication && defaultApplicationRedirectUris.length === 0) {
|
||||
return t('enterprise_sso_details.idp_initiated_auth_config.empty_redirect_uris_error');
|
||||
}
|
||||
}, [defaultApplication, defaultApplicationRedirectUris.length, t]);
|
||||
|
||||
// Force set autoSendAuthorizationRequest to false if the default application is set to SPA
|
||||
useEffect(() => {
|
||||
if (defaultApplication?.type === ApplicationType.SPA) {
|
||||
setValue('config.autoSendAuthorizationRequest', false);
|
||||
}
|
||||
}, [defaultApplication?.type, setValue]);
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (data) => {
|
||||
const { config, isIdpInitiatedSsoEnabled } = data;
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIdpInitiatedSsoEnabled || !config) {
|
||||
await api.delete(buildIdpInitiatedAuthConfigEndpoint(ssoConnector.id));
|
||||
await mutateIdpInitiatedConfig();
|
||||
toast.success(t('general.saved'));
|
||||
reset(parseResponseToFormData());
|
||||
return;
|
||||
}
|
||||
|
||||
const result = parseFormDataToRequestPayload(config);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
const payload = result.data;
|
||||
|
||||
const updated = await api
|
||||
.put(buildIdpInitiatedAuthConfigEndpoint(ssoConnector.id), { json: payload })
|
||||
.json<SsoConnectorIdpInitiatedAuthConfig>();
|
||||
await mutateIdpInitiatedConfig(updated);
|
||||
toast.success(t('general.saved'));
|
||||
reset(parseResponseToFormData(updated));
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<FormCard
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.card_title"
|
||||
description="enterprise_sso_details.idp_initiated_auth_config.card_description"
|
||||
>
|
||||
<FormField title="enterprise_sso_details.idp_initiated_auth_config.enable_idp_initiated_sso">
|
||||
<Switch
|
||||
required
|
||||
description="enterprise_sso_details.idp_initiated_auth_config.enable_idp_initiated_sso_description"
|
||||
{...register('isIdpInitiatedSsoEnabled')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isIdpInitiatedSsoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
isRequired
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.default_application"
|
||||
tip={t(
|
||||
'enterprise_sso_details.idp_initiated_auth_config.default_application_tooltip'
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="config.defaultApplicationId"
|
||||
rules={{
|
||||
required: t('errors.required_field_missing', {
|
||||
field: t(
|
||||
'enterprise_sso_details.idp_initiated_auth_config.default_application'
|
||||
),
|
||||
}),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={applications.map((application) => ({
|
||||
value: application.id,
|
||||
title: `${application.name} (${application.type}, ID: ${application.id})`,
|
||||
}))}
|
||||
value={value}
|
||||
error={emptyApplicationsError ?? errors.config?.defaultApplicationId?.message}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="enterprise_sso_details.idp_initiated_auth_config.authentication_type">
|
||||
<Controller
|
||||
control={control}
|
||||
name="config.autoSendAuthorizationRequest"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RadioGroup
|
||||
className={styles.radioGroup}
|
||||
name="autoSendAuthorizationRequest"
|
||||
value={value ? 'true' : 'false'}
|
||||
type="card"
|
||||
onChange={(value: string) => {
|
||||
onChange(value === 'true');
|
||||
}}
|
||||
>
|
||||
{[false, true].map((value) => (
|
||||
<Radio
|
||||
key={String(value)}
|
||||
className={styles.radioElement}
|
||||
value={String(value)}
|
||||
isDisabled={value && defaultApplication?.type === ApplicationType.SPA}
|
||||
>
|
||||
<div className={styles.radioCardTitle}>
|
||||
{t(
|
||||
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
|
||||
value ? 'enabled' : 'disabled'
|
||||
}_title`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
|
||||
value ? 'enabled' : 'disabled'
|
||||
}_description`
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.radioCardFooter}>
|
||||
{t(
|
||||
`enterprise_sso_details.idp_initiated_auth_config.auto_authentication_${
|
||||
value ? 'enabled' : 'disabled'
|
||||
}_app`
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
{/** Client redirect flow */}
|
||||
{!autoSendAuthorizationRequest && (
|
||||
<FormField
|
||||
isRequired
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri"
|
||||
tip={t(
|
||||
'enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri_tooltip'
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
{...register('config.clientIdpInitiatedAuthCallbackUri', {
|
||||
required: t('errors.required_field_missing', {
|
||||
field: t(
|
||||
'enterprise_sso_details.idp_initiated_auth_config.idp_initiated_auth_callback_uri'
|
||||
),
|
||||
}),
|
||||
validate: (value) =>
|
||||
!value || uriValidator(value) || t('errors.invalid_uri_format'),
|
||||
})}
|
||||
required
|
||||
placeholder="Redirect URI"
|
||||
error={errors.config?.clientIdpInitiatedAuthCallbackUri?.message}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{/** IdP-initiated direct sign-in flow */}
|
||||
{autoSendAuthorizationRequest && (
|
||||
<>
|
||||
<FormField
|
||||
isRequired
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.redirect_uri"
|
||||
tip={t('enterprise_sso_details.idp_initiated_auth_config.redirect_uri_tooltip')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="config.redirectUri"
|
||||
rules={{
|
||||
required: t('errors.required_field_missing', {
|
||||
field: t('enterprise_sso_details.idp_initiated_auth_config.redirect_uri'),
|
||||
}),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={defaultApplicationRedirectUris.map((uri) => ({
|
||||
value: uri,
|
||||
title: uri,
|
||||
}))}
|
||||
value={value}
|
||||
error={emptyRedirectUrisError ?? errors.config?.redirectUri?.message}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.auth_params"
|
||||
tip={t('enterprise_sso_details.idp_initiated_auth_config.auth_params_tooltip')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="config.authParameters"
|
||||
defaultValue="{}"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CodeEditor language="json" value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigForm;
|
|
@ -0,0 +1,37 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: _.unit(3);
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.radioCardTitle {
|
||||
font: var(--font-label-2);
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
|
||||
.radioCardFooter {
|
||||
margin-top: _.unit(4);
|
||||
font: var(--font-body-3);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.inlineLink {
|
||||
text-decoration: none;
|
||||
color: var(--color-text-link);
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
color: var(--color-primary-pressed);
|
||||
}
|
||||
}
|
||||
|
||||
div[class$='disabled'] {
|
||||
.radioCardFooter {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { type Application, type SsoConnectorWithProviderConfig } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import FormCard, { FormCardSkeleton } from '@/components/FormCard';
|
||||
import { type RequestError } from '@/hooks/use-api';
|
||||
|
||||
import ConfigForm from './ConfigForm';
|
||||
import useIdpInitiatedAuthConfigSWR from './use-idp-initiated-auth-config-swr';
|
||||
import { applicationsSearchUrl } from './utils';
|
||||
|
||||
type Props = {
|
||||
readonly ssoConnector: SsoConnectorWithProviderConfig;
|
||||
};
|
||||
|
||||
function IdpInitiatedAuth({ ssoConnector }: Props) {
|
||||
const { data: applications, error: applicationError } = useSWR<Application[], RequestError>(
|
||||
applicationsSearchUrl
|
||||
);
|
||||
|
||||
const {
|
||||
data: idpInitiatedAuthConfig,
|
||||
mutate,
|
||||
error: idpInitiatedAuthConfigError,
|
||||
} = useIdpInitiatedAuthConfigSWR(ssoConnector.id);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() =>
|
||||
!applications && !applicationError && !idpInitiatedAuthConfig && !idpInitiatedAuthConfigError,
|
||||
[applicationError, applications, idpInitiatedAuthConfig, idpInitiatedAuthConfigError]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<FormCard
|
||||
title="enterprise_sso_details.idp_initiated_auth_config.card_title"
|
||||
description="enterprise_sso_details.idp_initiated_auth_config.card_description"
|
||||
>
|
||||
<FormCardSkeleton />
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigForm
|
||||
ssoConnector={ssoConnector}
|
||||
applications={applications ?? []}
|
||||
idpInitiatedAuthConfig={idpInitiatedAuthConfig}
|
||||
mutateIdpInitiatedConfig={mutate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default IdpInitiatedAuth;
|
|
@ -0,0 +1,27 @@
|
|||
import { type SsoConnectorIdpInitiatedAuthConfig } from '@logto/schemas';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import useSwrFetcher from '@/hooks/use-swr-fetcher';
|
||||
import { shouldRetryOnError } from '@/utils/request';
|
||||
|
||||
import { buildIdpInitiatedAuthConfigEndpoint } from './utils';
|
||||
|
||||
/**
|
||||
* Silently fetches the IdP initiated auth config for the given connector
|
||||
* Hide error toast.
|
||||
*/
|
||||
const useIdpInitiatedAuthConfigSWR = (connectorId: string) => {
|
||||
const fetchApi = useApi({ hideErrorToast: true });
|
||||
const fetcher = useSwrFetcher<SsoConnectorIdpInitiatedAuthConfig>(fetchApi);
|
||||
|
||||
return useSWR<SsoConnectorIdpInitiatedAuthConfig, RequestError>(
|
||||
buildIdpInitiatedAuthConfigEndpoint(connectorId),
|
||||
{
|
||||
fetcher,
|
||||
shouldRetryOnError: shouldRetryOnError({ ignore: [404] }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default useIdpInitiatedAuthConfigSWR;
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
ApplicationType,
|
||||
SsoConnectorIdpInitiatedAuthConfigs,
|
||||
type CreateSsoConnectorIdpInitiatedAuthConfig,
|
||||
type SsoConnectorIdpInitiatedAuthConfig,
|
||||
} from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const applicationsSearchParams = new URLSearchParams([
|
||||
['types', ApplicationType.Traditional],
|
||||
['types', ApplicationType.SPA],
|
||||
['isThirdParty', 'false'],
|
||||
]);
|
||||
|
||||
export const applicationsSearchUrl = `api/applications?${applicationsSearchParams.toString()}`;
|
||||
|
||||
export const buildIdpInitiatedAuthConfigEndpoint = (connectorId: string) =>
|
||||
`api/sso-connectors/${connectorId}/idp-initiated-auth-config`;
|
||||
|
||||
type IdpInitiatedAuthConfigData = Pick<
|
||||
SsoConnectorIdpInitiatedAuthConfig,
|
||||
| 'defaultApplicationId'
|
||||
| 'autoSendAuthorizationRequest'
|
||||
| 'redirectUri'
|
||||
| 'clientIdpInitiatedAuthCallbackUri'
|
||||
> & { authParameters: string };
|
||||
|
||||
const authParametersGuard = SsoConnectorIdpInitiatedAuthConfigs.createGuard.shape.authParameters;
|
||||
|
||||
export type IdpInitiatedAuthConfigFormData = {
|
||||
isIdpInitiatedSsoEnabled: boolean;
|
||||
config?: IdpInitiatedAuthConfigData;
|
||||
};
|
||||
|
||||
export const parseResponseToFormData = (
|
||||
response?: SsoConnectorIdpInitiatedAuthConfig
|
||||
): IdpInitiatedAuthConfigFormData => {
|
||||
if (!response) {
|
||||
return {
|
||||
isIdpInitiatedSsoEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
defaultApplicationId,
|
||||
autoSendAuthorizationRequest,
|
||||
redirectUri,
|
||||
clientIdpInitiatedAuthCallbackUri,
|
||||
authParameters,
|
||||
} = response;
|
||||
|
||||
return {
|
||||
isIdpInitiatedSsoEnabled: true,
|
||||
config: {
|
||||
defaultApplicationId,
|
||||
autoSendAuthorizationRequest,
|
||||
redirectUri,
|
||||
clientIdpInitiatedAuthCallbackUri,
|
||||
authParameters: JSON.stringify(authParameters, null, 2),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const safeParseAuthParameters = (authParameters: string) => {
|
||||
try {
|
||||
const data = authParametersGuard.parse(JSON.parse(authParameters));
|
||||
return { success: true, data };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
export const parseFormDataToRequestPayload = ({
|
||||
defaultApplicationId,
|
||||
autoSendAuthorizationRequest = false,
|
||||
clientIdpInitiatedAuthCallbackUri,
|
||||
redirectUri,
|
||||
authParameters,
|
||||
}: IdpInitiatedAuthConfigData):
|
||||
| {
|
||||
success: true;
|
||||
data: Partial<CreateSsoConnectorIdpInitiatedAuthConfig>;
|
||||
}
|
||||
| { success: false } => {
|
||||
// Directly sign in flow
|
||||
if (autoSendAuthorizationRequest) {
|
||||
const parsedAuthParameters = safeParseAuthParameters(authParameters);
|
||||
|
||||
if (!parsedAuthParameters.success) {
|
||||
toast.error(t('admin_console.errors.invalid_parameters_format'));
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
defaultApplicationId,
|
||||
autoSendAuthorizationRequest,
|
||||
redirectUri,
|
||||
authParameters: parsedAuthParameters.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Client redirect flow
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
defaultApplicationId,
|
||||
autoSendAuthorizationRequest,
|
||||
clientIdpInitiatedAuthCallbackUri,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,6 +1,10 @@
|
|||
import { type SignInExperience, type SsoConnectorWithProviderConfig } from '@logto/schemas';
|
||||
import {
|
||||
SsoProviderType,
|
||||
type SignInExperience,
|
||||
type SsoConnectorWithProviderConfig,
|
||||
} from '@logto/schemas';
|
||||
import { pick } from '@silverhand/essentials';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
|
@ -12,6 +16,8 @@ import Skeleton from '@/components/DetailsPage/Skeleton';
|
|||
import Drawer from '@/components/Drawer';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import { EnterpriseSsoDetailsTabs } from '@/consts';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||
import ConfirmModal from '@/ds-components/ConfirmModal';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
|
@ -22,6 +28,7 @@ import SsoConnectorLogo from '../EnterpriseSso/SsoConnectorLogo';
|
|||
|
||||
import Connection from './Connection';
|
||||
import Experience from './Experience';
|
||||
import IdpInitiatedAuth from './IdpInitiatedAuth';
|
||||
import SsoGuide from './SsoGuide';
|
||||
import { enterpriseSsoPathname } from './config';
|
||||
import styles from './index.module.scss';
|
||||
|
@ -35,6 +42,7 @@ function EnterpriseSsoDetails() {
|
|||
const { ssoConnectorId, tab } = useParams();
|
||||
|
||||
const { isDeleted, isDeleting, onDeleteHandler } = useDeleteConnector(ssoConnectorId);
|
||||
const { currentSubscriptionQuota } = useContext(SubscriptionDataContext);
|
||||
|
||||
const [isReadmeOpen, setIsReadmeOpen] = useState(false);
|
||||
|
||||
|
@ -59,6 +67,15 @@ function EnterpriseSsoDetails() {
|
|||
|
||||
const isDarkModeEnabled = signInExperience?.color.isDarkModeEnabled ?? false;
|
||||
|
||||
const isIdpInitiatedAuthEnabled = useMemo(
|
||||
() =>
|
||||
isCloud &&
|
||||
ssoConnector?.providerType === SsoProviderType.SAML &&
|
||||
// TODO: @simeng: Replace this with new IdP-initiated auth quota guard
|
||||
Boolean(currentSubscriptionQuota.enterpriseSsoLimit),
|
||||
[ssoConnector, currentSubscriptionQuota]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleteAlertOpen(false);
|
||||
}, [pathname]);
|
||||
|
@ -136,6 +153,16 @@ function EnterpriseSsoDetails() {
|
|||
>
|
||||
<DynamicT forKey="enterprise_sso_details.tab_experience" />
|
||||
</TabNavItem>
|
||||
{isIdpInitiatedAuthEnabled && (
|
||||
<TabNavItem
|
||||
href={getSsoConnectorDetailsPathname(
|
||||
ssoConnectorId,
|
||||
EnterpriseSsoDetailsTabs.IdpInitiatedAuth
|
||||
)}
|
||||
>
|
||||
<DynamicT forKey="enterprise_sso_details.tab_idp_initiated_auth" />
|
||||
</TabNavItem>
|
||||
)}
|
||||
</TabNav>
|
||||
{tab === EnterpriseSsoDetailsTabs.Experience && (
|
||||
<Experience
|
||||
|
@ -156,6 +183,9 @@ function EnterpriseSsoDetails() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{isIdpInitiatedAuthEnabled && tab === EnterpriseSsoDetailsTabs.IdpInitiatedAuth && (
|
||||
<IdpInitiatedAuth ssoConnector={ssoConnector} />
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteAlertOpen}
|
||||
isLoading={isDeleting}
|
||||
|
|
|
@ -5,6 +5,7 @@ const enterprise_sso_details = {
|
|||
readme_drawer_subtitle: 'Set up enterprise SSO connectors to enable end users SSO',
|
||||
tab_experience: 'SSO Experience',
|
||||
tab_connection: 'Connection',
|
||||
tab_idp_initiated_auth: 'IdP-initiated SSO',
|
||||
general_settings_title: 'General',
|
||||
custom_branding_title: 'Display',
|
||||
custom_branding_description:
|
||||
|
@ -73,6 +74,40 @@ const enterprise_sso_details = {
|
|||
jwks_uri: 'JSON web key set endpoint',
|
||||
issuer: 'Issuer',
|
||||
},
|
||||
idp_initiated_auth_config: {
|
||||
card_title: 'IdP-initiated SSO',
|
||||
card_description:
|
||||
'User typically start the authentication process from your app using the SP-initiated SSO flow. DO NOT enable this feature unless absolutely necessary.',
|
||||
enable_idp_initiated_sso: 'Enable IdP-initiated SSO',
|
||||
enable_idp_initiated_sso_description:
|
||||
"Allow enterprise users to start the authentication process directly from the identity provider's portal. Please understand the potential security risks before enabling this feature.",
|
||||
default_application: 'Default application',
|
||||
default_application_tooltip:
|
||||
'Target application the user will be redirected to after authentication.',
|
||||
empty_applications_error:
|
||||
'No applications found. Please add one in the <a>Applications</a> section.',
|
||||
authentication_type: 'Authentication type',
|
||||
auto_authentication_disabled_title: 'Redirect to client for SP-initiated SSO (Recommended)',
|
||||
auto_authentication_disabled_description:
|
||||
'Redirect users to the client-side application to initiate a secure SP-initiated OIDC authentication. This will prevent the CSRF attack and increase the security of the authentication process.',
|
||||
auto_authentication_enabled_title: 'Directly sign in using the IdP-initiated SSO',
|
||||
auto_authentication_enabled_description:
|
||||
'Sign in directly using IdP-initiated SSO authentication. After successful sign-in, users will be redirected to the specified Redirect URI with the authorization code. (Without state and PKCE validation)',
|
||||
auto_authentication_disabled_app:
|
||||
'For traditional web applications and single-page applications (SPA)',
|
||||
auto_authentication_enabled_app: 'For traditional web applications only',
|
||||
idp_initiated_auth_callback_uri: 'Client callback URI',
|
||||
idp_initiated_auth_callback_uri_tooltip:
|
||||
'The client callback URI to initiate a SP-initiated SSO authentication flow. An ssoConnectorId will be appended to the URI as a query parameter. (e.g., https://your.domain/sso/callback?connectorId={{ssoConnectorId}})',
|
||||
redirect_uri: 'Post sign-in redirect URI',
|
||||
redirect_uri_tooltip:
|
||||
'The redirect URI to redirect users after successful sign-in. Logto will use this URI as the OIDC redirect URI in the authorization request. Use a dedicated URI for the IdP-initiated SSO authentication flow for better security.',
|
||||
empty_redirect_uris_error:
|
||||
'No redirect URI has been registered for the application. Please add one first.',
|
||||
auth_params: 'Additional authentication parameters',
|
||||
auth_params_tooltip:
|
||||
'Additional parameters to be passed in the authorization request. By default only (openid profile) scopes will be requested, you can specify additional scopes or a exclusive state value here. (e.g., { "scope": "organizations email", "state": "secret_state" }).',
|
||||
},
|
||||
};
|
||||
|
||||
export default Object.freeze(enterprise_sso_details);
|
||||
|
|
|
@ -7,6 +7,7 @@ const errors = {
|
|||
invalid_uri_format: 'Invalid URI format',
|
||||
invalid_origin_format: 'Invalid URI origin format',
|
||||
invalid_json_format: 'Invalid JSON format',
|
||||
invalid_parameters_format: 'Invalid parameters format',
|
||||
invalid_regex: 'Invalid regular expression',
|
||||
invalid_error_message_format: 'The error message format is invalid.',
|
||||
required_field_missing: 'Please enter {{field}}',
|
||||
|
|
Loading…
Add table
Reference in a new issue