Skip to content

Commit 2a44312

Browse files
[dev] [Marfuen] mariano/portal-settings (#2119)
* feat(portal): add device agent and security training step settings * feat(dashboard): enhance employee task components with security training info --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent c4c38db commit 2a44312

16 files changed

Lines changed: 424 additions & 141 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use server';
2+
3+
import { db } from '@db';
4+
import { revalidatePath, revalidateTag } from 'next/cache';
5+
import { headers } from 'next/headers';
6+
import { authActionClient } from '../safe-action';
7+
import { organizationDeviceAgentStepSchema } from '../schema';
8+
9+
export const updateOrganizationDeviceAgentStepAction = authActionClient
10+
.inputSchema(organizationDeviceAgentStepSchema)
11+
.metadata({
12+
name: 'update-organization-device-agent-step',
13+
track: {
14+
event: 'update-organization-device-agent-step',
15+
channel: 'server',
16+
},
17+
})
18+
.action(async ({ parsedInput, ctx }) => {
19+
const { deviceAgentStepEnabled } = parsedInput;
20+
const { activeOrganizationId } = ctx.session;
21+
22+
if (!activeOrganizationId) {
23+
throw new Error('No active organization');
24+
}
25+
26+
try {
27+
await db.organization.update({
28+
where: { id: activeOrganizationId },
29+
data: { deviceAgentStepEnabled },
30+
});
31+
32+
const headersList = await headers();
33+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
34+
path = path.replace(/\/[a-z]{2}\//, '/');
35+
36+
revalidatePath(path);
37+
revalidateTag(`organization_${activeOrganizationId}`, 'max');
38+
39+
return {
40+
success: true,
41+
};
42+
} catch (error) {
43+
console.error(error);
44+
throw new Error('Failed to update device agent step setting');
45+
}
46+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use server';
2+
3+
import { db } from '@db';
4+
import { revalidatePath, revalidateTag } from 'next/cache';
5+
import { headers } from 'next/headers';
6+
import { authActionClient } from '../safe-action';
7+
import { organizationSecurityTrainingStepSchema } from '../schema';
8+
9+
export const updateOrganizationSecurityTrainingStepAction = authActionClient
10+
.inputSchema(organizationSecurityTrainingStepSchema)
11+
.metadata({
12+
name: 'update-organization-security-training-step',
13+
track: {
14+
event: 'update-organization-security-training-step',
15+
channel: 'server',
16+
},
17+
})
18+
.action(async ({ parsedInput, ctx }) => {
19+
const { securityTrainingStepEnabled } = parsedInput;
20+
const { activeOrganizationId } = ctx.session;
21+
22+
if (!activeOrganizationId) {
23+
throw new Error('No active organization');
24+
}
25+
26+
try {
27+
await db.organization.update({
28+
where: { id: activeOrganizationId },
29+
data: { securityTrainingStepEnabled },
30+
});
31+
32+
const headersList = await headers();
33+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
34+
path = path.replace(/\/[a-z]{2}\//, '/');
35+
36+
revalidatePath(path);
37+
revalidateTag(`organization_${activeOrganizationId}`, 'max');
38+
39+
return {
40+
success: true,
41+
};
42+
} catch (error) {
43+
console.error(error);
44+
throw new Error('Failed to update security training step setting');
45+
}
46+
});

apps/app/src/actions/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export const organizationEvidenceApprovalSchema = z.object({
6969
evidenceApprovalEnabled: z.boolean(),
7070
});
7171

72+
export const organizationDeviceAgentStepSchema = z.object({
73+
deviceAgentStepEnabled: z.boolean(),
74+
});
75+
76+
export const organizationSecurityTrainingStepSchema = z.object({
77+
securityTrainingStepEnabled: z.boolean(),
78+
});
79+
7280
// Risks
7381
export const createRiskSchema = z.object({
7482
title: z

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx

Lines changed: 109 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
TabsTrigger,
1414
Text,
1515
} from '@trycompai/design-system';
16-
import { AlertCircle, Award, CheckCircle2, Download } from 'lucide-react';
16+
import { AlertCircle, Award, CheckCircle2, Download, Info } from 'lucide-react';
1717
import type { FleetPolicy, Host } from '../../devices/types';
1818
import { PolicyItem } from '../../devices/components/PolicyItem';
1919
import { downloadTrainingCertificate } from '../actions/download-training-certificate';
@@ -120,102 +120,126 @@ export const EmployeeTasks = ({
120120
</TabsContent>
121121

122122
<TabsContent value="training">
123-
<Stack gap="md">
124-
{/* Training Completion Summary */}
125-
{trainingVideos.length > 0 && (
126-
<div
127-
className={cn(
128-
'flex items-center justify-between rounded-lg border p-4',
129-
allTrainingComplete
130-
? 'border-primary/20 bg-primary/5'
131-
: 'border-muted bg-muted/30',
132-
)}
133-
>
134-
<div className="flex items-center gap-3">
135-
<div
136-
className={cn(
137-
'flex h-10 w-10 items-center justify-center rounded-full',
138-
allTrainingComplete ? 'bg-primary/10' : 'bg-muted',
139-
)}
140-
>
141-
<Award
123+
{!organization.securityTrainingStepEnabled ? (
124+
<div className="flex items-center gap-3 rounded-lg border border-muted bg-muted/30 p-4">
125+
<Info className="h-5 w-5 shrink-0 text-muted-foreground" />
126+
<div>
127+
<Text weight="medium">Security training is managed outside of Comp AI</Text>
128+
<Text size="sm" variant="muted">
129+
Evidence for security training completion can be logged in the Security Awareness
130+
Training evidence task.
131+
</Text>
132+
</div>
133+
</div>
134+
) : (
135+
<Stack gap="md">
136+
{/* Training Completion Summary */}
137+
{trainingVideos.length > 0 && (
138+
<div
139+
className={cn(
140+
'flex items-center justify-between rounded-lg border p-4',
141+
allTrainingComplete
142+
? 'border-primary/20 bg-primary/5'
143+
: 'border-muted bg-muted/30',
144+
)}
145+
>
146+
<div className="flex items-center gap-3">
147+
<div
142148
className={cn(
143-
'h-5 w-5',
144-
allTrainingComplete ? 'text-primary' : 'text-muted-foreground',
149+
'flex h-10 w-10 items-center justify-center rounded-full',
150+
allTrainingComplete ? 'bg-primary/10' : 'bg-muted',
145151
)}
146-
/>
147-
</div>
148-
<div>
149-
<Text weight="medium">
150-
{allTrainingComplete
151-
? 'All Training Complete'
152-
: `${completedVideos.length}/${trainingVideos.length} Videos Completed`}
153-
</Text>
154-
{trainingCompletionDate && (
155-
<Text size="sm" variant="muted">
156-
Completed on{' '}
157-
{new Date(trainingCompletionDate).toLocaleDateString('en-US', {
158-
year: 'numeric',
159-
month: 'long',
160-
day: 'numeric',
161-
})}
152+
>
153+
<Award
154+
className={cn(
155+
'h-5 w-5',
156+
allTrainingComplete ? 'text-primary' : 'text-muted-foreground',
157+
)}
158+
/>
159+
</div>
160+
<div>
161+
<Text weight="medium">
162+
{allTrainingComplete
163+
? 'All Training Complete'
164+
: `${completedVideos.length}/${trainingVideos.length} Videos Completed`}
162165
</Text>
163-
)}
166+
{trainingCompletionDate && (
167+
<Text size="sm" variant="muted">
168+
Completed on{' '}
169+
{new Date(trainingCompletionDate).toLocaleDateString('en-US', {
170+
year: 'numeric',
171+
month: 'long',
172+
day: 'numeric',
173+
})}
174+
</Text>
175+
)}
176+
</div>
164177
</div>
178+
{allTrainingComplete && (
179+
<button
180+
onClick={handleDownloadCertificate}
181+
className="inline-flex items-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-all duration-200 hover:bg-primary/90 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-1 cursor-pointer"
182+
>
183+
<Download className="h-3.5 w-3.5" />
184+
Certificate
185+
</button>
186+
)}
165187
</div>
166-
{allTrainingComplete && (
167-
<button
168-
onClick={handleDownloadCertificate}
169-
className="inline-flex items-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-all duration-200 hover:bg-primary/90 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-1 cursor-pointer"
170-
>
171-
<Download className="h-3.5 w-3.5" />
172-
Certificate
173-
</button>
174-
)}
175-
</div>
176-
)}
188+
)}
177189

178-
<Stack gap="sm">
179-
{trainingVideos.length === 0 ? (
180-
<div className="py-6 text-center">
181-
<Text variant="muted">No training videos required to watch.</Text>
182-
</div>
183-
) : (
184-
trainingVideos.map((video) => {
185-
const isCompleted = video.completedAt !== null;
190+
<Stack gap="sm">
191+
{trainingVideos.length === 0 ? (
192+
<div className="py-6 text-center">
193+
<Text variant="muted">No training videos required to watch.</Text>
194+
</div>
195+
) : (
196+
trainingVideos.map((video) => {
197+
const isCompleted = video.completedAt !== null;
186198

187-
return (
188-
<div
189-
key={video.id}
190-
className="flex items-center justify-between gap-2 rounded-md border p-3"
191-
>
192-
<Stack gap="xs">
193-
<div className="flex items-center gap-2">
194-
{isCompleted ? (
195-
<CheckCircle2 className="h-4 w-4 text-primary" />
196-
) : (
197-
<AlertCircle className="h-4 w-4 text-destructive" />
199+
return (
200+
<div
201+
key={video.id}
202+
className="flex items-center justify-between gap-2 rounded-md border p-3"
203+
>
204+
<Stack gap="xs">
205+
<div className="flex items-center gap-2">
206+
{isCompleted ? (
207+
<CheckCircle2 className="h-4 w-4 text-primary" />
208+
) : (
209+
<AlertCircle className="h-4 w-4 text-destructive" />
210+
)}
211+
<Text>{video.metadata.title}</Text>
212+
</div>
213+
{isCompleted && (
214+
<Text size="xs" variant="muted">
215+
Completed -{' '}
216+
{video.completedAt &&
217+
new Date(video.completedAt).toLocaleDateString()}
218+
</Text>
198219
)}
199-
<Text>{video.metadata.title}</Text>
200-
</div>
201-
{isCompleted && (
202-
<Text size="xs" variant="muted">
203-
Completed -{' '}
204-
{video.completedAt &&
205-
new Date(video.completedAt).toLocaleDateString()}
206-
</Text>
207-
)}
208-
</Stack>
209-
</div>
210-
);
211-
})
212-
)}
220+
</Stack>
221+
</div>
222+
);
223+
})
224+
)}
225+
</Stack>
213226
</Stack>
214-
</Stack>
227+
)}
215228
</TabsContent>
216229

217230
<TabsContent value="device">
218-
{host ? (
231+
{!organization.deviceAgentStepEnabled ? (
232+
<div className="flex items-center gap-3 rounded-lg border border-muted bg-muted/30 p-4">
233+
<Info className="h-5 w-5 shrink-0 text-muted-foreground" />
234+
<div>
235+
<Text weight="medium">Device agent is managed outside of Comp AI</Text>
236+
<Text size="sm" variant="muted">
237+
Evidence for device compliance can be logged in the Secure Device and Device List
238+
evidence tasks.
239+
</Text>
240+
</div>
241+
</div>
242+
) : host ? (
219243
<Card>
220244
<CardHeader>
221245
<CardTitle>{host.computer_name}&apos;s Policies</CardTitle>

0 commit comments

Comments
 (0)