Skip to content

Commit dcb240e

Browse files
claude[bot]Trigger.dev RepoOps
authored andcommitted
feat(webapp): let orgs opt out of a billing limit from the banner
The billing limit banner now has an "No limit" button that turns the billing limit off without leaving the page, behind a confirmation. Mono-RevId: c863f19f28e5613b42883bbc2a6354513372fe00
1 parent 0754931 commit dcb240e

5 files changed

Lines changed: 221 additions & 13 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The banner that prompts you to set a billing limit now lets you choose to run without one, so you don't have to open your billing limit settings to dismiss it.

‎apps/webapp/app/components/billing/OrgBanner.tsx‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { useLocation } from "@remix-run/react";
1+
import { DialogClose } from "@radix-ui/react-dialog";
2+
import { useFetcher, useLocation } from "@remix-run/react";
23
import { DateTime } from "~/components/primitives/DateTime";
34
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
45
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
56
import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner";
6-
import { LinkButton } from "~/components/primitives/Buttons";
7+
import { Button, LinkButton } from "~/components/primitives/Buttons";
8+
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
9+
import { FormButtons } from "~/components/primitives/FormButtons";
10+
import { Paragraph } from "~/components/primitives/Paragraph";
11+
import { SpinnerWhite } from "~/components/primitives/Spinner";
712
import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment";
813
import {
914
useOptionalOrganization,
@@ -23,6 +28,10 @@ import {
2328
} from "~/utils/pathBuilder";
2429
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
2530

31+
/** Wire values for the billing-limits route action's "remove the limit" submission. */
32+
const BILLING_LIMIT_INTENT = "billing-limit";
33+
const BILLING_LIMIT_MODE_NONE = "none";
34+
2635
function getUpgradeResetDate(): Date {
2736
const nextMonth = new Date();
2837
nextMonth.setUTCDate(1);
@@ -179,15 +188,12 @@ function NoLimitConfiguredBanner() {
179188
variant="warning"
180189
action={
181190
canManageBillingLimits ? (
182-
<LinkButton
183-
variant="tertiary/small"
184-
className="system:border-transparent system:bg-warning system:transition system:group-hover/button:bg-warning system:group-hover/button:brightness-90"
185-
to={v3BillingLimitsPath(organization)}
186-
>
187-
<span className="mx-auto grow self-center truncate text-text-bright system:text-white">
188-
Billing limit settings
189-
</span>
190-
</LinkButton>
191+
<div className="flex items-center gap-2">
192+
<NoBillingLimitButton />
193+
<LinkButton variant="warning/small" to={v3BillingLimitsPath(organization)}>
194+
Configure limit…
195+
</LinkButton>
196+
</div>
191197
) : undefined
192198
}
193199
>
@@ -198,6 +204,66 @@ function NoLimitConfiguredBanner() {
198204
);
199205
}
200206

207+
/**
208+
* Applies "no billing limit" without leaving the current page, by posting the same intent the
209+
* billing limits settings form posts. Confirmed first: this removes the org's only guard against
210+
* runaway spend.
211+
*/
212+
function NoBillingLimitButton() {
213+
const organization = useOrganization();
214+
const location = useLocation();
215+
const fetcher = useFetcher();
216+
217+
const isSubmitting = fetcher.state !== "idle";
218+
219+
const removeBillingLimit = () => {
220+
fetcher.submit(
221+
{
222+
intent: BILLING_LIMIT_INTENT,
223+
mode: BILLING_LIMIT_MODE_NONE,
224+
returnTo: `${location.pathname}${location.search}`,
225+
},
226+
{ method: "POST", action: v3BillingLimitsPath(organization) }
227+
);
228+
};
229+
230+
return (
231+
<Dialog>
232+
<DialogTrigger asChild>
233+
<Button variant="warning/small">No limit</Button>
234+
</DialogTrigger>
235+
<DialogContent>
236+
<DialogHeader>Continue without a billing limit?</DialogHeader>
237+
<div className="flex flex-col gap-3 pt-3">
238+
<Paragraph>
239+
Without a billing limit, runs will continue even if usage spikes unexpectedly. You may
240+
have to pay higher fees before you notice.
241+
</Paragraph>
242+
<FormButtons
243+
confirmButton={
244+
<Button
245+
type="button"
246+
variant="danger/medium"
247+
LeadingIcon={isSubmitting ? SpinnerWhite : undefined}
248+
disabled={isSubmitting}
249+
onClick={removeBillingLimit}
250+
shortcut={{ modifiers: ["mod"], key: "enter" }}
251+
>
252+
{isSubmitting ? "Applying..." : "No limit"}
253+
</Button>
254+
}
255+
cancelButton={
256+
<DialogClose asChild>
257+
<Button variant="tertiary/medium">Cancel</Button>
258+
</DialogClose>
259+
}
260+
/>
261+
</div>
262+
</DialogContent>
263+
</Dialog>
264+
);
265+
}
266+
201267
function UpgradeBanner() {
202268
const organization = useOrganization();
203269
const plan = useCurrentPlan();

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type {
22
BillingLimitResult,
33
ResolveBillingLimitRequest,
44
} from "~/services/billingLimit.schemas";
5+
import { sanitizeRedirectPath } from "~/utils";
6+
import { v3BillingLimitsPath } from "~/utils/pathBuilder";
57

68
export function isEnforcementActive(billingLimit: BillingLimitResult): boolean {
79
return (
@@ -39,3 +41,44 @@ export function getSubmittedResumeMode(
3941
}
4042
return null;
4143
}
44+
45+
/**
46+
* The only prefix a `returnTo` may use. The banner that posts one is rendered by `NavBar`, but it
47+
* reads the org layout loader's data, so every page able to show it is nested under `/orgs/`.
48+
*/
49+
const RETURN_TO_ALLOWED_PREFIX = "/orgs/";
50+
51+
/**
52+
* Never a redirect target. `sanitizeRedirectPath` already refuses both, and neither can match the
53+
* allowed prefix — kept explicit so widening that prefix later cannot silently admit a JSON
54+
* resource route or the public API.
55+
*/
56+
const RETURN_TO_DENIED_PREFIXES = ["/resources/", "/api/"];
57+
58+
/**
59+
* Where to send the user after saving the billing limit. Call sites outside the settings page
60+
* (the org banner) post a `returnTo` so the user stays on the page they were already on.
61+
*
62+
* Origin first, then shape. `sanitizeRedirectPath` parses the candidate against a fixed origin and
63+
* refuses anything whose hostname changes, which is what rules out reverse-solidus authorities like
64+
* `/\evil.com` that a literal `//` test lets through. Whatever survives must additionally be a
65+
* dashboard page path; anything else falls back to the settings page.
66+
*/
67+
export function getBillingLimitReturnTo(formData: FormData, organizationSlug: string): string {
68+
const fallback = v3BillingLimitsPath({ slug: organizationSlug });
69+
70+
const submitted = formData.get("returnTo");
71+
const sanitized = sanitizeRedirectPath(
72+
typeof submitted === "string" ? submitted : null,
73+
fallback
74+
);
75+
76+
if (
77+
!sanitized.startsWith(RETURN_TO_ALLOWED_PREFIX) ||
78+
RETURN_TO_DENIED_PREFIXES.some((prefix) => sanitized.startsWith(prefix))
79+
) {
80+
return fallback;
81+
}
82+
83+
return sanitized;
84+
}

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-limits/route.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashb
5858
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
5959
import {
6060
getAlertsResetRequested,
61+
getBillingLimitReturnTo,
6162
getEffectiveLimitCentsAfterLimitSave,
6263
getResolveSubmitted,
6364
getSubmittedResumeMode,
@@ -325,6 +326,10 @@ export const action = dashboardAction(
325326
return json({ formIntent: "billing-limit", submission: submission.reply() });
326327
}
327328

329+
// The org banner posts this intent from whatever page the user is on, so honour its
330+
// `returnTo` on success instead of dragging them over to the settings page.
331+
const returnToPath = getBillingLimitReturnTo(formData, organizationSlug);
332+
328333
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
329334
if (billingLimitError || !billingLimit) {
330335
return redirectWithErrorMessage(
@@ -425,7 +430,7 @@ export const action = dashboardAction(
425430
const session = await getSession(request.headers.get("cookie"));
426431
setSuccessMessage(session, "Billing limit updated");
427432

428-
return redirect(`${v3BillingLimitsPath({ slug: organizationSlug })}?alertsReset=1`, {
433+
return redirect(`${returnToPath}${returnToPath.includes("?") ? "&" : "?"}alertsReset=1`, {
429434
headers: {
430435
"Set-Cookie": await commitSession(session),
431436
},
@@ -436,7 +441,7 @@ export const action = dashboardAction(
436441
const session = await getSession(request.headers.get("cookie"));
437442
setSuccessMessage(session, "Billing limit updated");
438443

439-
return redirect(v3BillingLimitsPath({ slug: organizationSlug }), {
444+
return redirect(returnToPath, {
440445
headers: {
441446
"Set-Cookie": await commitSession(session),
442447
},

‎apps/webapp/test/billingLimitsRoute.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isBillingLimitSettingsFormSubmission } from "~/routes/_app.orgs.$organi
1111
import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat";
1212
import {
1313
getAlertsResetRequested,
14+
getBillingLimitReturnTo,
1415
getEffectiveLimitCentsAfterLimitSave,
1516
getResolveSubmitted,
1617
getSubmittedResumeMode,
@@ -164,6 +165,83 @@ describe("billingLimitsRoute.server", () => {
164165
expect(getEffectiveLimitCentsAfterLimitSave("none", 5000)).toBe(5000);
165166
});
166167
});
168+
169+
describe("getBillingLimitReturnTo", () => {
170+
const SETTINGS_PATH = "/orgs/acme/settings/billing-limits";
171+
172+
function formDataWithReturnTo(returnTo?: string): FormData {
173+
const formData = new FormData();
174+
if (returnTo !== undefined) {
175+
formData.set("returnTo", returnTo);
176+
}
177+
return formData;
178+
}
179+
180+
function returnToFor(returnTo?: string): string {
181+
return getBillingLimitReturnTo(formDataWithReturnTo(returnTo), "acme");
182+
}
183+
184+
it("returns an app-relative returnTo, including its query string", () => {
185+
expect(returnToFor("/orgs/acme/projects/p/env/dev/runs?page=2")).toBe(
186+
"/orgs/acme/projects/p/env/dev/runs?page=2"
187+
);
188+
});
189+
190+
it("keeps a dashboard page path with a multi-param query string", () => {
191+
expect(returnToFor("/orgs/acme/projects/p/env/dev/runs?statuses=FAILED&page=3")).toBe(
192+
"/orgs/acme/projects/p/env/dev/runs?statuses=FAILED&page=3"
193+
);
194+
});
195+
196+
it("falls back to the settings page when returnTo is missing", () => {
197+
expect(returnToFor()).toBe(SETTINGS_PATH);
198+
});
199+
200+
it("rejects protocol-relative URLs", () => {
201+
expect(returnToFor("//evil.com")).toBe(SETTINGS_PATH);
202+
});
203+
204+
// WHATWG URL parsing treats a reverse solidus like a solidus in the authority position, so
205+
// each of these resolves to a foreign origin even though it opens with a single "/".
206+
it("rejects a reverse-solidus authority", () => {
207+
expect(returnToFor("/\\evil.com")).toBe(SETTINGS_PATH);
208+
});
209+
210+
it("rejects a doubled reverse-solidus authority", () => {
211+
expect(returnToFor("/\\\\evil.com")).toBe(SETTINGS_PATH);
212+
});
213+
214+
it("rejects a mixed solidus/reverse-solidus authority", () => {
215+
expect(returnToFor("/\\/evil.com")).toBe(SETTINGS_PATH);
216+
});
217+
218+
it("rejects absolute URLs", () => {
219+
expect(returnToFor("https://evil.com/runs")).toBe(SETTINGS_PATH);
220+
expect(returnToFor("https://evil.example/runs")).toBe(SETTINGS_PATH);
221+
});
222+
223+
it("rejects resource routes", () => {
224+
expect(returnToFor("/resources/preferences/favorites")).toBe(SETTINGS_PATH);
225+
});
226+
227+
it("rejects API routes", () => {
228+
expect(returnToFor("/api/v1/tasks/my-task/trigger")).toBe(SETTINGS_PATH);
229+
});
230+
231+
it("rejects app pages outside the org section", () => {
232+
expect(returnToFor("/account/security")).toBe(SETTINGS_PATH);
233+
});
234+
235+
it("rejects a bare /orgs path not nested under an organization", () => {
236+
expect(returnToFor("/orgs")).toBe(SETTINGS_PATH);
237+
});
238+
239+
it("rejects a non-string returnTo", () => {
240+
const formData = new FormData();
241+
formData.set("returnTo", new Blob(["/orgs/acme/runs"]), "returnTo.txt");
242+
expect(getBillingLimitReturnTo(formData, "acme")).toBe(SETTINGS_PATH);
243+
});
244+
});
167245
});
168246

169247
describe("billing-alerts redirect route", () => {
@@ -217,6 +295,16 @@ describe("billing-limits form validation", () => {
217295
});
218296
});
219297

298+
it("accepts the org banner's no-limit field set", () => {
299+
const formData = new FormData();
300+
formData.set("intent", "billing-limit");
301+
formData.set("mode", "none");
302+
303+
const submission = parseWithZod(formData, { schema: billingLimitFormSchema });
304+
expect(submission.status).toBe("success");
305+
expect(submission.value?.mode).toBe("none");
306+
});
307+
220308
it("parses none mode with cancelInProgressRuns from the form", () => {
221309
const formData = new FormData();
222310
formData.set("mode", "none");

0 commit comments

Comments
 (0)