Skip to content

Commit 8b74e54

Browse files
[dev] [Marfuen] mariano/magic-link (#2279)
* fix(auth): enhance error handling in email triggering and sending processes - Added try/catch blocks in triggerEmail and sendEmailTask functions to handle errors gracefully and log relevant information. - Improved logging for email sending failures, including details about the recipient and subject for better debugging. - Enhanced comments in auth.server.ts to clarify the flow of magic link email sending. * feat(design-system): add guideline for running audit-design-system after frontend component edits - Updated CLAUDE.md to include a new guideline stating that after editing any frontend component, the `audit-design-system` skill should be run to catch imports from `@comp/ui` or `lucide-react` that need to be migrated. refactor(AppSidebar): remove penetration tests from sidebar items - Removed the 'Penetration Tests' item from the AppSidebar component as it is no longer accessible based on permissions. feat(AutomationRunsCard): implement CopyableCodeBlock for logs and output - Added a new CopyableCodeBlock component to allow users to copy logs and output content to the clipboard with a success toast notification. - Updated AutomationRunsCard to utilize CopyableCodeBlock for displaying logs and output, enhancing user experience. * fix(AutomationRunsCard): update CheckmarkFilled icon color in CopyableCodeBlock - Changed the color of the CheckmarkFilled icon to text-primary when the content is copied, enhancing visual feedback for users. --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 85a2f83 commit 8b74e54

File tree

6 files changed

+123
-72
lines changed

6 files changed

+123
-72
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Every customer-facing API endpoint MUST have:
109109
- **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `<div>` for custom styling
110110
- **Layout**: Use `PageLayout`, `PageHeader`, `Stack`, `HStack`, `Section`, `SettingGroup`
111111
- **Patterns**: Sheet (`Sheet > SheetContent > SheetHeader + SheetBody`), Drawer, Collapsible
112+
- **After editing any frontend component**: Run the `audit-design-system` skill to catch `@comp/ui` or `lucide-react` imports that should be migrated
112113

113114
## Data Fetching
114115

apps/api/src/auth/auth.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,13 @@ export const auth = betterAuth({
272272
magicLink({
273273
expiresIn: MAGIC_LINK_EXPIRES_IN_SECONDS,
274274
sendMagicLink: async ({ email, url }) => {
275+
// The `url` from better-auth points to the API's verify endpoint
276+
// and includes the callbackURL from the client's sign-in request.
277+
// Flow: user clicks link → API verifies token & sets session cookie
278+
// → API redirects (302) to callbackURL (the app).
275279
if (process.env.NODE_ENV === 'development') {
276280
console.log('[Auth] Sending magic link to:', email);
281+
console.log('[Auth] Magic link URL:', url);
277282
}
278283
await triggerEmail({
279284
to: email,

apps/api/src/email/trigger-email.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,34 +14,43 @@ export async function triggerEmail(params: {
1414
scheduledAt?: string;
1515
attachments?: EmailAttachment[];
1616
}): Promise<{ id: string }> {
17-
const html = await render(params.react);
17+
try {
18+
const html = await render(params.react);
1819

19-
const fromMarketing = process.env.RESEND_FROM_MARKETING;
20-
const fromSystem = process.env.RESEND_FROM_SYSTEM;
21-
const fromDefault = process.env.RESEND_FROM_DEFAULT;
20+
const fromMarketing = process.env.RESEND_FROM_MARKETING;
21+
const fromSystem = process.env.RESEND_FROM_SYSTEM;
22+
const fromDefault = process.env.RESEND_FROM_DEFAULT;
2223

23-
const fromAddress = params.marketing
24-
? fromMarketing
25-
: params.system
26-
? fromSystem
27-
: fromDefault;
24+
const fromAddress = params.marketing
25+
? fromMarketing
26+
: params.system
27+
? fromSystem
28+
: fromDefault;
2829

29-
const handle = await tasks.trigger<typeof sendEmailTask>('send-email', {
30-
to: params.to,
31-
subject: params.subject,
32-
html,
33-
from: fromAddress ?? undefined,
34-
cc: params.cc,
35-
scheduledAt: params.scheduledAt,
36-
attachments: params.attachments?.map((att) => ({
37-
filename: att.filename,
38-
content:
39-
typeof att.content === 'string'
40-
? att.content
41-
: att.content.toString('base64'),
42-
contentType: att.contentType,
43-
})),
44-
});
30+
const handle = await tasks.trigger<typeof sendEmailTask>('send-email', {
31+
to: params.to,
32+
subject: params.subject,
33+
html,
34+
from: fromAddress ?? undefined,
35+
cc: params.cc,
36+
scheduledAt: params.scheduledAt,
37+
attachments: params.attachments?.map((att) => ({
38+
filename: att.filename,
39+
content:
40+
typeof att.content === 'string'
41+
? att.content
42+
: att.content.toString('base64'),
43+
contentType: att.contentType,
44+
})),
45+
});
4546

46-
return { id: handle.id };
47+
return { id: handle.id };
48+
} catch (error) {
49+
console.error('[triggerEmail] Failed to trigger email task', {
50+
to: params.to,
51+
subject: params.subject,
52+
error: error instanceof Error ? error.message : String(error),
53+
});
54+
throw error;
55+
}
4756
}

apps/api/src/trigger/email/send-email.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ export const sendEmailTask = schemaTask({
3232
}),
3333
run: async (params) => {
3434
if (!resend) {
35+
logger.error('Resend not initialized - missing RESEND_API_KEY', {
36+
to: params.to,
37+
subject: params.subject,
38+
});
3539
throw new Error('Resend not initialized - missing API key');
3640
}
3741

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

50-
const { data, error } = await resend.emails.send({
51-
from: fromAddress,
52-
to: toAddress,
53-
cc: params.cc,
54-
subject: params.subject,
55-
html: params.html,
56-
scheduledAt: params.scheduledAt,
57-
attachments: params.attachments?.map((att) => ({
58-
filename: att.filename,
59-
content: att.content,
60-
contentType: att.contentType,
61-
})),
62-
});
53+
try {
54+
const { data, error } = await resend.emails.send({
55+
from: fromAddress,
56+
to: toAddress,
57+
cc: params.cc,
58+
subject: params.subject,
59+
html: params.html,
60+
scheduledAt: params.scheduledAt,
61+
attachments: params.attachments?.map((att) => ({
62+
filename: att.filename,
63+
content: att.content,
64+
contentType: att.contentType,
65+
})),
66+
});
6367

64-
if (error) {
65-
logger.error('Resend API error', { error });
66-
throw new Error(`Failed to send email: ${error.message}`);
67-
}
68+
if (error) {
69+
logger.error('Resend API error', {
70+
error,
71+
to: params.to,
72+
subject: params.subject,
73+
});
74+
throw new Error(`Failed to send email: ${error.message}`);
75+
}
6876

69-
logger.info('Email sent', { to: params.to, id: data?.id });
77+
logger.info('Email sent', { to: params.to, id: data?.id });
7078

71-
return { id: data?.id };
79+
return { id: data?.id };
80+
} catch (error) {
81+
logger.error('Email sending failed', {
82+
to: params.to,
83+
subject: params.subject,
84+
error: error instanceof Error ? error.message : String(error),
85+
});
86+
throw error;
87+
}
7288
},
7389
});

apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,6 @@ export function AppSidebar({
103103
name: 'Cloud Tests',
104104
hidden: !canAccessRoute(permissions, 'cloud-tests'),
105105
},
106-
{
107-
id: 'penetration-tests',
108-
path: `/${organization.id}/security/penetration-tests`,
109-
name: 'Penetration Tests',
110-
hidden: !canAccessRoute(permissions, 'penetration-tests'),
111-
},
112106
];
113107

114108
const isPathActive = (itemPath: string) => {

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/AutomationRunsCard.tsx

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { Badge } from '@comp/ui/badge';
44
import { EvidenceAutomationRun, EvidenceAutomationRunStatus } from '@db';
55
import { Stack, Text, Button } from '@trycompai/design-system';
66
import { formatDistanceToNow } from 'date-fns';
7-
import { ChevronDown } from 'lucide-react';
8-
import { useMemo, useState } from 'react';
7+
import { toast } from 'sonner';
8+
import { CheckmarkFilled, ChevronDown, CopyToClipboard } from '@trycompai/design-system/icons';
9+
import { useCallback, useMemo, useState } from 'react';
910

1011
type AutomationRunWithName = EvidenceAutomationRun & {
1112
evidenceAutomation: {
@@ -32,6 +33,39 @@ const getStatusStyles = (status: EvidenceAutomationRunStatus) => {
3233
}
3334
};
3435

36+
function CopyableCodeBlock({ label, content }: { label: string; content: unknown }) {
37+
const [copied, setCopied] = useState(false);
38+
const text = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
39+
40+
const handleCopy = useCallback(() => {
41+
navigator.clipboard.writeText(text);
42+
setCopied(true);
43+
toast.success('Copied to clipboard');
44+
setTimeout(() => setCopied(false), 2000);
45+
}, [text]);
46+
47+
return (
48+
<div>
49+
<Text size="xs" weight="medium" variant="muted">{label}</Text>
50+
<div className="relative mt-1">
51+
<div className="absolute top-1.5 left-1.5 z-10">
52+
<Button
53+
variant="outline"
54+
size="icon-xs"
55+
onClick={handleCopy}
56+
title="Copy to clipboard"
57+
>
58+
{copied ? <CheckmarkFilled className="!size-3 text-primary" /> : <CopyToClipboard className="!size-3" />}
59+
</Button>
60+
</div>
61+
<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">
62+
{text}
63+
</pre>
64+
</div>
65+
</div>
66+
);
67+
}
68+
3569
export function AutomationRunsCard({ runs }: AutomationRunsCardProps) {
3670
const [expandedId, setExpandedId] = useState<string | null>(null);
3771
const [showAll, setShowAll] = useState(false);
@@ -81,11 +115,13 @@ export function AutomationRunsCard({ runs }: AutomationRunsCardProps) {
81115
<div
82116
key={run.id}
83117
className={`rounded-lg border border-border hover:border-border/80 transition-colors ${styles.bg}`}
84-
onClick={() => hasDetails && setExpandedId(isExpanded ? null : run.id)}
85-
role={hasDetails ? 'button' : undefined}
86-
style={hasDetails ? { cursor: 'pointer' } : undefined}
87118
>
88-
<div className="flex items-center gap-3 px-4 py-2.5">
119+
<div
120+
className="flex items-center gap-3 px-4 py-2.5"
121+
onClick={() => hasDetails && setExpandedId(isExpanded ? null : run.id)}
122+
role={hasDetails ? 'button' : undefined}
123+
style={hasDetails ? { cursor: 'pointer' } : undefined}
124+
>
89125
<div className={`h-2 w-2 rounded-full shrink-0 ${styles.dot}`} />
90126

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

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

131167
{isExpanded && (
132-
<div className="px-4 pb-3 pt-2 border-t space-y-2">
168+
<div className="px-4 pb-3 pt-2 border-t space-y-2 select-text">
133169
{run.evaluationReason && (
134170
<div>
135171
<Text size="xs" weight="medium" variant="muted">Evaluation</Text>
136172
<Text size="xs" as="p">{run.evaluationReason}</Text>
137173
</div>
138174
)}
139175
{run.logs && (
140-
<div>
141-
<Text size="xs" weight="medium" variant="muted">Logs</Text>
142-
<pre className="text-xs bg-muted text-foreground p-2 rounded overflow-x-auto max-h-40 overflow-y-auto font-mono mt-1">
143-
{typeof run.logs === 'string' ? run.logs : JSON.stringify(run.logs, null, 2)}
144-
</pre>
145-
</div>
176+
<CopyableCodeBlock label="Logs" content={run.logs} />
146177
)}
147178
{run.output && (
148-
<div>
149-
<Text size="xs" weight="medium" variant="muted">Output</Text>
150-
<pre className="text-xs bg-muted text-foreground p-2 rounded overflow-x-auto max-h-40 overflow-y-auto font-mono mt-1">
151-
{typeof run.output === 'string' ? run.output : JSON.stringify(run.output, null, 2)}
152-
</pre>
153-
</div>
179+
<CopyableCodeBlock label="Output" content={run.output} />
154180
)}
155181
{run.status === 'failed' && run.error && (
156182
<div className="px-2 py-1.5 rounded bg-destructive/10 border border-destructive/20">

0 commit comments

Comments
 (0)