diff --git a/apps/api/package.json b/apps/api/package.json index 75fead8ab..48a9de0b2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,8 +4,6 @@ "version": "0.0.1", "author": "", "dependencies": { - "@prisma/client": "^6.13.0", - "prisma": "^6.13.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/s3-request-presigner": "^3.859.0", "@nestjs/common": "^11.0.1", @@ -13,13 +11,16 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.1.5", "@nestjs/swagger": "^11.2.0", - "@trycompai/db": "^1.3.7", + "@prisma/client": "^6.13.0", + "@trycompai/db": "^1.3.17", "archiver": "^7.0.1", "axios": "^1.12.2", "better-auth": "^1.3.27", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "dotenv": "^17.2.3", "jose": "^6.0.12", + "prisma": "^6.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 35b97a098..22e1f6794 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { PeopleModule } from './people/people.module'; import { DevicesModule } from './devices/devices.module'; import { DeviceAgentModule } from './device-agent/device-agent.module'; import { awsConfig } from './config/aws.config'; +import { betterAuthConfig } from './config/better-auth.config'; import { HealthModule } from './health/health.module'; import { OrganizationModule } from './organization/organization.module'; import { PoliciesModule } from './policies/policies.module'; @@ -23,7 +24,8 @@ import { TaskTemplateModule } from './framework-editor/task-template/task-templa imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [awsConfig], + // .env file is loaded manually in main.ts before NestJS starts + load: [awsConfig, betterAuthConfig], validationOptions: { allowUnknown: true, abortEarly: true, diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index ea2bde011..0e027e873 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -4,14 +4,32 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { db } from '@trycompai/db'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { ApiKeyService } from './api-key.service'; +import type { BetterAuthConfig } from '../config/better-auth.config'; import { AuthenticatedRequest } from './types'; @Injectable() export class HybridAuthGuard implements CanActivate { - constructor(private readonly apiKeyService: ApiKeyService) {} + private readonly betterAuthUrl: string; + + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly configService: ConfigService, + ) { + const betterAuthConfig = + this.configService.get('betterAuth'); + this.betterAuthUrl = + betterAuthConfig?.url || process.env.BETTER_AUTH_URL || ''; + + if (!this.betterAuthUrl) { + console.warn( + '[HybridAuthGuard] BETTER_AUTH_URL not configured. JWT authentication will fail.', + ); + } + } async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -61,20 +79,71 @@ export class HybridAuthGuard implements CanActivate { authHeader: string, ): Promise { try { + // Validate BETTER_AUTH_URL is configured + if (!this.betterAuthUrl) { + console.error( + '[HybridAuthGuard] BETTER_AUTH_URL environment variable is not set', + ); + throw new UnauthorizedException( + 'Authentication configuration error: BETTER_AUTH_URL not configured', + ); + } + // Extract token from "Bearer " const token = authHeader.substring(7); - // Create JWKS for token verification using Better Auth endpoint - const JWKS = createRemoteJWKSet( - new URL(`${process.env.BETTER_AUTH_URL}/api/auth/jwks`), - ); + const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`; - // Verify JWT token - const { payload } = await jwtVerify(token, JWKS, { - issuer: process.env.BETTER_AUTH_URL, - audience: process.env.BETTER_AUTH_URL, + // Create JWKS for token verification using Better Auth endpoint + // Use shorter cache time to handle key rotation better + const JWKS = createRemoteJWKSet(new URL(jwksUrl), { + cacheMaxAge: 60000, // 1 minute cache (default is 5 minutes) + cooldownDuration: 10000, // 10 seconds cooldown before refetching }); + // Verify JWT token with automatic retry on key mismatch + let payload; + try { + payload = ( + await jwtVerify(token, JWKS, { + issuer: this.betterAuthUrl, + audience: this.betterAuthUrl, + }) + ).payload; + } catch (verifyError: any) { + // If we get a key mismatch error, retry with a fresh JWKS fetch + if ( + verifyError.code === 'ERR_JWKS_NO_MATCHING_KEY' || + verifyError.message?.includes('no applicable key found') || + verifyError.message?.includes('JWKSNoMatchingKey') + ) { + console.log( + '[HybridAuthGuard] Key mismatch detected, fetching fresh JWKS and retrying...', + ); + + // Create a fresh JWKS instance with no cache to force immediate fetch + const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), { + cacheMaxAge: 0, // No cache - force fresh fetch + cooldownDuration: 0, // No cooldown - allow immediate retry + }); + + // Retry verification with fresh keys + payload = ( + await jwtVerify(token, freshJWKS, { + issuer: this.betterAuthUrl, + audience: this.betterAuthUrl, + }) + ).payload; + + console.log( + '[HybridAuthGuard] Successfully verified token with fresh JWKS', + ); + } else { + // Re-throw if it's not a key mismatch error + throw verifyError; + } + } + // Extract user information from JWT payload (user data is directly in payload for Better Auth JWT) const userId = payload.id as string; const userEmail = payload.email as string; @@ -112,6 +181,41 @@ export class HybridAuthGuard implements CanActivate { return true; } catch (error) { console.error('JWT verification failed:', error); + + // Provide more helpful error messages + if (error instanceof Error) { + // Connection errors + if ( + error.message.includes('ECONNREFUSED') || + error.message.includes('fetch failed') + ) { + console.error( + `[HybridAuthGuard] Cannot connect to Better Auth JWKS endpoint at ${this.betterAuthUrl}/api/auth/jwks`, + ); + console.error( + '[HybridAuthGuard] Make sure BETTER_AUTH_URL is set correctly and the Better Auth server is running', + ); + throw new UnauthorizedException( + `Cannot connect to authentication service. Please check BETTER_AUTH_URL configuration.`, + ); + } + + // Key mismatch errors should have been handled by retry logic above + // If we still get one here, it means the retry also failed (token truly invalid) + if ( + (error as any).code === 'ERR_JWKS_NO_MATCHING_KEY' || + error.message.includes('no applicable key found') || + error.message.includes('JWKSNoMatchingKey') + ) { + console.error( + '[HybridAuthGuard] Token key not found even after fetching fresh JWKS. Token may be from a different environment or truly invalid.', + ); + throw new UnauthorizedException( + 'Authentication token is invalid. Please log out and log back in to refresh your session.', + ); + } + } + throw new UnauthorizedException('Invalid or expired JWT token'); } } diff --git a/apps/api/src/config/better-auth.config.ts b/apps/api/src/config/better-auth.config.ts new file mode 100644 index 000000000..1df4de48a --- /dev/null +++ b/apps/api/src/config/better-auth.config.ts @@ -0,0 +1,34 @@ +import { registerAs } from '@nestjs/config'; +import { z } from 'zod'; + +const betterAuthConfigSchema = z.object({ + url: z.string().url('BETTER_AUTH_URL must be a valid URL'), +}); + +export type BetterAuthConfig = z.infer; + +export const betterAuthConfig = registerAs( + 'betterAuth', + (): BetterAuthConfig => { + const url = process.env.BETTER_AUTH_URL; + + if (!url) { + throw new Error('BETTER_AUTH_URL environment variable is required'); + } + + const config = { url }; + + // Validate configuration at startup + const result = betterAuthConfigSchema.safeParse(config); + + if (!result.success) { + throw new Error( + `Better Auth configuration validation failed: ${result.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', ')}`, + ); + } + + return result.data; + }, +); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 06001fcde..44e218727 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,9 +4,24 @@ import { NestFactory } from '@nestjs/core'; import type { OpenAPIObject } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as express from 'express'; +import { config } from 'dotenv'; +import path from 'path'; import { AppModule } from './app.module'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; -import path from 'path'; + +// Load .env file from apps/api directory before anything else +// This ensures .env values override any shell environment variables +// __dirname in compiled code is dist/src, so go up two levels to apps/api +const envPath = path.join(__dirname, '..', '..', '.env'); +if (existsSync(envPath)) { + config({ path: envPath, override: true }); +} else { + // Fallback: try current working directory (when run from apps/api) + const cwdEnvPath = path.join(process.cwd(), '.env'); + if (existsSync(cwdEnvPath)) { + config({ path: cwdEnvPath, override: true }); + } +} async function bootstrap(): Promise { const app: INestApplication = await NestFactory.create(AppModule); diff --git a/apps/app/package.json b/apps/app/package.json index 63a720e59..4fa4694a8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -4,7 +4,7 @@ "dependencies": { "@ai-sdk/anthropic": "^2.0.0", "@ai-sdk/groq": "^2.0.0", - "@ai-sdk/openai": "^2.0.0", + "@ai-sdk/openai": "^2.0.65", "@ai-sdk/provider": "^2.0.0", "@ai-sdk/react": "^2.0.60", "@ai-sdk/rsc": "^1.0.0", @@ -51,7 +51,7 @@ "@tiptap/extension-table-row": "^3.4.4", "@trigger.dev/react-hooks": "4.0.6", "@trigger.dev/sdk": "4.0.6", - "@trycompai/db": "^1.3.7", + "@trycompai/db": "^1.3.17", "@trycompai/email": "workspace:*", "@types/canvas-confetti": "^1.9.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/apps/app/src/actions/organization/invite-member.ts b/apps/app/src/actions/organization/invite-member.ts index 6ffe84644..ea826671d 100644 --- a/apps/app/src/actions/organization/invite-member.ts +++ b/apps/app/src/actions/organization/invite-member.ts @@ -8,7 +8,7 @@ import type { ActionResponse } from '../types'; const inviteMemberSchema = z.object({ email: z.string().email(), - role: z.enum(['owner', 'admin', 'auditor', 'employee']), + role: z.enum(['owner', 'admin', 'auditor', 'employee', 'contractor']), }); export const inviteMember = authActionClient diff --git a/apps/app/src/actions/policies/publish-all.ts b/apps/app/src/actions/policies/publish-all.ts index 4aa24a9e5..8ce0cdf79 100644 --- a/apps/app/src/actions/policies/publish-all.ts +++ b/apps/app/src/actions/policies/publish-all.ts @@ -104,9 +104,10 @@ export const publishAllPoliciesAction = authActionClient where: { organizationId: parsedInput.organizationId, isActive: true, - role: { - contains: Role.employee, - }, + OR: [ + { role: { contains: Role.employee } }, + { role: { contains: Role.contractor } }, + ], }, include: { user: { 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 62882a69b..cfc421958 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 @@ -93,6 +93,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow const canRemove = !isOwner; const isEmployee = currentRoles.includes('employee'); + const isContractor = currentRoles.includes('contractor'); const handleDialogItemSelect = () => { focusRef.current = dropdownTriggerRef.current; @@ -142,7 +143,7 @@ export function MemberRow({ member, onRemove, onUpdateRole, canEdit }: MemberRow
{memberName} - {isEmployee && ( + {(isEmployee || isContractor) && ( 0 ? `${selectedRoles.length} selected` : placeholder || 'Select role(s)'; const filteredRoles = availableRoles.filter((role) => { - const label = (() => { - switch (role.value) { - case 'admin': - return 'Admin'; - case 'auditor': - return 'Auditor'; - case 'employee': - return 'Employee'; - case 'contractor': - return 'Contractor'; - case 'owner': - return 'Owner'; - default: - return role.value; - } - })(); + const label = getRoleLabel(role.value); return label.toLowerCase().includes(searchTerm.toLowerCase()); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx index d5fcfca59..d1786cbdf 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -2,9 +2,22 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { cn } from '@comp/ui/cn'; import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'; +import { useMemo } from 'react'; import type { Host } from '../types'; export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void }) => { + const isMacOS = useMemo(() => { + return host.cpu_type && (host.cpu_type.includes('arm64') || host.cpu_type.includes('intel')); + }, [host]); + + const mdmEnabledStatus = useMemo(() => { + return { + id: 'mdm', + response: host?.mdm.connected_to_fleet ? 'pass' : 'fail', + name: 'MDM Enabled', + }; + }, [host]); + return (
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/EmptyState.tsx index db61533ef..dffc6e446 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/EmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/chat/EmptyState.tsx @@ -1,5 +1,6 @@ import { Card, CardDescription, CardHeader } from '@comp/ui/card'; -import Image from 'next/image'; +import { Skeleton } from '@comp/ui/skeleton'; +import { useState } from 'react'; import { Textarea } from '../../components/ui/textarea'; import { AUTOMATION_EXAMPLES, AutomationExample } from '../../constants/automation-examples'; @@ -10,6 +11,107 @@ interface EmptyStateProps { status: string; inputRef: React.RefObject; onSubmit: () => void; + suggestions?: { title: string; prompt: string; vendorName?: string; vendorWebsite?: string }[]; + isLoadingSuggestions?: boolean; +} + +function getVendorLogoUrl(vendorName?: string, vendorWebsite?: string): string { + // Prefer vendorWebsite if provided + if (vendorWebsite) { + // Clean up the website - remove protocol, www, and paths + const cleanDomain = vendorWebsite + .replace(/^https?:\/\//i, '') + .replace(/^www\./i, '') + .split('/')[0] + .split('?')[0]; + return `https://img.logo.dev/${cleanDomain}?token=pk_AZatYxV5QDSfWpRDaBxzRQ`; + } + + if (!vendorName) { + return 'https://img.logo.dev/trycomp.ai?token=pk_AZatYxV5QDSfWpRDaBxzRQ'; + } + + // Try to extract domain from vendor name or use a default + // Common vendor mappings + const vendorDomainMap: Record = { + github: 'github.com', + vercel: 'vercel.com', + cloudflare: 'cloudflare.com', + aws: 'aws.amazon.com', + gcp: 'cloud.google.com', + azure: 'azure.microsoft.com', + }; + + const lowerName = vendorName.toLowerCase(); + for (const [key, domain] of Object.entries(vendorDomainMap)) { + if (lowerName.includes(key)) { + return `https://img.logo.dev/${domain}?token=pk_AZatYxV5QDSfWpRDaBxzRQ`; + } + } + + // Try to extract domain from vendor name if it looks like a URL + const urlMatch = vendorName.match(/(?:https?:\/\/)?(?:www\.)?([^\/\s]+)/i); + if (urlMatch) { + return `https://img.logo.dev/${urlMatch[1]}?token=pk_AZatYxV5QDSfWpRDaBxzRQ`; + } + + return 'https://img.logo.dev/trycomp.ai?token=pk_AZatYxV5QDSfWpRDaBxzRQ'; +} + +function VendorCard({ + example, + onExampleClick, +}: { + example: AutomationExample; + onExampleClick: (prompt: string) => void; +}) { + const [imageError, setImageError] = useState(false); + const fallbackUrl = 'https://img.logo.dev/trycomp.ai?token=pk_AZatYxV5QDSfWpRDaBxzRQ'; + const imageUrl = imageError ? fallbackUrl : example.url; + + return ( + onExampleClick(example.prompt)} + > + +
+
+ {example.title} { + if (!imageError) { + setImageError(true); + } + }} + /> +
+ +

{example.title}

+
+
+
+
+ ); +} + +function SuggestionCardSkeleton() { + return ( + + +
+ +
+ +
+
+
+
+ ); } export function EmptyState({ @@ -19,7 +121,21 @@ export function EmptyState({ status, inputRef, onSubmit, + suggestions, + isLoadingSuggestions = false, }: EmptyStateProps) { + // Use dynamic suggestions if provided, otherwise fall back to static examples + const examplesToShow: AutomationExample[] = suggestions + ? suggestions.map((s) => ({ + title: s.title, + prompt: s.prompt, + url: getVendorLogoUrl(s.vendorName, s.vendorWebsite), + })) + : AUTOMATION_EXAMPLES; + + // Show skeleton loaders when loading suggestions for new automations + const showSkeletons = isLoadingSuggestions && suggestions?.length === 0; + return (
@@ -29,7 +145,7 @@ export function EmptyState({