Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 0 additions & 2 deletions apps/api/trigger.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { syncVercelEnvVars } from '@trigger.dev/build/extensions/core';
import { defineConfig } from '@trigger.dev/sdk';
import { prismaExtension } from './customPrismaExtension';
import { emailExtension } from './emailExtension';
Expand All @@ -17,7 +16,6 @@ export default defineConfig({
}),
integrationPlatformExtension(),
emailExtension(),
syncVercelEnvVars(),
],
},
retries: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberWithUser } from './TeamMembers';

// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({ orgId: 'org_123' }),
}));

// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));

// Mock sonner
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));

// Mock child components that aren't relevant
vi.mock('./MultiRoleCombobox', () => ({
MultiRoleCombobox: () => null,
}));
vi.mock('./RemoveDeviceAlert', () => ({
RemoveDeviceAlert: () => null,
}));
vi.mock('./RemoveMemberAlert', () => ({
RemoveMemberAlert: () => null,
}));

import { MemberRow } from './MemberRow';

const baseMember = {
id: 'mem_1',
userId: 'usr_1',
organizationId: 'org_123',
role: 'employee',
department: null,
isActive: true,
deactivated: false,
fleetDmLabelId: null,
createdAt: new Date(),
updatedAt: new Date(),
user: {
id: 'usr_1',
name: 'Jane Doe',
email: 'jane@example.com',
emailVerified: true,
image: null,
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
banned: false,
banReason: null,
banExpires: null,
},
} as unknown as MemberWithUser;

const noop = vi.fn();

function renderMemberRow(deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed') {
return render(
<table>
<tbody>
<MemberRow
member={baseMember}
onRemove={noop}
onRemoveDevice={noop}
onUpdateRole={noop}
onReactivate={noop}
canEdit={false}
isCurrentUserOwner={false}
deviceStatus={deviceStatus}
/>
</tbody>
</table>,
);
}

describe('MemberRow device status', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows "Not Installed" with red dot when deviceStatus is not-installed', () => {
renderMemberRow('not-installed');
expect(screen.getByText('Not Installed')).toBeInTheDocument();
expect(screen.getByText('Not Installed').className).toContain('text-muted-foreground');
});

it('shows "Not Installed" by default when deviceStatus is omitted', () => {
renderMemberRow();
expect(screen.getByText('Not Installed')).toBeInTheDocument();
});

it('shows "Compliant" with green dot when deviceStatus is compliant', () => {
renderMemberRow('compliant');
expect(screen.getByText('Compliant')).toBeInTheDocument();
expect(screen.getByText('Compliant').className).toContain('text-foreground');
});

it('shows "Non-Compliant" with yellow dot when deviceStatus is non-compliant', () => {
renderMemberRow('non-compliant');
expect(screen.getByText('Non-Compliant')).toBeInTheDocument();
expect(screen.getByText('Non-Compliant').className).toContain('text-foreground');
});

it('does not show device status for platform admin', () => {
const adminMember = {
...baseMember,
user: { ...baseMember.user, role: 'admin' as const },
} as MemberWithUser;

render(
<table>
<tbody>
<MemberRow
member={adminMember}
onRemove={noop}
onRemoveDevice={noop}
onUpdateRole={noop}
onReactivate={noop}
canEdit={false}
isCurrentUserOwner={false}
deviceStatus="compliant"
/>
</tbody>
</table>,
);

expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
expect(screen.queryByText('Not Installed')).not.toBeInTheDocument();
});

it('does not show device status for deactivated member', () => {
const deactivatedMember = {
...baseMember,
deactivated: true,
} as MemberWithUser;

render(
<table>
<tbody>
<MemberRow
member={deactivatedMember}
onRemove={noop}
onRemoveDevice={noop}
onUpdateRole={noop}
onReactivate={noop}
canEdit={false}
isCurrentUserOwner={false}
deviceStatus="non-compliant"
/>
</tbody>
</table>,
);

expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
});

it('renders correct dot colors for each status', () => {
const { container, unmount } = renderMemberRow('compliant');
const greenDot = container.querySelector('.bg-green-500');
expect(greenDot).toBeInTheDocument();
unmount();

const { container: c2, unmount: u2 } = renderMemberRow('non-compliant');
const yellowDot = c2.querySelector('.bg-yellow-500');
expect(yellowDot).toBeInTheDocument();
u2();

const { container: c3 } = renderMemberRow('not-installed');
const redDot = c3.querySelector('.bg-red-400');
expect(redDot).toBeInTheDocument();
});
});
28 changes: 21 additions & 7 deletions apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface MemberRowProps {
isCurrentUserOwner: boolean;
customRoles?: CustomRoleOption[];
taskCompletion?: TaskCompletion;
hasDeviceAgentDevice?: boolean;
deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed';
}

