Skip to content

Commit 10cf6b1

Browse files
github-actions[bot]chasprowebdevMarfuen
authored
CS-64 Add a way to remove the device from the org (#1902)
* feat(app): create alert for removing device * feat(api): create endpoint for unlinking device * feat(app): integrate unlink-device endpoint on click of 'Remove Device' menu * fix(app): hide Remove Device menu if the current user is not an owner * feat(app): integrate fleet endpoint to delete the hosts * fix(app): refresh after unlinking device * fix(app): remove unused variable --------- Co-authored-by: chasprowebdev <[email protected]> Co-authored-by: Mariano Fuentes <[email protected]>
1 parent 9adcd84 commit 10cf6b1

File tree

14 files changed

+602
-116
lines changed

14 files changed

+602
-116
lines changed

apps/api/src/lib/fleet.service.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,61 @@ export class FleetService {
7777
throw new Error('Failed to fetch multiple hosts');
7878
}
7979
}
80+
81+
/**
82+
* Remove all hosts from FleetDM that belong to a specific label
83+
* @param fleetDmLabelId - The FleetDM label ID
84+
* @returns Promise with deletion results
85+
*/
86+
async removeHostsByLabel(fleetDmLabelId: number): Promise<{
87+
deletedCount: number;
88+
failedCount: number;
89+
hostIds: number[];
90+
}> {
91+
try {
92+
// Get all hosts for this label
93+
const labelHosts = await this.getHostsByLabel(fleetDmLabelId);
94+
95+
if (!labelHosts.hosts || labelHosts.hosts.length === 0) {
96+
this.logger.log(`No hosts found for label ${fleetDmLabelId}`);
97+
return {
98+
deletedCount: 0,
99+
failedCount: 0,
100+
hostIds: [],
101+
};
102+
}
103+
104+
// Extract host IDs
105+
const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id);
106+
107+
// Delete each host
108+
const deletePromises = hostIds.map(async (hostId: number) => {
109+
try {
110+
await this.fleetInstance.delete(`/hosts/${hostId}`);
111+
this.logger.debug(`Deleted host ${hostId} from FleetDM`);
112+
return { success: true, hostId };
113+
} catch (error) {
114+
this.logger.error(`Failed to delete host ${hostId}:`, error);
115+
return { success: false, hostId };
116+
}
117+
});
118+
119+
const results = await Promise.all(deletePromises);
120+
const deletedCount = results.filter((r) => r.success).length;
121+
const failedCount = results.filter((r) => !r.success).length;
122+
123+
this.logger.log(
124+
`Removed hosts from FleetDM for label ${fleetDmLabelId}: ${deletedCount} deleted, ${failedCount} failed`,
125+
);
126+
127+
return {
128+
deletedCount,
129+
failedCount,
130+
hostIds,
131+
};
132+
} catch (error) {
133+
this.logger.error(`Failed to remove hosts for label ${fleetDmLabelId}:`, error);
134+
throw new Error(`Failed to remove hosts for label ${fleetDmLabelId}: ${error.message}`);
135+
}
136+
}
80137
}

apps/api/src/people/people.controller.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
Body,
88
Param,
99
UseGuards,
10+
HttpCode,
11+
HttpStatus,
1012
} from '@nestjs/common';
1113
import {
1214
ApiBody,
@@ -226,4 +228,36 @@ export class PeopleController {
226228
}),
227229
};
228230
}
231+
232+
@Patch(':id/unlink-device')
233+
@HttpCode(HttpStatus.OK)
234+
@ApiOperation(PEOPLE_OPERATIONS.unlinkDevice)
235+
@ApiParam(PEOPLE_PARAMS.memberId)
236+
@ApiResponse(UPDATE_MEMBER_RESPONSES[200])
237+
@ApiResponse(UPDATE_MEMBER_RESPONSES[400])
238+
@ApiResponse(UPDATE_MEMBER_RESPONSES[401])
239+
@ApiResponse(UPDATE_MEMBER_RESPONSES[404])
240+
@ApiResponse(UPDATE_MEMBER_RESPONSES[500])
241+
async unlinkDevice(
242+
@Param('id') memberId: string,
243+
@OrganizationId() organizationId: string,
244+
@AuthContext() authContext: AuthContextType,
245+
) {
246+
const updatedMember = await this.peopleService.unlinkDevice(
247+
memberId,
248+
organizationId,
249+
);
250+
251+
return {
252+
...updatedMember,
253+
authType: authContext.authType,
254+
...(authContext.userId &&
255+
authContext.userEmail && {
256+
authenticatedUser: {
257+
id: authContext.userId,
258+
email: authContext.userEmail,
259+
},
260+
}),
261+
};
262+
}
229263
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Module } from '@nestjs/common';
22
import { AuthModule } from '../auth/auth.module';
3+
import { FleetService } from '../lib/fleet.service';
34
import { PeopleController } from './people.controller';
45
import { PeopleService } from './people.service';
56

