From d65f226b36853ccc9f9bf9267be001f46b70f424 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Thu, 14 Mar 2024 07:12:41 +0800 Subject: [PATCH] feat(console): add tenant member and invitation lists --- packages/console/package.json | 2 +- .../console/src/assets/icons/invitation.svg | 3 + packages/console/src/assets/icons/members.svg | 3 + .../console/src/cloud/hooks/use-cloud-api.ts | 36 +++++- packages/console/src/cloud/types/router.ts | 10 ++ .../components/ItemPreview/UserPreview.tsx | 17 ++- packages/console/src/consts/page-tabs.ts | 1 + .../src/containers/ConsoleContent/index.tsx | 4 + .../TenantMembers/EditMemberModal/index.tsx | 87 +++++++++++++ .../TenantMembers/Invitations/index.tsx | 121 ++++++++++++++++++ .../TenantMembers/Members/index.tsx | 104 +++++++++++++++ .../TenantMembers/index.module.scss | 48 +++++++ .../TenantSettings/TenantMembers/index.tsx | 62 +++++++++ .../src/pages/TenantSettings/index.tsx | 7 + packages/console/src/utils/user.ts | 4 +- .../core-kit/scss/_console-themes.scss | 10 ++ pnpm-lock.yaml | 14 +- 17 files changed, 523 insertions(+), 10 deletions(-) create mode 100644 packages/console/src/assets/icons/invitation.svg create mode 100644 packages/console/src/assets/icons/members.svg create mode 100644 packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx create mode 100644 packages/console/src/pages/TenantSettings/TenantMembers/Invitations/index.tsx create mode 100644 packages/console/src/pages/TenantSettings/TenantMembers/Members/index.tsx create mode 100644 packages/console/src/pages/TenantSettings/TenantMembers/index.module.scss create mode 100644 packages/console/src/pages/TenantSettings/TenantMembers/index.tsx diff --git a/packages/console/package.json b/packages/console/package.json index 6df6957fee44..ca269cff4214 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -26,7 +26,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", diff --git a/packages/console/src/assets/icons/invitation.svg b/packages/console/src/assets/icons/invitation.svg new file mode 100644 index 000000000000..7ee86538506b --- /dev/null +++ b/packages/console/src/assets/icons/invitation.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/assets/icons/members.svg b/packages/console/src/assets/icons/members.svg new file mode 100644 index 000000000000..be8a9de010d8 --- /dev/null +++ b/packages/console/src/assets/icons/members.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/cloud/hooks/use-cloud-api.ts b/packages/console/src/cloud/hooks/use-cloud-api.ts index 56c9b9cf2bbd..350928b7184f 100644 --- a/packages/console/src/cloud/hooks/use-cloud-api.ts +++ b/packages/console/src/cloud/hooks/use-cloud-api.ts @@ -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(), @@ -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({ + 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; +}; diff --git a/packages/console/src/cloud/types/router.ts b/packages/console/src/cloud/types/router.ts index f741b0abd932..67ccba04067f 100644 --- a/packages/console/src/cloud/types/router.ts +++ b/packages/console/src/cloud/types/router.ts @@ -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['get']; +type GetTenantAuthRoutes = RouterRoutes['get']; export type GetArrayElementType = T extends Array ? U : never; @@ -17,3 +19,11 @@ export type InvoicesResponse = GuardedResponse>; + +export type TenantMemberResponse = GetArrayElementType< + GuardedResponse +>; + +export type TenantInvitationResponse = GetArrayElementType< + GuardedResponse +>; diff --git a/packages/console/src/components/ItemPreview/UserPreview.tsx b/packages/console/src/components/ItemPreview/UserPreview.tsx index 7f16a3d490ef..ab722f429d26 100644 --- a/packages/console/src/components/ItemPreview/UserPreview.tsx +++ b/packages/console/src/components/ItemPreview/UserPreview.tsx @@ -9,17 +9,26 @@ import UserAvatar from '../UserAvatar'; import ItemPreview from '.'; type Props = { - user: User; + user: Pick & + Partial>; + /** + * Whether to show the user's avatar. Explicitly set to `false` to hide it. + */ + showAvatar?: false; + /** + * 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, showAvatar, hasUserDetailsLink }: Props) { return ( } - to={`/users/${user.id}`} + icon={conditional(showAvatar !== false && )} + to={conditional(hasUserDetailsLink !== false && `/users/${user.id}`)} suffix={conditional(user.isSuspended && )} /> ); diff --git a/packages/console/src/consts/page-tabs.ts b/packages/console/src/consts/page-tabs.ts index 5a2803434c0b..83ad9f7bc154 100644 --- a/packages/console/src/consts/page-tabs.ts +++ b/packages/console/src/consts/page-tabs.ts @@ -37,6 +37,7 @@ export enum RoleDetailsTabs { export enum TenantSettingsTabs { Settings = 'settings', + Members = 'members', Domains = 'domains', Subscription = 'subscription', BillingHistory = 'billing-history', diff --git a/packages/console/src/containers/ConsoleContent/index.tsx b/packages/console/src/containers/ConsoleContent/index.tsx index e4ba7a0d51a8..eea7da055956 100644 --- a/packages/console/src/containers/ConsoleContent/index.tsx +++ b/packages/console/src/containers/ConsoleContent/index.tsx @@ -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'; @@ -196,6 +197,9 @@ function ConsoleContent() { }> } /> } /> + {isDevFeaturesEnabled && ( + } /> + )} } /> {!isDevTenant && ( <> diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx new file mode 100644 index 000000000000..060d0c87ec3f --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx @@ -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 ( + + {t('edit_modal.title', { name: getUserDisplayName(user) })}} + footer={ +