Skip to content

Commit 50b2ed9

Browse files
committed
fix(webapp): enforce manage:billing on billing/plan/portal routes
Migrate the billing settings, standalone select-plan page, select-plan mutation, billing-alerts (loader + action), and Stripe customer-portal routes to dashboardLoader/dashboardAction with a manage:billing authorization block, resolving the org for the auth scope from the URL slug. The isManagedCloud guards and org-membership queries are unchanged; gating the page loaders means denied roles can't reach the billing UI at all. Permissive in OSS, enforced under the enterprise plugin.
1 parent a452614 commit 50b2ed9

5 files changed

Lines changed: 351 additions & 277 deletions

File tree

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

Lines changed: 98 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
33
import { Form, useActionData, type MetaFunction } from "@remix-run/react";
4-
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
4+
import { json } from "@remix-run/server-runtime";
55
import { tryCatch } from "@trigger.dev/core";
66
import { Fragment, useEffect, useRef, useState } from "react";
77
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -25,11 +25,11 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page
2525
import { Paragraph } from "~/components/primitives/Paragraph";
2626
import { TextLink } from "~/components/primitives/TextLink";
2727
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
28-
import { prisma } from "~/db.server";
28+
import { $replica, prisma } from "~/db.server";
2929
import { featuresForRequest } from "~/features.server";
3030
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
3131
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
32-
import { requireUserId } from "~/services/session.server";
32+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3333
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
3434
import {
3535
docsPath,
@@ -47,39 +47,54 @@ export const meta: MetaFunction = () => {
4747
];
4848
};
4949

50-
export async function loader({ params, request }: LoaderFunctionArgs) {
51-
const userId = await requireUserId(request);
52-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
50+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
51+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
52+
return org?.id ?? null;
53+
}
5354

54-
const { isManagedCloud } = featuresForRequest(request);
55-
if (!isManagedCloud) {
56-
return redirect(organizationPath({ slug: organizationSlug }));
57-
}
55+
export const loader = dashboardLoader(
56+
{
57+
params: OrganizationParamsSchema,
58+
context: async (params) => {
59+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
60+
return organizationId ? { organizationId } : {};
61+
},
62+
authorization: { action: "manage", resource: { type: "billing" } },
63+
},
64+
async ({ params, request, user }) => {
65+
const userId = user.id;
66+
const { organizationSlug } = params;
5867

59-
const organization = await prisma.organization.findFirst({
60-
where: { slug: organizationSlug, members: { some: { userId } } },
61-
});
68+
const { isManagedCloud } = featuresForRequest(request);
69+
if (!isManagedCloud) {
70+
return redirect(organizationPath({ slug: organizationSlug }));
71+
}
6272

63-
if (!organization) {
64-
throw new Response(null, { status: 404, statusText: "Organization not found" });
65-
}
73+
const organization = await prisma.organization.findFirst({
74+
where: { slug: organizationSlug, members: { some: { userId } } },
75+
});
6676

67-
const [error, alerts] = await tryCatch(getBillingAlerts(organization.id));
68-
if (error) {
69-
throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` });
70-
}
77+
if (!organization) {
78+
throw new Response(null, { status: 404, statusText: "Organization not found" });
79+
}
7180

72-
if (!alerts) {
73-
throw new Response(null, { status: 404, statusText: "Billing alerts not found" });
74-
}
81+
const [error, alerts] = await tryCatch(getBillingAlerts(organization.id));
82+
if (error) {
83+
throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` });
84+
}
7585

76-
return typedjson({
77-
alerts: {
78-
...alerts,
79-
amount: alerts.amount / 100,
80-
},
81-
});
82-
}
86+
if (!alerts) {
87+
throw new Response(null, { status: 404, statusText: "Billing alerts not found" });
88+
}
89+
90+
return typedjson({
91+
alerts: {
92+
...alerts,
93+
amount: alerts.amount / 100,
94+
},
95+
});
96+
}
97+
);
8398

8499
const schema = z.object({
85100
amount: z
@@ -104,61 +119,71 @@ const schema = z.object({
104119
}, z.coerce.number().array().nonempty("At least one alert level is required")),
105120
});
106121

