diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/TaskCard.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/TaskCard.tsx
index 6991b903e..2100d2d6d 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/components/TaskCard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/components/TaskCard.tsx
@@ -1,13 +1,12 @@
'use client';
-import { api } from '@/lib/api-client';
import { Skeleton } from '@comp/ui/skeleton';
import { ArrowRight, Loader2 } from 'lucide-react';
-import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
interface RelevantTask {
+ taskId: string;
taskTemplateId: string;
taskName: string;
reason: string;
@@ -16,45 +15,14 @@ interface RelevantTask {
export function TaskCard({ task, orgId }: { task: RelevantTask; orgId: string }) {
const [isNavigating, setIsNavigating] = useState(false);
- const router = useRouter();
- const handleCardClick = async () => {
+ const handleCardClick = () => {
setIsNavigating(true);
- toast.loading('Opening task automation...', { id: 'navigating' });
+ toast.success('Opening task automation...', { duration: 1000 });
- try {
- const response = await api.get
>(
- '/v1/tasks',
- orgId,
- );
-
- if (response.error || !response.data) {
- throw new Error(response.error || 'Failed to fetch tasks');
- }
-
- const matchingTask = response.data.find(
- (t) => t.taskTemplateId && t.taskTemplateId === task.taskTemplateId,
- );
-
- if (!matchingTask) {
- toast.dismiss('navigating');
- toast.error(`Task "${task.taskName}" not found. Please create it first.`);
- setIsNavigating(false);
- await router.push(`/${orgId}/tasks`);
- return;
- }
-
- const url = `/${orgId}/tasks/${matchingTask.id}/automation/new?prompt=${encodeURIComponent(task.prompt)}`;
- toast.dismiss('navigating');
- toast.success('Redirecting...', { duration: 1000 });
-
- window.location.href = url;
- } catch (error) {
- console.error('Error finding task:', error);
- toast.dismiss('navigating');
- toast.error('Failed to find task');
- setIsNavigating(false);
- }
+ // Navigate directly using the task ID we already have
+ const url = `/${orgId}/tasks/${task.taskId}/automation/new?prompt=${encodeURIComponent(task.prompt)}`;
+ window.location.href = url;
};
return (
@@ -114,4 +82,3 @@ export function TaskCardSkeleton() {
);
}
-
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx
index 50de6920d..4fc96033a 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx
@@ -2,19 +2,38 @@ import { db } from '@db';
import { PageHeader, PageLayout, Stack } from '@trycompai/design-system';
import { PlatformIntegrations } from './components/PlatformIntegrations';
-export default async function IntegrationsPage() {
- // Fetch task templates server-side
- const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({
+interface PageProps {
+ params: Promise<{ orgId: string }>;
+}
+
+export default async function IntegrationsPage({ params }: PageProps) {
+ const { orgId } = await params;
+
+ // Fetch organization's tasks that have a template (can be automated)
+ const orgTasks = await db.task.findMany({
+ where: {
+ organizationId: orgId,
+ taskTemplateId: { not: null },
+ },
select: {
id: true,
- name: true,
+ title: true,
description: true,
+ taskTemplateId: true,
},
orderBy: {
- name: 'asc',
+ title: 'asc',
},
});
+ // Map to the format expected by the component
+ const taskTemplates = orgTasks.map((task) => ({
+ id: task.taskTemplateId as string, // Used as taskTemplateId for matching
+ taskId: task.id, // Actual task ID for navigation
+ name: task.title,
+ description: task.description,
+ }));
+
return (
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts
new file mode 100644
index 000000000..b316711db
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/download-training-certificate.ts
@@ -0,0 +1,68 @@
+'use server';
+
+import { authActionClient } from '@/actions/safe-action';
+import { env } from '@/env.mjs';
+import { z } from 'zod';
+
+const downloadCertificateSchema = z.object({
+ memberId: z.string(),
+ organizationId: z.string(),
+});
+
+/**
+ * Downloads a training certificate PDF for a member by calling the API
+ */
+export const downloadTrainingCertificate = authActionClient
+ .inputSchema(downloadCertificateSchema)
+ .metadata({
+ name: 'downloadTrainingCertificate',
+ track: {
+ event: 'downloadTrainingCertificate',
+ channel: 'server',
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { memberId, organizationId } = parsedInput;
+ const { session } = ctx;
+
+ // Verify the caller has access to this organization
+ if (session.activeOrganizationId !== organizationId) {
+ throw new Error(
+ 'Unauthorized: You do not have access to this organization',
+ );
+ }
+
+ // Call the API to generate the certificate
+ const apiUrl =
+ env.NEXT_PUBLIC_API_URL ||
+ process.env.API_BASE_URL ||
+ 'http://localhost:3333';
+
+ const internalToken = env.INTERNAL_API_TOKEN;
+ if (!internalToken) {
+ throw new Error('INTERNAL_API_TOKEN not configured');
+ }
+
+ const response = await fetch(`${apiUrl}/v1/training/generate-certificate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-internal-token': internalToken,
+ },
+ body: JSON.stringify({
+ memberId,
+ organizationId,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to generate certificate: ${errorText}`);
+ }
+
+ // Get the PDF as a buffer and convert to base64
+ const pdfBuffer = await response.arrayBuffer();
+ const pdfBase64 = Buffer.from(pdfBuffer).toString('base64');
+
+ return pdfBase64;
+ });
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx
index 0f7eea54f..a263922fc 100644
--- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx
@@ -1,7 +1,7 @@
'use client';
import type { TrainingVideo } from '@/lib/data/training-videos';
-import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db';
+import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db';
import { Stack } from '@trycompai/design-system';
import type { FleetPolicy, Host } from '../../devices/types';
import { EmployeeDetails } from './EmployeeDetails';
@@ -18,6 +18,7 @@ interface EmployeeDetailsProps {
fleetPolicies: FleetPolicy[];
host: Host;
canEdit: boolean;
+ organization: Organization;
}
export function Employee({
@@ -27,6 +28,7 @@ export function Employee({
fleetPolicies,
host,
canEdit,
+ organization,
}: EmployeeDetailsProps) {
return (
@@ -37,6 +39,7 @@ export function Employee({
trainingVideos={trainingVideos}
fleetPolicies={fleetPolicies}
host={host}
+ organization={organization}
/>
);
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx
index 84db2e6b2..cf66e3446 100644
--- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx
@@ -1,9 +1,8 @@
'use client';
import type { TrainingVideo } from '@/lib/data/training-videos';
-import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db';
+import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db';
-import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
import {
Section,
@@ -14,8 +13,11 @@ import {
TabsTrigger,
Text,
} from '@trycompai/design-system';
-import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
+import { AlertCircle, Award, CheckCircle2, Download } from 'lucide-react';
import type { FleetPolicy, Host } from '../../devices/types';
+import { PolicyItem } from '../../devices/components/PolicyItem';
+import { downloadTrainingCertificate } from '../actions/download-training-certificate';
+import { cn } from '@/lib/utils';
export const EmployeeTasks = ({
employee,
@@ -23,6 +25,7 @@ export const EmployeeTasks = ({
trainingVideos,
host,
fleetPolicies,
+ organization,
}: {
employee: Member & {
user: User;
@@ -33,7 +36,49 @@ export const EmployeeTasks = ({
})[];
host: Host;
fleetPolicies: FleetPolicy[];
+ organization: Organization;
}) => {
+ // Calculate training completion status
+ const completedVideos = trainingVideos.filter((v) => v.completedAt !== null);
+ const allTrainingComplete =
+ completedVideos.length === trainingVideos.length && trainingVideos.length > 0;
+
+ // Get the most recent completion date as the overall training completion date
+ const trainingCompletionDate = allTrainingComplete
+ ? completedVideos.reduce((latest, video) => {
+ if (!latest || !video.completedAt) return latest;
+ return video.completedAt > latest ? video.completedAt : latest;
+ }, completedVideos[0]?.completedAt || null)
+ : null;
+
+ const handleDownloadCertificate = async () => {
+ if (!trainingCompletionDate) return;
+
+ const result = await downloadTrainingCertificate({
+ memberId: employee.id,
+ organizationId: organization.id,
+ });
+
+ if (result?.data) {
+ // Convert base64 to blob and download
+ const byteCharacters = atob(result.data);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type: 'application/pdf' });
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `training-certificate-${employee.user.name?.replace(/\s+/g, '-').toLowerCase() || 'employee'}.pdf`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+ }
+ };
return (
@@ -75,40 +120,97 @@ export const EmployeeTasks = ({
-
- {trainingVideos.length === 0 ? (
-
- No training videos required to watch.
-
- ) : (
- trainingVideos.map((video) => {
- const isCompleted = video.completedAt !== null;
-
- return (
+
+ {/* Training Completion Summary */}
+ {trainingVideos.length > 0 && (
+
+
-
-
- {isCompleted ? (
-
- ) : (
-
- )}
-
{video.metadata.title}
-
- {isCompleted && (
-
- Completed -{' '}
- {video.completedAt && new Date(video.completedAt).toLocaleDateString()}
-
+
+ />
- );
- })
+
+
+ {allTrainingComplete
+ ? 'All Training Complete'
+ : `${completedVideos.length}/${trainingVideos.length} Videos Completed`}
+
+ {trainingCompletionDate && (
+
+ Completed on{' '}
+ {new Date(trainingCompletionDate).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })}
+
+ )}
+
+
+ {allTrainingComplete && (
+
+ )}
+
)}
+
+
+ {trainingVideos.length === 0 ? (
+
+ No training videos required to watch.
+
+ ) : (
+ trainingVideos.map((video) => {
+ const isCompleted = video.completedAt !== null;
+
+ return (
+
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
{video.metadata.title}
+
+ {isCompleted && (
+
+ Completed -{' '}
+ {video.completedAt &&
+ new Date(video.completedAt).toLocaleDateString()}
+
+ )}
+
+
+ );
+ })
+ )}
+
@@ -119,28 +221,7 @@ export const EmployeeTasks = ({
{host.computer_name}'s Policies
- {fleetPolicies.map((policy) => (
-
-
{policy.name}
- {policy.response === 'pass' ? (
-
-
- Pass
-
- ) : (
-
-
- Fail
-
- )}
-
- ))}
+ {fleetPolicies.map((policy) => )}
) : (
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx
index 486da9a02..807a57078 100644
--- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx
@@ -13,6 +13,8 @@ import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { Employee } from './components/Employee';
+const MDM_POLICY_ID = -9999;
+
export default async function EmployeeDetailsPage({
params,
}: {
@@ -47,6 +49,15 @@ export default async function EmployeeDetailsPage({
notFound();
}
+ // Get organization for certificate generation
+ const organization = await db.organization.findUnique({
+ where: { id: orgId },
+ });
+
+ if (!organization) {
+ notFound();
+ }
+
const { fleetPolicies, device } = await getFleetPolicies(employee);
return (
@@ -68,6 +79,7 @@ export default async function EmployeeDetailsPage({
fleetPolicies={fleetPolicies}
host={device}
canEdit={canEditMembers}
+ organization={organization}
/>
);
@@ -199,9 +211,38 @@ const getFleetPolicies = async (member: Member & { user: User }) => {
}
const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`);
- const fleetPolicies = deviceWithPolicies.data.host.policies || [];
-
- return { fleetPolicies, device: deviceWithPolicies.data.host };
+ const host = deviceWithPolicies.data.host;
+
+ const results = await db.fleetPolicyResult.findMany({
+ where: {
+ organizationId: member.organizationId,
+ userId: member.userId,
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ const platform = host.platform?.toLowerCase();
+ const osVersion = host.os_version?.toLowerCase();
+ const isMacOS =
+ platform === 'darwin' ||
+ platform === 'macos' ||
+ platform === 'osx' ||
+ osVersion?.includes('mac');
+
+ return {
+ fleetPolicies: [
+ ...(host.policies || []),
+ ...(isMacOS ? [{ id: MDM_POLICY_ID, name: 'MDM Enabled', response: host.mdm.connected_to_fleet ? 'pass' : 'fail' }] : []),
+ ].map((policy) => {
+ const policyResult = results.find((result) => result.fleetPolicyId === policy.id);
+ return {
+ ...policy,
+ response: policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail',
+ attachments: policyResult?.attachments || [],
+ };
+ }),
+ device: host
+ };
} catch (error) {
console.error(
`Failed to get device using individual fleet label for member: ${member.id}`,
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts
index 66d1da59d..8d72f710b 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts
+++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts
@@ -29,13 +29,25 @@ export const addEmployeeWithoutInvite = async ({
},
});
- if (
- !currentUserMember ||
- (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner'))
- ) {
+ if (!currentUserMember) {
throw new Error("You don't have permission to add members.");
}
+ const isAdmin =
+ currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner');
+ const isAuditor = currentUserMember.role.includes('auditor');
+
+ if (!isAdmin && !isAuditor) {
+ throw new Error("You don't have permission to add members.");
+ }
+
+ if (isAuditor && !isAdmin) {
+ const onlyAuditorRole = roles.length === 1 && roles[0] === 'auditor';
+ if (!onlyAuditorRole) {
+ throw new Error("Auditors can only add users with the 'auditor' role.");
+ }
+ }
+
let userId = '';
const existingUser = await db.user.findFirst({
where: {
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts
index 0634cef8b..a94692482 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts
+++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts
@@ -28,7 +28,9 @@ export const checkMemberStatus = async ({
if (
!currentUserMember ||
- (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner'))
+ (!currentUserMember.role.includes('admin') &&
+ !currentUserMember.role.includes('owner') &&
+ !currentUserMember.role.includes('auditor'))
) {
throw new Error("You don't have permission to reactivate members.");
}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/inviteNewMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/inviteNewMember.ts
new file mode 100644
index 000000000..30c12210a
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/inviteNewMember.ts
@@ -0,0 +1,115 @@
+'use server';
+
+import { maskEmailForLogs } from '@/lib/mask-email';
+import { auth } from '@/utils/auth';
+import type { Role } from '@db';
+import { db } from '@db';
+import { headers } from 'next/headers';
+
+export const inviteNewMember = async ({
+ email,
+ organizationId,
+ roles,
+}: {
+ email: string;
+ organizationId: string;
+ roles: Role[];
+}) => {
+ const requestId = crypto.randomUUID();
+ const safeEmail = maskEmailForLogs(email);
+ const roleString = roles.join(',');
+ const startTime = Date.now();
+
+ console.info('[inviteNewMember] start', {
+ requestId,
+ organizationId,
+ invitedEmail: safeEmail,
+ roles: roleString,
+ });
+
+ try {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session?.session) {
+ console.warn('[inviteNewMember] missing session', { requestId, organizationId });
+ throw new Error('Authentication required.');
+ }
+
+ const currentUserId = session.session.userId;
+ const currentUserMember = await db.member.findFirst({
+ where: {
+ organizationId: organizationId,
+ userId: currentUserId,
+ deactivated: false,
+ },
+ });
+
+ if (!currentUserMember) {
+ console.warn('[inviteNewMember] inviter not in org', {
+ requestId,
+ organizationId,
+ inviterUserId: currentUserId,
+ });
+ throw new Error("You don't have permission to invite members.");
+ }
+
+ const isAdmin =
+ currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner');
+ const isAuditor = currentUserMember.role.includes('auditor');
+
+ if (!isAdmin && !isAuditor) {
+ console.warn('[inviteNewMember] inviter lacks role', {
+ requestId,
+ organizationId,
+ inviterUserId: currentUserId,
+ inviterRole: currentUserMember.role,
+ });
+ throw new Error("You don't have permission to invite members.");
+ }
+
+ // Auditors can only invite other auditors
+ if (isAuditor && !isAdmin) {
+ const onlyAuditorRole = roles.length === 1 && roles[0] === 'auditor';
+ if (!onlyAuditorRole) {
+ console.warn('[inviteNewMember] auditor role mismatch', {
+ requestId,
+ organizationId,
+ inviterUserId: currentUserId,
+ invitedRoles: roleString,
+ });
+ throw new Error("Auditors can only invite users with the 'auditor' role.");
+ }
+ }
+
+ // Use server-side auth API to create the invitation
+ // Role should be a comma-separated string for multiple roles
+ const inviteResult = await auth.api.createInvitation({
+ headers: await headers(),
+ body: {
+ email: email.toLowerCase(),
+ role: roleString,
+ organizationId,
+ },
+ });
+
+ console.info('[inviteNewMember] success', {
+ requestId,
+ organizationId,
+ invitedEmail: safeEmail,
+ roles: roleString,
+ durationMs: Date.now() - startTime,
+ resultKeys: inviteResult && typeof inviteResult === 'object' ? Object.keys(inviteResult) : [],
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error('[inviteNewMember] failure', {
+ requestId,
+ organizationId,
+ invitedEmail: safeEmail,
+ roles: roleString,
+ durationMs: Date.now() - startTime,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+};
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts
index 0d9b4d66f..bb9adb0da 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts
+++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts
@@ -29,13 +29,25 @@ export const sendInvitationEmailToExistingMember = async ({
},
});
- if (
- !currentUserMember ||
- (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner'))
- ) {
+ if (!currentUserMember) {
throw new Error("You don't have permission to send invitations.");
}
+ const isAdmin =
+ currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner');
+ const isAuditor = currentUserMember.role.includes('auditor');
+
+ if (!isAdmin && !isAuditor) {
+ throw new Error("You don't have permission to send invitations.");
+ }
+
+ if (isAuditor && !isAdmin) {
+ const onlyAuditorRole = roles.length === 1 && roles[0] === 'auditor';
+ if (!onlyAuditorRole) {
+ throw new Error("Auditors can only add users with the 'auditor' role.");
+ }
+ }
+
// Get organization name
const organization = await db.organization.findUnique({
where: { id: organizationId },
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx
index bc04368b9..97443acc5 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx
@@ -4,13 +4,12 @@ import type { Role } from '@db';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, PlusCircle, Trash2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
-import { useEffect, useState } from 'react';
+import { useMemo, useState } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { ActionResponse } from '@/actions/types';
-import { authClient } from '@/utils/auth-client';
import { Button } from '@comp/ui/button';
import {
Dialog,
@@ -33,51 +32,57 @@ import { Input } from '@comp/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs';
import { addEmployeeWithoutInvite } from '../actions/addEmployeeWithoutInvite';
import { checkMemberStatus } from '../actions/checkMemberStatus';
+import { inviteNewMember } from '../actions/inviteNewMember';
import { sendInvitationEmailToExistingMember } from '../actions/sendInvitationEmail';
import { MultiRoleCombobox } from './MultiRoleCombobox';
// --- Constants for Roles ---
-const selectableRoles = ['admin', 'auditor', 'employee', 'contractor'] as const satisfies Readonly<
- [Role, ...Role[]]
->;
-type InviteRole = (typeof selectableRoles)[number];
+const ALL_SELECTABLE_ROLES = [
+ 'admin',
+ 'auditor',
+ 'employee',
+ 'contractor',
+] as const satisfies Readonly<[Role, ...Role[]]>;
+type InviteRole = (typeof ALL_SELECTABLE_ROLES)[number];
const DEFAULT_ROLES: InviteRole[] = [];
-// Type guard to check if a string is a valid InviteRole
-const isInviteRole = (role: string): role is InviteRole => {
- return role === 'admin' || role === 'auditor' || role === 'employee' || role === 'contractor';
+const isInviteRole = (role: string, allowedRoles: InviteRole[]): role is InviteRole => {
+ return allowedRoles.includes(role as InviteRole);
};
-// --- Schemas ---
-const manualInviteSchema = z.object({
- email: z.string().email({ message: 'Invalid email address.' }),
- roles: z.array(z.enum(selectableRoles)).min(1, { message: 'Please select at least one role.' }),
-});
+const createFormSchema = (allowedRoles: InviteRole[]) => {
+ const roleEnum = z.enum(allowedRoles as [InviteRole, ...InviteRole[]]);
+ const manualInviteSchema = z.object({
+ email: z.string().email({ message: 'Invalid email address.' }),
+ roles: z.array(roleEnum).min(1, { message: 'Please select at least one role.' }),
+ });
-// Define base schemas for each mode
-const manualModeSchema = z.object({
- mode: z.literal('manual'),
- manualInvites: z.array(manualInviteSchema).min(1, { message: 'Please add at least one invite.' }),
- csvFile: z.any().optional(), // Optional here, validated by union
-});
+ const manualModeSchema = z.object({
+ mode: z.literal('manual'),
+ manualInvites: z
+ .array(manualInviteSchema)
+ .min(1, { message: 'Please add at least one invite.' }),
+ csvFile: z.any().optional(), // Optional here, validated by union
+ });
-const csvModeSchema = z.object({
- mode: z.literal('csv'),
- manualInvites: z.array(manualInviteSchema).optional(), // Optional here
- csvFile: z.any().refine((val) => val instanceof FileList && val.length === 1, {
- message: 'Please select a single CSV file.',
- }),
-});
+ const csvModeSchema = z.object({
+ mode: z.literal('csv'),
+ manualInvites: z.array(manualInviteSchema).optional(), // Optional here
+ csvFile: z.any().refine((val) => val instanceof FileList && val.length === 1, {
+ message: 'Please select a single CSV file.',
+ }),
+ });
-// Combine using discriminatedUnion
-const formSchema = z.discriminatedUnion('mode', [manualModeSchema, csvModeSchema]);
+ return z.discriminatedUnion('mode', [manualModeSchema, csvModeSchema]);
+};
-type FormData = z.infer
;
+type FormData = z.infer>;
interface InviteMembersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
organizationId: string;
+ allowedRoles: InviteRole[];
}
interface BulkInviteResultData {
@@ -92,6 +97,7 @@ export function InviteMembersModal({
open,
onOpenChange,
organizationId,
+ allowedRoles,
}: InviteMembersModalProps) {
const router = useRouter();
const [mode, setMode] = useState<'manual' | 'csv'>('manual');
@@ -99,6 +105,12 @@ export function InviteMembersModal({
const [csvFileName, setCsvFileName] = useState(null);
const [lastResult, setLastResult] = useState | null>(null);
+ const normalizedAllowedRoles = allowedRoles.length > 0 ? allowedRoles : [...ALL_SELECTABLE_ROLES];
+ const formSchema = useMemo(
+ () => createFormSchema(normalizedAllowedRoles),
+ [normalizedAllowedRoles.join(',')],
+ );
+
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -114,13 +126,6 @@ export function InviteMembersModal({
mode: 'onChange',
});
- // Log form errors on change
- useEffect(() => {
- if (Object.keys(form.formState.errors).length > 0) {
- console.error('Form Validation Errors:', form.formState.errors);
- }
- }, [form.formState.errors]);
-
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'manualInvites',
@@ -154,7 +159,7 @@ export function InviteMembersModal({
return;
}
- // Process invitations client-side using authClient
+ // Process invitations
let successCount = 0;
const failedInvites: { email: string; error: string }[] = [];
@@ -185,10 +190,11 @@ export function InviteMembersModal({
roles: invite.roles,
});
} else {
- // Member doesn't exist - use authClient to send the invitation
- await authClient.organization.inviteMember({
+ // Member doesn't exist - use server action to send the invitation
+ await inviteNewMember({
email: invite.email.toLowerCase(),
- role: invite.roles.length === 1 ? invite.roles[0] : invite.roles,
+ organizationId,
+ roles: invite.roles,
});
}
}
@@ -206,13 +212,13 @@ export function InviteMembersModal({
if (successCount > 0) {
toast.success(`Successfully invited ${successCount} member(s).`);
- // Revalidate the page to refresh the member list
- router.refresh();
-
if (failedInvites.length === 0) {
form.reset();
onOpenChange(false);
}
+
+ // Revalidate the page to refresh the member list
+ router.refresh();
}
if (failedInvites.length > 0) {
@@ -326,12 +332,12 @@ export function InviteMembersModal({
// Validate role(s) - split by pipe for multiple roles
const roles = roleValue.split('|').map((r) => r.trim());
- const validRoles = roles.filter(isInviteRole);
+ const validRoles = roles.filter((role) => isInviteRole(role, normalizedAllowedRoles));
if (validRoles.length === 0) {
failedInvites.push({
email,
- error: `Invalid role(s): ${roleValue}. Must be one of: ${selectableRoles.join(', ')}`,
+ error: `Invalid role(s): ${roleValue}. Must be one of: ${normalizedAllowedRoles.join(', ')}`,
});
continue;
}
@@ -362,10 +368,11 @@ export function InviteMembersModal({
roles: validRoles,
});
} else {
- // Member doesn't exist - use authClient to send the invitation
- await authClient.organization.inviteMember({
+ // Member doesn't exist - use server action to send the invitation
+ await inviteNewMember({
email: email.toLowerCase(),
- role: validRoles,
+ organizationId,
+ roles: validRoles,
});
}
}
@@ -383,13 +390,13 @@ export function InviteMembersModal({
if (successCount > 0) {
toast.success(`Successfully invited ${successCount} member(s).`);
- // Revalidate the page to refresh the member list
- router.refresh();
-
if (failedInvites.length === 0) {
form.reset();
onOpenChange(false);
}
+
+ // Revalidate the page to refresh the member list
+ router.refresh();
}
if (failedInvites.length > 0) {
@@ -429,12 +436,21 @@ export function InviteMembersModal({
}
};
- const csvTemplate = `email,role
-john@company.com,employee
-jane@company.com,employee|admin
-bob@company.com,auditor
-sarah@company.com,employee|auditor
-mike@company.com,admin`;
+ const csvTemplate = useMemo(() => {
+ const primaryRole = normalizedAllowedRoles[0];
+ const secondaryRole = normalizedAllowedRoles[1];
+ const multiRoleExample =
+ normalizedAllowedRoles.length > 1 ? `${primaryRole}|${secondaryRole}` : primaryRole;
+
+ const rows = [
+ 'email,role',
+ `john@company.com,${primaryRole}`,
+ `jane@company.com,${multiRoleExample}`,
+ ];
+
+ return rows.join('\n');
+ }, [normalizedAllowedRoles]);
+
const csvTemplateDataUri = `data:text/csv;charset=utf-8,${encodeURIComponent(csvTemplate)}`;
return (
@@ -488,6 +504,7 @@ mike@company.com,admin`;
{error?.message}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx
index b529666ff..a4643cd52 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx
@@ -2,8 +2,8 @@
import { Edit, Laptop, MoreHorizontal, Trash2 } from 'lucide-react';
import Link from 'next/link';
-import { useParams, useRouter } from 'next/navigation';
-import { useRef, useState } from 'react';
+import { useParams } from 'next/navigation';
+import { useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar';
import { Badge } from '@comp/ui/badge';
@@ -15,7 +15,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
- DialogTrigger,
} from '@comp/ui/dialog';
import {
DropdownMenu,
@@ -64,9 +63,7 @@ export function MemberRow({
canEdit,
isCurrentUserOwner,
}: MemberRowProps) {
- const params = useParams<{ orgId: string }>();
- const router = useRouter();
- const { orgId } = params;
+ const { orgId } = useParams<{ orgId: string }>();
const [isRemoveAlertOpen, setIsRemoveAlertOpen] = useState(false);
const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false);
@@ -78,8 +75,6 @@ export function MemberRow({
const [isUpdating, setIsUpdating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isRemovingDevice, setIsRemovingDevice] = useState(false);
- const dropdownTriggerRef = useRef(null);
- const focusRef = useRef(null);
const memberName = member.user.name || member.user.email || 'Member';
const memberEmail = member.user.email || '';
@@ -95,26 +90,16 @@ export function MemberRow({
const isOwner = currentRoles.includes('owner');
const canRemove = !isOwner;
-
- const isEmployee = currentRoles.includes('employee');
- const isContractor = currentRoles.includes('contractor');
const isDeactivated = member.deactivated;
const canViewProfile = !isDeactivated;
const profileHref = canViewProfile ? `/${orgId}/people/${memberId}` : null;
- const handleDialogItemSelect = () => {
- focusRef.current = dropdownTriggerRef.current;
- };
-
- const handleDialogOpenChange = (open: boolean) => {
- setIsUpdateRolesOpen(open);
- if (open === false) {
- setDropdownOpen(false);
- }
+ const handleEditRolesClick = () => {
+ setDropdownOpen(false); // Close dropdown first
+ setIsUpdateRolesOpen(true); // Then open dialog
};
const handleUpdateRolesClick = async () => {
- console.log('handleUpdateRolesClick');
let rolesToUpdate = selectedRoles;
if (isOwner && !rolesToUpdate.includes('owner')) {
rolesToUpdate = [...rolesToUpdate, 'owner'];
@@ -133,17 +118,20 @@ export function MemberRow({
const handleRemoveClick = async () => {
if (!canRemove) return;
- setIsRemoving(true);
- await onRemove(memberId);
- setIsRemoving(false);
setIsRemoveAlertOpen(false);
+ setIsRemoving(true);
+ try {
+ await onRemove(memberId);
+ } finally {
+ setIsRemoving(false);
+ }
};
const handleRemoveDeviceClick = async () => {
try {
+ setIsRemoveDeviceAlertOpen(false);
setIsRemovingDevice(true);
await onRemoveDevice(memberId);
- setIsRemoveDeviceAlertOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to unlink device');
} finally {
@@ -232,82 +220,24 @@ export function MemberRow({
{!isDeactivated && (
-
- {
- if (focusRef.current) {
- focusRef.current.focus();
- focusRef.current = null;
- event.preventDefault();
- }
- }}
- >
+
{canEdit && (
-
+
+
+ {'Edit Roles'}
+
)}
{member.fleetDmLabelId && isCurrentUserOwner && (
- setIsRemoveDeviceAlertOpen(true)}>
+ {
+ setDropdownOpen(false);
+ setIsRemoveDeviceAlertOpen(true);
+ }}
+ >
{'Remove Device'}
@@ -315,7 +245,10 @@ export function MemberRow({
{canRemove && (
setIsRemoveAlertOpen(true)}
+ onSelect={() => {
+ setDropdownOpen(false);
+ setIsRemoveAlertOpen(true);
+ }}
>
{'Remove Member'}
@@ -341,6 +274,48 @@ export function MemberRow({
onRemove={handleRemoveDeviceClick}
isRemoving={isRemovingDevice}
/>
+
+ {/* Edit Roles Dialog - moved outside DropdownMenu to avoid overlay conflicts */}
+
>
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx
index 694bfef7f..e655597ae 100644
--- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx
+++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx
@@ -3,7 +3,7 @@
import type { Role } from '@db';
import * as React from 'react';
-import { Dialog, DialogContent } from '@comp/ui/dialog';
+import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover';
import { MultiRoleComboboxContent } from './MultiRoleComboboxContent';
import { MultiRoleComboboxTrigger } from './MultiRoleComboboxTrigger';
@@ -46,6 +46,7 @@ interface MultiRoleComboboxProps {
placeholder?: string;
disabled?: boolean;
lockedRoles?: Role[]; // Roles that cannot be deselected
+ allowedRoles?: Role[];
}
export function MultiRoleCombobox({
@@ -54,6 +55,7 @@ export function MultiRoleCombobox({
placeholder,
disabled = false,
lockedRoles = [],
+ allowedRoles,
}: MultiRoleComboboxProps) {
const [open, setOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState('');
@@ -67,10 +69,20 @@ export function MultiRoleCombobox({
const isOwner = selectedRoles.includes('owner');
+ const normalizedAllowedRoles = React.useMemo(() => {
+ if (allowedRoles && allowedRoles.length > 0) {
+ return allowedRoles;
+ }
+ return selectableRoles.map((role) => role.value);
+ }, [allowedRoles]);
+
// Filter out owner role for non-owners
const availableRoles = React.useMemo(() => {
- return selectableRoles.filter((role) => role.value !== 'owner' || isOwner);
- }, [isOwner]);
+ return selectableRoles.filter(
+ (role) =>
+ normalizedAllowedRoles.includes(role.value) && (role.value !== 'owner' || isOwner),
+ );
+ }, [isOwner, normalizedAllowedRoles]);
const handleSelect = (roleValue: Role) => {
// Never allow owner role to be changed
@@ -116,30 +128,31 @@ export function MultiRoleCombobox({
});
return (
- <>
- setOpen(true)}
- ariaExpanded={open}
- />
-