0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

feat(console): update jit config

This commit is contained in:
Gao Sun 2024-06-20 16:22:58 +08:00
parent 2cf30d2f03
commit a471d6a58c
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 300 additions and 185 deletions

View file

@ -57,7 +57,7 @@ function ActionMenu(props: Props) {
<Button
{...props.buttonProps}
ref={anchorReference}
className={styles.actionMenuButton}
className={classNames(styles.actionMenuButton, props.buttonProps.className)}
onClick={() => {
setIsOpen(true);
}}

View file

@ -1,5 +1,4 @@
.container {
background-color: var(--color-hover);
display: flex;
align-items: center;
justify-content: center;

View file

@ -12,3 +12,49 @@
.warning {
margin-top: _.unit(3);
}
.addSsoConnectorButton {
margin-top: _.unit(3);
}
.dropdownItem {
display: flex;
align-items: center;
gap: _.unit(2);
font: var(--font-body-2);
.icon {
width: 16px;
height: 16px;
}
}
.ssoConnectorList {
display: flex;
flex-direction: column;
gap: _.unit(1);
}
.ssoConnector {
display: flex;
align-items: center;
gap: _.unit(2);
.info {
flex: 1;
display: flex;
align-items: center;
width: 100%;
padding: _.unit(3);
gap: _.unit(3);
background-color: var(--color-layer-2);
font: var(--font-label-2);
border-radius: 8px;
color: var(--color-text);
img {
width: 20px;
height: 20px;
}
}
}

View file

@ -1,13 +1,19 @@
import { type SignInExperience, type Organization, type SsoConnector } from '@logto/schemas';
import {
type SignInExperience,
type Organization,
type SsoConnectorWithProviderConfig,
} from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import Minus from '@/assets/icons/minus.svg';
import Plus from '@/assets/icons/plus.svg';
import SsoIcon from '@/assets/icons/single-sign-on.svg';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
@ -15,13 +21,17 @@ import MultiOptionInput from '@/components/MultiOptionInput';
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isDevFeaturesEnabled } from '@/consts/env';
import ActionMenu from '@/ds-components/ActionMenu';
import CodeEditor from '@/ds-components/CodeEditor';
import { DropdownItem } from '@/ds-components/Dropdown';
import FormField from '@/ds-components/FormField';
import IconButton from '@/ds-components/IconButton';
import InlineNotification from '@/ds-components/InlineNotification';
import { type Option } from '@/ds-components/Select/MultiSelect';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
import useApi, { type RequestError } from '@/hooks/use-api';
import SsoConnectorLogo from '@/pages/EnterpriseSso/SsoConnectorLogo';
import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts';
import { trySubmitSafe } from '@/utils/form';
@ -30,9 +40,9 @@ import { type OrganizationDetailsOutletContext } from '../types';
import * as styles from './index.module.scss';
type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }> & {
isJitEnabled: boolean;
jitEmailDomains: string[];
jitRoles: Array<Option<string>>;
jitSsoConnectorIds: string[];
};
const isJsonObject = (value: string) => {
@ -42,18 +52,19 @@ const isJsonObject = (value: string) => {
const normalizeData = (
data: Organization,
jit: { emailDomains: string[]; roles: Array<Option<string>> }
jit: { emailDomains: string[]; roles: Array<Option<string>>; ssoConnectorIds: string[] }
): FormData => ({
...data,
isJitEnabled: jit.emailDomains.length > 0 || jit.roles.length > 0,
jitEmailDomains: jit.emailDomains,
jitRoles: jit.roles,
jitSsoConnectorIds: jit.ssoConnectorIds,
customData: JSON.stringify(data.customData, undefined, 2),
});
const assembleData = ({
isJitEnabled,
jitEmailDomains,
jitRoles,
jitSsoConnectorIds,
customData,
...data
}: FormData): Partial<Organization> => ({
@ -79,25 +90,23 @@ function Settings() {
defaultValues: normalizeData(data, {
emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain),
roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })),
ssoConnectorIds: jit.ssoConnectorIds,
}),
});
const [isJitEnabled, isMfaRequired] = watch(['isJitEnabled', 'isMfaRequired']);
const isMfaRequired = watch('isMfaRequired');
const api = useApi();
const [keyword, setKeyword] = useState('');
// Fetch all SSO connector to show if a domain is configured SSO
const { data: ssoConnectors } = useSWRInfinite<SsoConnector[]>(
const { data: ssoConnectorMatrix } = useSWRInfinite<SsoConnectorWithProviderConfig[]>(
(index, previous) => {
return previous && previous.length === 0 ? null : `api/sso-connectors?page=${index + 1}`;
},
{ initialSize: Number.POSITIVE_INFINITY }
);
const allSsoConnectors = useMemo(() => ssoConnectorMatrix?.flat(), [ssoConnectorMatrix]);
const hasSsoEnabled = useCallback(
(domain: string) =>
ssoConnectors?.some((connectors) =>
connectors.some(({ domains }) => domains.includes(domain))
),
[ssoConnectors]
(domain: string) => allSsoConnectors?.some(({ domains }) => domains.includes(domain)),
[allSsoConnectors]
);
const onSubmit = handleSubmit(
@ -106,8 +115,9 @@ function Settings() {
return;
}
const emailDomains = data.isJitEnabled ? data.jitEmailDomains : [];
const roles = data.isJitEnabled ? data.jitRoles : [];
const emailDomains = data.jitEmailDomains;
const roles = data.jitRoles;
const ssoConnectorIds = data.jitSsoConnectorIds;
const updatedData = await api
.patch(`api/organizations/${data.id}`, {
json: assembleData(data),
@ -121,9 +131,12 @@ function Settings() {
api.put(`api/organizations/${data.id}/jit/roles`, {
json: { organizationRoleIds: roles.map(({ value }) => value) },
}),
api.put(`api/organizations/${data.id}/jit/sso-connectors`, {
json: { ssoConnectorIds },
}),
]);
reset(normalizeData(updatedData, { emailDomains, roles }));
reset(normalizeData(updatedData, { emailDomains, roles, ssoConnectorIds }));
toast.success(t('general.saved'));
onUpdated(updatedData);
})
@ -169,104 +182,147 @@ function Settings() {
)}
/>
</FormField>
<FormField title="organization_details.mfa.title" tip={t('organization_details.mfa.tip')}>
<Switch
label={t('organization_details.mfa.description')}
{...register('isMfaRequired')}
/>
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
<InlineNotification severity="alert" className={styles.warning}>
{t('organization_details.mfa.no_mfa_warning')}
</InlineNotification>
)}
</FormField>
</FormCard>
{isDevFeaturesEnabled && (
<FormCard
title="organization_details.membership_policies"
description="organization_details.membership_policies_description"
title="organization_details.jit.title"
description="organization_details.jit.description"
>
<FormField title="organization_details.jit.title">
<div className={styles.jitContent}>
<Switch
label={t('organization_details.jit.description')}
{...register('isJitEnabled')}
/>
</div>
</FormField>
{isJitEnabled && (
<FormField
title="organization_details.jit.email_domains"
description={
<Trans
components={{
Icon: <SsoIcon className={styles.ssoEnabled} />,
}}
>
{t('organization_details.jit.sso_email_domain_description')}
</Trans>
}
>
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
values={value}
renderValue={(value) =>
hasSsoEnabled(value) ? (
<>
<SsoIcon className={styles.ssoEnabled} />
{value}
</>
) : (
value
<FormField
title="organization_details.jit.enterprise_sso"
description="organization_details.jit.enterprise_sso_description"
descriptionPosition="top"
>
<Controller
name="jitSsoConnectorIds"
control={control}
render={({ field: { onChange, value } }) => (
<div className={styles.ssoConnectorList}>
{value.map((id) => {
const connector = allSsoConnectors?.find(
({ id: connectorId }) => id === connectorId
);
return (
connector && (
<div key={connector.id} className={styles.ssoConnector}>
<div className={styles.info}>
<SsoConnectorLogo className={styles.icon} data={connector} />
<span>{connector.connectorName}</span>
</div>
<IconButton
onClick={() => {
onChange(value.filter((value) => value !== id));
}}
>
<Minus />
</IconButton>
</div>
)
}
validateInput={(input) => {
if (!domainRegExp.test(input)) {
return t('organization_details.jit.invalid_domain');
}
if (value.includes(input)) {
return t('organization_details.jit.domain_already_added');
}
return { value: input };
);
})}
<ActionMenu
buttonProps={{
type: 'default',
size: 'medium',
title: 'organization_details.jit.add_enterprise_connector',
icon: <Plus />,
className: styles.addSsoConnectorButton,
}}
placeholder={t('organization_details.jit.email_domains_placeholder')}
error={errors.jitEmailDomains?.message}
onChange={onChange}
onError={(error) => {
setError('jitEmailDomains', { type: 'custom', message: error });
}}
onClearError={() => {
clearErrors('jitEmailDomains');
}}
/>
)}
/>
</FormField>
)}
{isJitEnabled && (
<FormField
title="organization_details.jit.organization_roles"
description="organization_details.jit.organization_roles_description"
descriptionPosition="top"
>
<Controller
name="jitRoles"
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationRolesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
)}
<FormField title="organization_details.mfa.title" tip={t('organization_details.mfa.tip')}>
<Switch
label={t('organization_details.mfa.description')}
{...register('isMfaRequired')}
dropdownHorizontalAlign="start"
>
{allSsoConnectors
?.filter(({ id }) => !value.includes(id))
.map((connector) => (
<DropdownItem
key={connector.id}
className={styles.dropdownItem}
onClick={() => {
onChange([...value, connector.id]);
}}
>
<SsoConnectorLogo className={styles.icon} data={connector} />
<span>{connector.connectorName}</span>
</DropdownItem>
))}
</ActionMenu>
</div>
)}
/>
</FormField>
<FormField
title="organization_details.jit.email_domain"
description="organization_details.jit.email_domain_description"
descriptionPosition="top"
>
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
values={value}
renderValue={(value) =>
hasSsoEnabled(value) ? (
<>
<SsoIcon className={styles.ssoEnabled} />
{value}
</>
) : (
value
)
}
validateInput={(input) => {
if (!domainRegExp.test(input)) {
return t('organization_details.jit.invalid_domain');
}
if (value.includes(input)) {
return t('organization_details.jit.domain_already_added');
}
return { value: input };
}}
placeholder={t('organization_details.jit.email_domain_placeholder')}
error={errors.jitEmailDomains?.message}
onChange={onChange}
onError={(error) => {
setError('jitEmailDomains', { type: 'custom', message: error });
}}
onClearError={() => {
clearErrors('jitEmailDomains');
}}
/>
)}
/>
</FormField>
<FormField
title="organization_details.jit.organization_roles"
description="organization_details.jit.organization_roles_description"
descriptionPosition="top"
>
<Controller
name="jitRoles"
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationRolesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
onChange={onChange}
/>
)}
/>
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
<InlineNotification severity="alert" className={styles.warning}>
{t('organization_details.mfa.no_mfa_warning')}
</InlineNotification>
)}
</FormField>
</FormCard>
)}