67
@Module({
78
imports: [AuthModule],
89
controllers: [PeopleController],
9-
providers: [PeopleService],
10+
providers: [PeopleService, FleetService],
1011
exports: [PeopleService],
1112
})
1213
export class PeopleModule {}

apps/api/src/people/people.service.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Logger,
55
BadRequestException,
66
} from '@nestjs/common';
7+
import { FleetService } from '../lib/fleet.service';
78
import type { PeopleResponseDto } from './dto/people-responses.dto';
89
import type { CreatePeopleDto } from './dto/create-people.dto';
910
import type { UpdatePeopleDto } from './dto/update-people.dto';
@@ -15,6 +16,8 @@ import { MemberQueries } from './utils/member-queries';
1516
export class PeopleService {
1617
private readonly logger = new Logger(PeopleService.name);
1718

19+
constructor(private readonly fleetService: FleetService) {}
20+
1821
async findAllByOrganization(
1922
organizationId: string,
2023
): Promise<PeopleResponseDto[]> {
@@ -273,4 +276,66 @@ export class PeopleService {
273276
throw new Error(`Failed to delete member: ${error.message}`);
274277
}
275278
}
279+
280+
async unlinkDevice(
281+
memberId: string,
282+
organizationId: string,
283+
): Promise<PeopleResponseDto> {
284+
try {
285+
await MemberValidator.validateOrganization(organizationId);
286+
const existingMember = await MemberQueries.findByIdInOrganization(
287+
memberId,
288+
organizationId,
289+
);
290+
291+
if (!existingMember) {
292+
throw new NotFoundException(
293+
`Member with ID ${memberId} not found in organization ${organizationId}`,
294+
);
295+
}
296+
297+
// Remove hosts from FleetDM before unlinking the device
298+
if (existingMember.fleetDmLabelId) {
299+
try {
300+
const removalResult = await this.fleetService.removeHostsByLabel(
301+
existingMember.fleetDmLabelId,
302+
);
303+
this.logger.log(
304+
`Removed ${removalResult.deletedCount} host(s) from FleetDM for label ${existingMember.fleetDmLabelId}`,
305+
);
306+
if (removalResult.failedCount > 0) {
307+
this.logger.warn(
308+
`Failed to remove ${removalResult.failedCount} host(s) from FleetDM`,
309+
);
310+
}
311+
} catch (fleetError) {
312+
// Log FleetDM error but don't fail the entire operation
313+
this.logger.error(
314+
`Failed to remove hosts from FleetDM for label ${existingMember.fleetDmLabelId}:`,
315+
fleetError,
316+
);
317+
// Continue with unlinking the device even if FleetDM removal fails
318+
}
319+
}
320+
321+
const updatedMember = await MemberQueries.unlinkDevice(memberId);
322+
323+
this.logger.log(
324+
`Unlinked device for member: ${updatedMember.user.name} (${memberId})`,
325+
);
326+
return updatedMember;
327+
} catch (error) {
328+
if (
329+
error instanceof NotFoundException ||
330+
error instanceof BadRequestException
331+
) {
332+
throw error;
333+
}
334+
this.logger.error(
335+
`Failed to unlink device for member ${memberId} in organization ${organizationId}:`,
336+
error,
337+
);
338+
throw new Error(`Failed to unlink device: ${error.message}`);
339+
}
340+
}
276341
}

apps/api/src/people/schemas/people-operations.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,9 @@ export const PEOPLE_OPERATIONS: Record<string, ApiOperationOptions> = {
3131
description:
3232
'Permanently removes a member from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
3333
},
34+
unlinkDevice: {
35+
summary: 'Unlink device from member',
36+
description:
37+
'Resets the fleetDmLabelId for a member, effectively unlinking their device from FleetDM. This will disconnect the device from the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
38+
},
3439
};

apps/api/src/people/utils/member-queries.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ export class MemberQueries {
144144
});
145145
}
146146

147+
/**
148+
* Unlink device by resetting fleetDmLabelId to null
149+
*/
150+
static async unlinkDevice(
151+
memberId: string,
152+
): Promise<PeopleResponseDto> {
153+
return db.member.update({
154+
where: { id: memberId },
155+
data: { fleetDmLabelId: null },
156+
select: this.MEMBER_SELECT,
157+
});
158+
}
159+
147160
/**
148161
* Bulk create members for an organization
149162
*/

0 commit comments

Comments
 (0)