Skip to content

Commit 863a467

Browse files
feat(app): add 'Remove Device' menu on Devices tab
* feat(app): add 'Remove Device' menu on Devices tab * feat(api): define DELETE endpoint to remove single device agent * feat(app): integrate remove-device endpoint on Devices tab * fix(api): set permission to remove-device-agent endpoint * fix(app): use people action hook for agent device removal --------- Co-authored-by: chasprowebdev <chasgarciaprowebdev@gmail.com> Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com>
1 parent 1b62b52 commit 863a467

7 files changed

Lines changed: 372 additions & 5 deletions

File tree

apps/api/src/devices/devices.controller.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('DevicesController', () => {
3838
findAllByOrganization: jest.fn(),
3939
findAllByMember: jest.fn(),
4040
getMemberById: jest.fn(),
41+
removeDeviceById: jest.fn(),
4142
};
4243

4344
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
@@ -202,4 +203,28 @@ describe('DevicesController', () => {
202203
).rejects.toThrow('FleetDM unavailable');
203204
});
204205
});
206+
207+
describe('deleteDevice', () => {
208+
it('should call service removeDeviceById with org, device, and user', async () => {
209+
mockService.removeDeviceById.mockResolvedValue(undefined);
210+
211+
await controller.deleteDevice('dev_1', 'org_1', mockAuthContext);
212+
213+
expect(service.removeDeviceById).toHaveBeenCalledWith({
214+
organizationId: 'org_1',
215+
deviceId: 'dev_1',
216+
userId: 'usr_1',
217+
});
218+
});
219+
220+
it('should propagate service errors', async () => {
221+
mockService.removeDeviceById.mockRejectedValue(
222+
new Error('Only organization owners can remove devices'),
223+
);
224+
225+
await expect(
226+
controller.deleteDevice('dev_1', 'org_1', mockAuthContext),
227+
).rejects.toThrow('Only organization owners can remove devices');
228+
});
229+
});
205230
});

apps/api/src/devices/devices.controller.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
1+
import { Controller, Delete, Get, HttpCode, Param, UseGuards } from '@nestjs/common';
22
import {
33
ApiOperation,
44
ApiParam,
@@ -220,4 +220,41 @@ export class DevicesController {
220220
}),
221221
};
222222
}
223+
224+
@Delete(':id')
225+
@RequirePermission('member', 'delete')
226+
@HttpCode(204)
227+
@ApiOperation({
228+
summary: 'Delete device',
229+
description:
230+
'Deletes a single device in the authenticated organization. Only organization owners can delete devices.',
231+
})
232+
@ApiParam({
233+
name: 'id',
234+
description: 'Device ID to delete',
235+
example: 'dev_abc123def456',
236+
})
237+
@ApiResponse({
238+
status: 204,
239+
description: 'Device deleted successfully',
240+
})
241+
@ApiResponse({
242+
status: 403,
243+
description: 'Forbidden - only organization owners can delete devices',
244+
})
245+
@ApiResponse({
246+
status: 404,
247+
description: 'Organization or device not found',
248+
})
249+
async deleteDevice(
250+
@Param('id') id: string,
251+
@OrganizationId() organizationId: string,
252+
@AuthContext() authContext: AuthContextType,
253+
): Promise<void> {
254+
await this.devicesService.removeDeviceById({
255+
organizationId,
256+
deviceId: id,
257+
userId: authContext.userId,
258+
});
259+
}
223260
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ForbiddenException, NotFoundException } from '@nestjs/common';
3+
import { db } from '@db';
4+
import { FleetService } from '../lib/fleet.service';
5+
import { DevicesService } from './devices.service';
6+
7+
jest.mock('@db', () => ({
8+
db: {
9+
organization: { findUnique: jest.fn() },
10+
member: { findFirst: jest.fn() },
11+
device: { deleteMany: jest.fn() },
12+
},
13+
}));
14+
15+
describe('DevicesService', () => {
16+
let service: DevicesService;
17+
18+
const mockFleetService = {
19+
getHostsByLabel: jest.fn(),
20+
getMultipleHosts: jest.fn(),
21+
};
22+
23+
beforeEach(async () => {
24+
const module: TestingModule = await Test.createTestingModule({
25+
providers: [
26+
DevicesService,
27+
{ provide: FleetService, useValue: mockFleetService },
28+
],
29+
}).compile();
30+
31+
service = module.get<DevicesService>(DevicesService);
32+
jest.clearAllMocks();
33+
});
34+
35+
describe('removeDeviceById', () => {
36+
it('throws when organization does not exist', async () => {
37+
(db.organization.findUnique as jest.Mock).mockResolvedValue(null);
38+
39+
await expect(
40+
service.removeDeviceById({
41+
organizationId: 'org_missing',
42+
deviceId: 'dev_1',
43+
userId: 'usr_1',
44+
}),
45+
).rejects.toThrow(NotFoundException);
46+
});
47+
48+
it('throws when user id is missing', async () => {
49+
(db.organization.findUnique as jest.Mock).mockResolvedValue({
50+
id: 'org_1',
51+
});
52+
53+
await expect(
54+
service.removeDeviceById({
55+
organizationId: 'org_1',
56+
deviceId: 'dev_1',
57+
}),
58+
).rejects.toThrow(ForbiddenException);
59+
});
60+
61+
it('throws when user is not a member of organization', async () => {
62+
(db.organization.findUnique as jest.Mock).mockResolvedValue({
63+
id: 'org_1',
64+
});
65+
(db.member.findFirst as jest.Mock).mockResolvedValue(null);
66+
67+
await expect(
68+
service.removeDeviceById({
69+
organizationId: 'org_1',
70+
deviceId: 'dev_1',
71+
userId: 'usr_1',
72+
}),
73+
).rejects.toThrow('User is not a member of this organization');
74+
});
75+
76+
it('throws when member is not an owner', async () => {
77+
(db.organization.findUnique as jest.Mock).mockResolvedValue({
78+
id: 'org_1',
79+
});
80+
(db.member.findFirst as jest.Mock).mockResolvedValue({
81+
role: 'admin',
82+
});
83+
84+
await expect(
85+
service.removeDeviceById({
86+
organizationId: 'org_1',
87+
deviceId: 'dev_1',
88+
userId: 'usr_1',
89+
}),
90+
).rejects.toThrow('Only organization owners can remove devices');
91+
});
92+
93+
it('throws when device does not exist in organization', async () => {
94+
(db.organization.findUnique as jest.Mock).mockResolvedValue({
95+
id: 'org_1',
96+
});
97+
(db.member.findFirst as jest.Mock).mockResolvedValue({
98+
role: 'owner',
99+
});
100+
(db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 0 });
101+
102+
await expect(
103+
service.removeDeviceById({
104+
organizationId: 'org_1',
105+
deviceId: 'dev_missing',
106+
userId: 'usr_1',
107+
}),
108+
).rejects.toThrow(NotFoundException);
109+
});
110+
111+
it('deletes device when caller is owner', async () => {
112+
(db.organization.findUnique as jest.Mock).mockResolvedValue({
113+
id: 'org_1',
114+
});
115+
(db.member.findFirst as jest.Mock).mockResolvedValue({
116+
role: ' employee , owner ',
117+
});
118+
(db.device.deleteMany as jest.Mock).mockResolvedValue({ count: 1 });
119+
120+
await service.removeDeviceById({
121+
organizationId: 'org_1',
122+
deviceId: 'dev_1',
123+
userId: 'usr_1',
124+
});
125+
126+
expect(db.device.deleteMany).toHaveBeenCalledWith({
127+
where: {
128+
id: 'dev_1',
129+
organizationId: 'org_1',
130+
},
131+
});
132+
});
133+
});
134+
});

