Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console): m2m pages in organizations #6090

Merged
merged 1 commit into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
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
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
Loading