diff --git a/apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx new file mode 100644 index 000000000..519d311d9 --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useNavigate } from 'react-router'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminOrganisationMemberDeleteDialogProps = { + organisationId: string; + organisationName: string; + organisationMemberId: string; + organisationMemberName: string; + organisationMemberEmail: string; +}; + +export const AdminOrganisationMemberDeleteDialog = ({ + organisationId, + organisationName, + organisationMemberId, + organisationMemberName, + organisationMemberEmail, +}: AdminOrganisationMemberDeleteDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: deleteOrganisationMember, isPending } = + trpc.admin.organisationMember.delete.useMutation({ + onSuccess: async () => { + toast({ + title: _(msg`Success`), + description: _(msg`Member has been removed from the organisation.`), + duration: 5000, + }); + + setOpen(false); + + // Refresh the page to show updated data + await navigate(0); + }, + onError: (err) => { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: _(msg`An error occurred`), + description: _(msg`We couldn't remove this member. Please try again later.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + + + + + + + + Remove Organisation Member + + + + + This action is not reversible. Please be certain. + + + + +
+ + + You are about to remove the following user from the organisation{' '} + {organisationName}: + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + +
+ +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx new file mode 100644 index 000000000..05f0f5a1e --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useNavigate } from 'react-router'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminTeamMemberDeleteDialogProps = { + teamId: number; + teamName: string; + memberId: string; + memberName: string; + memberEmail: string; +}; + +export const AdminTeamMemberDeleteDialog = ({ + teamId, + teamName, + memberId, + memberName, + memberEmail, +}: AdminTeamMemberDeleteDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: deleteTeamMember, isPending } = trpc.admin.teamMember.delete.useMutation({ + onSuccess: async () => { + toast({ + title: _(msg`Success`), + description: _(msg`Member has been removed from the team.`), + duration: 5000, + }); + + setOpen(false); + + // Refresh the page to show updated data + await navigate(0); + }, + onError: (err) => { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: _(msg`An error occurred`), + description: _(msg`We couldn't remove this member. Please try again later.`), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + + + + + + + + Remove Team Member + + + + + This action is not reversible. Please be certain. + + + + +
+ + + You are about to remove the following user from the team{' '} + {teamName}: + + + + + {memberName}} + secondaryText={memberEmail} + /> + +
+ +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index f3fc58aae..50de7485d 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -44,6 +44,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; @@ -135,6 +136,10 @@ export default function OrganisationGroupSettingsPage({ }, [i18n, t]); const organisationMembersColumns = useMemo(() => { + if (!organisation) { + return []; + } + return [ { header: t`Member`, @@ -164,10 +169,6 @@ export default function OrganisationGroupSettingsPage({ { header: t`Role`, cell: ({ row }) => { - if (!organisation) { - return null; - } - const isOwner = row.original.userId === organisation.ownerUserId; if (isOwner) { @@ -201,7 +202,9 @@ export default function OrganisationGroupSettingsPage({ { header: t`Actions`, cell: ({ row }) => { - const isOwner = row.original.userId === organisation?.ownerUserId; + const isOwner = row.original.userId === organisation.ownerUserId; + + const memberName = row.original.user.name ?? row.original.user.email; return (
@@ -215,6 +218,16 @@ export default function OrganisationGroupSettingsPage({ organisationMember={row.original} isOwner={isOwner} /> + + {!isOwner && ( + + )}
); }, diff --git a/apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx index 0f320ee07..fa5c18ada 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx @@ -22,6 +22,7 @@ import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/dat import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { AdminTeamMemberDeleteDialog } from '~/components/dialogs/admin-team-member-delete-dialog'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; @@ -53,6 +54,10 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) { }; const teamMembersColumns = useMemo(() => { + if (!team) { + return []; + } + return [ { header: _(msg`Member`), @@ -87,7 +92,7 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) { header: _(msg`Organisation role`), accessorKey: 'organisationRole', cell: ({ row }) => { - const isOwner = row.original.userId === team?.organisation.ownerUserId; + const isOwner = row.original.userId === team.organisation.ownerUserId; if (isOwner) { return {_(msg`Owner`)}; @@ -105,6 +110,30 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) { accessorKey: 'createdAt', cell: ({ row }) => i18n.date(row.original.createdAt), }, + { + header: _(msg`Actions`), + cell: ({ row }) => { + const isOwner = row.original.userId === team.organisation.ownerUserId; + + if (isOwner) { + return null; + } + + const memberName = row.original.user.name ?? row.original.user.email; + + return ( +
+ +
+ ); + }, + }, ] satisfies DataTableColumnDef[]; }, [team, _, i18n]); diff --git a/packages/trpc/server/admin-router/delete-organisation-member.ts b/packages/trpc/server/admin-router/delete-organisation-member.ts new file mode 100644 index 000000000..ed7fb8a9b --- /dev/null +++ b/packages/trpc/server/admin-router/delete-organisation-member.ts @@ -0,0 +1,126 @@ +import { OrganisationMemberInviteStatus } from '@prisma/client'; + +import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { jobs } from '@documenso/lib/jobs/client'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZDeleteAdminOrganisationMemberRequestSchema, + ZDeleteAdminOrganisationMemberResponseSchema, +} from './delete-organisation-member.types'; + +export const deleteAdminOrganisationMemberRoute = adminProcedure + .input(ZDeleteAdminOrganisationMemberRequestSchema) + .output(ZDeleteAdminOrganisationMemberResponseSchema) + .mutation(async ({ ctx, input }) => { + const { organisationId, organisationMemberId } = input; + + ctx.logger.info({ + input: { + organisationId, + organisationMemberId, + }, + }); + + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + subscription: true, + organisationClaim: true, + teams: { + select: { + id: true, + }, + }, + members: { + select: { + id: true, + userId: true, + }, + }, + invites: { + where: { + status: OrganisationMemberInviteStatus.PENDING, + }, + select: { + id: true, + }, + }, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Organisation not found', + }); + } + + const memberToDelete = organisation.members.find( + (member) => member.id === organisationMemberId, + ); + + if (!memberToDelete) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Member not found in this organisation', + }); + } + + if (memberToDelete.userId === organisation.ownerUserId) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Cannot remove the organisation owner. Transfer ownership first.', + }); + } + + const newMemberCount = organisation.members.length + organisation.invites.length - 1; + + // Removing a member is a reducing operation, so we don't gate it on the + // subscription being present. Sync Stripe only when one exists. + if (organisation.subscription) { + await syncMemberCountWithStripeSeatPlan( + organisation.subscription, + organisation.organisationClaim, + newMemberCount, + ); + } + + const teamIds = organisation.teams.map((team) => team.id); + + await prisma.$transaction(async (tx) => { + // Removing an OrganisationMember cascades the user out of every team in + // the org via OrganisationGroupMember, but their authored Envelope rows + // still reference them. Reassign those to the org owner so they remain + // reachable after the member loses access (mirrors delete-user.ts). + if (teamIds.length > 0) { + await tx.envelope.updateMany({ + where: { + userId: memberToDelete.userId, + teamId: { + in: teamIds, + }, + }, + data: { + userId: organisation.ownerUserId, + }, + }); + } + + await tx.organisationMember.delete({ + where: { + id: organisationMemberId, + organisationId, + }, + }); + }); + + await jobs.triggerJob({ + name: 'send.organisation-member-left.email', + payload: { + organisationId, + memberUserId: memberToDelete.userId, + }, + }); + }); diff --git a/packages/trpc/server/admin-router/delete-organisation-member.types.ts b/packages/trpc/server/admin-router/delete-organisation-member.types.ts new file mode 100644 index 000000000..50ef9d987 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-organisation-member.types.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ZDeleteAdminOrganisationMemberRequestSchema = z.object({ + organisationId: z.string().min(1), + organisationMemberId: z.string().min(1), +}); + +export const ZDeleteAdminOrganisationMemberResponseSchema = z.void(); + +export type TDeleteAdminOrganisationMemberRequest = z.infer< + typeof ZDeleteAdminOrganisationMemberRequestSchema +>; +export type TDeleteAdminOrganisationMemberResponse = z.infer< + typeof ZDeleteAdminOrganisationMemberResponseSchema +>; diff --git a/packages/trpc/server/admin-router/delete-team-member.ts b/packages/trpc/server/admin-router/delete-team-member.ts new file mode 100644 index 000000000..fb313117a --- /dev/null +++ b/packages/trpc/server/admin-router/delete-team-member.ts @@ -0,0 +1,106 @@ +import { OrganisationGroupType } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZDeleteAdminTeamMemberRequestSchema, + ZDeleteAdminTeamMemberResponseSchema, +} from './delete-team-member.types'; + +export const deleteAdminTeamMemberRoute = adminProcedure + .input(ZDeleteAdminTeamMemberRequestSchema) + .output(ZDeleteAdminTeamMemberResponseSchema) + .mutation(async ({ ctx, input }) => { + const { teamId, memberId } = input; + + ctx.logger.info({ + input: { + teamId, + memberId, + }, + }); + + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + include: { + organisation: { + select: { + ownerUserId: true, + }, + }, + teamGroups: { + where: { + organisationGroup: { + type: OrganisationGroupType.INTERNAL_TEAM, + organisationGroupMembers: { + some: { + organisationMember: { + id: memberId, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Team not found', + }); + } + + const teamGroupToRemoveMemberFrom = team.teamGroups[0]; + + if (!teamGroupToRemoveMemberFrom) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: + 'Member is not directly assigned to this team. Inherited members cannot be removed here.', + }); + } + + const member = await prisma.organisationMember.findUnique({ + where: { + id: memberId, + }, + select: { + userId: true, + }, + }); + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Member not found', + }); + } + + await prisma.$transaction(async (tx) => { + // Removing a user from a single team drops their INTERNAL_TEAM + // OrganisationGroupMember link, but Envelope rows they authored in this + // team still point at their userId. Reassign to the org owner so those + // envelopes remain reachable after the member loses team access. + await tx.envelope.updateMany({ + where: { + userId: member.userId, + teamId, + }, + data: { + userId: team.organisation.ownerUserId, + }, + }); + + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: memberId, + groupId: teamGroupToRemoveMemberFrom.organisationGroupId, + }, + }, + }); + }); + }); diff --git a/packages/trpc/server/admin-router/delete-team-member.types.ts b/packages/trpc/server/admin-router/delete-team-member.types.ts new file mode 100644 index 000000000..b2a482691 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-team-member.types.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const ZDeleteAdminTeamMemberRequestSchema = z.object({ + teamId: z.number().min(1), + memberId: z.string().min(1), +}); + +export const ZDeleteAdminTeamMemberResponseSchema = z.void(); + +export type TDeleteAdminTeamMemberRequest = z.infer; +export type TDeleteAdminTeamMemberResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 48e75b31f..c36ef55bb 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -3,7 +3,9 @@ import { createAdminOrganisationRoute } from './create-admin-organisation'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; import { deleteDocumentRoute } from './delete-document'; +import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member'; import { deleteSubscriptionClaimRoute } from './delete-subscription-claim'; +import { deleteAdminTeamMemberRoute } from './delete-team-member'; import { deleteUserRoute } from './delete-user'; import { disableUserRoute } from './disable-user'; import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs'; @@ -44,6 +46,7 @@ export const adminRouter = router({ organisationMember: { promoteToOwner: promoteMemberToOwnerRoute, updateRole: updateOrganisationMemberRoleRoute, + delete: deleteAdminOrganisationMemberRoute, }, claims: { find: findSubscriptionClaimsRoute, @@ -86,5 +89,8 @@ export const adminRouter = router({ team: { get: getAdminTeamRoute, }, + teamMember: { + delete: deleteAdminTeamMemberRoute, + }, updateSiteSetting: updateSiteSettingRoute, }); diff --git a/packages/trpc/server/team-router/delete-team-member.ts b/packages/trpc/server/team-router/delete-team-member.ts index 9e50217d2..b230f5d11 100644 --- a/packages/trpc/server/team-router/delete-team-member.ts +++ b/packages/trpc/server/team-router/delete-team-member.ts @@ -34,6 +34,11 @@ export const deleteTeamMemberRoute = authenticatedProcedure roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], }), include: { + organisation: { + select: { + ownerUserId: true, + }, + }, teamGroups: { where: { organisationGroup: { @@ -106,12 +111,39 @@ export const deleteTeamMemberRoute = authenticatedProcedure }); } - await prisma.organisationGroupMember.delete({ - where: { - organisationMemberId_groupId: { - organisationMemberId: memberId, - groupId: teamGroupToRemoveMemberFrom.organisationGroupId, + const removedMember = + teamGroupToRemoveMemberFrom.organisationGroup.organisationGroupMembers.find( + (ogm) => ogm.organisationMember.id === memberId, + ); + + if (!removedMember) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Member not found in this team', + }); + } + + await prisma.$transaction(async (tx) => { + // Removing a user from a single team drops their INTERNAL_TEAM + // OrganisationGroupMember link, but Envelope rows they authored in this + // team still point at their userId. Reassign to the org owner so those + // envelopes remain reachable after the member loses team access. + await tx.envelope.updateMany({ + where: { + userId: removedMember.organisationMember.userId, + teamId, }, - }, + data: { + userId: team.organisation.ownerUserId, + }, + }); + + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: memberId, + groupId: teamGroupToRemoveMemberFrom.organisationGroupId, + }, + }, + }); }); });