107-
export const action: ActionFunction = async ({ request, params }) => {
108-
const userId = await requireUserId(request);
109-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
122+
export const action = dashboardAction(
123+
{
124+
params: OrganizationParamsSchema,
125+
context: async (params) => {
126+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
127+
return organizationId ? { organizationId } : {};
128+
},
129+
authorization: { action: "manage", resource: { type: "billing" } },
130+
},
131+
async ({ request, params, user }) => {
132+
const userId = user.id;
133+
const { organizationSlug } = params;
110134

111-
const formData = await request.formData();
112-
const submission = parse(formData, { schema });
135+
const formData = await request.formData();
136+
const submission = parse(formData, { schema });
113137

114-
if (!submission.value || submission.intent !== "submit") {
115-
return json(submission);
116-
}
138+
if (!submission.value || submission.intent !== "submit") {
139+
return json(submission);
140+
}
117141

118-
try {
119-
const organization = await prisma.organization.findFirst({
120-
where: { slug: organizationSlug, members: { some: { userId } } },
121-
});
142+
try {
143+
const organization = await prisma.organization.findFirst({
144+
where: { slug: organizationSlug, members: { some: { userId } } },
145+
});
122146

123-
if (!organization) {
124-
return redirectWithErrorMessage(
125-
v3BillingAlertsPath({ slug: organizationSlug }),
126-
request,
127-
"You are not authorized to update billing alerts"
128-
);
129-
}
147+
if (!organization) {
148+
return redirectWithErrorMessage(
149+
v3BillingAlertsPath({ slug: organizationSlug }),
150+
request,
151+
"You are not authorized to update billing alerts"
152+
);
153+
}
130154

131-
const [error, updatedAlert] = await tryCatch(
132-
setBillingAlert(organization.id, {
133-
...submission.value,
134-
amount: submission.value.amount * 100,
135-
})
136-
);
137-
if (error) {
138-
return redirectWithErrorMessage(
139-
v3BillingAlertsPath({ slug: organizationSlug }),
140-
request,
141-
"Failed to update billing alert"
155+
const [error, updatedAlert] = await tryCatch(
156+
setBillingAlert(organization.id, {
157+
...submission.value,
158+
amount: submission.value.amount * 100,
159+
})
142160
);
143-
}
161+
if (error) {
162+
return redirectWithErrorMessage(
163+
v3BillingAlertsPath({ slug: organizationSlug }),
164+
request,
165+
"Failed to update billing alert"
166+
);
167+
}
168+
169+
if (!updatedAlert) {
170+
return redirectWithErrorMessage(
171+
v3BillingAlertsPath({ slug: organizationSlug }),
172+
request,
173+
"Failed to update billing alert"
174+
);
175+
}
144176

145-
if (!updatedAlert) {
146-
return redirectWithErrorMessage(
177+
return redirectWithSuccessMessage(
147178
v3BillingAlertsPath({ slug: organizationSlug }),
148179
request,
149-
"Failed to update billing alert"
180+
"Billing alert updated"
150181
);
182+
} catch (error: any) {
183+
return json({ errors: { body: error.message } }, { status: 400 });
151184
}
152-
153-
return redirectWithSuccessMessage(
154-
v3BillingAlertsPath({ slug: organizationSlug }),
155-
request,
156-
"Billing alert updated"
157-
);
158-
} catch (error: any) {
159-
return json({ errors: { body: error.message } }, { status: 400 });
160185
}
161-
};
186+
);
162187

163188
export default function Page() {
164189
const { alerts } = useTypedLoaderData<typeof loader>();

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

Lines changed: 66 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
2-
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
32
import { type PlanDefinition } from "@trigger.dev/platform";
43
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
54
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
65
import { LinkButton } from "~/components/primitives/Buttons";
76
import { DateTime } from "~/components/primitives/DateTime";
87
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
9-
import { prisma } from "~/db.server";
8+
import { $replica, prisma } from "~/db.server";
109
import { featuresForRequest } from "~/features.server";
1110
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
12-
import { requireUserId } from "~/services/session.server";
11+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
1312
import {
1413
OrganizationParamsSchema,
1514
organizationPath,
@@ -27,58 +26,73 @@ export const meta: MetaFunction = () => {
2726
];
2827
};
2928

30-
export async function loader({ params, request }: LoaderFunctionArgs) {
31-
const userId = await requireUserId(request);
32-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
33-
34-
const { isManagedCloud } = featuresForRequest(request);
35-
if (!isManagedCloud) {
36-
return redirect(organizationPath({ slug: organizationSlug }));
37-
}
38-
39-
const plans = await getPlans();
40-
if (!plans) {
41-
throw new Response(null, { status: 404, statusText: "Plans not found" });
42-
}
29+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
30+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
31+
return org?.id ?? null;
32+
}
4333

44-
const organization = await prisma.organization.findFirst({
45-
where: { slug: organizationSlug, members: { some: { userId } } },
46-
});
34+
export const loader = dashboardLoader(
35+
{
36+
params: OrganizationParamsSchema,
37+
context: async (params) => {
38+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
39+
return organizationId ? { organizationId } : {};
40+
},
41+
authorization: { action: "manage", resource: { type: "billing" } },
42+
},
43+
async ({ params, request, user }) => {
44+
const userId = user.id;
45+
const { organizationSlug } = params;
46+
47+
const { isManagedCloud } = featuresForRequest(request);
48+
if (!isManagedCloud) {
49+
return redirect(organizationPath({ slug: organizationSlug }));
50+
}
51+
52+
const plans = await getPlans();
53+
if (!plans) {
54+
throw new Response(null, { status: 404, statusText: "Plans not found" });
55+
}
56+
57+
const organization = await prisma.organization.findFirst({
58+
where: { slug: organizationSlug, members: { some: { userId } } },
59+
});
60+
61+
if (!organization) {
62+
throw new Response(null, { status: 404, statusText: "Organization not found" });
63+
}
64+
65+
const currentPlan = await getCurrentPlan(organization.id);
66+
67+
//periods
68+
const periodStart = new Date();
69+
periodStart.setUTCHours(0, 0, 0, 0);
70+
periodStart.setUTCDate(1);
71+
72+
const periodEnd = new Date();
73+
periodEnd.setUTCMonth(periodEnd.getMonth() + 1);
74+
periodEnd.setUTCDate(0);
75+
periodEnd.setUTCHours(0, 0, 0, 0);
76+
77+
const daysRemaining = Math.ceil(
78+
(periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
79+
);
4780

48-
if (!organization) {
49-
throw new Response(null, { status: 404, statusText: "Organization not found" });
81+
// Extract 'message' from search params
82+
const url = new URL(request.url);
83+
const message = url.searchParams.get("message");
84+
85+
return typedjson({
86+
...plans,
87+
...currentPlan,
88+
organizationSlug,
89+
periodStart,
90+
periodEnd,
91+
daysRemaining,
92+
message,
93+
});
5094
}
51-
52-
const currentPlan = await getCurrentPlan(organization.id);
53-
54-
//periods
55-
const periodStart = new Date();
56-
periodStart.setUTCHours(0, 0, 0, 0);
57-
periodStart.setUTCDate(1);
58-
59-
const periodEnd = new Date();
60-
periodEnd.setUTCMonth(periodEnd.getMonth() + 1);
61-
periodEnd.setUTCDate(0);
62-
periodEnd.setUTCHours(0, 0, 0, 0);
63-
64-
const daysRemaining = Math.ceil(
65-
(periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
66-
);
67-
68-
// Extract 'message' from search params
69-
const url = new URL(request.url);
70-
const message = url.searchParams.get("message");
71-
72-
return typedjson({
73-
...plans,
74-
...currentPlan,
75-
organizationSlug,
76-
periodStart,
77-
periodEnd,
78-
daysRemaining,
79-
message,
80-
});
81-
}
95+
);
8296

8397
export default function ChoosePlanPage() {
8498
const {

0 commit comments

Comments
 (0)