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 (
+
+ );
+};
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 (
+
+ );
+};
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,
+ },
+ },
+ });
});
});