From 3a0f5cbc6e1d2de84b946acd828c1901632b9a60 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 18 May 2026 19:03:52 +0330 Subject: [PATCH] bugfix: a new flow added to re-enable disabled staffs. --- backend/src/modules/staff/staff.controller.ts | 13 +++ backend/src/modules/staff/staff.service.ts | 76 ++++++++++++ frontend/src/app/(dashboard)/staff/page.tsx | 110 +++++++++++++++++- .../OrganizationSelectorContent.tsx | 14 ++- frontend/src/lib/api/staff.ts | 7 ++ 5 files changed, 216 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index 40e5ca3..8183f32 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/enable') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Re-enable a disabled staff member (uses one plan seat; no new invitation)', + }) + enableMember( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.enableMember(req.user.id, organizationId, membershipId); + } + @Patch('members/:membershipId/disable') @UseGuards(JwtAuthGuard) @ApiOperation({ diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index e5355f2..60f4b23 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; @@ -399,6 +400,50 @@ export class StaffService { return { success: true, message: 'Member updated' }; } + async enableMember(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 }, + include: { + invitations: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + + if (!target) { + throw new NotFoundException('Member not found'); + } + if (target.isOwner) { + throw new ForbiddenException('Cannot enable the organization owner'); + } + if (target.isActive) { + throw new BadRequestException('This member is already active'); + } + + const invitation = target.invitations[0]; + if (invitation && !invitation.acceptedAt) { + throw new BadRequestException( + 'This member has not completed their invitation yet. Share the invite link instead.', + ); + } + + await this.prisma.$transaction(async (tx) => { + await this.assertOrganizationHasAvailableSeat(organizationId, tx); + await tx.membership.update({ + where: { id: membershipId }, + data: { isActive: true }, + }); + }); + + return { + success: true, + message: 'Member enabled. They can sign in to this organization again.', + }; + } + async disableMember(actorUserId: string, organizationId: string, membershipId: string) { const actor = await this.getActorMembership(actorUserId, organizationId); if (!actor || !this.canEditStaff(actor)) { @@ -459,6 +504,37 @@ export class StaffService { return { success: true, message: 'Member removed' }; } + private async assertOrganizationHasAvailableSeat( + organizationId: string, + db: Prisma.TransactionClient | PrismaService = this.prisma, + ) { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + include: { plan: true }, + }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + if (!org.plan) { + throw new BadRequestException( + 'This organization has no active subscription. Please choose a plan before adding staff.', + ); + } + + const maxUsers = org.plan.maxUsers; + const seatsUsed = await db.membership.count({ + where: { + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + }); + if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) { + throw new BadRequestException( + `Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`, + ); + } + } + private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 98df259..0f8eb87 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, UserX } from 'lucide-react'; +import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; @@ -67,6 +67,10 @@ function canDisableStaff(member: StaffMemberDto): boolean { return !member.isOwner && member.isActive; } +function canEnableStaff(member: StaffMemberDto): boolean { + return !member.isOwner && member.invitationStatus === 'DISABLED'; +} + function PermissionGrid({ state, onChange, @@ -161,6 +165,8 @@ export default function StaffPage() { const [editLoading, setEditLoading] = useState(false); const [disableTarget, setDisableTarget] = useState(null); const [disablingMembershipId, setDisablingMembershipId] = useState(null); + const [enableTarget, setEnableTarget] = useState(null); + const [enablingMembershipId, setEnablingMembershipId] = useState(null); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const hasActivePlan = Boolean(currentOrganization?.plan); @@ -170,6 +176,12 @@ export default function StaffPage() { return seats.used >= seats.limit; }, [seats]); + const hasAvailableSeat = useMemo(() => { + if (!seats || seats.unlimited) return true; + if (seats.limit == null) return true; + return seats.used < seats.limit; + }, [seats]); + const load = useCallback(async () => { toast.setError(''); setLoading(true); @@ -352,6 +364,23 @@ export default function StaffPage() { } } + async function confirmEnableMember() { + if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return; + + setEnablingMembershipId(enableTarget.id); + toast.setError(''); + try { + await staffApi.enableMember(enableTarget.id); + toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`); + setEnableTarget(null); + await load(); + } catch (e) { + toast.showError(formatApiErrorMessage(e, 'Failed to enable member.')); + } finally { + setEnablingMembershipId(null); + } + } + if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

Redirecting…

@@ -547,6 +576,25 @@ export default function StaffPage() { )} )} + {canEnableStaff(m) && ( + + )} {canDisableStaff(m) && ( + + + + + )} + {disableTarget && (
canUserCreateOrganization(organizations), - [organizations], + () => canCreateOrganizationFromCurrentOrg(currentOrganization), + [currentOrganization], ); const [isCreateOpen, setIsCreateOpen] = useState(false); const [organizationName, setOrganizationName] = useState(''); diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index c25b46e..1b03460 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -107,6 +107,13 @@ export const staffApi = { return response.data; }, + enableMember: async ( + membershipId: string, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.patch(`/staff/members/${membershipId}/enable`); + return response.data; + }, + removeMember: async ( membershipId: string, ): Promise<{ success: boolean; message: string }> => {