Skip to content

Commit

Permalink
feat(console): m2m pages in organizations
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 23, 2024
1 parent 88f94c7 commit 5ccef4c
Show file tree
Hide file tree
Showing 34 changed files with 449 additions and 80 deletions.
33 changes: 33 additions & 0 deletions packages/console/src/components/ItemPreview/ApplicationPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type Application } from '@logto/schemas';
import { useTranslation } from 'react-i18next';

import ApplicationIcon from '@/components/ApplicationIcon';
import { applicationTypeI18nKey } from '@/types/applications';

import ItemPreview from '.';
import * as styles from './index.module.scss';

const applicationsPathname = '/applications';
const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`;

type Props = {
readonly data: Pick<Application, 'id' | 'name' | 'isThirdParty' | 'type'>;
};

function ApplicationPreview({ data: { id, name, isThirdParty, type } }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

return (
<ItemPreview
title={name}
subtitle={
isThirdParty
? t(`${applicationTypeI18nKey.thirdParty}.title`)
: t(`${applicationTypeI18nKey[type]}.title`)
}
icon={<ApplicationIcon className={styles.icon} type={type} isThirdParty={isThirdParty} />}
to={buildDetailsPathname(id)}
/>
);
}
export default ApplicationPreview;
13 changes: 4 additions & 9 deletions packages/console/src/components/ItemPreview/UserPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,10 @@ type Props = {
/**
* A subset of User schema type that is used in the preview component.
*/
readonly user: {
id: UserInfo['id'];
avatar?: UserInfo['avatar'];
name?: UserInfo['name'];
primaryEmail?: UserInfo['primaryEmail'];
primaryPhone?: UserInfo['primaryPhone'];
username?: UserInfo['username'];
isSuspended?: UserInfo['isSuspended'];
};
readonly user: Partial<
Pick<UserInfo, 'avatar' | 'name' | 'primaryEmail' | 'primaryPhone' | 'username' | 'isSuspended'>
> &
Pick<UserInfo, 'id'>;
/**
* Whether to provide a link to user details page. Explicitly set to `false` to hide it.
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/components/ItemPreview/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@
}
}
}

.icon {
flex-shrink: 0;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type OrganizationScope } from '@logto/schemas';
import { type OrganizationRole, type RoleType } from '@logto/schemas';
import classNames from 'classnames';

import RoleIcon from '@/assets/icons/role-feature.svg';
Expand Down Expand Up @@ -30,18 +30,21 @@ type Props = {
readonly onChange: (value: Array<Option<string>>) => void;
readonly keyword: string;
readonly setKeyword: (keyword: string) => void;
readonly roleType: RoleType;
};

function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props) {
const { data: scopes, isLoading } = useSearchValues<OrganizationScope>(
function OrganizationRolesSelect({ value, onChange, keyword, setKeyword, roleType }: Props) {
const { data: roles, isLoading } = useSearchValues<OrganizationRole>(
'api/organization-roles',
keyword
);

return (
<MultiSelect
value={value}
options={scopes.map(({ id, name }) => ({ value: id, title: name }))}
options={roles
.filter(({ type }) => type === roleType)
.map(({ id, name }) => ({ value: id, title: name }))}
placeholder="organizations.search_role_placeholder"
isOptionsLoading={isLoading}
renderOption={RoleOption}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { condArray } from '@silverhand/essentials';
import { Navigate, type RouteObject } from 'react-router-dom';

import { isDevFeaturesEnabled } from '@/consts/env';
import OrganizationDetails from '@/pages/OrganizationDetails';
import MachineToMachine from '@/pages/OrganizationDetails/MachineToMachine';
import Members from '@/pages/OrganizationDetails/Members';
import Settings from '@/pages/OrganizationDetails/Settings';
import { OrganizationDetailsTabs } from '@/pages/OrganizationDetails/types';
Expand All @@ -15,11 +17,15 @@ export const organizations: RouteObject = {
{
path: ':id/*',
element: <OrganizationDetails />,
children: [
children: condArray(
{ index: true, element: <Navigate replace to={OrganizationDetailsTabs.Settings} /> },
{ path: OrganizationDetailsTabs.Settings, element: <Settings /> },
{ path: OrganizationDetailsTabs.Members, element: <Members /> },
],
isDevFeaturesEnabled && {
path: OrganizationDetailsTabs.MachineToMachine,
element: <MachineToMachine />,
}
),
}
),
};
23 changes: 2 additions & 21 deletions packages/console/src/pages/Applications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { useLocation } from 'react-router-dom';

import Plus from '@/assets/icons/plus.svg';
import ApplicationCreation from '@/components/ApplicationCreation';
import ApplicationIcon from '@/components/ApplicationIcon';
import ChargeNotification from '@/components/ChargeNotification';
import { type SelectedGuide } from '@/components/Guide/GuideCard';
import ItemPreview from '@/components/ItemPreview';
import ApplicationPreview from '@/components/ItemPreview/ApplicationPreview';
import PageMeta from '@/components/PageMeta';
import { isCloud } from '@/consts/env';
import Button from '@/ds-components/Button';
Expand All @@ -20,7 +19,6 @@ import Table from '@/ds-components/Table';
import useApplicationsUsage from '@/hooks/use-applications-usage';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as pageLayout from '@/scss/page-layout.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import { buildUrl } from '@/utils/url';

import GuideLibrary from './components/GuideLibrary';
Expand Down Expand Up @@ -178,24 +176,7 @@ function Applications({ tab }: Props) {
title: t('applications.application_name'),
dataIndex: 'name',
colSpan: 6,
render: ({ id, name, type, isThirdParty }) => (
<ItemPreview
title={name}
subtitle={
isThirdParty
? t(`${applicationTypeI18nKey.thirdParty}.title`)
: t(`${applicationTypeI18nKey[type]}.title`)
}
icon={
<ApplicationIcon
className={styles.icon}
type={type}
isThirdParty={isThirdParty}
/>
}
to={buildDetailsPathname(id)}
/>
),
render: (data) => <ApplicationPreview data={data} />,
},
{
title: t('applications.app_id'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type UserWithOrganizationRoles } from '@logto/schemas';
import { RoleType, type OrganizationRoleEntity } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
Expand All @@ -12,27 +13,39 @@ import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { decapitalize } from '@/utils/string';

type WithOrganizationRoles = {
id: string;
name?: Nullable<string>;
organizationRoles: OrganizationRoleEntity[];
};

type Props = {
readonly type: 'user' | 'application';
readonly organizationId: string;
readonly user: UserWithOrganizationRoles;
readonly data: WithOrganizationRoles;
readonly isOpen: boolean;
readonly onClose: () => void;
};

function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: Props) {
const keyToRoleType = Object.freeze({
user: RoleType.User,
application: RoleType.MachineToMachine,
} satisfies Record<Props['type'], RoleType>);

function EditOrganizationRolesModal({ organizationId, data, isOpen, onClose, type }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [keyword, setKeyword] = useState('');
const [roles, setRoles] = useState<Array<Option<string>>>(
user.organizationRoles.map(({ id, name }) => ({ value: id, title: name }))
data.organizationRoles.map(({ id, name }) => ({ value: id, title: name }))
);
const name = user.name ?? decapitalize(t('organization_details.user'));
const name = data.name ?? decapitalize(t(`organization_details.${type}`));
const [isLoading, setIsLoading] = useState(false);
const api = useApi();

const onSubmit = async () => {
setIsLoading(true);
try {
await api.put(`api/organizations/${organizationId}/users/${user.id}/roles`, {
await api.put(`api/organizations/${organizationId}/${type}s/${data.id}/roles`, {
json: {
organizationRoleIds: roles.map(({ value }) => value),
},
Expand Down Expand Up @@ -72,6 +85,7 @@ function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: P
>
<FormField title="organizations.organization_role_other">
<OrganizationRolesSelect
roleType={keyToRoleType[type]}
value={roles}
keyword={keyword}
setKeyword={setKeyword}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { type Organization, type Application, ApplicationType, RoleType } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import EntitiesTransfer from '@/components/EntitiesTransfer';
import { ApplicationItem } from '@/components/EntitiesTransfer/components/EntityItem';
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import { type Option } from '@/ds-components/Select/MultiSelect';
import useActionTranslation from '@/hooks/use-action-translation';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';

type Props = {
readonly organization: Organization;
readonly isOpen: boolean;
readonly onClose: () => void;
};

function AddAppsToOrganization({ organization, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tAction = useActionTranslation();
const api = useApi();
const {
reset,
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<{
applications: Application[];
roles: Array<Option<string>>;
}>({
defaultValues: { applications: [], roles: [] },
});
const [keyword, setKeyword] = useState('');

const onSubmit = handleSubmit(
trySubmitSafe(async ({ applications, roles }) => {
await api.post(`api/organizations/${organization.id}/applications`, {
json: {
applicationIds: applications.map(({ id }) => id),
},
});

if (roles.length > 0) {
await api.post(`api/organizations/${organization.id}/applications/roles`, {
json: {
applicationIds: applications.map(({ id }) => id),
organizationRoleIds: roles.map(({ value }) => value),
},
});
}
onClose();
})
);

useEffect(() => {
if (isOpen) {
reset();
setKeyword('');
}
}, [isOpen, reset]);

return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onClose}
>
<ModalLayout
size="large"
title={
<DangerousRaw>
{t('organization_details.add_applications_to_organization', {
name: organization.name,
})}
</DangerousRaw>
}
subtitle="organization_details.add_applications_to_organization_description"
footer={
<Button
isLoading={isSubmitting}
size="large"
type="primary"
title={<>{tAction('add', 'organization_details.application_other')}</>}
onClick={onSubmit}
/>
}
onClose={onClose}
>
<FormField title="organization_details.application_other">
<Controller
name="applications"
control={control}
rules={{
validate: (value) => {
if (value.length === 0) {
return t('organization_details.at_least_one_application');
}
return true;
},
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<EntitiesTransfer
errorMessage={error?.message}
searchProps={{
pathname: 'api/applications',
parameters: {
excludeOrganizationId: organization.id,
types: ApplicationType.MachineToMachine,
},
}}
selectedEntities={value}
emptyPlaceholder="errors.empty"
renderEntity={(entity) => <ApplicationItem entity={entity} />}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField title="organization_details.add_with_organization_role">
<Controller
name="roles"
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationRolesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
roleType={RoleType.MachineToMachine}
onChange={onChange}
/>
)}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
}

export default AddAppsToOrganization;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use '@/scss/underscore' as _;

.roles {
display: flex;
flex-wrap: wrap;
gap: _.unit(2);
}

.filter {
display: flex;
gap: _.unit(2);
justify-content: space-between;
align-items: center;
}
Loading

0 comments on commit 5ccef4c

Please sign in to comment.