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:
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;
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user