Skip to content
Open
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: 1 addition & 1 deletion apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ APP_AWS_ENDPOINT="" # optional for using services like MinIO
REVALIDATION_SECRET="" # Revalidate server side, generate something random

GROQ_API_KEY="" # For the AI chat, on dashboard
NEXT_PUBLIC_PORTAL_URL="http://localhost:3001"
NEXT_PUBLIC_PORTAL_URL="http://localhost:3002"
ANTHROPIC_API_KEY="" # Optional, For more options with models
BETTER_AUTH_URL=http://localhost:3000 # For auth

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { createTrainingVideoEntries } from '@/lib/db/employee';
import { auth } from '@/utils/auth';
import { sendInviteMemberEmail } from '@comp/email';
import type { Role } from '@db';
import { db } from '@db';
import { headers } from 'next/headers';
Expand Down Expand Up @@ -48,6 +49,16 @@ export const addEmployeeWithoutInvite = async ({
}
}

// Get organization name
const organization = await db.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});

if (!organization) {
throw new Error('Organization not found.');
}

let userId = '';
const existingUser = await db.user.findFirst({
where: {
Expand Down Expand Up @@ -112,7 +123,37 @@ export const addEmployeeWithoutInvite = async ({
await createTrainingVideoEntries(member.id);
}

return { success: true, data: member };
// Generate invite link
const isLocalhost = process.env.NODE_ENV === 'development';
const protocol = isLocalhost ? 'http' : 'https';

const betterAuthUrl = process.env.NEXT_PUBLIC_PORTAL_URL;
const isProdEnv = betterAuthUrl?.includes('portal.trycomp.ai');

const domain = isProdEnv ? 'app.trycomp.ai' : 'localhost:3002';
const inviteLink = `${protocol}://${domain}/${organizationId}`;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invite link uses wrong domain

High Severity

inviteLink is built from a hardcoded domain (app.trycomp.ai or localhost:3002) based on NEXT_PUBLIC_PORTAL_URL string matching, instead of using the actual configured portal base URL. This can send employees/contractors to the admin app (where they may be blocked) or to localhost in non-prod environments.

Fix in Cursor Fix in Web

// Send the invitation email (non-fatal: member is already created)
let emailSent = true;
let emailError: string | undefined;
try {
await sendInviteMemberEmail({
inviteeEmail: email.toLowerCase(),
inviteLink,
organizationName: organization.name,
});
} catch (emailErr) {
emailSent = false;
emailError = emailErr instanceof Error ? emailErr.message : 'Failed to send invite email';
console.error('Invite email failed after member was added:', { email, organizationId, error: emailErr });
}

return {
success: true,
data: member,
emailSent,
...(emailError && { emailError }),
};
} catch (error) {
console.error('Error adding employee:', error);
return { success: false, error: 'Failed to add employee' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function InviteMembersModal({
// Process invitations
let successCount = 0;
const failedInvites: { email: string; error: string }[] = [];
const emailFailedEmails: string[] = [];

// Process each invitation sequentially
for (const invite of values.manualInvites) {
Expand All @@ -170,11 +171,14 @@ export function InviteMembersModal({
(invite.roles.includes('employee') || invite.roles.includes('contractor'));
try {
if (hasEmployeeRoleAndNoAdmin) {
await addEmployeeWithoutInvite({
const result = await addEmployeeWithoutInvite({
organizationId,
email: invite.email.toLowerCase(),
roles: invite.roles,
});
if (result.success && 'emailSent' in result && result.emailSent === false) {
emailFailedEmails.push(invite.email);
}
} else {
// Check member status and reactivate if needed
const memberStatus = await checkMemberStatus({
Expand Down Expand Up @@ -226,6 +230,12 @@ export function InviteMembersModal({
`Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`,
);
}

if (emailFailedEmails.length > 0) {
toast.warning(
`${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`,
);
}
} else if (values.mode === 'csv') {
// Handle CSV file uploads
console.log('Processing CSV mode');
Expand Down Expand Up @@ -305,6 +315,7 @@ export function InviteMembersModal({
// Track results
let successCount = 0;
const failedInvites: { email: string; error: string }[] = [];
const emailFailedEmails: string[] = [];

// Process each row
for (const row of dataRows) {
Expand Down Expand Up @@ -348,11 +359,14 @@ export function InviteMembersModal({
!validRoles.includes('admin');
try {
if (hasEmployeeRoleAndNoAdmin) {
await addEmployeeWithoutInvite({
const result = await addEmployeeWithoutInvite({
organizationId,
email: email.toLowerCase(),
roles: validRoles,
});
if (result.success && 'emailSent' in result && result.emailSent === false) {
emailFailedEmails.push(email);
}
} else {
// Check member status and reactivate if needed
const memberStatus = await checkMemberStatus({
Expand Down Expand Up @@ -404,6 +418,12 @@ export function InviteMembersModal({
`Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`,
);
}

if (emailFailedEmails.length > 0) {
toast.warning(
`${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`,
);
}
} catch (csvError) {
console.error('Error parsing CSV:', csvError);
toast.error('Failed to parse CSV file. Please check the format.');
Expand Down