function getInitials(name?: string | null, email?: string | null): string {
Expand Down Expand Up @@ -95,7 +95,7 @@ export function MemberRow({
isCurrentUserOwner,
customRoles = [],
taskCompletion,
hasDeviceAgentDevice,
deviceStatus = 'not-installed',
}: MemberRowProps) {
const { orgId } = useParams<{ orgId: string }>();

Expand Down Expand Up @@ -231,7 +231,7 @@ export function MemberRow({
</div>
</TableCell>

{/* AGENT */}
{/* DEVICE */}
<TableCell>
{isPlatformAdmin || isDeactivated ? (
<Text size="sm" variant="muted">
Expand All @@ -241,11 +241,25 @@ export function MemberRow({
<div className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
hasDeviceAgentDevice ? 'bg-green-500' : 'bg-red-400'
deviceStatus === 'compliant'
? 'bg-green-500'
: deviceStatus === 'non-compliant'
? 'bg-yellow-500'
: 'bg-red-400'
}`}
/>
<span className={`text-sm ${hasDeviceAgentDevice ? 'text-foreground' : 'text-muted-foreground'}`}>
{hasDeviceAgentDevice ? 'Installed' : 'Not Installed'}
<span
className={`text-sm ${
deviceStatus === 'not-installed'
? 'text-muted-foreground'
: 'text-foreground'
}`}
>
{deviceStatus === 'compliant'
? 'Compliant'
: deviceStatus === 'non-compliant'
? 'Non-Compliant'
: 'Not Installed'}
</span>
</div>
)}
Expand Down Expand Up @@ -307,7 +321,7 @@ export function MemberRow({
</DropdownMenuItem>
)}
{!isDeactivated &&
(member.fleetDmLabelId || hasDeviceAgentDevice) &&
(member.fleetDmLabelId || deviceStatus !== 'not-installed') &&
isCurrentUserOwner && (
<DropdownMenuItem
onSelect={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ export interface TaskCompletion {
hipaa?: { completed: number; total: number };
}

export type DeviceStatus = 'compliant' | 'non-compliant' | 'not-installed';

export interface TeamMembersProps {
canManageMembers: boolean;
canInviteUsers: boolean;
isAuditor: boolean;
isCurrentUserOwner: boolean;
organizationId: string;
deviceStatusMap: Record<string, DeviceStatus>;
}

export async function TeamMembers(props: TeamMembersProps) {
const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId } = props;
const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId, deviceStatusMap } = props;

if (!organizationId) {
return null;
Expand Down Expand Up @@ -64,19 +67,6 @@ export async function TeamMembers(props: TeamMembersProps) {

const employeeMembers = await filterComplianceMembers(members, organizationId);

// Build a set of member IDs that have device-agent devices
const memberIds = members.map((m) => m.id);
const devicesForMembers = await db.device.findMany({
where: {
organizationId,
memberId: { in: memberIds },
},
select: { memberId: true },
});
const memberIdsWithDeviceAgent = [
...new Set(devicesForMembers.map((d) => d.memberId)),
];

if (employeeMembers.length > 0) {
const [org, hipaaInstance] = await Promise.all([
db.organization.findUnique({
Expand Down Expand Up @@ -156,7 +146,7 @@ export async function TeamMembers(props: TeamMembersProps) {
isCurrentUserOwner={isCurrentUserOwner}
employeeSyncData={employeeSyncData}
taskCompletionMap={taskCompletionMap}
memberIdsWithDeviceAgent={memberIdsWithDeviceAgent}
deviceStatusMap={deviceStatusMap}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ interface TeamMembersClientProps {
isCurrentUserOwner: boolean;
employeeSyncData: EmployeeSyncConnectionsData;
taskCompletionMap: Record<string, TaskCompletion>;
memberIdsWithDeviceAgent: string[];
deviceStatusMap: Record<string, 'compliant' | 'non-compliant' | 'not-installed'>;
}

export function TeamMembersClient({
Expand All @@ -65,7 +65,7 @@ export function TeamMembersClient({
isCurrentUserOwner,
employeeSyncData,
taskCompletionMap,
memberIdsWithDeviceAgent,
deviceStatusMap,
}: TeamMembersClientProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
Expand Down Expand Up @@ -470,9 +470,7 @@ export function TeamMembersClient({
isCurrentUserOwner={isCurrentUserOwner}
customRoles={customRoles}
taskCompletion={taskCompletionMap[(item as MemberWithUser).id]}
hasDeviceAgentDevice={memberIdsWithDeviceAgent.includes(
(item as MemberWithUser).id,
)}
deviceStatus={deviceStatusMap[(item as MemberWithUser).id] ?? 'not-installed'}
/>
) : (
<PendingInvitationRow
Expand Down
Loading
Loading