From 4487d3260b0b911efd5947f8faa4b53274da6c68 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 14:56:07 +0330 Subject: [PATCH] bugfix: a new flow added to disable staffs and free the used seats. --- backend/src/modules/auth/auth.service.ts | 13 +- backend/src/modules/staff/staff.controller.ts | 13 ++ backend/src/modules/staff/staff.service.ts | 47 ++++++- frontend/src/app/(dashboard)/staff/page.tsx | 125 +++++++++++++++--- frontend/src/lib/api/staff.ts | 9 +- 5 files changed, 184 insertions(+), 23 deletions(-) diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 102e59c..7e3cad7 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -756,7 +756,18 @@ export class AuthService { throw new UnauthorizedException('Access denied to this organization'); } if (!membership.isOwner && !membership.isActive) { - throw new UnauthorizedException('Your invitation is still pending activation'); + const acceptedInvite = await this.prisma.staffInvitation.findFirst({ + where: { + membershipId: membership.id, + acceptedAt: { not: null }, + }, + select: { id: true }, + }); + throw new UnauthorizedException( + acceptedInvite + ? 'Your access to this organization has been disabled.' + : 'Your invitation is still pending activation.', + ); } // 2. Build payload WITH org context diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index e4732ea..40e5ca3 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -80,6 +80,19 @@ export class StaffController { return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); } + @Patch('members/:membershipId/disable') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Disable staff member (frees a seat; member cannot access this organization)', + }) + disableMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.disableMember(req.user.id, organizationId, membershipId); + } + @Delete('members/:membershipId') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Remove staff member from organization' }) diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 2dcd3b2..e5355f2 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -399,6 +399,44 @@ export class StaffService { return { success: true, message: 'Member updated' }; } + async disableMember(actorUserId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot manage staff'); + } + + const target = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + }); + + if (!target) { + throw new NotFoundException('Member not found'); + } + if (target.isOwner) { + throw new ForbiddenException('Cannot disable the organization owner'); + } + if (actorUserId === target.userId) { + throw new BadRequestException('You cannot disable your own access'); + } + if (!target.isActive) { + throw new BadRequestException('This member is already disabled or pending activation'); + } + + await this.prisma.membership.update({ + where: { id: membershipId }, + data: { isActive: false }, + }); + + await this.prisma.session.deleteMany({ + where: { userId: target.userId }, + }); + + return { + success: true, + message: 'Member disabled. Their seat is now available for another invite.', + }; + } + async removeMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { @@ -435,11 +473,12 @@ export class StaffService { isOwner: boolean; isActive: boolean; invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[]; - }): 'ACTIVE' | 'PENDING' | 'EXPIRED' { - if (m.isOwner || m.isActive) return 'ACTIVE'; + }): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' { + if (m.isOwner) return 'ACTIVE'; + if (m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; - if (!invitation) return 'EXPIRED'; - if (invitation.acceptedAt) return 'ACTIVE'; + if (invitation?.acceptedAt) return 'DISABLED'; + if (!invitation) return 'DISABLED'; if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 958e489..98df259 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -16,7 +16,7 @@ import { formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; -import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; +import { Pencil, Trash2, Copy, Check, X, UserX } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; @@ -57,7 +57,14 @@ function writeStoredInviteLinks(orgId: string, links: Record emptyFeaturePermissionState()); const [editLoading, setEditLoading] = useState(false); + const [disableTarget, setDisableTarget] = useState(null); + const [disablingMembershipId, setDisablingMembershipId] = useState(null); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const hasActivePlan = Boolean(currentOrganization?.plan); @@ -322,20 +331,24 @@ export default function StaffPage() { } } - async function removeMember(m: StaffMemberDto) { - if (m.isOwner) return; - if (m.userId === user?.id) { - if (!confirm('Remove yourself from this organization? You will lose access.')) return; - } else { - if (!confirm(`Remove ${m.name} from this organization?`)) return; - } + function handleDeleteMember() { + toast.showError('Delete is not implemented yet.'); + } + + async function confirmDisableMember() { + if (!disableTarget || !canDisableStaff(disableTarget)) return; + + setDisablingMembershipId(disableTarget.id); toast.setError(''); try { - await staffApi.removeMember(m.id); - toast.showSuccess('Member removed.'); + await staffApi.disableMember(disableTarget.id); + toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`); + setDisableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to remove member.')); + toast.showError(formatApiErrorMessage(e, 'Failed to disable member.')); + } finally { + setDisablingMembershipId(null); } } @@ -477,7 +490,9 @@ export default function StaffPage() { Role Status Access - Actions + + Action + } body={ @@ -498,6 +513,8 @@ export default function StaffPage() { Active ) : m.invitationStatus === 'PENDING' ? ( Pending + ) : m.invitationStatus === 'DISABLED' ? ( + Disabled ) : ( Expired )} @@ -511,9 +528,9 @@ export default function StaffPage() { )} - + {!m.isOwner && ( -
+
{canShareStaffInviteLink(m) && ( + )}
)} + {disableTarget && ( +
+
+
+

+ Disable team member +

+ { + if (disablingMembershipId) return; + setDisableTarget(null); + }} + /> +
+

+ Disable {disableTarget.name} ( + {disableTarget.email})? +

+
    +
  • They will not be able to sign in to this organization.
  • +
  • No data will be removed.
  • +
  • + Disabling frees one seat on your + plan so you can invite someone else. +
  • +
+
+ + +
+
+
+ )} + {editing && (
=> { + const response = await apiClient.patch(`/staff/members/${membershipId}/disable`); + return response.data; + }, + removeMember: async ( membershipId: string, ): Promise<{ success: boolean; message: string }> => {