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 ca22bc6
Show file tree
Hide file tree
Showing 38 changed files with 527 additions and 84 deletions.
43 changes: 43 additions & 0 deletions packages/console/src/assets/icons/organization-role-feature.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions packages/console/src/assets/icons/role-feature-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
Loading

0 comments on commit ca22bc6

Please sign in to comment.