diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 728626271f..fdd6981293 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -9,6 +9,7 @@ import { StarIcon, } from "@heroicons/react/20/solid"; import type { Prisma } from "@trigger.dev/database"; +import { useCallback, useState } from "react"; import { z } from "zod"; import { cn } from "~/utils/cn"; @@ -76,7 +77,7 @@ export function Avatar({ /> ); case "image": - return ; + return ; } } @@ -190,20 +191,36 @@ function AvatarIcon({ } function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) { - if (!avatar.url) { + const [failed, setFailed] = useState(false); + + // A server-rendered image can finish failing before hydration, so onError never fires. + const detectFailedLoad = useCallback((node: HTMLImageElement | null) => { + if (node && node.complete && node.naturalWidth === 0) { + setFailed(true); + } + }, []); + + if (!avatar.url || failed) { return ( - + ); } return ( - + Organization avatar setFailed(true)} /> ); diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index d936a81b92..8a0a89eae7 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 data-disabled:hover:bg-background-bright hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -30,7 +30,7 @@ const variants = { }, button: { button: - "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", + "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected data-disabled:opacity-70", label: "text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-1", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", + "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected", label: "text-text-bright font-semibold -mt-0.5 text-left text-sm", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -46,7 +46,7 @@ const variants = { }, icon: { button: - "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed", + "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected", label: "text-text-bright font-semibold -mt-1 text-left", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index f1f267425c..c27eb15497 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1028,7 +1028,7 @@ function PresetOptions({ id={`api-key-access-${preset.id}`} value={preset.id} variant="description" - className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10" + className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10 hover:data-[state=checked]:bg-indigo-500/15" label={ preset.id === "FULL_ACCESS" ? ( diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index e9955747a8..19f3f5b7d6 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -154,7 +154,10 @@ --color-background-dimmed: var(--color-charcoal-850); --color-background-bright: var(--color-charcoal-800); --color-background-hover: var(--color-charcoal-750); + --color-surface-hover-subtle: var(--color-charcoal-750); --color-background-raised: var(--color-charcoal-700); + --color-surface-selected: var(--color-charcoal-650); + --color-surface-selected-hover: var(--color-charcoal-600); --color-surface-control: var(--color-charcoal-600); --color-surface-control-hover: var(--color-charcoal-550); --color-surface-control-active: var(--color-charcoal-500); @@ -163,6 +166,9 @@ /* Borders, from subtlest to most visible */ --color-grid-dimmed: var(--color-charcoal-750); --color-grid-bright: var(--color-charcoal-700); + /* Blend of border-bright over the selected card, so it tracks whatever ramp + border-bright is on - the dark themes need no contrast entry of their own */ + --color-border-selected: color-mix(in srgb, var(--color-border-bright) 50%, var(--color-surface-selected)); --color-border-bright: var(--color-charcoal-600); --color-border-brighter: var(--color-charcoal-550); --color-border-brightest: var(--color-charcoal-500); @@ -766,14 +772,21 @@ --color-background-dimmed: #fbfbfc; --color-background-bright: #ffffff; --color-background-hover: #f2f3f5; + /* Cards on white need a gentler hover than the app-wide surfaces */ + --color-surface-hover-subtle: #f7f8f9; --color-background-raised: #e9eaee; --color-surface-control: #dcdee3; --color-surface-control-hover: #cfd2d9; --color-surface-control-active: #b8bcc6; + /* Selection sits lighter than the controls here - on white a soft grey is + already enough to read as selected */ + --color-surface-selected: #eff0f2; + --color-surface-selected-hover: #e7e9ec; /* Borders */ --color-grid-dimmed: #eceef1; --color-grid-bright: #e2e4e9; + --color-border-selected: #e0e2e6; --color-border-bright: #d2d5db; --color-border-brighter: #b9bdc7; --color-border-brightest: #9ba1ad; @@ -931,6 +944,7 @@ --color-background-dimmed: color-mix(in srgb, var(--color-charcoal-850), #000 calc(var(--theme-contrast, 0) * 55%)); --color-background-bright: color-mix(in srgb, var(--color-charcoal-800), #000 calc(var(--theme-contrast, 0) * 45%)); --color-background-hover: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%)); + --color-surface-hover-subtle: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%)); --color-background-raised: color-mix(in srgb, var(--color-charcoal-700), #000 calc(var(--theme-contrast, 0) * 25%)); --color-input-bg: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%)); @@ -955,6 +969,9 @@ page-behind surfaces darken a touch so panels separate */ --color-background-deep: color-mix(in srgb, #f1f2f4, #000 calc(var(--theme-contrast, 0) * 10%)); --color-background-hover: color-mix(in srgb, #f2f3f5, #000 calc(var(--theme-contrast, 0) * 8%)); + /* Radio card surfaces take no ramp at all on white - their contrast is + carried by border-selected below. Deliberately absent: surface-hover-subtle, + surface-selected, surface-selected-hover. */ --color-background-raised: color-mix(in srgb, #e9eaee, #000 calc(var(--theme-contrast, 0) * 10%)); /* Controls and borders push hard toward black - this is where light-mode @@ -964,6 +981,8 @@ --color-surface-control-active: color-mix(in srgb, #b8bcc6, #000 calc(var(--theme-contrast, 0) * 32%)); --color-grid-dimmed: color-mix(in srgb, #eceef1, #000 calc(var(--theme-contrast, 0) * 28%)); --color-grid-bright: color-mix(in srgb, #e2e4e9, #000 calc(var(--theme-contrast, 0) * 32%)); + /* Steeper than border-bright so the selected card stays the loudest edge */ + --color-border-selected: color-mix(in srgb, #e0e2e6, #000 calc(var(--theme-contrast, 0) * 45%)); --color-border-bright: color-mix(in srgb, #d2d5db, #000 calc(var(--theme-contrast, 0) * 38%)); --color-border-brighter: color-mix(in srgb, #b9bdc7, #000 calc(var(--theme-contrast, 0) * 42%)); --color-border-brightest: color-mix(in srgb, #9ba1ad, #000 calc(var(--theme-contrast, 0) * 46%)); diff --git a/apps/webapp/app/utils/cspImageOrigins.test.ts b/apps/webapp/app/utils/cspImageOrigins.test.ts index 0ad1e34697..c08c6cde4b 100644 --- a/apps/webapp/app/utils/cspImageOrigins.test.ts +++ b/apps/webapp/app/utils/cspImageOrigins.test.ts @@ -17,7 +17,11 @@ function directivePermits(directive: string, imageUrl: string): boolean { if (!source.startsWith("http")) return false; const parsed = new URL(source); if (parsed.protocol !== url.protocol || parsed.host !== url.host) return false; - return parsed.pathname === "/" || parsed.pathname === url.pathname; + // CSP path matching: a source path ending in "/" matches by prefix, otherwise it + // must match exactly. The query string is never part of the match. + return parsed.pathname.endsWith("/") + ? url.pathname.startsWith(parsed.pathname) + : parsed.pathname === url.pathname; }); } @@ -104,9 +108,9 @@ describe("parseCspImageOrigins", () => { }); describe("buildImgSrcDirective", () => { - it("is self, data, blob, the SSO avatar hosts and the favicon endpoint by default", () => { + it("is self, data, blob, the SSO avatar hosts, the favicon endpoints and the changelog by default", () => { expect(buildImgSrcDirective()).toBe( - "img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons" + "img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons https://t0.gstatic.com/faviconV2 https://t1.gstatic.com/faviconV2 https://t2.gstatic.com/faviconV2 https://t3.gstatic.com/faviconV2 https://trigger.dev/changelog/" ); }); @@ -120,6 +124,29 @@ describe("buildImgSrcDirective", () => { ); }); + it("permits the gstatic shard the favicon endpoint redirects to", () => { + expect( + directivePermits( + buildImgSrcDirective(), + "https://t2.gstatic.com/faviconV2?url=https://example.com&size=128" + ) + ).toBe(true); + }); + + it("permits nothing else on a gstatic shard, and no shard we did not list", () => { + const directive = buildImgSrcDirective(); + expect(directivePermits(directive, "https://t2.gstatic.com/beacon.png")).toBe(false); + expect(directivePermits(directive, "https://t9.gstatic.com/faviconV2")).toBe(false); + }); + + it("permits changelog images by path prefix, and nothing else on our domain", () => { + const directive = buildImgSrcDirective(); + expect(directivePermits(directive, "https://trigger.dev/changelog/some-post/image.png")).toBe( + true + ); + expect(directivePermits(directive, "https://trigger.dev/anything.png")).toBe(false); + }); + it("permits both OAuth avatar hosts", () => { const directive = buildImgSrcDirective(); expect(directivePermits(directive, "https://avatars.githubusercontent.com/u/1?v=4")).toBe(true); diff --git a/apps/webapp/app/utils/cspImageOrigins.ts b/apps/webapp/app/utils/cspImageOrigins.ts index ed6fc03061..267c72df92 100644 --- a/apps/webapp/app/utils/cspImageOrigins.ts +++ b/apps/webapp/app/utils/cspImageOrigins.ts @@ -6,9 +6,12 @@ */ /** - * Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, and the - * favicon endpoint org avatars are stored as (see `utils/favicon.ts`). The path pins - * that one endpoint — CSP matches the path and ignores the query string. + * Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, the + * favicon endpoint org avatars are stored as (see `utils/favicon.ts`), and our own + * changelog images. The path pins each endpoint — CSP matches the path and ignores the + * query string. The favicon endpoint 302s to a `tN.gstatic.com` shard and CSP re-checks + * only the host on a redirect, so the shards are listed too; their path pin limits + * direct loads only. A trailing "/" matches by prefix. */ export const BASE_IMG_SRC_SOURCES = [ "'self'", @@ -17,6 +20,11 @@ export const BASE_IMG_SRC_SOURCES = [ "https://avatars.githubusercontent.com", "https://lh3.googleusercontent.com", "https://www.google.com/s2/favicons", + "https://t0.gstatic.com/faviconV2", + "https://t1.gstatic.com/faviconV2", + "https://t2.gstatic.com/faviconV2", + "https://t3.gstatic.com/faviconV2", + "https://trigger.dev/changelog/", ] as const; export type RejectedOrigin = { value: string; reason: string }; diff --git a/apps/webapp/test/dashboardAgentImageCsp.test.ts b/apps/webapp/test/dashboardAgentImageCsp.test.ts index 52406fffd3..ff716623a7 100644 --- a/apps/webapp/test/dashboardAgentImageCsp.test.ts +++ b/apps/webapp/test/dashboardAgentImageCsp.test.ts @@ -24,6 +24,10 @@ describe("document image CSP", () => { expect(directive).not.toMatch(/(^|\s)https?:(\s|$)/); }); + it("allows changelog images", () => { + expect(buildImgSrcDirective().split(" ")).toContain("https://trigger.dev/changelog/"); + }); + it("sets the header on every document response, not only on /login", () => { // The set() call must sit outside the /login branch. const loginBranch = source.slice(