Skip to content

Commit 36ecb25

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): support plugin-provided project notices
Add a plugin contract for showing notices to a project in the dashboard. Mono-RevId: 655340e9244152ca38610e026a725581215375e9
1 parent 0836a47 commit 36ecb25

10 files changed

Lines changed: 520 additions & 0 deletions

File tree

‎apps/webapp/app/components/primitives/PageHeader.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link, useNavigation } from "@remix-run/react";
22
import { type ReactNode } from "react";
33
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
44
import { OrgBanner } from "../billing/OrgBanner";
5+
import { SecurityNoticeBanner } from "../securityNotices/SecurityNoticeBanner";
56
import { BreadcrumbIcon } from "./BreadcrumbIcon";
67
import { Header2 } from "./Headers";
78
import { LoadingBarDivider } from "./LoadingBarDivider";
@@ -27,6 +28,7 @@ export function NavBar({ children }: WithChildren) {
2728
</div>
2829
<LoadingBarDivider isLoading={isLoading} />
2930
</div>
31+
<SecurityNoticeBanner />
3032
<OrgBanner />
3133
</div>
3234
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useLocation, useRouteLoaderData } from "@remix-run/react";
2+
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
3+
import { LinkButton } from "~/components/primitives/Buttons";
4+
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
5+
import { useOptionalOrganization } from "~/hooks/useOrganizations";
6+
import { useOptionalProject } from "~/hooks/useProject";
7+
import type { loader as environmentLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route";
8+
import { v3SecurityNoticesPath } from "~/utils/pathBuilder";
9+
10+
const environmentRouteId =
11+
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam";
12+
13+
export function SecurityNoticeBanner() {
14+
const data = useRouteLoaderData<typeof environmentLoader>(environmentRouteId);
15+
const organization = useOptionalOrganization();
16+
const project = useOptionalProject();
17+
const environment = useOptionalEnvironment();
18+
const location = useLocation();
19+
20+
const banner = data && "securityNoticeBanner" in data ? data.securityNoticeBanner : null;
21+
if (!banner || !organization || !project || !environment) return null;
22+
23+
const path = v3SecurityNoticesPath(organization, project, environment);
24+
const onNoticePage = location.pathname === path;
25+
const count = banner.openEnvironmentCount;
26+
27+
return (
28+
<AnimatedOrgBannerBar
29+
show
30+
variant="error"
31+
action={
32+
onNoticePage ? undefined : (
33+
<LinkButton variant="danger/small" leadingIconClassName="px-0" to={path}>
34+
See what to do
35+
</LinkButton>
36+
)
37+
}
38+
>
39+
<span className="font-medium">{banner.title}.</span> Action is needed for {count}{" "}
40+
{count === 1 ? "environment" : "environments"} in this project.
41+
</AnimatedOrgBannerBar>
42+
);
43+
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
2+
import { Form, useNavigation } from "@remix-run/react";
3+
import type { SecurityNotice, SecurityNoticeEnvironment } from "@trigger.dev/plugins";
4+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
5+
import { z } from "zod";
6+
import { InlineCode } from "~/components/code/InlineCode";
7+
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
8+
import {
9+
MainHorizontallyCenteredContainer,
10+
PageBody,
11+
PageContainer,
12+
} from "~/components/layout/AppLayout";
13+
import { Badge } from "~/components/primitives/Badge";
14+
import { Button, LinkButton } from "~/components/primitives/Buttons";
15+
import { DateTime } from "~/components/primitives/DateTime";
16+
import { Header2, Header3 } from "~/components/primitives/Headers";
17+
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
18+
import { Paragraph } from "~/components/primitives/Paragraph";
19+
import { TextLink } from "~/components/primitives/TextLink";
20+
import { useOrganization } from "~/hooks/useOrganizations";
21+
import { useProject } from "~/hooks/useProject";
22+
import { prisma } from "~/db.server";
23+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
24+
import { logger } from "~/services/logger.server";
25+
import { resolveProjectAuthScope } from "~/services/projectAuthScope.server";
26+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
27+
import {
28+
canSeeSecurityNotices,
29+
getSecurityNoticesController,
30+
} from "~/services/securityNotices.server";
31+
import { cn } from "~/utils/cn";
32+
import {
33+
EnvironmentParamSchema,
34+
v3ApiKeysPath,
35+
v3EnvironmentVariablesPath,
36+
v3SecurityNoticesPath,
37+
} from "~/utils/pathBuilder";
38+
39+
/** The project scope, or a 404 unless the plugin is loaded and the user is an Owner or Admin. */
40+
async function requireNoticeScope(params: z.infer<typeof EnvironmentParamSchema>, userId: string) {
41+
const controller = await getSecurityNoticesController();
42+
const project = controller
43+
? await prisma.project.findFirst({
44+
where: {
45+
slug: params.projectParam,
46+
deletedAt: null,
47+
organization: { slug: params.organizationSlug, members: { some: { userId } } },
48+
},
49+
select: { id: true, organizationId: true },
50+
})
51+
: null;
52+
const scope = project
53+
? { organizationId: project.organizationId, projectId: project.id, userId }
54+
: null;
55+
if (!controller || !scope || !(await canSeeSecurityNotices(scope))) {
56+
throw new Response("Not Found", { status: 404 });
57+
}
58+
return { controller, scope };
59+
}
60+
61+
export const loader = dashboardLoader(
62+
{
63+
params: EnvironmentParamSchema,
64+
context: (params) => resolveProjectAuthScope(params.organizationSlug, params.projectParam),
65+
},
66+
async ({ params, user }) => {
67+
const { controller, scope } = await requireNoticeScope(params, user.id);
68+
return typedjson({ notices: await controller.listForProject(scope) });
69+
}
70+
);
71+
72+
const ActionSchema = z.object({
73+
intent: z.literal("mark-handled"),
74+
noticeEnvironmentId: z.string().min(1),
75+
});
76+
77+
export const action = dashboardAction(
78+
{
79+
params: EnvironmentParamSchema,
80+
context: (params) => resolveProjectAuthScope(params.organizationSlug, params.projectParam),
81+
},
82+
async ({ request, params, user }) => {
83+
const path = v3SecurityNoticesPath(
84+
{ slug: params.organizationSlug },
85+
{ slug: params.projectParam },
86+
{ slug: params.envParam }
87+
);
88+
89+
const parsed = ActionSchema.safeParse(Object.fromEntries(await request.formData()));
90+
if (!parsed.success) {
91+
return redirectWithErrorMessage(path, request, "Invalid request");
92+
}
93+
94+
const { controller, scope } = await requireNoticeScope(params, user.id);
95+
const { noticeEnvironmentId } = parsed.data;
96+
const result = await controller.acknowledge({ ...scope, noticeEnvironmentId });
97+
if (!result.ok) {
98+
return redirectWithErrorMessage(path, request, "That notice no longer exists");
99+
}
100+
101+
logger.info("Security notice marked as handled", { ...scope, noticeEnvironmentId });
102+
return redirectWithSuccessMessage(path, request, "Marked as handled");
103+
}
104+
);
105+
106+
export default function Page() {
107+
const { notices } = useTypedLoaderData<typeof loader>();
108+
109+
return (
110+
<PageContainer>
111+
<NavBar>
112+
<PageTitle title="Security notices" />
113+
</NavBar>
114+
<PageBody>
115+
<MainHorizontallyCenteredContainer className="flex max-w-3xl flex-col gap-10 py-6">
116+
{notices.length === 0 ? (
117+
<Paragraph>There are no security notices for this project.</Paragraph>
118+
) : (
119+
notices.map((notice) => <Notice key={notice.id} notice={notice} />)
120+
)}
121+
</MainHorizontallyCenteredContainer>
122+
</PageBody>
123+
</PageContainer>
124+
);
125+
}
126+
127+
function Notice({ notice }: { notice: SecurityNotice }) {
128+
return (
129+
<section className="flex flex-col gap-4">
130+
<div className="flex flex-col gap-2">
131+
<Header2>{notice.title}</Header2>
132+
{notice.summary
133+
.split(/\n\s*\n/)
134+
.map((p) => p.trim())
135+
.filter(Boolean)
136+
.map((paragraph, i) => (
137+
<Paragraph key={i} variant="small" className="whitespace-pre-line">
138+
{paragraph}
139+
</Paragraph>
140+
))}
141+
{notice.detailsUrl && (
142+
<Paragraph variant="small">
143+
<TextLink href={notice.detailsUrl} target="_blank" rel="noreferrer">
144+
Read the full details
145+
</TextLink>
146+
</Paragraph>
147+
)}
148+
</div>
149+
{notice.environments.map((env) => (
150+
<AffectedEnvironment key={env.id} env={env} />
151+
))}
152+
</section>
153+
);
154+
}
155+
156+
function StepStatus({ done, label }: { done: boolean; label: string }) {
157+
return (
158+
<span
159+
className={cn(
160+
"inline-flex items-center gap-1 text-xs font-medium",
161+
done ? "text-success" : "text-warning"
162+
)}
163+
>
164+
{done ? (
165+
<CheckCircleIcon className="size-4" />
166+
) : (
167+
<ExclamationTriangleIcon className="size-4" />
168+
)}
169+
{label}
170+
</span>
171+
);
172+
}
173+
174+
function ApiKeyStatus({ env }: { env: SecurityNoticeEnvironment }) {
175+
switch (env.apiKeyStatus) {
176+
case "exposed":
177+
return <StepStatus done={false} label="Still the exposed key" />;
178+
case "grace":
179+
return <StepStatus done={false} label="Regenerated, exposed key still works" />;
180+
case "revoked":
181+
return <StepStatus done label="Exposed key revoked" />;
182+
}
183+
}
184+
185+
function AffectedEnvironment({ env }: { env: SecurityNoticeEnvironment }) {
186+
const organization = useOrganization();
187+
const project = useProject();
188+
const navigation = useNavigation();
189+
const environmentForPath = { slug: env.environmentSlug };
190+
const environment = { type: env.environmentType, branchName: env.branchName } as Parameters<
191+
typeof EnvironmentCombo
192+
>[0]["environment"];
193+
const isMarking = navigation.formData?.get("noticeEnvironmentId") === env.id;
194+
195+
return (
196+
<div className="flex flex-col gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
197+
<div className="flex items-center justify-between gap-2">
198+
<Header3>
199+
<EnvironmentCombo
200+
environment={environment}
201+
className="text-base"
202+
iconClassName="size-5"
203+
/>
204+
</Header3>
205+
{env.acknowledgedAt ? (
206+
<Badge variant="extra-small">Handled</Badge>
207+
) : (
208+
<Badge variant="extra-small" className="system-mono-label text-error">
209+
Action needed
210+
</Badge>
211+
)}
212+
</div>
213+
214+
<Paragraph variant="small">
215+
Exposed on <DateTime date={env.exposedAt} includeTime />. Treat this environment's secret
216+
API key and every environment variable in it as exposed.
217+
</Paragraph>
218+
219+
<div className="flex flex-col gap-2">
220+
<div className="flex items-center justify-between gap-2">
221+
<Paragraph variant="small/bright">1. Regenerate the secret API key</Paragraph>
222+
<ApiKeyStatus env={env} />
223+
</div>
224+
<Paragraph variant="small">
225+
Do this first and choose &quot;Revoke the previous key immediately&quot;, then update{" "}
226+
<InlineCode>TRIGGER_SECRET_KEY</InlineCode> wherever you trigger tasks from.
227+
</Paragraph>
228+
{env.apiKeyStatus === "grace" && env.apiKeyGraceEndsAt && (
229+
<Paragraph variant="small" className="text-warning">
230+
The exposed key still works until <DateTime date={env.apiKeyGraceEndsAt} includeTime />.
231+
Regenerate again with &quot;Revoke the previous key immediately&quot; to stop it now.
232+
</Paragraph>
233+
)}
234+
<div>
235+
<LinkButton
236+
variant="secondary/small"
237+
to={v3ApiKeysPath(organization, project, environmentForPath)}
238+
>
239+
Go to API keys
240+
</LinkButton>
241+
</div>
242+
</div>
243+
244+
<div className="flex flex-col gap-2">
245+
<Paragraph variant="small/bright">
246+
2. Rotate every secret in this environment&apos;s variables
247+
</Paragraph>
248+
<Paragraph variant="small">
249+
Revoke each value at the service that issued it, create a new one, then update it on the
250+
environment variables page. Changing a value in Trigger.dev doesn't revoke the old one.
251+
</Paragraph>
252+
<div>
253+
<LinkButton
254+
variant="secondary/small"
255+
to={v3EnvironmentVariablesPath(organization, project, environmentForPath)}
256+
>
257+
Go to environment variables
258+
</LinkButton>
259+
</div>
260+
</div>
261+
262+
<div className="flex items-center justify-between gap-2 border-t border-grid-bright pt-3">
263+
{env.acknowledgedAt ? (
264+
<Paragraph variant="small">
265+
Marked as handled
266+
{env.acknowledgedBy
267+
? ` by ${env.acknowledgedBy.name ?? env.acknowledgedBy.email}`
268+
: ""}{" "}
269+
on <DateTime date={env.acknowledgedAt} includeTime />.
270+
</Paragraph>
271+
) : (
272+
<>
273+
<Paragraph variant="small">
274+
3. When you've finished, mark this environment as handled to clear the banner.
275+
</Paragraph>
276+
<Form method="post">
277+
<input type="hidden" name="intent" value="mark-handled" />
278+
<input type="hidden" name="noticeEnvironmentId" value={env.id} />
279+
<Button
280+
type="submit"
281+
variant="primary/small"
282+
isLoading={isMarking}
283+
disabled={navigation.state === "submitting"}
284+
>
285+
{isMarking ? "Marking…" : "Mark as handled"}
286+
</Button>
287+
</Form>
288+
</>
289+
)}
290+
</div>
291+
</div>
292+
);
293+
}

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RouteErrorDisplay } from "~/components/ErrorDisplay";
99
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
1010
import { prisma } from "~/db.server";
1111
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
12+
import { getSecurityNoticeBanner } from "~/services/securityNotices.server";
1213
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
1314
import { logger } from "~/services/logger.server";
1415
import {
@@ -86,6 +87,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8687

8788
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
8889

90+
const securityNoticeBannerPromise = getSecurityNoticeBanner({
91+
organizationId: project.organization.id,
92+
projectId: project.id,
93+
userId: user.id,
94+
});
95+
8996
// Resolve dashboard-agent access here (single source of truth: global env,
9097
// admins/impersonators, then the global/per-org feature flag, default off) so
9198
// the launcher button is hidden when it's not enabled. The org's featureFlags
@@ -148,6 +155,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
148155

149156
return {
150157
...project,
158+
securityNoticeBanner: await securityNoticeBannerPromise,
151159
hasDashboardAgentAccess,
152160
dashboardAgentWatchEnabled,
153161
promotedDashboardAgentPrompt,

‎apps/webapp/app/routes/healthcheck.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { LoaderFunction } from "@remix-run/node";
33
import { env } from "~/env.server";
44
import { rbac } from "~/services/rbac.server";
55
import { getExternalOidcController } from "~/services/oidc/externalOidc.server";
6+
import { getSecurityNoticesController } from "~/services/securityNotices.server";
67
import { isOtlpWorkerPoolHealthy } from "~/v3/otlpWorkerPool.server";
78

89
export const loader: LoaderFunction = async ({ request }) => {
@@ -15,6 +16,7 @@ export const loader: LoaderFunction = async ({ request }) => {
1516
// not be silently bypassed by the DB-disabled flag.
1617
await rbac.isUsingPlugin();
1718
await getExternalOidcController();
19+
await getSecurityNoticesController();
1820

1921
if (env.OTEL_TRANSFORM_WORKER_POOL_ENABLED && !isOtlpWorkerPoolHealthy()) {
2022
console.log("healthcheck ❌ otlp worker pool has no alive workers");

0 commit comments

Comments
 (0)