View file

@ -2,6 +2,7 @@ import {
type OrganizationJitEmailDomain,
type Organization,
type OrganizationRole,
type SsoConnector,
} from '@logto/schemas';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -30,6 +31,7 @@ import { OrganizationDetailsTabs, type OrganizationDetailsOutletContext } from '
const pathname = '/organizations';
// eslint-disable-next-line complexity
function OrganizationDetails() {
const { id } = useParams();
const { navigate } = useTenantPathname();
@ -41,6 +43,9 @@ function OrganizationDetails() {
const jitRoles = useSWR<OrganizationRole[], RequestError>(
id && `api/organizations/${id}/jit/roles`
);
const jitSsoConnectors = useSWR<SsoConnector[], RequestError>(
id && `api/organizations/${id}/jit/sso-connectors`
);
const [isDeleting, setIsDeleting] = useState(false);
const [isGuideDrawerOpen, setIsGuideDrawerOpen] = useState(false);
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
@ -63,81 +68,88 @@ function OrganizationDetails() {
const isLoading =
(!organization.data && !organization.error) ||
(!jitEmailDomains.data && !jitEmailDomains.error) ||
(!jitRoles.data && !jitRoles.error);
const error = organization.error ?? jitEmailDomains.error ?? jitRoles.error;
(!jitRoles.data && !jitRoles.error) ||
(!jitSsoConnectors.data && !jitSsoConnectors.error);
const error =
organization.error ?? jitEmailDomains.error ?? jitRoles.error ?? jitSsoConnectors.error;
return (
<DetailsPage backLink={pathname} backLinkTitle="organizations.title" className={styles.page}>
<PageMeta titleKey="organization_details.page_title" />
{isLoading && <Skeleton />}
{error && <AppError errorCode={error.body?.code} errorMessage={error.body?.message} />}
{id && organization.data && jitEmailDomains.data && jitRoles.data && (
<>
<DetailsPageHeader
icon={<ThemedIcon for={OrganizationIcon} size={60} />}
title={organization.data.name}
identifier={{ name: t('organization_details.organization_id'), value: id }}
additionalActionButton={{
icon: <File />,
title: 'application_details.check_guide',
onClick: () => {
setIsGuideDrawerOpen(true);
},
}}
actionMenuItems={[
{
icon: <Delete />,
title: 'general.delete',
type: 'danger',
{id &&
organization.data &&
jitEmailDomains.data &&
jitRoles.data &&
jitSsoConnectors.data && (
<>
<DetailsPageHeader
icon={<ThemedIcon for={OrganizationIcon} size={60} />}
title={organization.data.name}
identifier={{ name: t('organization_details.organization_id'), value: id }}
additionalActionButton={{
icon: <File />,
title: 'application_details.check_guide',
onClick: () => {
setIsDeleteFormOpen(true);
setIsGuideDrawerOpen(true);
},
},
]}
/>
<Drawer
title="organizations.guide.title"
subtitle="organizations.guide.subtitle"
isOpen={isGuideDrawerOpen}
onClose={() => {
setIsGuideDrawerOpen(false);
}}
>
<Introduction />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={deleteOrganization}
>
{t('organization_details.delete_confirmation')}
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Settings}`}>
{t('general.settings_nav')}
</TabNavItem>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Members}`}>
{t('organizations.members')}
</TabNavItem>
</TabNav>
<Outlet
context={
{
data: organization.data,
jit: {
emailDomains: jitEmailDomains.data,
roles: jitRoles.data,
}}
actionMenuItems={[
{
icon: <Delete />,
title: 'general.delete',
type: 'danger',
onClick: () => {
setIsDeleteFormOpen(true);
},
},
isDeleting,
onUpdated: async (data) => organization.mutate(data),
} satisfies OrganizationDetailsOutletContext
}
/>
</>
)}
]}
/>
<Drawer
title="organizations.guide.title"
subtitle="organizations.guide.subtitle"
isOpen={isGuideDrawerOpen}
onClose={() => {
setIsGuideDrawerOpen(false);
}}
>
<Introduction />
</Drawer>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={deleteOrganization}
>
{t('organization_details.delete_confirmation')}
</DeleteConfirmModal>
<TabNav>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Settings}`}>
{t('general.settings_nav')}
</TabNavItem>
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Members}`}>
{t('organizations.members')}
</TabNavItem>
</TabNav>
<Outlet
context={
{
data: organization.data,
jit: {
emailDomains: jitEmailDomains.data,
roles: jitRoles.data,
ssoConnectorIds: jitSsoConnectors.data.map(({ id }) => id),
},
isDeleting,
onUpdated: async (data) => organization.mutate(data),
} satisfies OrganizationDetailsOutletContext
}
/>
</>
)}
</DetailsPage>
);
}

View file

@ -8,6 +8,7 @@ export type OrganizationDetailsOutletContext = {
data: Organization;
jit: {
emailDomains: OrganizationJitEmailDomain[];
ssoConnectorIds: string[];
roles: OrganizationRole[];
};
/**

View file

@ -26,19 +26,20 @@ const organization_details = {
custom_data_tip:
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
invalid_json_object: 'Invalid JSON object.',
membership_policies: 'Membership policies',
membership_policies_description:
'Define how users can join this organization and what requirements they must meet for access.',
jit: {
title: 'Enable just-in-time provisioning',
title: 'Just-in-time provisioning',
description:
'Users can automatically join the organization and receive role assignments if their verified email matches specific domains, either during sign-up or when added via the Management API.',
email_domains: 'JIT provisioning email domains',
email_domains_placeholder: 'Enter email domains for just-in-time provisioning',
email_domain: 'Email domain provisioning',
email_domain_description:
'New users signing up with their verified email addresses or through social sign-in with synced verified email addresses will automatically join the organization.',
email_domain_placeholder: 'Enter email domains for just-in-time provisioning',
invalid_domain: 'Invalid domain',
sso_email_domain_description:
'<Icon /> means the domain is enabled for enterprise SSO. Users who signed in through the configured IdP can automatically join the organization.',
domain_already_added: 'Domain already added',
enterprise_sso: 'Enterprise SSO provisioning',
add_enterprise_connector: 'Add enterprise connector',
enterprise_sso_description:
'New or existing users signing in through enterprise SSO for the first time will automatically join the organization.',
organization_roles: 'Default organization roles',
organization_roles_description:
'Assign roles to users upon joining the organization through just-in-time provisioning.',