0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(console): polish ui (#6122)

* refactor(console): polish ui

* refactor: fix code editor title color
This commit is contained in:
Gao Sun 2024-06-28 12:56:35 +08:00 committed by GitHub
parent 07e3725740
commit f8f84f5d75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 304 additions and 205 deletions

View file

@ -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={<EmptyDataPlaceholder />}
placeholder={placeholder ?? <EmptyDataPlaceholder />}
columns={[
{
title: t('general.name'),

View file

@ -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}

View file

@ -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',
});

View file

@ -13,6 +13,7 @@
padding-bottom: _.unit(2);
margin-bottom: _.unit(3);
border-bottom: 1px solid var(--color-border);
color: #f7f8f8;
}
.placeholder {

View file

@ -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 /> },
mfa,
connectors,
enterpriseSso,
webhooks,

View file

@ -0,0 +1,5 @@
import { type RouteObject } from 'react-router-dom';
import Mfa from '@/pages/Mfa';
export const mfa: RouteObject = { path: 'mfa', element: <Mfa /> };

View file

@ -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}
>
<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>
</>
)}

View file

@ -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;

View file

@ -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<OrganizationDetailsOutletContext>();
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
register,
reset,
control,
handleSubmit,
formState: { isDirty, isSubmitting, errors },
setError,
clearErrors,
watch,
} = useForm<FormData>({
const form = useForm<FormData>({
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']);
const {
register,
reset,
control,
handleSubmit,
formState: { isDirty, isSubmitting, errors },
watch,
} = 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<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(
trySubmitSafe(async (data) => {
@ -160,159 +127,20 @@ function Settings() {
/>
{isMfaRequired && signInExperience?.mfa.factors.length === 0 && (
<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>
)}
</FormField>
</FormCard>
{isDevFeaturesEnabled && (
<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>
)}
{isDevFeaturesEnabled && <JitSettings form={form} />}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>
);
// eslint-disable-next-line max-lines -- Should be ok once dev features flag is removed
}
export default Settings;

View file

@ -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\/.+$/);
}

View file

@ -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>.',
session_duration: 'Session duration (days)',
try_it: 'Try it',
no_organization_placeholder: 'No organization found. <a>Go to organizations</a>',
branding: {
name: 'Branding',
description: "Customize your application's display name and logo on the consent screen.",

View file

@ -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. <a>Learn more</a>',
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 wont be provisioned to this organization unless enterprise SSO provisioning is configured.',
enterprise_sso: 'Enterprise SSO provisioning',
no_enterprise_connector_set:
'You havent 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',
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_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 <a>multi-factor authentication method</a> is enabled.',
},
};