0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

Merge pull request #6073 from logto-io/gao-update-console-jit

feat(console): update jit config
This commit is contained in:
Gao Sun 2024-06-21 19:57:59 +08:00 committed by GitHub
commit 5065eea03b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 367 additions and 218 deletions

View file

@ -13,6 +13,7 @@ type CanBePromise<T> = T | Promise<T>;
type Props<T> = {
readonly className?: string;
readonly valueClassName?: string | ((value: T) => string | undefined);
readonly values: T[];
readonly getId?: (value: T) => string;
readonly onError?: (error: string) => void;
@ -27,6 +28,7 @@ type Props<T> = {
function MultiOptionInput<T>({
className,
valueClassName,
values,
getId: getIdInput,
onError,
@ -98,7 +100,11 @@ function MultiOptionInput<T>({
<Tag
key={getId(option)}
variant="cell"
className={classNames(styles.tag, getId(option) === focusedValueId && styles.focused)}
className={classNames(
styles.tag,
getId(option) === focusedValueId && styles.focused,
typeof valueClassName === 'function' ? valueClassName(option) : valueClassName
)}
onClick={() => {
ref.current?.focus();
}}

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

@ -4,11 +4,65 @@
margin-top: _.unit(3);
}
.ssoEnabled {
vertical-align: middle;
color: var(--color-text-link);
// Additional layer of nesting to override the styles from the imported components
.jitEmailDomains .ssoEnabled {
display: flex;
align-items: center;
gap: _.unit(1);
background-color: var(--color-alert-container);
> svg {
color: var(--color-on-alert-container);
}
}
.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,18 @@
import { type SignInExperience, type Organization, type SsoConnector } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { useState, useCallback } from 'react';
import {
type SignInExperience,
type Organization,
type SsoConnectorWithProviderConfig,
} from '@logto/schemas';
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,52 +20,23 @@ 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';
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>>;
};
const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};
const normalizeData = (
data: Organization,
jit: { emailDomains: string[]; roles: Array<Option<string>> }
): FormData => ({
...data,
isJitEnabled: jit.emailDomains.length > 0 || jit.roles.length > 0,
jitEmailDomains: jit.emailDomains,
jitRoles: jit.roles,
customData: JSON.stringify(data.customData, undefined, 2),
});
const assembleData = ({
isJitEnabled,
jitEmailDomains,
customData,
...data
}: FormData): Partial<Organization> => ({
...data,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
customData: JSON.parse(customData ?? '{}'),
});
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
function Settings() {
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
@ -79,25 +55,28 @@ 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, emailDomains] = watch(['isMfaRequired', 'jitEmailDomains']);
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]
);
/** If any of the email domains has SSO enabled. */
const hasSsoEnabledEmailDomain = useMemo(
() => emailDomains.some((domain) => hasSsoEnabled(domain)),
[emailDomains, hasSsoEnabled]
);
const onSubmit = handleSubmit(
@ -106,8 +85,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 +101,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,105 +152,158 @@ 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} - {connector.providerName}
</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>
)}
/>
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
</FormField>
<FormField
title="organization_details.jit.email_domain"
description="organization_details.jit.email_domain_description"
descriptionPosition="top"
className={styles.jitEmailDomains}
>
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
values={value}
valueClassName={(domain) =>
hasSsoEnabled(domain) ? styles.ssoEnabled : undefined
}
renderValue={(value) =>
hasSsoEnabled(value) ? (
<>
<SsoIcon />
{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');
}}
/>
)}
/>
{hasSsoEnabledEmailDomain && (
<InlineNotification severity="alert" className={styles.warning}>
{t('organization_details.mfa.no_mfa_warning')}
{t('organization_details.jit.sso_enabled_domain_warning')}
</InlineNotification>
)}
</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}
/>
)}
/>
</FormField>
</FormCard>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />

View file

@ -0,0 +1,38 @@
import { type Organization } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { type Option } from '@/ds-components/Select/MultiSelect';
export type FormData = Partial<Omit<Organization, 'customData'> & { customData: string }> & {
jitEmailDomains: string[];
jitRoles: Array<Option<string>>;
jitSsoConnectorIds: string[];
};
export const isJsonObject = (value: string) => {
const parsed = trySafe<unknown>(() => JSON.parse(value));
return Boolean(parsed && typeof parsed === 'object');
};
export const normalizeData = (
data: Organization,
jit: { emailDomains: string[]; roles: Array<Option<string>>; ssoConnectorIds: string[] }
): FormData => ({
...data,
jitEmailDomains: jit.emailDomains,
jitRoles: jit.roles,
jitSsoConnectorIds: jit.ssoConnectorIds,
customData: JSON.stringify(data.customData, undefined, 2),
});
export const assembleData = ({
jitEmailDomains,
jitRoles,
jitSsoConnectorIds,
customData,
...data
}: FormData): Partial<Organization> => ({
...data,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
customData: JSON.parse(customData ?? '{}'),
});

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,22 @@ 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',
'Users can automatically join the organization and be assigned roles upon their first sign-in through some authentication methods. You can set requirements to meet 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 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',
sso_enabled_domain_warning:
'You have entered one or more email domains associated to enterprise SSO. Users with these emails will follow the standard SSO flow and wont be provisioned to this organization unless enterprise SSO provisioning is configured.',
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.',