From 20aa3cefeb5dc8ed912417accef9859dce34bcd7 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 27 Jun 2024 22:18:22 +0800 Subject: [PATCH 1/2] refactor(console): polish ui --- .../src/components/OrganizationList/index.tsx | 8 +- .../index.tsx | 2 +- packages/console/src/consts/external-links.ts | 5 + .../src/hooks/use-console-routes/index.tsx | 4 +- .../hooks/use-console-routes/routes/mfa.tsx | 5 + .../ApplicationDetailsContent/index.tsx | 18 +- .../Settings/JitSettings.tsx | 239 ++++++++++++++++++ .../OrganizationDetails/Settings/index.tsx | 214 ++-------------- .../src/ui-helpers/expect-organizations.ts | 2 +- .../admin-console/application-details.ts | 1 + .../admin-console/organization-details.ts | 8 +- 11 files changed, 302 insertions(+), 204 deletions(-) create mode 100644 packages/console/src/hooks/use-console-routes/routes/mfa.tsx create mode 100644 packages/console/src/pages/OrganizationDetails/Settings/JitSettings.tsx diff --git a/packages/console/src/components/OrganizationList/index.tsx b/packages/console/src/components/OrganizationList/index.tsx index b68c766c7b3..a924e830646 100644 --- a/packages/console/src/components/OrganizationList/index.tsx +++ b/packages/console/src/components/OrganizationList/index.tsx @@ -1,5 +1,5 @@ import { type OrganizationWithRoles } from '@logto/schemas'; -import { useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; @@ -23,9 +23,11 @@ import * as styles from './index.module.scss'; type Props = { readonly type: 'user' | 'application'; 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 { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { getPathname } = useTenantPathname(); @@ -44,7 +46,7 @@ function OrganizationList({ type, data: { id } }: Props) { isLoading={isLoading} rowIndexKey="id" rowGroups={[{ key: 'data', data }]} - placeholder={} + placeholder={placeholder ?? } columns={[ { title: t('general.name'), diff --git a/packages/console/src/components/OrganizationRolePermissionsAssignmentModal/index.tsx b/packages/console/src/components/OrganizationRolePermissionsAssignmentModal/index.tsx index e39ba0c4194..c277e8f1c16 100644 --- a/packages/console/src/components/OrganizationRolePermissionsAssignmentModal/index.tsx +++ b/packages/console/src/components/OrganizationRolePermissionsAssignmentModal/index.tsx @@ -97,7 +97,7 @@ function OrganizationRolePermissionsAssignmentModal({ subtitle="organization_role_details.permissions.assign_description" confirmButtonType="primary" confirmButtonText="general.save" - cancelButtonText="general.discard" + cancelButtonText="general.skip" size="large" onCancel={onCloseHandler} onConfirm={onSubmitHandler} diff --git a/packages/console/src/consts/external-links.ts b/packages/console/src/consts/external-links.ts index 170e4a2d4de..67e91885637 100644 --- a/packages/console/src/consts/external-links.ts +++ b/packages/console/src/consts/external-links.ts @@ -27,3 +27,8 @@ export const organizationRoleLink = export const organizationPermissionLink = '/docs/recipes/organizations/understand-how-it-works/#organization-permission'; 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', +}); diff --git a/packages/console/src/hooks/use-console-routes/index.tsx b/packages/console/src/hooks/use-console-routes/index.tsx index c92790d5eb9..f03eeec9e4a 100644 --- a/packages/console/src/hooks/use-console-routes/index.tsx +++ b/packages/console/src/hooks/use-console-routes/index.tsx @@ -5,7 +5,6 @@ import { type RouteObject } from 'react-router-dom'; import { isCloud } from '@/consts/env'; import Dashboard from '@/pages/Dashboard'; import GetStarted from '@/pages/GetStarted'; -import Mfa from '@/pages/Mfa'; import NotFound from '@/pages/NotFound'; import SigningKeys from '@/pages/SigningKeys'; @@ -15,6 +14,7 @@ import { auditLogs } from './routes/audit-logs'; import { connectors } from './routes/connectors'; import { customizeJwt } from './routes/customize-jwt'; import { enterpriseSso } from './routes/enterprise-sso'; +import { mfa } from './routes/mfa'; import { organizationTemplate } from './routes/organization-template'; import { organizations } from './routes/organizations'; import { roles } from './routes/roles'; @@ -35,7 +35,7 @@ export const useConsoleRoutes = () => { applications, apiResources, signInExperience, - { path: 'mfa', element: }, + mfa, connectors, enterpriseSso, webhooks, diff --git a/packages/console/src/hooks/use-console-routes/routes/mfa.tsx b/packages/console/src/hooks/use-console-routes/routes/mfa.tsx new file mode 100644 index 00000000000..8225a573d4c --- /dev/null +++ b/packages/console/src/hooks/use-console-routes/routes/mfa.tsx @@ -0,0 +1,5 @@ +import { type RouteObject } from 'react-router-dom'; + +import Mfa from '@/pages/Mfa'; + +export const mfa: RouteObject = { path: 'mfa', element: }; diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx index 8fb430664f5..ccf9522ffa8 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx @@ -15,6 +15,7 @@ import ApplicationIcon from '@/components/ApplicationIcon'; import DetailsForm from '@/components/DetailsForm'; import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader'; import Drawer from '@/components/Drawer'; +import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import OrganizationList from '@/components/OrganizationList'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import { ApplicationDetailsTabs, logtoThirdPartyGuideLink, protectedAppLink } from '@/consts'; @@ -22,7 +23,9 @@ import { isDevFeaturesEnabled } from '@/consts/env'; import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal'; import TabNav, { TabNavItem } from '@/ds-components/TabNav'; import TabWrapper from '@/ds-components/TabWrapper'; +import TextLink from '@/ds-components/TextLink'; import useApi from '@/hooks/use-api'; +import { organizations } from '@/hooks/use-console-routes/routes/organizations'; import useDocumentationUrl from '@/hooks/use-documentation-url'; import useTenantPathname from '@/hooks/use-tenant-pathname'; import { applicationTypeI18nKey } from '@/types/applications'; @@ -237,7 +240,20 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P isActive={tab === ApplicationDetailsTabs.Organizations} className={styles.tabContainer} > - + }} + /> + } + /> + } + /> )} diff --git a/packages/console/src/pages/OrganizationDetails/Settings/JitSettings.tsx b/packages/console/src/pages/OrganizationDetails/Settings/JitSettings.tsx new file mode 100644 index 00000000000..c7c4f889903 --- /dev/null +++ b/packages/console/src/pages/OrganizationDetails/Settings/JitSettings.tsx @@ -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; +}; + +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( + (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 ( + + + ), + }} + /> + } + descriptionPosition="top" + > + {ssoConnectorIds.length === 0 && ( + + }} + /> + + )} + {ssoConnectorIds.length > 0 && ( + ( +
+ {value.map((id) => { + const connector = allSsoConnectors?.find( + ({ id: connectorId }) => id === connectorId + ); + return ( + connector && ( +
+
+ + + {connector.connectorName} - {connector.providerName} + +
+ { + onChange(value.filter((value) => value !== id)); + }} + > + + +
+ ) + ); + })} + , + className: styles.addSsoConnectorButton, + }} + dropdownHorizontalAlign="start" + > + {allSsoConnectors + ?.filter(({ id }) => !value.includes(id)) + .map((connector) => ( + { + onChange([...value, connector.id]); + }} + > + + {connector.connectorName} + + ))} + +
+ )} + /> + )} +
+ + ), + }} + /> + } + descriptionPosition="top" + className={styles.jitEmailDomains} + > + ( + (hasSsoEnabled(domain) ? styles.ssoEnabled : undefined)} + renderValue={(value) => + hasSsoEnabled(value) ? ( + <> + + {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 && ( + + {t('organization_details.jit.sso_enabled_domain_warning')} + + )} + + + ( + + )} + /> + +
+ ); +} + +export default JitSettings; diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx index 3b2978e108d..55e2d164829 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx @@ -1,41 +1,27 @@ -import { - type SignInExperience, - type Organization, - type SsoConnectorWithProviderConfig, - RoleType, -} from '@logto/schemas'; -import { useState, useCallback, useMemo } from 'react'; +import { type SignInExperience, type Organization } from '@logto/schemas'; import { Controller, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; +import { Trans, 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'; -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 Switch from '@/ds-components/Switch'; import TextInput from '@/ds-components/TextInput'; +import TextLink from '@/ds-components/TextLink'; import useApi, { type RequestError } from '@/hooks/use-api'; -import SsoConnectorLogo from '@/pages/EnterpriseSso/SsoConnectorLogo'; -import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts'; +import { mfa } from '@/hooks/use-console-routes/routes/mfa'; import { trySubmitSafe } from '@/utils/form'; import { type OrganizationDetailsOutletContext } from '../types'; +import JitSettings from './JitSettings'; import * as styles from './index.module.scss'; import { assembleData, isJsonObject, normalizeData, type FormData } from './utils'; @@ -43,42 +29,23 @@ function Settings() { const { isDeleting, data, jit, onUpdated } = useOutletContext(); const { data: signInExperience } = useSWR('api/sign-in-exp'); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const form = useForm({ + defaultValues: normalizeData(data, { + emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain), + roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })), + ssoConnectorIds: jit.ssoConnectorIds, + }), + }); const { register, reset, control, handleSubmit, formState: { isDirty, isSubmitting, errors }, - setError, - clearErrors, watch, - } = useForm({ - defaultValues: normalizeData(data, { - emailDomains: jit.emailDomains.map(({ emailDomain }) => emailDomain), - roles: jit.roles.map(({ id, name }) => ({ value: id, title: name })), - ssoConnectorIds: jit.ssoConnectorIds, - }), - }); - const [isMfaRequired, emailDomains] = watch(['isMfaRequired', 'jitEmailDomains']); + } = form; + 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: ssoConnectorMatrix } = useSWRInfinite( - (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( trySubmitSafe(async (data) => { @@ -160,159 +127,20 @@ function Settings() { /> {isMfaRequired && signInExperience?.mfa.factors.length === 0 && ( - {t('organization_details.mfa.no_mfa_warning')} + , + }} + /> )} - {isDevFeaturesEnabled && ( - - - ( -
- {value.map((id) => { - const connector = allSsoConnectors?.find( - ({ id: connectorId }) => id === connectorId - ); - return ( - connector && ( -
-
- - - {connector.connectorName} - {connector.providerName} - -
- { - onChange(value.filter((value) => value !== id)); - }} - > - - -
- ) - ); - })} - , - className: styles.addSsoConnectorButton, - }} - dropdownHorizontalAlign="start" - > - {allSsoConnectors - ?.filter(({ id }) => !value.includes(id)) - .map((connector) => ( - { - onChange([...value, connector.id]); - }} - > - - {connector.connectorName} - - ))} - -
- )} - /> -
- - ( - - hasSsoEnabled(domain) ? styles.ssoEnabled : undefined - } - renderValue={(value) => - hasSsoEnabled(value) ? ( - <> - - {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 && ( - - {t('organization_details.jit.sso_enabled_domain_warning')} - - )} - - - ( - - )} - /> - -
- )} + {isDevFeaturesEnabled && } ); - - // eslint-disable-next-line max-lines -- Should be ok once dev features flag is removed } export default Settings; diff --git a/packages/integration-tests/src/ui-helpers/expect-organizations.ts b/packages/integration-tests/src/ui-helpers/expect-organizations.ts index 53a97c7403f..9d96ec62dfd 100644 --- a/packages/integration-tests/src/ui-helpers/expect-organizations.ts +++ b/packages/integration-tests/src/ui-helpers/expect-organizations.ts @@ -72,7 +72,7 @@ export default class ExpectOrganizations extends ExpectConsole { // Skip permission assignment await this.toExpectModal('Assign permissions'); - await this.toClickButton('Discard'); + await this.toClickButton('Skip'); this.toMatchUrl(/\/organization-template\/organization-roles\/.+$/); } diff --git a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts index b7161bb9a8e..2973a4b4d9c 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts @@ -92,6 +92,7 @@ const application_details = { 'Ensure to protect your origin server from direct access. Refer to the guide for more detailed instructions.', session_duration: 'Session duration (days)', try_it: 'Try it', + no_organization_placeholder: 'No organization found. Go to organizations', branding: { name: 'Branding', description: "Customize your application's display name and logo on the consent screen.", diff --git a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts index 2e051a54410..e15c948dfd1 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts @@ -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.', 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.', + 'New users signing up with their verified email addresses or through social sign-in with verified email addresses will automatically join the organization. Learn more', email_domain_placeholder: 'Enter email domains for just-in-time provisioning', invalid_domain: 'Invalid domain', 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 won’t be provisioned to this organization unless enterprise SSO provisioning is configured.', 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. Set up', 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.', + 'New users or existing users signing in through enterprise SSO for the first time will automatically join the organization. Learn more', organization_roles: 'Default organization roles', organization_roles_description: 'Assign roles to users upon joining the organization through just-in-time provisioning.', @@ -64,7 +66,7 @@ const organization_details = { description: 'Require users to configure multi-factor authentication to access this organization.', 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 multi-factor authentication method is enabled.', }, }; From 20c1bf87073c7db53355aafc1b953d54723197ab Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 28 Jun 2024 12:34:05 +0800 Subject: [PATCH 2/2] refactor: fix code editor title color --- packages/console/src/ds-components/CodeEditor/index.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/src/ds-components/CodeEditor/index.module.scss b/packages/console/src/ds-components/CodeEditor/index.module.scss index 4a28deeb5d4..1b93856fcd9 100644 --- a/packages/console/src/ds-components/CodeEditor/index.module.scss +++ b/packages/console/src/ds-components/CodeEditor/index.module.scss @@ -13,6 +13,7 @@ padding-bottom: _.unit(2); margin-bottom: _.unit(3); border-bottom: 1px solid var(--color-border); + color: #f7f8f8; } .placeholder {