Skip to content

Commit

Permalink
Merge pull request #6088 from logto-io/gao-console-m2m-organizations
Browse files Browse the repository at this point in the history
feat(console): show organization list for m2m apps
  • Loading branch information
gao-sun authored Jun 24, 2024
2 parents d7d2af6 + 88f94c7 commit b489626
Show file tree
Hide file tree
Showing 24 changed files with 164 additions and 122 deletions.
13 changes: 13 additions & 0 deletions packages/console/src/components/OrganizationList/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use '@/scss/underscore' as _;

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

.rolesHeader {
display: flex;
align-items: center;
gap: _.unit(0.5);
}
109 changes: 109 additions & 0 deletions packages/console/src/components/OrganizationList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { type OrganizationWithRoles } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';

import OrganizationIcon from '@/assets/icons/organization-preview.svg';
import Tip from '@/assets/icons/tip.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview';
import { RoleOption } from '@/components/OrganizationRolesSelect';
import ThemedIcon from '@/components/ThemedIcon';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import IconButton from '@/ds-components/IconButton';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag';
import { ToggleTip } from '@/ds-components/Tip';
import { type RequestError } from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';

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

type Props = {
readonly type: 'user' | 'application';
readonly data: { id: string };
};

function OrganizationList({ type, data: { id } }: Props) {
const [keyword, setKeyword] = useState('');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getPathname } = useTenantPathname();

// Since these APIs' pagination are optional or disabled (to align with ID token claims):
// - We don't need to use the `page` state.
// - We can perform frontend filtering.
const { data: rawData, error } = useSWR<OrganizationWithRoles[], RequestError>(
`api/${type}s/${id}/organizations`
);
const isLoading = !rawData && !error;
const data = rawData?.filter(({ name }) => name.toLowerCase().includes(keyword.toLowerCase()));

return (
<Table
isLoading={isLoading}
rowIndexKey="id"
rowGroups={[{ key: 'data', data }]}
placeholder={<EmptyDataPlaceholder />}
columns={[
{
title: t('general.name'),
dataIndex: 'name',
render: ({ name, id }) => (
<ItemPreview
title={name}
icon={<ThemedIcon for={OrganizationIcon} />}
to={getPathname(`/organizations/${id}`)}
/>
),
},
{
title: t('organizations.organization_id'),
dataIndex: 'id',
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
},
{
title: (
<div className={styles.rolesHeader}>
{t('organizations.organization_role_other')}
<ToggleTip
content={t('organization_details.organization_roles_tooltip', {
type: t(`organization_details.${type}`),
})}
horizontalAlign="start"
>
<IconButton size="small">
<Tip />
</IconButton>
</ToggleTip>
</div>
),
dataIndex: 'roles',
render: ({ organizationRoles }) => (
<div className={styles.roles}>
{organizationRoles.map(({ id, name }) => (
<Tag key={id} variant="cell">
<RoleOption value={id} title={name} />
</Tag>
))}
{organizationRoles.length === 0 && '-'}
</div>
),
},
]}
filter={
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t(`organization_details.search_${type}_placeholder`)}
onSearch={setKeyword}
onClearSearch={() => {
setKeyword('');
}}
/>
}
/>
);
}

export default OrganizationList;
1 change: 1 addition & 0 deletions packages/console/src/consts/page-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum ApplicationDetailsTabs {
Logs = 'logs',
Branding = 'branding',
Permissions = 'permissions',
Organizations = 'organizations',
}