apps/api/src/devices/devices.service.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
Logger,
5+
ForbiddenException,
6+
} from '@nestjs/common';
27
import { db } from '@db';
38
import { getDeviceComplianceStatus } from '@trycompai/utils/devices';
49
import { FleetService } from '../lib/fleet.service';
@@ -175,6 +180,66 @@ export class DevicesService {
175180
}
176181
}
177182

183+
async removeDeviceById({
184+
organizationId,
185+
deviceId,
186+
userId,
187+
}: {
188+
organizationId: string;
189+
deviceId: string;
190+
userId?: string;
191+
}): Promise<void> {
192+
const organization = await db.organization.findUnique({
193+
where: { id: organizationId },
194+
select: { id: true },
195+
});
196+
197+
if (!organization) {
198+
throw new NotFoundException(
199+
`Organization with ID ${organizationId} not found`,
200+
);
201+
}
202+
203+
if (!userId) {
204+
throw new ForbiddenException('Only organization owners can remove devices');
205+
}
206+
207+
const member = await db.member.findFirst({
208+
where: {
209+
userId,
210+
organizationId,
211+
deactivated: false,
212+
},
213+
select: { role: true },
214+
});
215+
216+
if (!member) {
217+
throw new ForbiddenException('User is not a member of this organization');
218+
}
219+
220+
const memberRoles = member.role
221+
.split(',')
222+
.map((role) => role.trim().toLowerCase());
223+
const isOwner = memberRoles.includes('owner');
224+
225+
if (!isOwner) {
226+
throw new ForbiddenException('Only organization owners can remove devices');
227+
}
228+
229+
const deleteResult = await db.device.deleteMany({
230+
where: {
231+
id: deviceId,
232+
organizationId,
233+
},
234+
});
235+
236+
if (deleteResult.count === 0) {
237+
throw new NotFoundException(
238+
`Device with ID ${deviceId} not found in organization ${organizationId}`,
239+
);
240+
}
241+
}
242+
178243
// --- Private helpers ---
179244

180245
private async getFleetDevicesForOrg(

0 commit comments

Comments
 (0)