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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';

import { CRON_SECRET } from '@/lib/config.server';
import { dispatchEnterpriseRecommendationsDigests } from '@/lib/organizations/recommendations-digest';
import { sentryLogger } from '@/lib/utils.server';

if (!CRON_SECRET) {
throw new Error('CRON_SECRET is not configured in environment variables');
}

export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
const expectedAuth = `Bearer ${CRON_SECRET}`;
if (authHeader !== expectedAuth) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be a crypto.timingSafeEqual?

sentryLogger(
'cron',
'warning'
)(
'SECURITY: Invalid CRON job authorization attempt: ' +
(authHeader ? 'Invalid authorization header' : 'Missing authorization header')
);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const summary = await dispatchEnterpriseRecommendationsDigests();

return NextResponse.json(
{
success: true,
summary,
timestamp: new Date().toISOString(),
},
{ status: 200 }
);
}
15 changes: 15 additions & 0 deletions apps/web/src/app/api/organizations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,21 @@ export function useUpdateMinimumBalanceAlert() {
);
}

export function useUpdateRecommendationsDigest() {
const trpc = useTRPC();
const queryClient = useQueryClient();

return useMutation(
trpc.organizations.settings.updateRecommendationsDigest.mutationOptions({
onSuccess: () => {
// Invalidate organization data to refresh settings (shared with the
// spending-alerts surface, so both stay in sync).
void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() });
},
})
);
}

export function useEnableOssSponsorship() {
const trpc = useTRPC();
const invalidate = useInvalidateAllOrganizationData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useOrganizationWithMembers } from '@/app/api/organizations/hooks';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { MessageCircleQuestion, Terminal } from 'lucide-react';
import { OrganizationProvidersAndModelsConfigurationCard } from '@/components/organizations/OrganizationProvidersAndModelsConfigurationCard';
import { OrganizationEmailPreferencesCard } from '@/components/organizations/OrganizationEmailPreferencesCard';
import { OrgActiveKiloclawsCard } from '@/components/organizations/OrgActiveKiloclawsCard';
import { OpenInExtensionButton } from '@/components/auth/OpenInExtensionButton';
import Image from 'next/image';
Expand Down Expand Up @@ -168,6 +169,9 @@ export function OrganizationDashboard({
<SSOSignupCard organization={organizationData} role={currentRole} />
</LockableContainer>
)}
{(currentRole === 'owner' || isKiloAdmin) && (
<OrganizationEmailPreferencesCard organizationId={organizationId} />
)}
<Card>
<CardHeader>
<CardTitle>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
'use client';

import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Bell, ChartLine, Mail } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { toast } from 'sonner';
import {
useOrganizationWithMembers,
useUpdateRecommendationsDigest,
} from '@/app/api/organizations/hooks';
import { SpendingAlertsModal } from './SpendingAlertsModal';

type Props = {
organizationId: string;
};

function recipientStateLabel(recipientCount: number): string {
if (recipientCount === 0) {
return 'Off';
}
return `On · ${recipientCount} recipient${recipientCount === 1 ? '' : 's'}`;
}