export enum ApiResourceDetailsTabs {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Application, Role } from '@logto/schemas';
import { RoleType, Theme } from '@logto/schemas';
import { RoleType, Theme, roleTypeToKey } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
Expand Down Expand Up @@ -86,7 +86,7 @@ function MachineToMachineApplicationRoles({ application }: Props) {
rowIndexKey="id"
columns={[
{
title: t('application_details.roles.name_column'),
title: t('roles.role_name'),
dataIndex: 'name',
colSpan: 6,
render: ({ id, name }) => (
Expand All @@ -104,7 +104,13 @@ function MachineToMachineApplicationRoles({ application }: Props) {
),
},
{
title: t('application_details.roles.description_column'),
title: t('roles.col_type'),
dataIndex: 'type',
colSpan: 4,
render: ({ type }) => <div>{t(`roles.type_${roleTypeToKey[type]}`)}</div>,
},
{
title: t('roles.col_description'),
dataIndex: 'description',
colSpan: 9,
render: ({ description }) => <div className={styles.description}>{description}</div>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import ApplicationIcon from '@/components/ApplicationIcon';
import DetailsForm from '@/components/DetailsForm';
import DetailsPageHeader from '@/components/DetailsPage/DetailsPageHeader';
import Drawer from '@/components/Drawer';
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';
Expand Down Expand Up @@ -168,11 +170,16 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
{data.type === ApplicationType.MachineToMachine && (
<>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Roles}`}>
{t('application_details.application_roles')}
{t('roles.col_roles')}
</TabNavItem>
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Logs}`}>
{t('application_details.machine_logs')}
</TabNavItem>
{isDevFeaturesEnabled && (
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Organizations}`}>
{t('organizations.title')}
</TabNavItem>
)}
</>
)}
{data.isThirdParty && (
Expand Down Expand Up @@ -212,7 +219,6 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} onConfirm={reset} />
)}
</TabWrapper>

{data.type === ApplicationType.MachineToMachine && (
<>
<TabWrapper
Expand All @@ -227,6 +233,12 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
>
<MachineLogs applicationId={data.id} />
</TabWrapper>
<TabWrapper
isActive={tab === ApplicationDetailsTabs.Organizations}
className={styles.tabContainer}
>
<OrganizationList type="application" data={data} />
</TabWrapper>
</>
)}
{data.isThirdParty && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: P
<ModalLayout
title={
<>
{t('organization_details.edit_organization_roles_of_user', {
{t('organization_details.edit_organization_roles_title', {
name,
})}
</>
Expand Down
102 changes: 3 additions & 99 deletions packages/console/src/pages/UserDetails/UserOrganizations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,108 +1,12 @@
import { type OrganizationWithRoles } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import useSWR from 'swr';

import OrganizationIcon from '@/assets/icons/organization-preview.svg';
import Tip from '@/assets/icons/tip.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import ItemPreview from '@/components/ItemPreview';
import { RoleOption } from '@/components/OrganizationRolesSelect';
import ThemedIcon from '@/components/ThemedIcon';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import IconButton from '@/ds-components/IconButton';
import Search from '@/ds-components/Search';
import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag';
import { ToggleTip } from '@/ds-components/Tip';
import { type RequestError } from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { buildUrl } from '@/utils/url';
import OrganizationList from '@/components/OrganizationList';

import { type UserDetailsOutletContext } from '../types';

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

function UserOrganizations() {
const [keyword, setKeyword] = useState('');
const {
user: { id },
} = useOutletContext<UserDetailsOutletContext>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getPathname } = useTenantPathname();

// Since this API has no pagination (to align with ID token claims):
// - We don't need to use the `page` state.
// - We can perform frontend filtering.
const { data: rawData, error } = useSWR<OrganizationWithRoles[], RequestError>(
buildUrl(`api/users/${id}/organizations`, { showFeatured: '1' })
);
const isLoading = !rawData && !error;
const data = rawData?.filter(({ name }) => name.toLowerCase().includes(keyword.toLowerCase()));

return (
<Table
isLoading={isLoading}
rowIndexKey="id"
rowGroups={[{ key: 'data', data }]}
placeholder={<EmptyDataPlaceholder />}
columns={[
{
title: t('general.name'),
dataIndex: 'name',
render: ({ name, id }) => (
<ItemPreview
title={name}
icon={<ThemedIcon for={OrganizationIcon} />}
to={getPathname(`/organizations/${id}`)}
/>
),
},
{
title: t('organizations.organization_id'),
dataIndex: 'id',
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
},
{
title: (
<div className={styles.rolesHeader}>
{t('organizations.organization_role_other')}
<ToggleTip
content={t('user_details.organization_roles_tooltip')}
horizontalAlign="start"
>
<IconButton size="small">
<Tip />
</IconButton>
</ToggleTip>
</div>
),
dataIndex: 'roles',
render: ({ organizationRoles }) => (
<div className={styles.roles}>
{organizationRoles.map(({ id, name }) => (
<Tag key={id} variant="cell">
<RoleOption value={id} title={name} />
</Tag>
))}
</div>
),
},
]}
filter={
<Search
defaultValue={keyword}
isClearable={Boolean(keyword)}
placeholder={t('organization_details.search_user_placeholder')}
onSearch={setKeyword}
onClearSearch={() => {
setKeyword('');
}}
/>
}
/>
);
const { user } = useOutletContext<UserDetailsOutletContext>();
return <OrganizationList type="user" data={user} />;
}

export default UserOrganizations;
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ describe('M2M RBAC', () => {
it('add a role to m2m app on the application details page', async () => {
// Go to roles tab
await expect(page).toClick('nav div[class$=item] div[class$=link] a', {
text: 'Machine-to-machine roles',
text: 'Roles',
});

await expect(page).toClick('div[class$=filter] button span', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const application_details = {
refresh_token_settings: 'Auffrischungstoken',
refresh_token_settings_description:
'Verwalten Sie die Auffrischungstoken-Regeln für diese Anwendung.',
application_roles: 'Rollen von Maschine zu Maschine',
machine_logs: 'Maschinenprotokolle',
application_name: 'Anwendungsname',
application_name_placeholder: 'Meine App',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ const application_details = {
'Use the following endpoints and credentials to set up the OIDC connection in your application.',
refresh_token_settings: 'Refresh token',
refresh_token_settings_description: 'Manage the refresh token rules for this application.',
application_roles: 'Machine-to-machine roles',
machine_logs: 'Machine logs',
application_name: 'Application name',
application_name_placeholder: 'My App',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,26 @@ const organization_details = {
'Find appropriate users by searching name, email, phone, or user ID. Existing members are not shown in the search results.',
add_with_organization_role: 'Add with organization role(s)',
user: 'User',
application: 'Application',
application_other: 'Applications',
add_applications_to_organization: 'Add applications to organization {{name}}',
add_applications_to_organization_description:
'Find appropriate applications by searching app ID, name, or description. Existing applications are not shown in the search results.',
at_least_one_application: 'At least one application is required.',
remove_application_from_organization: 'Remove application from organization',
remove_application_from_organization_description:
'Once removed, the application will lose its association and roles in this organization. This action cannot be undone.',
search_application_placeholder: 'Search by app ID, name, or description',
roles: 'Organization roles',
authorize_to_roles: 'Authorize {{name}} to access the following roles:',
edit_organization_roles: 'Edit organization roles',
edit_organization_roles_of_user: 'Edit organization roles of {{name}}',
edit_organization_roles_title: 'Edit organization roles of {{name}}',
remove_user_from_organization: 'Remove user from organization',
remove_user_from_organization_description:
'Once removed, the user will lose their membership and roles in this organization. This action cannot be undone.',
search_user_placeholder: 'Search by name, email, phone or user ID',
at_least_one_user: 'At least one user is required.',
organization_roles_tooltip: 'The roles assigned to the {{type}} within this organization.',
custom_data: 'Custom data',
custom_data_tip:
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ const application_details = {
refresh_token_settings: 'Token de actualización',
refresh_token_settings_description:
'Gestiona las reglas del token de actualización para esta aplicación.',
application_roles: 'Roles de máquina a máquina',
machine_logs: 'Registros de Máquina',
application_name: 'Nombre de Aplicación',
application_name_placeholder: 'Mi App',
Expand Down
Loading

0 comments on commit b489626

Please sign in to comment.