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): show organization list for m2m apps #6088

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
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
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
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
Loading