diff --git a/apps/api/src/cloud-security/ai-remediation.service.spec.ts b/apps/api/src/cloud-security/ai-remediation.service.spec.ts index aa0774e67f..22bb90eb62 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.spec.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.spec.ts @@ -399,3 +399,112 @@ describe('AiRemediationService.refineStepFromError', () => { expect(callArgs.temperature).toBe(0); }); }); + +describe('AiRemediationService.generateManualSteps', () => { + const generateObjectMock = generateObject as unknown as jest.Mock; + + const finding = { + title: 'No CloudTrail trails configured', + description: 'Account has no active CloudTrail trails.', + severity: 'high', + resourceType: 'AwsAccount', + resourceId: 'account-level', + remediation: 'Create a multi-region trail with log file validation.', + findingKey: 'cloudtrail-no-trails', + evidence: { awsAccountId: '123456789012', region: 'us-east-1' }, + }; + + beforeEach(() => { + generateObjectMock.mockReset(); + }); + + it('returns AI-generated guidedSteps + reason in the happy path', async () => { + generateObjectMock.mockResolvedValueOnce({ + object: { + guidedSteps: [ + 'Open AWS Console → CloudTrail → Trails.', + 'Click "Create trail" and name it compai-cloudtrail.', + 'Enable multi-region and log file validation, then Save.', + ], + reason: + 'Auto-fix could not generate valid create-trail params for this account.', + }, + }); + + const result = await new AiRemediationService().generateManualSteps({ + finding, + failureReason: 'Required param "S3BucketName" is missing or empty', + }); + + expect(result.guidedSteps).toHaveLength(3); + expect(result.guidedSteps[0]).toMatch(/CloudTrail/); + expect(result.reason).toMatch(/Auto-fix could not/); + }); + + it('falls back to the adapter remediation text when the AI call throws', async () => { + // Hard guarantee: even if the AI is down, the customer must see + // SOMETHING actionable instead of a raw error. + generateObjectMock.mockRejectedValueOnce(new Error('AI provider down')); + + const result = await new AiRemediationService().generateManualSteps({ + finding, + failureReason: 'anything', + }); + + expect(result.guidedSteps).toEqual([ + 'Create a multi-region trail with log file validation.', + ]); + expect(result.reason).toMatch(/Automatic fix is not available/i); + }); + + it('falls back to a generic step when there is no adapter remediation text either', async () => { + generateObjectMock.mockRejectedValueOnce(new Error('AI down')); + + const result = await new AiRemediationService().generateManualSteps({ + finding: { ...finding, remediation: null }, + failureReason: 'x', + }); + + expect(result.guidedSteps).toHaveLength(1); + expect(result.guidedSteps[0]).toMatch(/AWS Console/i); + }); + + it('passes failing-step context to the model so manual steps reference the same resources', async () => { + generateObjectMock.mockResolvedValueOnce({ + object: { guidedSteps: ['x'], reason: 'y' }, + }); + + await new AiRemediationService().generateManualSteps({ + finding, + failedPlan: { + canAutoFix: true, + risk: 'low', + description: 'd', + currentState: {}, + proposedState: {}, + requiredPermissions: [], + readSteps: [], + fixSteps: [ + { + service: 's3', + command: 'CreateBucketCommand', + params: { Bucket: '' }, + purpose: 'create log bucket', + }, + ], + rollbackSteps: [], + rollbackSupported: false, + requiresAcknowledgment: false, + }, + failureReason: 'Required param "Bucket" is missing or empty', + }); + + const callArgs = generateObjectMock.mock.calls[0][0]; + // The prompt must include both the failing reason AND the original + // command so the model can translate it into a real manual step, + // not just regurgitate the finding text. + expect(callArgs.prompt).toContain('Required param "Bucket" is missing'); + expect(callArgs.prompt).toContain('s3:CreateBucketCommand'); + expect(callArgs.prompt).toContain('account-level'); + }); +}); diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts index 85a788d057..0bafbbdb7d 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { generateObject } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; +import { z } from 'zod'; import { type FixPlan, type PermissionFix, @@ -28,9 +29,13 @@ import { import { normalizeFixPlan } from './plan-normalizer'; const MODEL = anthropic('claude-opus-4-6'); +// Cheaper, faster model for the manual-steps fallback. The output is pure +// natural language with no SDK-call shape to validate, so the strongest +// model is overkill — we just need clear instructions. +const FALLBACK_MODEL = anthropic('claude-sonnet-4-5'); const REMEDIATION_ROLE_NAME = 'CompAI-Remediator'; -interface FindingContext { +export interface FindingContext { title: string; description: string | null; severity: string | null; @@ -328,6 +333,104 @@ INSTRUCTIONS: } } + /** + * Generate real, user-actionable manual steps when auto-fix cannot + * proceed for a finding. Called by the remediation service as a + * fallback so customers never see a raw "Fix could not be applied — + * " — they get clear instructions instead. + * + * Inputs: + * - the finding (so the AI knows what's actually broken), + * - the fix plan we tried to apply (so steps reference the same + * resources / commands the customer expected), + * - the concrete failure reason from validation or AWS execution. + * + * Output: ordered list of steps that the customer can copy-paste + * into AWS Console / CLI to resolve the finding manually. + * + * Uses Sonnet for cost — this only fires on the failure path and + * the output is plain natural language. On AI failure, falls back + * to the finding's `remediation` text so the customer at least sees + * the adapter's pre-baked guidance instead of an error. + */ + async generateManualSteps(params: { + finding: FindingContext; + failedPlan?: FixPlan; + failureReason: string; + }): Promise<{ guidedSteps: string[]; reason: string }> { + const fallback = (): { guidedSteps: string[]; reason: string } => ({ + guidedSteps: params.finding.remediation + ? [params.finding.remediation] + : [ + 'Review the finding in your AWS Console and apply the recommended remediation manually.', + ], + reason: + 'Automatic fix is not available for this finding. Follow the guided steps to resolve it manually.', + }); + + try { + const stepsSummary = params.failedPlan?.fixSteps + ? params.failedPlan.fixSteps + .map( + (s, i) => + `${i + 1}. ${s.service}:${s.command} — ${s.purpose}`, + ) + .join('\n') + : '(no fix steps were generated)'; + + const { object } = await generateObject({ + model: FALLBACK_MODEL, + schema: z.object({ + guidedSteps: z + .array(z.string()) + .min(1) + .describe( + 'Ordered list of clear, copy-pasteable manual instructions the customer can follow in AWS Console or CLI. Each step is one concrete action.', + ), + reason: z + .string() + .describe( + 'One-sentence summary of WHY automatic fix could not proceed — phrased for the customer, not for an engineer.', + ), + }), + system: + 'You are an AWS security expert writing manual remediation steps for a customer whose automatic fix failed. Be concrete: name exact services, exact resources, and exact actions. Prefer AWS Console clicks over CLI when the path is short, but include CLI commands when they are clearer. Never reference SDK class names. Never apologize. Never speculate about "if the issue persists" — just give the steps.', + prompt: `A finding could not be auto-remediated. Generate clear manual steps the customer can follow. + +FINDING: +- title: ${params.finding.title} +- description: ${params.finding.description ?? '(none)'} +- severity: ${params.finding.severity ?? '(unknown)'} +- resource type: ${params.finding.resourceType} +- resource id: ${params.finding.resourceId} +- adapter remediation guidance: ${params.finding.remediation ?? '(none)'} +- evidence: ${JSON.stringify(params.finding.evidence ?? {}, null, 2)} + +WHAT WE TRIED TO DO AUTOMATICALLY (do not just repeat — translate into customer-facing actions): +${stepsSummary} + +WHY IT FAILED: +${params.failureReason} + +Produce 3-8 ordered steps. Each step is a single concrete action the customer can perform in AWS Console or CLI. Reference the EXACT resource (${params.finding.resourceType} ${params.finding.resourceId}) and the EXACT region from evidence when relevant. End with a verification step so the customer knows they fixed it.`, + temperature: 0.2, + }); + + this.logger.log( + `Manual-steps fallback for ${params.finding.findingKey}: ${object.guidedSteps.length} step(s)`, + ); + return { + guidedSteps: object.guidedSteps, + reason: object.reason, + }; + } catch (err) { + this.logger.warn( + `Manual-steps AI call failed for ${params.finding.findingKey}; using adapter remediation as fallback. ${err instanceof Error ? err.message : String(err)}`, + ); + return fallback(); + } + } + // ─── GCP Methods ────────────────────────────────────────────────────── async generateGcpFixPlan(finding: FindingContext): Promise { @@ -556,6 +659,18 @@ const ACTIONABLE_PREFIXES = [ 'Enable', 'Attach', 'Set', + 'Authorize', + 'Revoke', + 'Allow', + 'Deny', + 'Disable', + 'Detach', + 'Add', + 'Remove', + 'Register', + 'Deregister', + 'Tag', + 'Untag', ] as const; /** diff --git a/apps/api/src/cloud-security/aws-command-executor.ts b/apps/api/src/cloud-security/aws-command-executor.ts index af6022e555..26a913a4c5 100644 --- a/apps/api/src/cloud-security/aws-command-executor.ts +++ b/apps/api/src/cloud-security/aws-command-executor.ts @@ -428,7 +428,11 @@ export function looksLikeValidationError(message: string): boolean { lower.includes('must be a valid') || lower.includes('is required') || lower.includes('missing required') || - lower.includes('must contain') + lower.includes('must contain') || + lower.includes('missingparameter') || + lower.includes('missing parameter') || + lower.includes('parameter is required') || + lower.includes('must specify') ); } diff --git a/apps/api/src/cloud-security/remediation.service.ts b/apps/api/src/cloud-security/remediation.service.ts index bf29f982d5..7b7275b605 100644 --- a/apps/api/src/cloud-security/remediation.service.ts +++ b/apps/api/src/cloud-security/remediation.service.ts @@ -3,7 +3,10 @@ import { db, Prisma } from '@db'; import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; import { parseAwsPermissionError } from './remediation-error.utils'; import { AWSSecurityService } from './providers/aws-security.service'; -import { AiRemediationService } from './ai-remediation.service'; +import { + AiRemediationService, + type FindingContext, +} from './ai-remediation.service'; import { GcpRemediationService } from './gcp-remediation.service'; import { AzureRemediationService } from './azure-remediation.service'; import { @@ -498,11 +501,34 @@ export class RemediationService { }, }); + // Build the finding context once — reused by refineFixPlan, the + // step-repair callback, and the manual-steps fallback path. + const evidence = (finding.evidence ?? {}) as Record; + const findingCtx: FindingContext = { + title: finding.title ?? 'Unknown', + description: finding.description, + severity: finding.severity, + resourceType: finding.resourceType, + resourceId: finding.resourceId, + remediation: finding.remediation, + findingKey: evidence.findingKey as string, + evidence, + }; + try { // Validate read steps first const readErrors = validatePlanSteps(plan.readSteps); if (readErrors.length > 0) { - throw new Error(`Invalid read steps: ${readErrors.join('; ')}`); + // Read step validation rarely fails (Get*/Describe* commands are + // simple), so don't attempt repair here — fall straight to + // manual steps using the original plan for context. + return await this.respondWithManualSteps({ + actionId: action.id, + finding: findingCtx, + failedPlan: plan, + failureReason: `Read steps invalid: ${readErrors.join('; ')}`, + resourceId: finding.resourceId, + }); } const remediationCreds = @@ -523,18 +549,8 @@ export class RemediationService { ); // Phase 2: Send real AWS state back to AI to generate EXACT fix steps - const evidence = (finding.evidence ?? {}) as Record; const refinedPlan = await this.aiRemediationService.refineFixPlan({ - finding: { - title: finding.title ?? 'Unknown', - description: finding.description, - severity: finding.severity, - resourceType: finding.resourceType, - resourceId: finding.resourceId, - remediation: finding.remediation, - findingKey: evidence.findingKey as string, - evidence, - }, + finding: findingCtx, originalPlan: plan, realAwsState: previousState, }); @@ -561,11 +577,45 @@ export class RemediationService { // Validate refined fix steps if (!refinedPlan.fixSteps || refinedPlan.fixSteps.length === 0) { - throw new Error('AI refined plan has no fix steps. Cannot proceed.'); + return await this.respondWithManualSteps({ + actionId: action.id, + finding: findingCtx, + failedPlan: refinedPlan, + failureReason: 'AI refined plan produced no executable fix steps.', + resourceId: finding.resourceId, + }); } - const fixErrors = validatePlanSteps(refinedPlan.fixSteps); + + // First validation pass over the refined plan. If anything fails, + // attempt a single AI repair pass on the offending steps and + // re-validate — many "missing required param" cases the AI's + // first refinement misses are recoverable when given the exact + // error back as context. If repair still doesn't satisfy the + // validator, fall back to real manual steps so the customer + // never sees a raw "Invalid fix steps" error. + let plannedFix = refinedPlan; + let fixErrors = validatePlanSteps(plannedFix.fixSteps); if (fixErrors.length > 0) { - throw new Error(`Invalid fix steps: ${fixErrors.join('; ')}`); + this.logger.warn( + `Refined plan failed validation (${fixErrors.length} error(s)); attempting AI repair before falling back to manual.`, + ); + plannedFix = await this.repairInvalidSteps({ + plan: plannedFix, + validationErrors: fixErrors, + finding: findingCtx, + syntheticErrorPrefix: + 'Pre-execution validator rejected this step:', + }); + fixErrors = validatePlanSteps(plannedFix.fixSteps); + } + if (fixErrors.length > 0) { + return await this.respondWithManualSteps({ + actionId: action.id, + finding: findingCtx, + failedPlan: plannedFix, + failureReason: `Even after AI repair the plan still has invalid steps: ${fixErrors.join('; ')}`, + resourceId: finding.resourceId, + }); } // Phase 3: Execute the refined fix steps (now with REAL values). @@ -575,27 +625,18 @@ export class RemediationService { // we give up — universal fix for "AI omitted a required param" // bugs that no per-command map can fully cover. const fixResult = await executePlanSteps({ - steps: refinedPlan.fixSteps, + steps: plannedFix.fixSteps, credentials: remediationCreds, region, - autoRollbackSteps: refinedPlan.rollbackSteps, + autoRollbackSteps: plannedFix.rollbackSteps, repairStep: async ({ step, awsError }) => this.aiRemediationService.refineStepFromError({ step, awsError, - finding: { - title: finding.title ?? 'Unknown', - description: finding.description, - severity: finding.severity, - resourceType: finding.resourceType, - resourceId: finding.resourceId, - remediation: finding.remediation, - findingKey: evidence.findingKey as string, - evidence, - }, + finding: findingCtx, planContext: { - fixSteps: refinedPlan.fixSteps, - readSteps: refinedPlan.readSteps, + fixSteps: plannedFix.fixSteps, + readSteps: plannedFix.readSteps, }, }), }); @@ -607,7 +648,21 @@ export class RemediationService { this.logger.error( `Step params: ${JSON.stringify(fixResult.error.step.params).slice(0, 500)}`, ); - throw new Error(fixResult.error.message); + // Permission errors still flow through the catch block below + // (parseAwsPermissionError produces the polished `fixScript` + // payload). For all OTHER unrecoverable execution errors fall + // back to manual steps so the customer sees real instructions + // rather than the raw AWS message. + if (parseAwsPermissionError(fixResult.error.message).isPermissionError) { + throw new Error(fixResult.error.message); + } + return await this.respondWithManualSteps({ + actionId: action.id, + finding: findingCtx, + failedPlan: plannedFix, + failureReason: `Step ${fixResult.error.stepIndex + 1} (${fixResult.error.step.service}:${fixResult.error.step.command}) was rejected by AWS: ${fixResult.error.message}`, + resourceId: finding.resourceId, + }); } const appliedState = { @@ -615,7 +670,7 @@ export class RemediationService { command: `${r.step.service}:${r.step.command}`, output: r.output, })), - rollbackSteps: refinedPlan.rollbackSteps, + rollbackSteps: plannedFix.rollbackSteps, }; await db.remediationAction.update({ @@ -1068,4 +1123,104 @@ export class RemediationService { if (withBucket !== required && existing.has(withBucket)) return true; return false; } + + /** + * Best-effort AI repair pass over fix steps that failed validation. + * Parses step indices from the error strings (format + * "Step N (Command): ..."), then asks `refineStepFromError` to + * regenerate just those steps. If repair returns a new step, replace + * it; otherwise leave it alone for the caller to re-validate. + * + * Why this exists: `validatePlanSteps` runs BEFORE the executor, so + * the executor's own AI repair never gets a chance to fix + * empty-required-param plans. Without this pass, customers would see + * raw "Invalid fix steps" errors. With it, every plan gets one shot + * at AI repair before we fall back to manual guidance. + */ + private async repairInvalidSteps(args: { + plan: FixPlan; + validationErrors: string[]; + finding: FindingContext; + syntheticErrorPrefix: string; + }): Promise { + const failingIndices = new Set(); + for (const err of args.validationErrors) { + const m = err.match(/^Step (\d+)\s*\(/); + if (m) failingIndices.add(Number(m[1]) - 1); + } + if (failingIndices.size === 0) return args.plan; + + const newSteps = [...args.plan.fixSteps]; + let anyRepaired = false; + for (const idx of failingIndices) { + const step = newSteps[idx]; + if (!step) continue; + // Group the validator's complaints for THIS step into a synthetic + // "AWS error" the repair prompt can reason about. + const stepErrors = args.validationErrors + .filter((e) => e.startsWith(`Step ${idx + 1} `)) + .join('; '); + const awsError = `${args.syntheticErrorPrefix} ${stepErrors}`; + const repaired = await this.aiRemediationService.refineStepFromError({ + step, + awsError, + finding: args.finding, + planContext: { + fixSteps: args.plan.fixSteps, + readSteps: args.plan.readSteps, + }, + }); + if (repaired) { + newSteps[idx] = repaired; + anyRepaired = true; + } + } + return anyRepaired ? { ...args.plan, fixSteps: newSteps } : args.plan; + } + + /** + * Universal "graceful fallback to manual steps" path. Called when an + * auto-fix attempt cannot succeed (validation rejects after repair, + * executor returns an unrecoverable error, plan has no usable steps). + * + * Persists the action as failed, generates real customer-facing + * manual instructions via the AI, and returns the same shape the + * frontend already renders for `canAutoFix: false` plans — so the + * customer sees clear steps instead of a raw error. + */ + private async respondWithManualSteps(args: { + actionId: string; + finding: FindingContext; + failedPlan?: FixPlan; + failureReason: string; + resourceId: string; + }) { + const { guidedSteps, reason } = + await this.aiRemediationService.generateManualSteps({ + finding: args.finding, + failedPlan: args.failedPlan, + failureReason: args.failureReason, + }); + + await db.remediationAction.update({ + where: { id: args.actionId }, + data: { + status: 'failed', + errorMessage: reason, + }, + }); + + this.logger.warn( + `Manual-steps fallback for action ${args.actionId}: ${args.failureReason}`, + ); + + return { + actionId: args.actionId, + status: 'failed' as const, + resourceId: args.resourceId, + error: reason, + guidedSteps, + guidedOnly: true, + }; + } } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts index c089343b4c..f50c8db977 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/batch-fix.ts @@ -195,6 +195,13 @@ export async function retryFinding( missingPermissions: result.permissionError.missingActions, }; } + if (result.type === 'manual') { + // Batch flow doesn't surface per-finding manual steps in the UI + // today — mark as failed with the AI-generated reason so the + // user knows it needs manual attention. (Per-finding manual + // steps are available via the single-fix dialog.) + return { status: 'failed', error: result.reason }; + } return { status: 'failed', error: result.error }; } catch (err) { diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index de9b051038..62e28ab1f0 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -24,10 +24,11 @@ interface PreviewProgress { } interface SingleFixProgress { - phase: 'executing' | 'success' | 'failed' | 'needs_permissions'; + phase: 'executing' | 'success' | 'failed' | 'needs_permissions' | 'manual'; error?: string; actionId?: string; permissionError?: { missingActions: string[]; fixScript?: string }; + guidedSteps?: string[]; } interface RemediationDialogProps { @@ -379,6 +380,26 @@ export function RemediationDialog({ setExecuteRunId(null); setExecuteAccessToken(null); }, 4000); + } else if (progress.phase === 'manual') { + // Auto-fix gave up but the API returned real manual steps. + // Switch the dialog into guided rendering instead of showing a + // raw error — same UI the preview flow already uses for + // canAutoFix:false plans. + setIsExecuting(false); + setError(null); + const steps = progress.guidedSteps ?? []; + setPreview({ + currentState: {}, + proposedState: {}, + description: progress.error ?? description ?? '', + risk: risk ?? 'medium', + apiCalls: [], + guidedOnly: true, + guidedSteps: steps, + rollbackSupported: false, + }); + setExecuteRunId(null); + setExecuteAccessToken(null); } else if (progress.phase === 'failed') { setIsExecuting(false); setError(progress.error || 'Remediation failed'); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts index c089343b4c..597f7d7b1e 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts @@ -195,6 +195,9 @@ export async function retryFinding( missingPermissions: result.permissionError.missingActions, }; } + if (result.type === 'manual') { + return { status: 'failed', error: result.reason }; + } return { status: 'failed', error: result.error }; } catch (err) { diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckPathCard.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckPathCard.tsx index 7fdc36afad..e7dee2ff32 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckPathCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckPathCard.tsx @@ -13,7 +13,7 @@ export interface PathCardProps { iconTone?: 'default' | 'warning'; title: string; description: string; - meta: ReactNode; + meta?: ReactNode; disabled?: boolean; } @@ -57,9 +57,7 @@ export function BackgroundCheckPathCard({ className={cn( 'group relative rounded-[var(--radius)] border px-4 py-3.5 transition-colors duration-200 ease-out', 'outline-none focus-visible:ring-[3px] focus-visible:ring-primary/20', - disabled - ? 'cursor-not-allowed opacity-60' - : 'cursor-pointer hover:border-muted-foreground', + disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-muted-foreground', selected ? 'border-primary bg-[oklch(0.985_0.012_167)] shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border bg-background', @@ -81,7 +79,7 @@ export function BackgroundCheckPathCard({
{title}
{description}
-
{meta}
+ {meta ?
{meta}
: null} ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckV1Page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckV1Page.tsx index 952c5272e1..8fd80206c6 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckV1Page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckV1Page.tsx @@ -1,20 +1,11 @@ 'use client'; import { Text } from '@trycompai/design-system'; -import { Attachment, Flash, Security, Warning } from '@trycompai/design-system/icons'; +import { Attachment, Flash, Security } from '@trycompai/design-system/icons'; import { useState } from 'react'; -import { - BackgroundCheckAttachForm, - type AttachFormValues, -} from './BackgroundCheckAttachForm'; -import { - BackgroundCheckExemptForm, - type ExemptFormValues, -} from './BackgroundCheckExemptForm'; -import { - BackgroundCheckOrderForm, - type OrderFormValues, -} from './BackgroundCheckOrderForm'; +import { BackgroundCheckAttachForm, type AttachFormValues } from './BackgroundCheckAttachForm'; +import { BackgroundCheckExemptForm, type ExemptFormValues } from './BackgroundCheckExemptForm'; +import { BackgroundCheckOrderForm, type OrderFormValues } from './BackgroundCheckOrderForm'; import { BackgroundCheckPathCard } from './BackgroundCheckPathCard'; import { BackgroundCheckScopePanel } from './BackgroundCheckScopePanel'; import { BackgroundCheckStatusStrip } from './BackgroundCheckStatusStrip'; @@ -90,7 +81,11 @@ export function BackgroundCheckV1Page(props: V1PageProps) { How would you like to proceed? -
+
handleSelect('order')} @@ -116,12 +111,6 @@ export function BackgroundCheckV1Page(props: V1PageProps) { icon={Security} title="Mark as exempt" description="This employee won't be required to pass a check." - meta={ - - - Logs a compliance exception - - } />
@@ -141,7 +130,9 @@ export function BackgroundCheckV1Page(props: V1PageProps) { Boolean(props.orderValues.employeeEmail) } disabledReason={ - !props.hasAllowance ? "You're out of credits. Choose a plan to continue." : undefined + !props.hasAllowance + ? "You're out of credits. Choose a plan to continue." + : undefined } /> )} @@ -152,7 +143,9 @@ export function BackgroundCheckV1Page(props: V1PageProps) { onSubmit={props.onAttachSubmit} submitting={props.isAttachSubmitting} canSubmit={ - props.canRequest && Boolean(props.attachValues.vendor) && Boolean(props.attachValues.file) + props.canRequest && + Boolean(props.attachValues.vendor) && + Boolean(props.attachValues.file) } /> )} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index 16e427dbaa..e6ea6fbe95 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -93,6 +93,7 @@ describe('EmployeeBackgroundCheck — V1 two-paths', () => { expect(screen.getByText('Order a new check')).toBeInTheDocument(); expect(screen.getByText('Attach an existing report')).toBeInTheDocument(); expect(screen.getByText('Mark as exempt')).toBeInTheDocument(); + expect(screen.queryByText('Logs a compliance exception')).not.toBeInTheDocument(); const orderCard = screen.getByRole('radio', { name: /Order a new check/i }); expect(orderCard).toHaveAttribute('aria-checked', 'true'); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx index 09aece5784..af69fde670 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.test.tsx @@ -1,11 +1,19 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const navigationMock = vi.hoisted(() => ({ + refresh: vi.fn(), +})); + +const trustPortalSettingsMock = vi.hoisted(() => ({ + updateToggleSettings: vi.fn(), +})); vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -16,7 +24,7 @@ vi.mock('@/hooks/use-permissions', () => ({ vi.mock('@/hooks/use-trust-portal-settings', () => ({ useTrustPortalSettings: () => ({ - updateToggleSettings: vi.fn(), + updateToggleSettings: trustPortalSettingsMock.updateToggleSettings, }), })); @@ -24,6 +32,10 @@ vi.mock('@/hooks/useDebounce', () => ({ useDebounce: (value: string) => value, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: navigationMock.refresh }), +})); + vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); @@ -32,21 +44,21 @@ import { BrandSettings } from './BrandSettings'; describe('BrandSettings permission gating', () => { const defaultProps = { - orgId: 'org-1', primaryColor: '#FF0000', }; beforeEach(() => { vi.clearAllMocks(); + trustPortalSettingsMock.updateToggleSettings.mockResolvedValue({ + success: true, + }); }); it('renders title and description regardless of permissions', () => { setMockPermissions({}); render(); expect(screen.getByText('Brand Settings')).toBeInTheDocument(); - expect( - screen.getByText('Customize the appearance of your trust portal'), - ).toBeInTheDocument(); + expect(screen.getByText('Customize the appearance of your trust portal')).toBeInTheDocument(); }); it('enables the color picker input when user has trust:update permission', () => { @@ -75,4 +87,55 @@ describe('BrandSettings permission gating', () => { render(); expect(screen.getByText('Brand Color')).toBeInTheDocument(); }); + + it('persists a valid brand color and refreshes settings', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + const handlePrimaryColorChange = vi.fn(); + + render(); + + const textInput = screen.getByRole('textbox'); + fireEvent.change(textInput, { target: { value: '#00ff00' } }); + + await waitFor(() => { + expect(trustPortalSettingsMock.updateToggleSettings).toHaveBeenCalledWith({ + enabled: true, + primaryColor: '#00FF00', + }); + }); + expect(handlePrimaryColorChange).toHaveBeenCalledWith('#00FF00'); + expect(navigationMock.refresh).toHaveBeenCalled(); + }); + + it('persists an empty brand color as a reset to default branding', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + const handlePrimaryColorChange = vi.fn(); + + render(); + + const textInput = screen.getByRole('textbox'); + fireEvent.change(textInput, { target: { value: '' } }); + + await waitFor(() => { + expect(trustPortalSettingsMock.updateToggleSettings).toHaveBeenCalledWith({ + enabled: true, + primaryColor: '', + }); + }); + expect(handlePrimaryColorChange).toHaveBeenCalledWith(null); + expect(navigationMock.refresh).toHaveBeenCalled(); + }); + + it('does not persist an invalid brand color', () => { + setMockPermissions(ADMIN_PERMISSIONS); + + render(); + + const textInput = screen.getByRole('textbox'); + fireEvent.change(textInput, { target: { value: 'zzzzzz' } }); + fireEvent.blur(textInput); + + expect(trustPortalSettingsMock.updateToggleSettings).not.toHaveBeenCalled(); + expect(navigationMock.refresh).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx index ed82a875e0..3fd2089bb4 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/BrandSettings.tsx @@ -1,29 +1,55 @@ 'use client'; -import { useDebounce } from '@/hooks/useDebounce'; import { usePermissions } from '@/hooks/use-permissions'; import { useTrustPortalSettings } from '@/hooks/use-trust-portal-settings'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input } from '@trycompai/design-system'; -import { Form, FormControl, FormField, FormItem, FormLabel } from '@trycompai/ui/form'; +import { useDebounce } from '@/hooks/useDebounce'; import { zodResolver } from '@hookform/resolvers/zod'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Field, + FieldLabel, + Input, +} from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +const DEFAULT_PRIMARY_COLOR = '#000000'; +const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; + const trustSettingsSchema = z.object({ - primaryColor: z.string().optional(), + primaryColor: z.string().regex(HEX_COLOR_PATTERN, 'Enter a valid hex color').optional(), }); interface BrandSettingsProps { - orgId: string; + enabled?: boolean; primaryColor: string | null; + onPrimaryColorChange?: (primaryColor: string | null) => void; +} + +function normalizePrimaryColor(value: unknown): string | null | undefined { + if (value === null) return null; + if (typeof value !== 'string') return undefined; + + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) return null; + if (!HEX_COLOR_PATTERN.test(trimmedValue)) return undefined; + + return trimmedValue.toUpperCase(); } export function BrandSettings({ - orgId, + enabled = true, primaryColor, + onPrimaryColorChange, }: BrandSettingsProps) { + const router = useRouter(); const { hasPermission } = usePermissions(); const canUpdate = hasPermission('trust', 'update'); const { updateToggleSettings } = useTrustPortalSettings(); @@ -36,7 +62,7 @@ export function BrandSettings({ }); const lastSaved = useRef<{ [key: string]: string | null }>({ - primaryColor: primaryColor ?? null, + primaryColor: normalizePrimaryColor(primaryColor) ?? null, }); const savingRef = useRef<{ [key: string]: boolean }>({ @@ -49,16 +75,25 @@ export function BrandSettings({ return; } - if (lastSaved.current[field] !== value) { + const nextPrimaryColor = normalizePrimaryColor(value); + if (nextPrimaryColor === undefined) { + return; + } + + if (lastSaved.current[field] !== nextPrimaryColor) { savingRef.current[field] = true; try { await updateToggleSettings({ - enabled: true, + enabled, primaryColor: - field === 'primaryColor' ? (value as string) : (form.getValues('primaryColor') ?? undefined), + field === 'primaryColor' + ? (nextPrimaryColor ?? '') + : (form.getValues('primaryColor') ?? undefined), }); toast.success('Brand settings updated'); - lastSaved.current[field] = value as string | null; + lastSaved.current[field] = nextPrimaryColor; + onPrimaryColorChange?.(nextPrimaryColor); + router.refresh(); } catch { toast.error('Failed to update brand settings'); } finally { @@ -66,30 +101,43 @@ export function BrandSettings({ } } }, - [form, updateToggleSettings], + [enabled, form, onPrimaryColorChange, router, updateToggleSettings], ); const [primaryColorValue, setPrimaryColorValue] = useState(form.getValues('primaryColor') || ''); const debouncedPrimaryColor = useDebounce(primaryColorValue, 800); + useEffect(() => { + const normalizedPrimaryColor = normalizePrimaryColor(primaryColor); + form.reset({ primaryColor: normalizedPrimaryColor ?? undefined }); + setPrimaryColorValue(normalizedPrimaryColor ?? ''); + lastSaved.current.primaryColor = normalizedPrimaryColor ?? null; + }, [form, primaryColor]); + useEffect(() => { if ( debouncedPrimaryColor !== undefined && - debouncedPrimaryColor !== lastSaved.current.primaryColor && + normalizePrimaryColor(debouncedPrimaryColor) !== lastSaved.current.primaryColor && !savingRef.current.primaryColor ) { - form.setValue('primaryColor', debouncedPrimaryColor || undefined); - void autoSave('primaryColor', debouncedPrimaryColor || null); + const normalizedPrimaryColor = normalizePrimaryColor(debouncedPrimaryColor); + if (normalizedPrimaryColor !== undefined) { + form.setValue('primaryColor', normalizedPrimaryColor ?? undefined); + void autoSave('primaryColor', normalizedPrimaryColor); + } } }, [debouncedPrimaryColor, autoSave, form]); const handlePrimaryColorBlur = useCallback( (e: React.FocusEvent) => { - const value = e.target.value; - if (value) { - form.setValue('primaryColor', value); + const value = normalizePrimaryColor(e.target.value); + if (value === undefined) { + toast.error('Enter a valid hex color'); + return; } - void autoSave('primaryColor', value || null); + form.setValue('primaryColor', value ?? undefined); + setPrimaryColorValue(value ?? ''); + void autoSave('primaryColor', value); }, [form, autoSave], ); @@ -101,69 +149,68 @@ export function BrandSettings({ Customize the appearance of your trust portal -
-
- ( - - Brand Color - +
+ ( + + Brand Color +
+
-
-
- { - field.onChange(e); - setPrimaryColorValue(e.target.value); - }} - onBlur={handlePrimaryColorBlur} - type="color" - className="sr-only" - id="color-picker" - disabled={!canUpdate} - /> - -
-
-
- { - let value = e.target.value; - if (!value.startsWith('#')) { - value = '#' + value; - } - field.onChange(value); - setPrimaryColorValue(value); - }} - onBlur={handlePrimaryColorBlur} - placeholder="#000000" - maxLength={7} - disabled={!canUpdate} - /> -
-
+ { + field.onChange(e); + setPrimaryColorValue(e.target.value); + }} + onBlur={handlePrimaryColorBlur} + type="color" + className="sr-only" + id="color-picker" + disabled={!canUpdate} + /> + +
+
+
+ { + let value = e.target.value; + if (value.length > 0 && !value.startsWith('#')) { + value = '#' + value; + } + field.onChange(value); + setPrimaryColorValue(value); + }} + onBlur={handlePrimaryColorBlur} + placeholder="#000000" + maxLength={7} + disabled={!canUpdate} + />
- - - )} - /> -

- Used for branding across your trust portal -

-
- +
+
+
+ )} + /> +

+ Used for branding across your trust portal +

+
); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx new file mode 100644 index 0000000000..9f653b2472 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TrustPortalBrandingSettings } from './TrustPortalBrandingSettings'; + +interface MockFaviconProps { + currentFaviconUrl: string | null; + onFaviconChange?: (faviconUrl: string | null) => void; +} + +interface MockBrandProps { + primaryColor: string | null; + onPrimaryColorChange?: (primaryColor: string | null) => void; +} + +vi.mock('./UpdateTrustFavicon', () => ({ + UpdateTrustFavicon: ({ currentFaviconUrl, onFaviconChange }: MockFaviconProps) => ( +
+
{currentFaviconUrl ?? 'default'}
+ +
+ ), +})); + +vi.mock('./BrandSettings', () => ({ + BrandSettings: ({ primaryColor, onPrimaryColorChange }: MockBrandProps) => ( +
+
{primaryColor ?? 'default'}
+ +
+ ), +})); + +describe('TrustPortalBrandingSettings', () => { + it('keeps saved branding values available for remounted tab content', () => { + render(); + + expect(screen.getByTestId('favicon-url')).toHaveTextContent('default'); + expect(screen.getByTestId('primary-color')).toHaveTextContent('default'); + + fireEvent.click(screen.getByRole('button', { name: /set favicon/i })); + fireEvent.click(screen.getByRole('button', { name: /set color/i })); + + expect(screen.getByTestId('favicon-url')).toHaveTextContent('https://example.com/favicon.png'); + expect(screen.getByTestId('primary-color')).toHaveTextContent('#00FF00'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx new file mode 100644 index 0000000000..4ca0f86900 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalBrandingSettings.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { BrandSettings } from './BrandSettings'; +import { UpdateTrustFavicon } from './UpdateTrustFavicon'; + +interface TrustPortalBrandingSettingsProps { + enabled: boolean; + primaryColor: string | null; + faviconUrl: string | null; +} + +export function TrustPortalBrandingSettings({ + enabled, + primaryColor, + faviconUrl, +}: TrustPortalBrandingSettingsProps) { + const [currentPrimaryColor, setCurrentPrimaryColor] = useState(primaryColor); + const [currentFaviconUrl, setCurrentFaviconUrl] = useState(faviconUrl); + + useEffect(() => { + setCurrentPrimaryColor(primaryColor); + }, [primaryColor]); + + useEffect(() => { + setCurrentFaviconUrl(faviconUrl); + }, [faviconUrl]); + + return ( +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx index 56a1be66ba..90c2e71e89 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx @@ -1,11 +1,11 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -100,8 +100,12 @@ vi.mock('@dnd-kit/utilities', () => ({ // Mock design system vi.mock('@trycompai/design-system', () => ({ - Button: ({ children, onClick, disabled, iconLeft, iconRight, ...props }: any) => ( - + Button: ({ children, onClick, disabled, iconLeft, iconRight, loading, ...props }: any) => ( + ), Card: ({ children }: any) =>
{children}
, CardContent: ({ children }: any) =>
{children}
, @@ -112,6 +116,8 @@ vi.mock('@trycompai/design-system', () => ({ DropdownMenuContent: ({ children }: any) =>
{children}
, DropdownMenuItem: ({ children, onClick }: any) => , DropdownMenuTrigger: ({ children }: any) => , + Field: ({ children }: any) =>
{children}
, + FieldLabel: ({ children }: any) => , Input: (props: any) => , Select: ({ children, disabled }: any) =>
{children}
, SelectContent: ({ children }: any) =>
{children}
, @@ -130,7 +136,11 @@ vi.mock('@trycompai/design-system', () => ({ Tabs: ({ children }: any) =>
{children}
, TabsContent: ({ children, value }: any) =>
{children}
, TabsList: ({ children }: any) =>
{children}
, - TabsTrigger: ({ children, value }: any) => , + TabsTrigger: ({ children, value }: any) => ( + + ), Textarea: (props: any) =>