Skip to content

Commit

Permalink
feat(console): add tenant member and invitation lists (#5501)
Browse files Browse the repository at this point in the history
* feat(console): add tenant member and invitation lists

* refactor: polish code per comments

* fix: lockfile
  • Loading branch information
charIeszhao committed Mar 21, 2024
1 parent f1f6b1c commit 37cd6cc
Show file tree
Hide file tree
Showing 17 changed files with 561 additions and 12 deletions.
2 changes: 1 addition & 1 deletion packages/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.4.0",
"@logto/cloud": "0.2.5-4ef0b45",
"@logto/cloud": "0.2.5-d9576f9",
"@logto/connector-kit": "workspace:^2.1.0",
"@logto/core-kit": "workspace:^2.3.0",
"@logto/language-kit": "workspace:^1.1.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/console/src/assets/icons/invitation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/console/src/assets/icons/members.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 35 additions & 1 deletion packages/console/src/cloud/hooks/use-cloud-api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type router from '@logto/cloud/routes';
import { type tenantAuthRouter } from '@logto/cloud/routes';
import { useLogto } from '@logto/react';
import { getTenantOrganizationId } from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials';
import Client, { ResponseError } from '@withtyped/client';
import { useMemo } from 'react';
import { useContext, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import { z } from 'zod';

import { cloudApi } from '@/consts';
import { TenantsContext } from '@/contexts/TenantsProvider';

const responseErrorBodyGuard = z.object({
message: z.string(),
Expand Down Expand Up @@ -57,3 +60,34 @@ export const useCloudApi = ({ hideErrorToast = false }: UseCloudApiProps = {}):

return api;
};

/**
* This hook is used to request the cloud `tenantAuthRouter` endpoints, with an organization token.
*/
export const useAuthedCloudApi = ({ hideErrorToast = false }: UseCloudApiProps = {}): Client<
typeof tenantAuthRouter
> => {
const { currentTenantId } = useContext(TenantsContext);
const { isAuthenticated, getOrganizationToken } = useLogto();
const api = useMemo(
() =>
new Client<typeof tenantAuthRouter>({
baseUrl: window.location.origin,
headers: async () => {
if (isAuthenticated) {
return {
Authorization: `Bearer ${
(await getOrganizationToken(getTenantOrganizationId(currentTenantId))) ?? ''
}`,
};
}
},
before: {
...conditional(!hideErrorToast && { error: toastResponseError }),
},
}),
[currentTenantId, getOrganizationToken, hideErrorToast, isAuthenticated]
);

return api;
};
10 changes: 10 additions & 0 deletions packages/console/src/cloud/types/router.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type router from '@logto/cloud/routes';
import { type tenantAuthRouter } from '@logto/cloud/routes';
import { type GuardedResponse, type RouterRoutes } from '@withtyped/client';

type GetRoutes = RouterRoutes<typeof router>['get'];
type GetTenantAuthRoutes = RouterRoutes<typeof tenantAuthRouter>['get'];

export type GetArrayElementType<T> = T extends Array<infer U> ? U : never;

Expand All @@ -17,3 +19,11 @@ export type InvoicesResponse = GuardedResponse<GetRoutes['/api/tenants/:tenantId

// The response of GET /api/tenants is TenantResponse[].
export type TenantResponse = GetArrayElementType<GuardedResponse<GetRoutes['/api/tenants']>>;

export type TenantMemberResponse = GetArrayElementType<
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/members']>
>;

export type TenantInvitationResponse = GetArrayElementType<
GuardedResponse<GetTenantAuthRoutes['/api/tenants/:tenantId/invitations']>
>;
21 changes: 18 additions & 3 deletions packages/console/src/components/ItemPreview/UserPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,32 @@ import UserAvatar from '../UserAvatar';
import ItemPreview from '.';

type Props = {
user: UserInfo;
/**
* A subset of User schema type that is used in the preview component.
*/
user: {
id: UserInfo['id'];
avatar?: UserInfo['avatar'];
name?: UserInfo['name'];
primaryEmail?: UserInfo['primaryEmail'];
primaryPhone?: UserInfo['primaryPhone'];
username?: UserInfo['username'];
isSuspended?: UserInfo['isSuspended'];
};
/**
* Whether to provide a link to user details page. Explicitly set to `false` to hide it.
*/
hasUserDetailsLink?: false;
};

/** A component that renders a preview of a user. It's useful for displaying a user in a list. */
function UserPreview({ user }: Props) {
function UserPreview({ user, hasUserDetailsLink }: Props) {
return (
<ItemPreview
title={getUserTitle(user)}
subtitle={getUserSubtitle(user)}
icon={<UserAvatar size="large" user={user} />}
to={`/users/${user.id}`}
to={conditional(hasUserDetailsLink !== false && `/users/${user.id}`)}
suffix={conditional(user.isSuspended && <SuspendedTag />)}
/>
);
Expand Down
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 @@ -37,6 +37,7 @@ export enum RoleDetailsTabs {

export enum TenantSettingsTabs {
Settings = 'settings',
Members = 'members',
Domains = 'domains',
Subscription = 'subscription',
BillingHistory = 'billing-history',
Expand Down
4 changes: 4 additions & 0 deletions packages/console/src/containers/ConsoleContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import BillingHistory from '@/pages/TenantSettings/BillingHistory';
import Subscription from '@/pages/TenantSettings/Subscription';
import TenantBasicSettings from '@/pages/TenantSettings/TenantBasicSettings';
import TenantDomainSettings from '@/pages/TenantSettings/TenantDomainSettings';
import TenantMembers from '@/pages/TenantSettings/TenantMembers';
import UserDetails from '@/pages/UserDetails';
import UserLogs from '@/pages/UserDetails/UserLogs';
import UserOrganizations from '@/pages/UserDetails/UserOrganizations';
Expand Down Expand Up @@ -196,6 +197,9 @@ function ConsoleContent() {
<Route path="tenant-settings" element={<TenantSettings />}>
<Route index element={<Navigate replace to={TenantSettingsTabs.Settings} />} />
<Route path={TenantSettingsTabs.Settings} element={<TenantBasicSettings />} />
{isDevFeaturesEnabled && (
<Route path={`${TenantSettingsTabs.Members}/*`} element={<TenantMembers />} />
)}
<Route path={TenantSettingsTabs.Domains} element={<TenantDomainSettings />} />
{!isDevTenant && (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { TenantRole } from '@logto/schemas';
import { getUserDisplayName } from '@logto/shared/universal';
import { useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { z } from 'zod';

import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantMemberResponse } from '@/cloud/types/router';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select from '@/ds-components/Select';
import * as modalStyles from '@/scss/modal.module.scss';

type Props = {
user: TenantMemberResponse;
isOpen: boolean;
onClose: () => void;
};

function EditMemberModal({ user, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const { currentTenantId } = useContext(TenantsContext);

const [isLoading, setIsLoading] = useState(false);
const [role, setRole] = useState(TenantRole.Member);
const cloudApi = useAuthedCloudApi();

const roleOptions = useMemo(
() => [
{ value: TenantRole.Admin, title: t('admin') },
{ value: TenantRole.Member, title: t('member') },
],
[t]
);

const onSubmit = async () => {
setIsLoading(true);
try {
await cloudApi.put(`/api/tenants/:tenantId/members/:userId/roles`, {
params: { tenantId: currentTenantId, userId: user.id },
body: { roleName: role },
});
onClose();
} finally {
setIsLoading(false);
}
};

return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onClose}
>
<ModalLayout
title={<>{t('edit_modal.title', { name: getUserDisplayName(user) })}</>}
footer={
<Button
size="large"
type="primary"
title="general.save"
isLoading={isLoading}
onClick={onSubmit}
/>
}
onClose={onClose}
>
<FormField title="tenant_members.roles">
<Select
options={roleOptions}
value={role}
onChange={(value) => {
const guardResult = z.nativeEnum(TenantRole).safeParse(value);
setRole(guardResult.success ? guardResult.data : TenantRole.Member);
}}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
}

export default EditMemberModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
import { format } from 'date-fns';
import { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';

import Plus from '@/assets/icons/plus.svg';
import UsersEmptyDark from '@/assets/images/users-empty-dark.svg';
import UsersEmpty from '@/assets/images/users-empty.svg';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantInvitationResponse } from '@/cloud/types/router';
import ActionsButton from '@/components/ActionsButton';
import { RoleOption } from '@/components/OrganizationRolesSelect';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button';
import Table from '@/ds-components/Table';
import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import Tag, { type Props as TagProps } from '@/ds-components/Tag';
import { type RequestError } from '@/hooks/use-api';

const convertInvitationStatusToTagStatus = (
status: OrganizationInvitationStatus
): TagProps['status'] => {
switch (status) {
case OrganizationInvitationStatus.Pending: {
return 'alert';
}
case OrganizationInvitationStatus.Accepted: {
return 'success';
}
case OrganizationInvitationStatus.Revoked: {
return 'error';
}
default: {
return 'info';
}
}
};

function Invitations() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const cloudApi = useAuthedCloudApi();
const { currentTenantId } = useContext(TenantsContext);

const { data, error, isLoading, mutate } = useSWR<TenantInvitationResponse[], RequestError>(
`api/tenant/${currentTenantId}/invitations`,
async () =>
cloudApi.get('/api/tenants/:tenantId/invitations', { params: { tenantId: currentTenantId } })
);

const [showInviteModal, setShowInviteModal] = useState(false);

return (
<>
<Table
isRowHoverEffectDisabled
placeholder={
<TablePlaceholder
image={<UsersEmpty />}
imageDark={<UsersEmptyDark />}
title="tenant_members.invitation_empty_placeholder.title"
description="tenant_members.invitation_empty_placeholder.description"
action={
<Button
title="tenant_members.invite_member"
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
setShowInviteModal(true);
}}
/>
}
/>
}
isLoading={isLoading}
errorMessage={error?.toString()}
rowGroups={[{ key: 'data', data }]}
columns={[
{
dataIndex: 'user',
colSpan: 4,
title: t('user'),
render: ({ invitee }) => <span>{invitee}</span>,
},
{
dataIndex: 'roles',
colSpan: 4,
title: t('roles'),
render: ({ organizationRoles }) => {
if (organizationRoles.length === 0) {
return '-';
}

return organizationRoles.map(({ id, name }) => (
<Tag key={id} variant="cell">
<RoleOption value={id} title={name} />
</Tag>
));
},
},
{
dataIndex: 'status',
colSpan: 4,
title: t('invitation_status'),
render: ({ status }) => (
<Tag type="state" status={convertInvitationStatusToTagStatus(status)}>
{status}
</Tag>
),
},
{
dataIndex: 'sentAt',
colSpan: 4,
title: t('invitation_sent'),
render: ({ createdAt }) => <span>{format(createdAt, 'MMM do, yyyy')}</span>,
},
{
dataIndex: 'expiresAt',
colSpan: 4,
title: t('expiration_date'),
render: ({ expiresAt }) => <span>{format(expiresAt, 'MMM do, yyyy')}</span>,
},
{
dataIndex: 'actions',
title: null,
render: (invitation) => (
<ActionsButton
deleteConfirmation="tenant_members.delete_user_confirm"
fieldName="tenant_members.user"
textOverrides={{
edit: 'tenant_members.menu_options.resend_invite',
delete: 'tenant_members.menu_options.revoke',
deleteConfirmation: 'general.remove',
}}
onDelete={async () => {
await cloudApi.delete(`/api/tenants/:tenantId/invitations/:invitationId`, {
params: { tenantId: currentTenantId, invitationId: invitation.id },
});
void mutate();
}}
/>
),
},
]}
rowIndexKey="id"
/>
{/* TODO: Implemented in the follow-up PR */}
{/* {showInviteModal && <InviteModal isOpen={showInviteModal} />} */}
</>
);
}

export default Invitations;
Loading

0 comments on commit 37cd6cc

Please sign in to comment.