Skip to content

Commit

Permalink
refactor(console): polish ui
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 27, 2024
1 parent b1e7f62 commit 20aa3ce
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 204 deletions.
8 changes: 5 additions & 3 deletions packages/console/src/components/OrganizationList/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
Expand All @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions packages/console/src/consts/external-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
4 changes: 2 additions & 2 deletions packages/console/src/hooks/use-console-routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand All @@ -35,7 +35,7 @@ export const useConsoleRoutes = () => {
applications,
apiResources,
signInExperience,
{ path: 'mfa', element: <Mfa /> },
mfa,
connectors,
enterpriseSso,
webhooks,
Expand Down
5 changes: 5 additions & 0 deletions packages/console/src/hooks/use-console-routes/routes/mfa.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { type RouteObject } from 'react-router-dom';

import Mfa from '@/pages/Mfa';

export const mfa: RouteObject = { path: 'mfa', element: <Mfa /> };
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ 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';
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';
Expand Down Expand Up @@ -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>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 20aa3ce

Please sign in to comment.