feat: member management for platform (#16803)

* init members page for platform

* add missing icon

* add members tab to sidebar

* use data from useMe query instead of useSession

* return user profiles since platform users are stored there

* small refactor

* fixup

* remove unused logs from console

* fixup

* resolve merge conflicts

* add missing tabs for members and billing

* update schema

* fixup

* memoise selection options for org and platform

* update onSubmit handler

* resolve merge conflicts

* add members tab

* improve team billing view when user not subscribed

* update members tab

* add function to find if user is admin of team

* update invite members handler to handle platform user as well

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv SahalandMorgan authored and GitHub committed 2024-10-14 21:19:02 +03:00
1 parent 3312d9cfc6
commit 5726d18027
15 files changed
+234 -58

No files matched your search

@@ -24,7 +24,9 @@ export const OAuthClientsDropdown = ({
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button color="secondary">{initialClientName}</Button>
<Button color="secondary" EndIcon="chevron-down">
{initialClientName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{oauthClients.map((client) => {
@@ -7,6 +7,7 @@ import Shell from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { PlatformPricing } from "@calcom/web/components/settings/platform/pricing/platform-pricing/index";
import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPlan";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
@@ -23,13 +24,33 @@ export default function PlatformBillingUpgrade() {
const onContactSupportClick = async () => {
await open();
};
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData } = useGetUserAttributes();
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
useGetUserAttributes();
if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) {
return <div className="m-5">Loading...</div>;
}
if (!isPlatformUser) return <NoPlatformPlan />;
if (isPlatformUser && !isPaidUser)
return (
<PlatformPricing
teamId={userOrgId}
heading={
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
}
/>
);
if (!isPlatformUser)
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
<NoPlatformPlan />
</Shell>
</div>
);
return (
<div>
@@ -0,0 +1,9 @@
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
import PageWrapper from "@components/PageWrapper";
const Page = () => <PlatformMembersView />;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,74 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui";
import NoPlatformPlan from "@calcom/web/components/settings/platform/dashboard/NoPlatformPlan";
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
import { PlatformPricing } from "@calcom/web/components/settings/platform/pricing/platform-pricing/index";
const PlatformMembersView = () => {
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
useGetUserAttributes();
const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner =
currentOrg &&
(currentOrg.user.role === MembershipRole.OWNER || currentOrg.user.role === MembershipRole.ADMIN);
const canLoggedInUserSeeMembers =
(currentOrg?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !currentOrg?.isPrivate;
if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) {
return <div className="m-5">Loading...</div>;
}
if (isPlatformUser && !isPaidUser)
return (
<PlatformPricing
teamId={userOrgId}
heading={
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
}
/>
);
if (!isPlatformUser)
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
<NoPlatformPlan />
</Shell>
</div>
);
return (
<Shell
heading={
<div className="flex">
<h1>Member management</h1>
<Button
tooltip="Only teammates invited as admins can create OAuth clients while teammates invited as members have read only access"
tooltipSide="right"
className="mx-2 hover:bg-transparent"
color="minimal"
variant="icon"
StartIcon="info"
/>
</div>
}
title="Platform members"
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage the admins and members in your platform team"
isPlatformUser={true}>
<div>{!isPending && canLoggedInUserSeeMembers && <UserListTable />}</div>
</Shell>
);
};
export default PlatformMembersView;
@@ -124,6 +124,11 @@ const platformNavigation: NavigationItemType[] = [
href: "/settings/platform/billing",
icon: "credit-card",
},
{
name: "Members",
href: "/settings/platform/members",
icon: "users",
},
];
export const getDesktopNavigationItems = (isPlatformNavigation = false) => {
@@ -5,6 +5,7 @@ import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberIn
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
import usePlatformMe from "@calcom/web/components/settings/platform/hooks/usePlatformMe";
import type { Action } from "./UserListTable";
@@ -14,6 +15,7 @@ interface Props {
export function InviteMemberModal(props: Props) {
const { data: session } = useSession();
const { data: platformUser } = usePlatformMe();
const utils = trpc.useUtils();
const { t, i18n } = useLocale();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
@@ -45,9 +47,9 @@ export function InviteMemberModal(props: Props) {
},
});
if (!session?.user.org?.id) return null;
const orgId = session?.user.org?.id ?? platformUser?.organizationId;
const orgId = session.user.org.id;
if (!orgId) return null;
return (
<MemberInvitationModal
@@ -67,6 +69,7 @@ export function InviteMemberModal(props: Props) {
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
isPlatform: platformUser?.organization.isPlatform,
});
}}
/>
@@ -11,6 +11,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui";
import type { ActionItem } from "@calcom/ui/components/data-table/DataTableSelectionBar";
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
import { useOrgBranding } from "../../../ee/organizations/context/provider";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
@@ -114,6 +116,7 @@ function reducer(state: State, action: Action): State {
export function UserListTable() {
const { data: session } = useSession();
const { isPlatformUser } = useGetUserAttributes();
const { copyToClipboard, isCopied } = useCopy();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
@@ -138,6 +141,10 @@ export function UserListTable() {
const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER";
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
const totalFetched = flatData.length;
const memorisedColumns = useMemo(() => {
const permissions = {
canEdit: adminOrOwner,
@@ -169,7 +176,7 @@ export function UserListTable() {
{
id: "member",
accessorFn: (data) => data.email,
header: `Member (${totalDBRowCount})`,
header: `Member (${isPlatformUser ? totalFetched : totalDBRowCount})`,
cell: ({ row }) => {
const { username, email, avatarUrl } = row.original;
return (
@@ -299,9 +306,56 @@ export function UserListTable() {
return cols;
}, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]);
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
const totalFetched = flatData.length;
const memoisedSelectionOptions = useMemo(() => {
const selectionOptionsForOrg: ActionItem<User>[] = [
{
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "render",
render: (table) => <MassAssignAttributesBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} orgTeams={teams} />,
},
{
type: "render",
render: (table) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
];
const selectionOptionsForPlatform: ActionItem<User>[] = [
{
type: "render",
render: (table: Table<User>) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
];
const selectionOptions = isPlatformUser ? selectionOptionsForPlatform : selectionOptionsForOrg;
return selectionOptions;
}, [isPlatformUser, teams]);
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = useCallback(
@@ -339,38 +393,7 @@ export function UserListTable() {
}}
data-testid="user-list-data-table"
onSearch={(value) => setDebouncedSearchTerm(value)}
selectionOptions={[
{
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "render",
render: (table) => <MassAssignAttributesBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} orgTeams={teams} />,
},
{
type: "render",
render: (table) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
]}
selectionOptions={memoisedSelectionOptions}
renderAboveSelection={(table: Table<User>) => {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
+21 -1
View File
@@ -604,7 +604,27 @@ export class UserRepository {
});
return !!teams.length;
}
static async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
AND: [
{
members: {
some: {
userId,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
},
],
},
select: {
id: true,
},
});
return !!team;
}
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await prisma.user.findUnique({
where: {
@@ -71,6 +71,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
profiles: true,
},
});
@@ -17,11 +17,12 @@ type BulkDeleteUsersHandler = {
export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHandler) {
const currentUser = ctx.user;
const currentUserOrgId = currentUser.organizationId ?? currentUser.profiles[0].organizationId;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!currentUserOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
if (!(await isOrganisationAdmin(currentUser?.id, currentUserOrgId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
// Loop over all users in input.userIds and remove all memberships for the organization including child teams
@@ -35,7 +36,7 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
{
parentId: currentUser.organizationId,
},
{ id: currentUser.organizationId },
{ id: currentUserOrgId },
],
},
},
@@ -64,7 +65,7 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
// We also do this to make sure that if one of the queries fail, the whole transaction fails
await prisma.$transaction([removeProfiles, deleteMany, removeOrgrelation]);
const teamBilling = await TeamBilling.findAndInit(currentUser.organizationId);
const teamBilling = await TeamBilling.findAndInit(currentUserOrgId);
await teamBilling.updateQuantity();
return {
@@ -12,12 +12,13 @@ type GetTeamsHandler = {
export async function getTeamsHandler({ ctx }: GetTeamsHandler) {
const currentUser = ctx.user;
const currentUserOrgId = ctx.user.organizationId ?? currentUser.profiles[0].organizationId;
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!currentUserOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
const allOrgTeams = await prisma.team.findMany({
where: {
parentId: currentUser.organizationId,
parentId: currentUserOrgId,
},
select: {
id: true,
@@ -11,12 +11,13 @@ type ListHandlerInput = {
// This functionality is essentially the same as the teams/list.handler.ts but it's easier for SOC to have it in a separate file.
export const listHandler = async ({ ctx }: ListHandlerInput) => {
if (!ctx.user.organization?.id) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You do not belong to an organization" });
const organizationId = ctx.user.organization?.id ?? ctx.user.profiles[0].organizationId;
if (!organizationId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "You do not belong to an organization" });
}
return await OrganizationRepository.findCurrentOrg({
userId: ctx.user.id,
orgId: ctx.user.organization.id,
orgId: organizationId,
});
};
@@ -14,7 +14,7 @@ type GetOptions = {
};
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const organizationId = ctx.user.organizationId;
const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId;
const searchTerm = input.searchTerm;
if (!organizationId) {
@@ -43,6 +43,9 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const teamMembers = await prisma.membership.findMany({
where: {
teamId: organizationId,
user: {
isPlatformManaged: false,
},
...(searchTerm && {
user: {
OR: [
@@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -234,14 +235,20 @@ export const inviteMembersWithNoInviterPermissionCheck = async (
const inviteMembers = async ({ ctx, input }: InviteMemberOptions) => {
const { user: inviter } = ctx;
const { usernameOrEmail, role, isPlatform } = input;
const inviterOrg = inviter.organization;
const team = await getTeamOrThrow(input.teamId);
const requestedSlugForTeam = team?.metadata?.requestedSlug ?? null;
const isTeamAnOrg = team.isOrganization;
const organization = inviter.profile.organization;
let inviterOrgId = inviter.organization.id;
let orgSlug = organization ? organization.slug || organization.requestedSlug : null;
let isInviterOrgAdmin = inviter.organization.isOrgAdmin;
const invitations = buildInvitationsFromInput({
usernameOrEmail: input.usernameOrEmail,
roleForAllInvitees: input.role,
usernameOrEmail,
roleForAllInvitees: role,
});
const isAddingNewOwner = !!invitations.find((invitation) => invitation.role === MembershipRole.OWNER);
@@ -249,14 +256,18 @@ const inviteMembers = async ({ ctx, input }: InviteMemberOptions) => {
await throwIfInviterCantAddOwnerToOrg();
}
if (isPlatform) {
inviterOrgId = team.id;
orgSlug = team ? team.slug || requestedSlugForTeam : null;
isInviterOrgAdmin = await UserRepository.isAdminOrOwnerOfTeam({ userId: inviter.id, teamId: team.id });
}
await ensureAtleastAdminPermissions({
userId: inviter.id,
teamId: inviterOrg.id && inviterOrg.isOrgAdmin ? inviterOrg.id : input.teamId,
teamId: inviterOrgId && isInviterOrgAdmin ? inviterOrgId : input.teamId,
isOrg: isTeamAnOrg,
});
const organization = inviter.profile.organization;
const orgSlug = organization ? organization.slug || organization.requestedSlug : null;
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: inviter.name,
team,
@@ -55,6 +55,7 @@ export const ZInviteMemberInputSchema = z.object({
),
role: z.nativeEnum(MembershipRole).optional(),
language: z.string(),
isPlatform: z.boolean().optional(),
});
export type TInviteMemberInputSchema = z.infer<typeof ZInviteMemberInputSchema>;