Skip to content

Commit 58c22ba

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 0cdd98e commit 58c22ba

3 files changed

Lines changed: 187 additions & 152 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,61 @@
1-
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
21
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
32
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
43
import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout";
54
import { Header1 } from "~/components/primitives/Headers";
6-
import { prisma } from "~/db.server";
5+
import { $replica, prisma } from "~/db.server";
76
import { featuresForRequest } from "~/features.server";
87
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
9-
import { requireUserId } from "~/services/session.server";
8+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
109
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
1110
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
1211

13-
export async function loader({ params, request }: LoaderFunctionArgs) {
14-
await requireUserId(request);
15-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
12+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
13+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
14+
return org?.id ?? null;
15+
}
1616

17-
const { isManagedCloud } = featuresForRequest(request);
18-
if (!isManagedCloud) {
19-
return redirect(organizationPath({ slug: organizationSlug }));
20-
}
17+
export const loader = dashboardLoader(
18+
{
19+
params: OrganizationParamsSchema,
20+
context: async (params) => {
21+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
22+
return organizationId ? { organizationId } : {};
23+
},
24+
authorization: { action: "manage", resource: { type: "billing" } },
25+
},
26+
async ({ params, request }) => {
27+
const { organizationSlug } = params;
2128

22-
const plans = await getPlans();
23-
if (!plans) {
24-
throw new Response(null, { status: 404, statusText: "Plans not found" });
25-
}
29+
const { isManagedCloud } = featuresForRequest(request);
30+
if (!isManagedCloud) {
31+
return redirect(organizationPath({ slug: organizationSlug }));
32+
}
2633

27-
const organization = await prisma.organization.findUnique({
28-
where: { slug: organizationSlug },
29-
});
34+
const plans = await getPlans();
35+
if (!plans) {
36+
throw new Response(null, { status: 404, statusText: "Plans not found" });
37+
}
3038

31-
if (!organization) {
32-
throw new Response(null, { status: 404, statusText: "Organization not found" });
33-
}
39+
const organization = await prisma.organization.findFirst({
40+
where: { slug: organizationSlug },
41+
});
3442

35-
if (organization.v3Enabled) {
36-
return redirect(organizationPath({ slug: organizationSlug }));
37-
}
43+
if (!organization) {
44+
throw new Response(null, { status: 404, statusText: "Organization not found" });
45+
}
3846

39-
const currentPlan = await getCurrentPlan(organization.id);
47+
if (organization.v3Enabled) {
48+
return redirect(organizationPath({ slug: organizationSlug }));
49+
}
4050

41-
const periodEnd = new Date();
42-
periodEnd.setMonth(periodEnd.getMonth() + 1);
51+
const currentPlan = await getCurrentPlan(organization.id);
4352

44-
return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd });
45-
}
53+
const periodEnd = new Date();
54+
periodEnd.setMonth(periodEnd.getMonth() + 1);
55+
56+
return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd });
57+
}
58+
);
4659

4760
export default function ChoosePlanPage() {
4861
const { plans, v3Subscription, organizationSlug, periodEnd, addOnPricing } =
Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,58 @@
1-
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
21
import { redirect } from "remix-typedjson";
3-
import { prisma } from "~/db.server";
2+
import { $replica, prisma } from "~/db.server";
43
import { redirectWithErrorMessage } from "~/models/message.server";
54
import { customerPortalUrl } from "~/services/platform.v3.server";
6-
import { requireUserId } from "~/services/session.server";
5+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
76
import { OrganizationParamsSchema, v3BillingPath } from "~/utils/pathBuilder";
87

9-
export async function loader({ request, params }: ActionFunctionArgs) {
10-
const userId = await requireUserId(request);
11-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
8+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
9+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
10+
return org?.id ?? null;
11+
}
1212

13-
const org = await prisma.organization.findUnique({
14-
select: {
15-
id: true,
13+
export const loader = dashboardLoader(
14+
{
15+
params: OrganizationParamsSchema,
16+
context: async (params) => {
17+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
18+
return organizationId ? { organizationId } : {};
1619
},
17-
where: {
18-
slug: organizationSlug,
19-
members: {
20-
some: {
21-
userId,
20+
authorization: { action: "manage", resource: { type: "billing" } },
21+
},
22+
async ({ request, params, user }) => {
23+
const { organizationSlug } = params;
24+
25+
const org = await prisma.organization.findFirst({
26+
select: {
27+
id: true,
28+
},
29+
where: {
30+
slug: organizationSlug,
31+
members: {
32+
some: {
33+
userId: user.id,
34+
},
2235
},
2336
},
24-
},
25-
});
37+
});
2638

27-
if (!org) {
28-
return redirectWithErrorMessage(
29-
v3BillingPath({ slug: organizationSlug }),
30-
request,
31-
"Something went wrong. Please try again later."
32-
);
33-
}
39+
if (!org) {
40+
return redirectWithErrorMessage(
41+
v3BillingPath({ slug: organizationSlug }),
42+
request,
43+
"Something went wrong. Please try again later."
44+
);
45+
}
3446

35-
const result = await customerPortalUrl(org.id, organizationSlug);
36-
if (!result || !result.success || !result.customerPortalUrl) {
37-
return redirectWithErrorMessage(
38-
v3BillingPath({ slug: organizationSlug }),
39-
request,
40-
"Something went wrong. Please try again later."
41-
);
42-
}
47+
const result = await customerPortalUrl(org.id, organizationSlug);
48+
if (!result || !result.success || !result.customerPortalUrl) {
49+
return redirectWithErrorMessage(
50+
v3BillingPath({ slug: organizationSlug }),
51+
request,
52+
"Something went wrong. Please try again later."
53+
);
54+
}
4355

44-
return redirect(result.customerPortalUrl);
45-
}
56+
return redirect(result.customerPortalUrl);
57+
}
58+
);

0 commit comments

Comments
 (0)