mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
refactor(console): polish ui (#6122)
* refactor(console): polish ui * refactor: fix code editor title color
This commit is contained in:
parent
07e3725740
commit
f8f84f5d75
12 changed files with 304 additions and 205 deletions
|
@ -1,5 +1,5 @@
|
||||||
import { type OrganizationWithRoles } from '@logto/schemas';
|
import { type OrganizationWithRoles } from '@logto/schemas';
|
||||||
import { useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
@ -23,9 +23,11 @@ import * as styles from './index.module.scss';
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly type: 'user' | 'application';
|
readonly type: 'user' | 'application';
|
||||||
readonly data: { id: string };
|
readonly data: { id: string };
|
||||||
|
/** Placeholder to show when there is no data. */
|
||||||
|
readonly placeholder?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function OrganizationList({ type, data: { id } }: Props) {
|
function OrganizationList({ type, data: { id }, placeholder }: Props) {
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { getPathname } = useTenantPathname();
|
const { getPathname } = useTenantPathname();
|
||||||
|
@ -44,7 +46,7 @@ function OrganizationList({ type, data: { id } }: Props) {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rowIndexKey="id"
|
rowIndexKey="id"
|
||||||
rowGroups={[{ key: 'data', data }]}
|
rowGroups={[{ key: 'data', data }]}
|
||||||
placeholder={<EmptyDataPlaceholder />}
|
placeholder={placeholder ?? <EmptyDataPlaceholder />}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: t('general.name'),
|
title: t('general.name'),
|
||||||
|
|
|
@ -97,7 +97,7 @@ function OrganizationRolePermissionsAssignmentModal({
|
||||||
subtitle="organization_role_details.permissions.assign_description"
|
subtitle="organization_role_details.permissions.assign_description"
|
||||||
confirmButtonType="primary"
|
confirmButtonType="primary"
|
||||||
confirmButtonText="general.save"
|
confirmButtonText="general.save"
|
||||||
cancelButtonText="general.discard"
|
cancelButtonText="general.skip"
|
||||||
size="large"
|
size="large"
|
||||||
onCancel={onCloseHandler}
|
onCancel={onCloseHandler}
|
||||||
onConfirm={onSubmitHandler}
|
onConfirm={onSubmitHandler}
|
||||||
|
|
|
@ -27,3 +27,8 @@ export const organizationRoleLink =
|
||||||
export const organizationPermissionLink =
|
export const organizationPermissionLink =
|
||||||
'/docs/recipes/organizations/understand-how-it-works/#organization-permission';
|
'/docs/recipes/organizations/understand-how-it-works/#organization-permission';
|
||||||
export const profilePropertyReferenceLink = '/docs/references/users/#profile-1';
|
export const profilePropertyReferenceLink = '/docs/references/users/#profile-1';
|
||||||
|
export const organizationJit = Object.freeze({
|
||||||
|
enterpriseSso:
|
||||||
|
'/docs/recipes/organizations/just-in-time-provisioning/#enterprise-sso-provisioning',
|
||||||
|
emailDomain: '/docs/recipes/organizations/just-in-time-provisioning/#email-domain-provisioning',
|
||||||
|
});
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
padding-bottom: _.unit(2);
|
padding-bottom: _.unit(2);
|
||||||
margin-bottom: _.unit(3);
|
margin-bottom: _.unit(3);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: #f7f8f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { type RouteObject } from 'react-router-dom';
|
||||||
import { isCloud } from '@/consts/env';
|
import { isCloud } from '@/consts/env';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/Dashboard';
|
||||||
import GetStarted from '@/pages/GetStarted';
|
import GetStarted from '@/pages/GetStarted';
|
||||||
import Mfa from '@/pages/Mfa';
|
|
||||||
import NotFound from '@/pages/NotFound';
|
import NotFound from '@/pages/NotFound';
|
||||||
import SigningKeys from '@/pages/SigningKeys';
|
import SigningKeys from '@/pages/SigningKeys';
|
||||||
|
|
||||||
|
@ -15,6 +14,7 @@ import { auditLogs } from './routes/audit-logs';
|
||||||
import { connectors } from './routes/connectors';
|
import { connectors } from './routes/connectors';
|
||||||
import { customizeJwt } from './routes/customize-jwt';
|
import { customizeJwt } from './routes/customize-jwt';
|
||||||
import { enterpriseSso } from './routes/enterprise-sso';
|
import { enterpriseSso } from './routes/enterprise-sso';
|
||||||
|
import { mfa } from './routes/mfa';
|
||||||
import { organizationTemplate } from './routes/organization-template';
|
import { organizationTemplate } from './routes/organization-template';
|
||||||
import { organizations } from './routes/organizations';
|
import { organizations } from './routes/organizations';
|
||||||
import { roles } from './routes/roles';
|
import { roles } from './routes/roles';
|
||||||
|
@ -35,7 +35,7 @@ export const useConsoleRoutes = () => {
|
||||||
applications,
|
applications,
|
||||||
apiResources,
|
apiResources,
|
||||||
signInExperience,
|
signInExperience,
|
||||||
{ path: 'mfa', element: <Mfa /> },
|
mfa,
|
||||||
connectors,
|
connectors,
|
||||||
enterpriseSso,
|
enterpriseSso,
|
||||||
webhooks,
|
webhooks,
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { type RouteObject } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Mfa from '@/pages/Mfa';
|
||||||
|
|
||||||
|
export const mfa: RouteObject = { path: 'mfa', element: <Mfa /> };
|
|
@ -15,6 +15,7 @@ import ApplicationIcon from '@/components/ApplicationIcon';
|
||||||
import DetailsForm from '@/components/DetailsForm';
|
import DetailsForm from '@/components/DetailsForm';
|
||||||
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
|
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
|
||||||
import Drawer from '@/components/Drawer';
|
import Drawer from '@/components/Drawer';
|
||||||
|
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||||
import OrganizationList from '@/components/OrganizationList';
|
import OrganizationList from '@/components/OrganizationList';
|
||||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||||
import { ApplicationDetailsTabs, logtoThirdPartyGuideLink, protectedAppLink } from '@/consts';
|
import { ApplicationDetailsTabs, logtoThirdPartyGuideLink, protectedAppLink } from '@/consts';
|
||||||
|
@ -22,7 +23,9 @@ import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
||||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||||
import TabWrapper from '@/ds-components/TabWrapper';
|
import TabWrapper from '@/ds-components/TabWrapper';
|
||||||
|
import TextLink from '@/ds-components/TextLink';
|
||||||
import useApi from '@/hooks/use-api';
|
import useApi from '@/hooks/use-api';
|
||||||
|
import { organizations } from '@/hooks/use-console-routes/routes/organizations';
|
||||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
import useTenantPathname from '@/hooks/use-tenant-pathname';
|
||||||
import { applicationTypeI18nKey } from '@/types/applications';
|
import { applicationTypeI18nKey } from '@/types/applications';
|
||||||
|
@ -237,7 +240,20 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
|
||||||
isActive={tab === ApplicationDetailsTabs.Organizations}
|
isActive={tab === ApplicationDetailsTabs.Organizations}
|
||||||
className={styles.tabContainer}
|
className={styles.tabContainer}
|
||||||
>
|
>
|
||||||
<OrganizationList type="application" data={data} />
|
<OrganizationList
|
||||||
|
type="application"
|
||||||
|
data={data}
|
||||||
|
placeholder={
|
||||||
|
<EmptyDataPlaceholder
|
||||||
|
title={
|
||||||
|
<Trans
|
||||||
|
i18nKey="admin_console.application_details.no_organization_placeholder"
|
||||||
|
components={{ a: <TextLink to={'/' + organizations.path} /> }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TabWrapper>
|
</TabWrapper>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { RoleType, type SsoConnectorWithProviderConfig } from '@logto/schemas';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Controller, type UseFormReturn } from 'react-hook-form';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
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 FormCard from '@/components/FormCard';
|
||||||
|
import MultiOptionInput from '@/components/MultiOptionInput';
|
||||||
|
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
|
||||||
|
import { organizationJit } from '@/consts';
|
||||||
|
import ActionMenu from '@/ds-components/ActionMenu';
|
||||||
|
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 TextLink from '@/ds-components/TextLink';
|
||||||
|
import { enterpriseSso } from '@/hooks/use-console-routes/routes/enterprise-sso';
|
||||||
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
|
import SsoConnectorLogo from '@/pages/EnterpriseSso/SsoConnectorLogo';
|
||||||
|
import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
import { type FormData } from './utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
readonly form: UseFormReturn<FormData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function JitSettings({ form }: Props) {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
watch,
|
||||||
|
} = form;
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
const [emailDomains, ssoConnectorIds] = watch(['jitEmailDomains', 'jitSsoConnectorIds']);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
// Fetch all SSO connector to show if a domain is configured SSO
|
||||||
|
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) => 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 { getDocumentationUrl } = useDocumentationUrl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormCard
|
||||||
|
title="organization_details.jit.title"
|
||||||
|
description="organization_details.jit.description"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
title="organization_details.jit.enterprise_sso"
|
||||||
|
description={
|
||||||
|
<Trans
|
||||||
|
i18nKey="admin_console.organization_details.jit.enterprise_sso_description"
|
||||||
|
components={{
|
||||||
|
a: (
|
||||||
|
<TextLink
|
||||||
|
to={getDocumentationUrl(organizationJit.enterpriseSso)}
|
||||||
|
targetBlank="noopener"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
descriptionPosition="top"
|
||||||
|
>
|
||||||
|
{ssoConnectorIds.length === 0 && (
|
||||||
|
<InlineNotification>
|
||||||
|
<Trans
|
||||||
|
i18nKey="admin_console.organization_details.jit.no_enterprise_connector_set"
|
||||||
|
components={{ a: <TextLink to={'/' + enterpriseSso.path} /> }}
|
||||||
|
/>
|
||||||
|
</InlineNotification>
|
||||||
|
)}
|
||||||
|
{ssoConnectorIds.length > 0 && (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ActionMenu
|
||||||
|
buttonProps={{
|
||||||
|
type: 'default',
|
||||||
|
size: 'medium',
|
||||||
|
title: 'organization_details.jit.add_enterprise_connector',
|
||||||
|
icon: <Plus />,
|
||||||
|
className: styles.addSsoConnectorButton,
|
||||||
|
}}
|
||||||
|
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={
|
||||||
|
<Trans
|
||||||
|
i18nKey="admin_console.organization_details.jit.email_domain_description"
|
||||||
|
components={{
|
||||||
|
a: (
|
||||||
|
<TextLink
|
||||||
|
to={getDocumentationUrl(organizationJit.emailDomain)}
|
||||||
|
targetBlank="noopener"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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.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
|
||||||
|
roleType={RoleType.User}
|
||||||
|
keyword={keyword}
|
||||||
|
setKeyword={setKeyword}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JitSettings;
|
|
@ -1,41 +1,27 @@
|
||||||
import {
|
import { type SignInExperience, type Organization } from '@logto/schemas';
|
||||||
type SignInExperience,
|
|
||||||
type Organization,
|
|
||||||
type SsoConnectorWithProviderConfig,
|
|
||||||
RoleType,
|
|
||||||
} from '@logto/schemas';
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import useSWR from 'swr';
|
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 DetailsForm from '@/components/DetailsForm';
|
||||||
import FormCard from '@/components/FormCard';
|
import FormCard from '@/components/FormCard';
|
||||||
import MultiOptionInput from '@/components/MultiOptionInput';
|
|
||||||
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
|
|
||||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||||
import ActionMenu from '@/ds-components/ActionMenu';
|
|
||||||
import CodeEditor from '@/ds-components/CodeEditor';
|
import CodeEditor from '@/ds-components/CodeEditor';
|
||||||
import { DropdownItem } from '@/ds-components/Dropdown';
|
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import IconButton from '@/ds-components/IconButton';
|
|
||||||
import InlineNotification from '@/ds-components/InlineNotification';
|
import InlineNotification from '@/ds-components/InlineNotification';
|
||||||
import Switch from '@/ds-components/Switch';
|
import Switch from '@/ds-components/Switch';
|
||||||
import TextInput from '@/ds-components/TextInput';
|
import TextInput from '@/ds-components/TextInput';
|
||||||
|
import TextLink from '@/ds-components/TextLink';
|
||||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||||
import SsoConnectorLogo from '@/pages/EnterpriseSso/SsoConnectorLogo';
|
import { mfa } from '@/hooks/use-console-routes/routes/mfa';
|
||||||
import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts';
|
|
||||||
import { trySubmitSafe } from '@/utils/form';
|
import { trySubmitSafe } from '@/utils/form';
|
||||||
|
|
||||||
import { type OrganizationDetailsOutletContext } from '../types';
|
import { type OrganizationDetailsOutletContext } from '../types';
|
||||||
|
|
||||||
|
import JitSettings from './JitSettings';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
|
import { assembleData, isJsonObject, normalizeData, type FormData } from './utils';
|
||||||
|
|
||||||
|
@ -43,42 +29,23 @@ function Settings() {
|
||||||
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
|
const { isDeleting, data, jit, onUpdated } = useOutletContext<OrganizationDetailsOutletContext>();
|
||||||
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
|
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const {
|
const form = useForm<FormData>({
|
||||||
register,
|
|
||||||
reset,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { isDirty, isSubmitting, errors },
|
|
||||||
setError,
|
|
||||||
clearErrors,
|
|
||||||
watch,
|
|
||||||
} = useForm<FormData>({
|
|
||||||
defaultValues: normalizeData(data, {
|
defaultValues: normalizeData(data, {
|
||||||
emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain),
|
emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain),
|
||||||
roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })),
|
roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })),
|
||||||
ssoConnectorIds: jit.ssoConnectorIds,
|
ssoConnectorIds: jit.ssoConnectorIds,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const [isMfaRequired, emailDomains] = watch(['isMfaRequired', 'jitEmailDomains']);
|
const {
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isDirty, isSubmitting, errors },
|
||||||
|
watch,
|
||||||
|
} = form;
|
||||||
|
const [isMfaRequired] = watch(['isMfaRequired']);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
// Fetch all SSO connector to show if a domain is configured SSO
|
|
||||||
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) => 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(
|
const onSubmit = handleSubmit(
|
||||||
trySubmitSafe(async (data) => {
|
trySubmitSafe(async (data) => {
|
||||||
|
@ -160,159 +127,20 @@ function Settings() {
|
||||||
/>
|
/>
|
||||||
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
|
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
|
||||||
<InlineNotification severity="alert" className={styles.warning}>
|
<InlineNotification severity="alert" className={styles.warning}>
|
||||||
{t('organization_details.mfa.no_mfa_warning')}
|
<Trans
|
||||||
|
i18nKey="admin_console.organization_details.mfa.no_mfa_warning"
|
||||||
|
components={{
|
||||||
|
a: <TextLink to={'/' + mfa.path} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</InlineNotification>
|
</InlineNotification>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
{isDevFeaturesEnabled && (
|
{isDevFeaturesEnabled && <JitSettings form={form} />}
|
||||||
<FormCard
|
|
||||||
title="organization_details.jit.title"
|
|
||||||
description="organization_details.jit.description"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<ActionMenu
|
|
||||||
buttonProps={{
|
|
||||||
type: 'default',
|
|
||||||
size: 'medium',
|
|
||||||
title: 'organization_details.jit.add_enterprise_connector',
|
|
||||||
icon: <Plus />,
|
|
||||||
className: styles.addSsoConnectorButton,
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
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.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
|
|
||||||
roleType={RoleType.User}
|
|
||||||
keyword={keyword}
|
|
||||||
setKeyword={setKeyword}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</FormCard>
|
|
||||||
)}
|
|
||||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||||
</DetailsForm>
|
</DetailsForm>
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line max-lines -- Should be ok once dev features flag is removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|
|
@ -72,7 +72,7 @@ export default class ExpectOrganizations extends ExpectConsole {
|
||||||
|
|
||||||
// Skip permission assignment
|
// Skip permission assignment
|
||||||
await this.toExpectModal('Assign permissions');
|
await this.toExpectModal('Assign permissions');
|
||||||
await this.toClickButton('Discard');
|
await this.toClickButton('Skip');
|
||||||
|
|
||||||
this.toMatchUrl(/\/organization-template\/organization-roles\/.+$/);
|
this.toMatchUrl(/\/organization-template\/organization-roles\/.+$/);
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,6 +92,7 @@ const application_details = {
|
||||||
'Ensure to protect your origin server from direct access. Refer to the guide for more <a>detailed instructions</a>.',
|
'Ensure to protect your origin server from direct access. Refer to the guide for more <a>detailed instructions</a>.',
|
||||||
session_duration: 'Session duration (days)',
|
session_duration: 'Session duration (days)',
|
||||||
try_it: 'Try it',
|
try_it: 'Try it',
|
||||||
|
no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
|
||||||
branding: {
|
branding: {
|
||||||
name: 'Branding',
|
name: 'Branding',
|
||||||
description: "Customize your application's display name and logo on the consent screen.",
|
description: "Customize your application's display name and logo on the consent screen.",
|
||||||
|
|
|
@ -44,16 +44,18 @@ const organization_details = {
|
||||||
'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.',
|
'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: 'Email domain provisioning',
|
||||||
email_domain_description:
|
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.',
|
'New users signing up with their verified email addresses or through social sign-in with verified email addresses will automatically join the organization. <a>Learn more</a>',
|
||||||
email_domain_placeholder: 'Enter email domains for just-in-time provisioning',
|
email_domain_placeholder: 'Enter email domains for just-in-time provisioning',
|
||||||
invalid_domain: 'Invalid domain',
|
invalid_domain: 'Invalid domain',
|
||||||
domain_already_added: 'Domain already added',
|
domain_already_added: 'Domain already added',
|
||||||
sso_enabled_domain_warning:
|
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 won’t be provisioned to this organization unless enterprise SSO provisioning is configured.',
|
'You have entered one or more email domains associated to enterprise SSO. Users with these emails will follow the standard SSO flow and won’t be provisioned to this organization unless enterprise SSO provisioning is configured.',
|
||||||
enterprise_sso: 'Enterprise SSO provisioning',
|
enterprise_sso: 'Enterprise SSO provisioning',
|
||||||
|
no_enterprise_connector_set:
|
||||||
|
'You haven’t set up any enterprise SSO connector yet. Add connectors first to enable enterprise SSO provisioning. <a>Set up</a>',
|
||||||
add_enterprise_connector: 'Add enterprise connector',
|
add_enterprise_connector: 'Add enterprise connector',
|
||||||
enterprise_sso_description:
|
enterprise_sso_description:
|
||||||
'New or existing users signing in through enterprise SSO for the first time will automatically join the organization.',
|
'New users or existing users signing in through enterprise SSO for the first time will automatically join the organization. <a>Learn more</a>',
|
||||||
organization_roles: 'Default organization roles',
|
organization_roles: 'Default organization roles',
|
||||||
organization_roles_description:
|
organization_roles_description:
|
||||||
'Assign roles to users upon joining the organization through just-in-time provisioning.',
|
'Assign roles to users upon joining the organization through just-in-time provisioning.',
|
||||||
|
@ -64,7 +66,7 @@ const organization_details = {
|
||||||
description:
|
description:
|
||||||
'Require users to configure multi-factor authentication to access this organization.',
|
'Require users to configure multi-factor authentication to access this organization.',
|
||||||
no_mfa_warning:
|
no_mfa_warning:
|
||||||
'No multi-factor authentication methods are enabled for your tenant. Users will not be able to access this organization until at least one multi-factor authentication method is enabled.',
|
'No multi-factor authentication methods are enabled for your tenant. Users will not be able to access this organization until at least one <a>multi-factor authentication method</a> is enabled.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue