Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/api/src/lib/fleet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ export class FleetService {
}
}

/**
* Remove a single host from FleetDM by ID
* @param hostId - The FleetDM host ID
*/
async removeHostById(hostId: number): Promise<void> {
try {
await this.fleetInstance.delete(`/hosts/${hostId}`);
this.logger.debug(`Deleted host ${hostId} from FleetDM`);
} catch (error) {
this.logger.error(`Failed to delete host ${hostId}:`, error);
throw new Error(`Failed to remove host ${hostId}`);
}
}

async getMultipleHosts(hostIds: number[]) {
try {
const requests = hostIds.map((id) => this.getHostById(id));
Expand Down Expand Up @@ -104,14 +118,12 @@ export class FleetService {
// Extract host IDs
const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id);

// Delete each host
// Delete each host using removeHostById for consistent behavior
const deletePromises = hostIds.map(async (hostId: number) => {
try {
await this.fleetInstance.delete(`/hosts/${hostId}`);
this.logger.debug(`Deleted host ${hostId} from FleetDM`);
await this.removeHostById(hostId);
return { success: true, hostId };
} catch (error) {
this.logger.error(`Failed to delete host ${hostId}:`, error);
} catch {
return { success: false, hostId };
}
});
Expand Down
38 changes: 38 additions & 0 deletions apps/api/src/people/people.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Delete,
Body,
Param,
ParseIntPipe,
UseGuards,
HttpCode,
HttpStatus,
Expand All @@ -22,6 +23,7 @@ import {
} from '@nestjs/swagger';
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { RequireRoles } from '../auth/role-validator.guard';
import type { AuthContext as AuthContextType } from '../auth/types';
import { CreatePeopleDto } from './dto/create-people.dto';
import { UpdatePeopleDto } from './dto/update-people.dto';
Expand All @@ -34,6 +36,7 @@ import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.res
import { GET_PERSON_BY_ID_RESPONSES } from './schemas/get-person-by-id.responses';
import { UPDATE_MEMBER_RESPONSES } from './schemas/update-member.responses';
import { DELETE_MEMBER_RESPONSES } from './schemas/delete-member.responses';
import { REMOVE_HOST_RESPONSES } from './schemas/remove-host.responses';
import { PEOPLE_OPERATIONS } from './schemas/people-operations';
import { PEOPLE_PARAMS } from './schemas/people-params';
import { PEOPLE_BODIES } from './schemas/people-bodies';
Expand Down Expand Up @@ -199,6 +202,41 @@ export class PeopleController {
};
}

@Delete(':id/host/:hostId')
@HttpCode(HttpStatus.OK)
@UseGuards(RequireRoles('owner'))
@ApiOperation(PEOPLE_OPERATIONS.removeHost)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiParam(PEOPLE_PARAMS.hostId)
@ApiResponse(REMOVE_HOST_RESPONSES[200])
@ApiResponse(REMOVE_HOST_RESPONSES[401])
@ApiResponse(REMOVE_HOST_RESPONSES[404])
@ApiResponse(REMOVE_HOST_RESPONSES[500])
async removeHost(
@Param('id') memberId: string,
@Param('hostId', ParseIntPipe) hostId: number,
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
) {
const result = await this.peopleService.removeHostById(
memberId,
organizationId,
hostId,
);

return {
...result,
authType: authContext.authType,
...(authContext.userId &&
authContext.userEmail && {
authenticatedUser: {
id: authContext.userId,
email: authContext.userEmail,
},
}),
};
}

@Delete(':id')
@ApiOperation(PEOPLE_OPERATIONS.deleteMember)
@ApiParam(PEOPLE_PARAMS.memberId)
Expand Down
57 changes: 57 additions & 0 deletions apps/api/src/people/people.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,4 +338,61 @@ export class PeopleService {
throw new Error(`Failed to unlink device: ${error.message}`);
}
}

async removeHostById(
memberId: string,
organizationId: string,
hostId: number,
): Promise<{ success: true }> {
try {
await MemberValidator.validateOrganization(organizationId);
const member = await MemberQueries.findByIdInOrganization(
memberId,
organizationId,
);

if (!member) {
throw new NotFoundException(
`Member with ID ${memberId} not found in organization ${organizationId}`,
);
}

if (!member.fleetDmLabelId) {
throw new BadRequestException(
`Member ${memberId} has no Fleet label; cannot remove host`,
);
}

const labelHosts = await this.fleetService.getHostsByLabel(
member.fleetDmLabelId,
);
const hostIds = (labelHosts?.hosts ?? []).map(
(host: { id: number }) => host.id,
);
if (!hostIds.includes(hostId)) {
throw new NotFoundException(
`Host ${hostId} not found for member ${memberId} in organization ${organizationId}`,
);
}

await this.fleetService.removeHostById(hostId);

this.logger.log(
`Removed host ${hostId} from FleetDM for member ${memberId} in organization ${organizationId}`,
);
return { success: true };
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
this.logger.error(
`Failed to remove host ${hostId} for member ${memberId} in organization ${organizationId}:`,
error,
);
throw new Error(`Failed to remove host: ${error.message}`);
}
}
}
5 changes: 5 additions & 0 deletions apps/api/src/people/schemas/people-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,9 @@ export const PEOPLE_OPERATIONS: Record<string, ApiOperationOptions> = {
description:
'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).',
},
removeHost: {
summary: 'Remove host (device) from Fleet',
description:
'Removes a single host (device) from FleetDM by host ID. Only organization owners can perform this action. Validates that the organization exists and the member exists within the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
},
};
5 changes: 5 additions & 0 deletions apps/api/src/people/schemas/people-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ export const PEOPLE_PARAMS: Record<string, ApiParamOptions> = {
description: 'Member ID',
example: 'mem_abc123def456',
},
hostId: {
name: 'hostId',
description: 'FleetDM host ID',
example: 1,
},
};
74 changes: 74 additions & 0 deletions apps/api/src/people/schemas/remove-host.responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ApiResponseOptions } from '@nestjs/swagger';

export const REMOVE_HOST_RESPONSES: Record<string, ApiResponseOptions> = {
200: {
status: 200,
description: 'Host removed from Fleet successfully',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates successful removal',
example: true,
},
authType: {
type: 'string',
enum: ['api-key', 'session'],
description: 'How the request was authenticated',
},
},
},
},
},
},
401: {
status: 401,
description:
'Unauthorized - Invalid authentication, insufficient permissions, or not organization owner',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: { type: 'string', example: 'Unauthorized' },
},
},
},
},
},
404: {
status: 404,
description: 'Organization or member not found',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {
type: 'string',
example:
'Member with ID mem_abc123def456 not found in organization org_abc123def456',
},
},
},
},
},
},
500: {
status: 500,
description: 'Internal server error',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: { type: 'string', example: 'Failed to remove host' },
},
},
},
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,14 @@ export function MemberRow({
/>
<RemoveDeviceAlert
open={isRemoveDeviceAlertOpen}
title="Remove Device"
description={(
<>
{'Are you sure you want to remove all devices for this user '} <strong>{memberName}</strong>?{' '}
{'This will disconnect all devices from the organization.'}
</>
)}
onOpenChange={setIsRemoveDeviceAlertOpen}
memberName={memberName}
onRemove={handleRemoveDeviceClick}
isRemoving={isRemovingDevice}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,32 @@ import {
AlertDialogTitle,
} from '@comp/ui/alert-dialog';
import { Button } from '@comp/ui/button';
import { ReactNode } from 'react';

interface RemoveDeviceAlertProps {
open: boolean;
title: string;
description: ReactNode;
onOpenChange: (open: boolean) => void;
memberName: string;
onRemove: () => void;
isRemoving: boolean;
}

export function RemoveDeviceAlert({
open,
title,
description,
onOpenChange,
memberName,
onRemove,
isRemoving,
}: RemoveDeviceAlertProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{'Remove Device'}</AlertDialogTitle>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{'Are you sure you want to remove the device for this user '} <strong>{memberName}</strong>?{' '}
{'This will disconnect the device from the organization.'}
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ export interface TeamMembersData {
pendingInvitations: Invitation[];
}

export async function TeamMembers() {
export interface TeamMembersProps {
canManageMembers: boolean;
canInviteUsers: boolean;
isAuditor: boolean;
isCurrentUserOwner: boolean;
}

export async function TeamMembers(props: TeamMembersProps) {
const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner } = props;
const session = await auth.api.getSession({
headers: await headers(),
});
Expand All @@ -28,20 +36,6 @@ export async function TeamMembers() {
return null;
}

const currentUserMember = await db.member.findFirst({
where: {
organizationId: organizationId,
userId: session?.user.id,
},
});

// Parse roles from comma-separated string and check if user has admin or owner role
const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? [];
const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role));
const isAuditor = currentUserRoles.includes('auditor');
const canInviteUsers = canManageMembers || isAuditor;
const isCurrentUserOwner = currentUserRoles.includes('owner');

let members: MemberWithUser[] = [];
let pendingInvitations: Invitation[] = [];

Expand Down
Loading
Loading