Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Every customer-facing API endpoint MUST have:
- **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `<div>` for custom styling
- **Layout**: Use `PageLayout`, `PageHeader`, `Stack`, `HStack`, `Section`, `SettingGroup`
- **Patterns**: Sheet (`Sheet > SheetContent > SheetHeader + SheetBody`), Drawer, Collapsible
- **After editing any frontend component**: Run the `audit-design-system` skill to catch `@comp/ui` or `lucide-react` imports that should be migrated

## Data Fetching

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,13 @@ export const auth = betterAuth({
magicLink({
expiresIn: MAGIC_LINK_EXPIRES_IN_SECONDS,
sendMagicLink: async ({ email, url }) => {
// The `url` from better-auth points to the API's verify endpoint
// and includes the callbackURL from the client's sign-in request.
// Flow: user clicks link → API verifies token & sets session cookie
// → API redirects (302) to callbackURL (the app).
if (process.env.NODE_ENV === 'development') {
console.log('[Auth] Sending magic link to:', email);
console.log('[Auth] Magic link URL:', url);
}
await triggerEmail({
to: email,
Expand Down
61 changes: 35 additions & 26 deletions apps/api/src/email/trigger-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,43 @@ export async function triggerEmail(params: {
scheduledAt?: string;
attachments?: EmailAttachment[];
}): Promise<{ id: string }> {
const html = await render(params.react);
try {
const html = await render(params.react);

const fromMarketing = process.env.RESEND_FROM_MARKETING;
const fromSystem = process.env.RESEND_FROM_SYSTEM;
const fromDefault = process.env.RESEND_FROM_DEFAULT;
const fromMarketing = process.env.RESEND_FROM_MARKETING;
const fromSystem = process.env.RESEND_FROM_SYSTEM;
const fromDefault = process.env.RESEND_FROM_DEFAULT;

const fromAddress = params.marketing
? fromMarketing
: params.system
? fromSystem
: fromDefault;
const fromAddress = params.marketing
? fromMarketing
: params.system
? fromSystem
: fromDefault;

const handle = await tasks.trigger<typeof sendEmailTask>('send-email', {
to: params.to,
subject: params.subject,
html,
from: fromAddress ?? undefined,
cc: params.cc,
scheduledAt: params.scheduledAt,
attachments: params.attachments?.map((att) => ({
filename: att.filename,
content:
typeof att.content === 'string'
? att.content
: att.content.toString('base64'),
contentType: att.contentType,
})),
});
const handle = await tasks.trigger<typeof sendEmailTask>('send-email', {
to: params.to,
subject: params.subject,
html,
from: fromAddress ?? undefined,
cc: params.cc,
scheduledAt: params.scheduledAt,
attachments: params.attachments?.map((att) => ({
filename: att.filename,
content:
typeof att.content === 'string'
? att.content
: att.content.toString('base64'),
contentType: att.contentType,
})),
});

return { id: handle.id };
return { id: handle.id };
} catch (error) {
console.error('[triggerEmail] Failed to trigger email task', {
to: params.to,
subject: params.subject,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
56 changes: 36 additions & 20 deletions apps/api/src/trigger/email/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ export const sendEmailTask = schemaTask({
}),
run: async (params) => {
if (!resend) {
logger.error('Resend not initialized - missing RESEND_API_KEY', {
to: params.to,
subject: params.subject,
});
throw new Error('Resend not initialized - missing API key');
}

const fromMarketing = process.env.RESEND_FROM_MARKETING;
const fromSystem = process.env.RESEND_FROM_SYSTEM;
const fromDefault = process.env.RESEND_FROM_DEFAULT;
const toTest = process.env.RESEND_TO_TEST;
Expand All @@ -47,27 +50,40 @@ export const sendEmailTask = schemaTask({
throw new Error('Missing FROM address in environment variables');
}

const { data, error } = await resend.emails.send({
from: fromAddress,
to: toAddress,
cc: params.cc,
subject: params.subject,
html: params.html,
scheduledAt: params.scheduledAt,
attachments: params.attachments?.map((att) => ({
filename: att.filename,
content: att.content,
contentType: att.contentType,
})),
});
try {
const { data, error } = await resend.emails.send({
from: fromAddress,
to: toAddress,
cc: params.cc,
subject: params.subject,
html: params.html,
scheduledAt: params.scheduledAt,
attachments: params.attachments?.map((att) => ({
filename: att.filename,
content: att.content,
contentType: att.contentType,
})),
});

if (error) {
logger.error('Resend API error', { error });
throw new Error(`Failed to send email: ${error.message}`);
}
if (error) {
logger.error('Resend API error', {
error,
to: params.to,
subject: params.subject,
});
throw new Error(`Failed to send email: ${error.message}`);
}

logger.info('Email sent', { to: params.to, id: data?.id });
logger.info('Email sent', { to: params.to, id: data?.id });

return { id: data?.id };
return { id: data?.id };
} catch (error) {
logger.error('Email sending failed', {
to: params.to,
subject: params.subject,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
});
6 changes: 0 additions & 6 deletions apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,6 @@ export function AppSidebar({
name: 'Cloud Tests',
hidden: !canAccessRoute(permissions, 'cloud-tests'),
},
{
id: 'penetration-tests',
path: `/${organization.id}/security/penetration-tests`,
name: 'Penetration Tests',
hidden: !canAccessRoute(permissions, 'penetration-tests'),
},
];

const isPathActive = (itemPath: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Badge } from '@comp/ui/badge';
import { EvidenceAutomationRun, EvidenceAutomationRunStatus } from '@db';
import { Stack, Text, Button } from '@trycompai/design-system';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CheckmarkFilled, ChevronDown, CopyToClipboard } from '@trycompai/design-system/icons';
import { useCallback, useMemo, useState } from 'react';

type AutomationRunWithName = EvidenceAutomationRun & {
evidenceAutomation: {
Expand All @@ -32,6 +33,39 @@ const getStatusStyles = (status: EvidenceAutomationRunStatus) => {
}
};

function CopyableCodeBlock({ label, content }: { label: string; content: unknown }) {
const [copied, setCopied] = useState(false);
const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(text);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
}, [text]);

return (
<div>
<Text size="xs" weight="medium" variant="muted">{label}</Text>
<div className="relative mt-1">
<div className="absolute top-1.5 left-1.5 z-10">
<Button
variant="outline"
size="icon-xs"
onClick={handleCopy}
title="Copy to clipboard"
>
{copied ? <CheckmarkFilled className="!size-3 text-primary" /> : <CopyToClipboard className="!size-3" />}
</Button>
</div>
<pre className="text-xs bg-muted text-foreground p-2 pl-9 rounded overflow-x-auto max-h-40 overflow-y-auto font-mono select-text cursor-text">
{text}
</pre>
</div>
</div>
);
}

export function AutomationRunsCard({ runs }: AutomationRunsCardProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
Expand Down Expand Up @@ -81,11 +115,13 @@ export function AutomationRunsCard({ runs }: AutomationRunsCardProps) {
<div
key={run.id}
className={`rounded-lg border border-border hover:border-border/80 transition-colors ${styles.bg}`}
onClick={() => hasDetails && setExpandedId(isExpanded ? null : run.id)}
role={hasDetails ? 'button' : undefined}
style={hasDetails ? { cursor: 'pointer' } : undefined}
>
<div className="flex items-center gap-3 px-4 py-2.5">
<div
className="flex items-center gap-3 px-4 py-2.5"
onClick={() => hasDetails && setExpandedId(isExpanded ? null : run.id)}
role={hasDetails ? 'button' : undefined}
style={hasDetails ? { cursor: 'pointer' } : undefined}
>
<div className={`h-2 w-2 rounded-full shrink-0 ${styles.dot}`} />

<div className="flex-1 min-w-0">
Expand Down Expand Up @@ -124,33 +160,23 @@ export function AutomationRunsCard({ runs }: AutomationRunsCardProps) {
</div>

{hasDetails && (
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
<ChevronDown size={16} className={`text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
)}
</div>

{isExpanded && (
<div className="px-4 pb-3 pt-2 border-t space-y-2">
<div className="px-4 pb-3 pt-2 border-t space-y-2 select-text">
{run.evaluationReason && (
<div>
<Text size="xs" weight="medium" variant="muted">Evaluation</Text>
<Text size="xs" as="p">{run.evaluationReason}</Text>
</div>
)}
{run.logs && (
<div>
<Text size="xs" weight="medium" variant="muted">Logs</Text>
<pre className="text-xs bg-muted text-foreground p-2 rounded overflow-x-auto max-h-40 overflow-y-auto font-mono mt-1">
{typeof run.logs === 'string' ? run.logs : JSON.stringify(run.logs, null, 2)}
</pre>
</div>
<CopyableCodeBlock label="Logs" content={run.logs} />
)}
{run.output && (
<div>
<Text size="xs" weight="medium" variant="muted">Output</Text>
<pre className="text-xs bg-muted text-foreground p-2 rounded overflow-x-auto max-h-40 overflow-y-auto font-mono mt-1">
{typeof run.output === 'string' ? run.output : JSON.stringify(run.output, null, 2)}
</pre>
</div>
<CopyableCodeBlock label="Output" content={run.output} />
)}
{run.status === 'failed' && run.error && (
<div className="px-2 py-1.5 rounded bg-destructive/10 border border-destructive/20">
Expand Down
Loading