function PreferenceRow({
icon: Icon,
title,
description,
stateLabel,
isOn,
control,
}: {
icon: LucideIcon;
title: string;
description: string;
stateLabel?: string;
isOn?: boolean;
control: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
<div className="flex items-start gap-3">
<Icon className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{description}</p>
{stateLabel && (
<p className="text-muted-foreground text-xs tabular-nums">
<span className={isOn ? 'text-foreground font-medium' : undefined}>{stateLabel}</span>
</p>
)}
</div>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}

export function OrganizationEmailPreferencesCard({ organizationId }: Props) {
const { data } = useOrganizationWithMembers(organizationId);
const [isSpendingAlertsOpen, setIsSpendingAlertsOpen] = useState(false);
const updateRecommendationsDigest = useUpdateRecommendationsDigest();

if (!data) {
return null;
}

const settings = data.settings;
const isEnterprise = data.plan === 'enterprise';

// Low-balance alerts are "on" only when both a threshold and at least one
// recipient are configured (matches SpendingAlertsModal's enabled check).
const spendingRecipientCount =
settings?.minimum_balance !== undefined
? (settings?.minimum_balance_alert_email?.length ?? 0)
: 0;
const digestEnabled = settings?.recommendations_digest_enabled === true;

const handleDigestToggle = (next: boolean) => {
updateRecommendationsDigest.mutate(
{ organizationId, enabled: next },
{
onSuccess: () => {
toast.success(
next
? 'Weekly recommendations email enabled. Organization owners will receive it.'
: 'Weekly recommendations email disabled.'
);
},
onError: (error: unknown) => {
toast.error(
error instanceof Error
? error.message
: 'Failed to update the recommendations digest setting'
);
},
}
);
};

return (
<Card>
<CardHeader>
<CardTitle>
<Mail className="mr-2 inline h-5 w-5" />
Email preferences
</CardTitle>
<CardDescription>Choose which emails this organization receives.</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-border divide-y">
<PreferenceRow
icon={Bell}
title="Low balance alerts"
description="Notify recipients when the organization balance falls below a threshold."
stateLabel={recipientStateLabel(spendingRecipientCount)}
isOn={spendingRecipientCount > 0}
control={
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => setIsSpendingAlertsOpen(true)}
>
Configure
</Button>
}
/>
{isEnterprise && (
<PreferenceRow
icon={ChartLine}
title="Weekly recommendations email"
description="Email the organization's owners a weekly summary of open recommendations and feature setup."
control={
<Switch
checked={digestEnabled}
disabled={updateRecommendationsDigest.isPending}
onCheckedChange={handleDigestToggle}
aria-label="Weekly recommendations email"
/>
}
/>
)}
</div>
</CardContent>

<SpendingAlertsModal
open={isSpendingAlertsOpen}
onOpenChange={setIsSpendingAlertsOpen}
organizationId={organizationId}
settings={settings}
/>
</Card>
);
}
1 change: 1 addition & 0 deletions apps/web/src/emails/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,4 @@ Every template must include this branding footer below the content table:
| `securityFindingNew.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `action_url`, `manage_notifications_url`, `year` | — |
| `securityFindingSlaWarning.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `sla_deadline`, `action_url`, `manage_notifications_url`, `year` | — |
| `securityFindingSlaBreach.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `sla_deadline`, `action_url`, `manage_notifications_url`, `year` | — |
| `recommendationsDigest.html` | `organization_name`, `adopted_summary`, `open_count`, `recommendations_section`, `dashboard_url`, `year` | — |
149 changes: 149 additions & 0 deletions apps/web/src/emails/recommendationsDigest.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your weekly Kilo recommendations</title>
</head>
<body
style="
margin: 0;
padding: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1a1a1a;
"
>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff">
<tr>
<td align="center" style="padding: 40px 20px">
<table width="520" cellpadding="0" cellspacing="0">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px">
<h1
style="
margin: 0;
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
Your weekly Kilo recommendations
</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 0 40px 30px">
<p
style="
margin: 0 0 16px;
font-size: 14px;
line-height: 20px;
color: #333;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
Here's how to get more from Kilo for
<strong style="color: #1a1a1a">{{ organization_name }}</strong> this week. You
have <strong style="color: #1a1a1a">{{ open_count }}</strong> open
recommendations, and your organization has set up
<strong style="color: #1a1a1a">{{ adopted_summary }}</strong> features.
</p>

<!-- Recommendations -->
{{ recommendations_section }}

<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 10px 0 20px">
<a
href="{{ dashboard_url }}"
style="
display: inline-block;
padding: 10px 20px;
background-color: #1a1a1a;
color: #ffffff;
text-decoration: none;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
View recommendations
</a>
</td>
</tr>
</table>

<p
style="
margin: 0;
font-size: 14px;
line-height: 20px;
color: #333;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
You're receiving this because you're an owner of this organization. You can turn
the weekly recommendations email off from the organization's Email preferences.
</p>
</td>
</tr>
<!-- Sign-off -->
<tr>
<td style="padding: 0 40px 40px; border-top: 1px solid #ebebea">
<p
style="
margin: 20px 0 0;
font-size: 14px;
line-height: 20px;
color: #333;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
— The Kilo Team
</p>
</td>
</tr>
</table>
<!-- Branding Footer -->
<table width="520" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 30px 20px; border-top: 1px solid #eee">
<p
style="
margin: 0;
font-size: 11px;
color: #ccc;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif;
"
>
© {{ year }} Kilo Code, Inc<br />455 Market St, Ste 1940 PMB 993504<br />San
Francisco, CA 94105, USA
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Loading
Loading