Skip to content

Commit c6cb229

Browse files
committed
fix(webapp): show a permission panel instead of redirecting on gated pages
The billing, billing alerts, and invite pages hard-redirected to the org home when the current role lacked access, which looked like a broken link. They now render the page shell with a PermissionDenied panel (and a link to view roles), and withhold their data server-side when access is denied. The matching mutations stay enforced independently.
1 parent dbca7f4 commit c6cb229

3 files changed

Lines changed: 95 additions & 11 deletions

File tree

  • apps/webapp/app/routes
    • _app.orgs.$organizationSlug.invite
    • _app.orgs.$organizationSlug.settings.billing-alerts
    • _app.orgs.$organizationSlug.settings.billing

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import {
99
import { json } from "@remix-run/node";
1010
import { Form, useActionData } from "@remix-run/react";
1111
import { Fragment, useRef, useState } from "react";
12-
import { typedjson, useTypedLoaderData } from "remix-typedjson";
12+
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
1313
import simplur from "simplur";
1414
import { z } from "zod";
1515
import { MainCenteredContainer } from "~/components/layout/AppLayout";
16+
import { PermissionDenied } from "~/components/PermissionDenied";
1617
import { Button, LinkButton } from "~/components/primitives/Buttons";
1718
import { Fieldset } from "~/components/primitives/Fieldset";
1819
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -52,15 +53,21 @@ export const loader = dashboardLoader(
5253
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
5354
return organizationId ? { organizationId } : {};
5455
},
55-
authorization: { action: "manage", resource: { type: "members" } },
56+
// No hard authorization block: a denial renders a PermissionDenied panel
57+
// rather than blindly redirecting. Enforced via canManageMembers below
58+
// (the invite action gates manage:members independently).
5659
},
57-
async ({ user, context }) => {
60+
async ({ user, context, ability }) => {
5861
const organizationId = context.organizationId;
5962
if (!organizationId) {
6063
throw new Response("Not Found", { status: 404 });
6164
}
6265
const userId = user.id;
6366

67+
if (!ability.can("manage", { type: "members" })) {
68+
return typedjson({ canManageMembers: false as const });
69+
}
70+
6471
const presenter = new TeamPresenter();
6572
const result = await presenter.call({
6673
userId,
@@ -94,7 +101,7 @@ export const loader = dashboardLoader(
94101
.map((r) => r.id)
95102
: [];
96103

97-
return typedjson({ ...result, offerableRoleIds });
104+
return typedjson({ canManageMembers: true as const, ...result, offerableRoleIds });
98105
}
99106
);
100107

@@ -255,7 +262,23 @@ export const action = dashboardAction(
255262
}
256263
);
257264

265+
type InviteData = Extract<UseDataFunctionReturn<typeof loader>, { canManageMembers: true }>;
266+
258267
export default function Page() {
268+
const loaderData = useTypedLoaderData<typeof loader>();
269+
270+
if (!loaderData.canManageMembers) {
271+
return (
272+
<MainCenteredContainer className="max-w-[26rem]">
273+
<PermissionDenied message="With your current role, you can't invite team members." />
274+
</MainCenteredContainer>
275+
);
276+
}
277+
278+
return <InvitePage data={loaderData} />;
279+
}
280+
281+
function InvitePage({ data }: { data: InviteData }) {
259282
const {
260283
limits,
261284
canPurchaseSeats,
@@ -265,7 +288,7 @@ export default function Page() {
265288
planSeatLimit,
266289
roles,
267290
offerableRoleIds,
268-
} = useTypedLoaderData<typeof loader>();
291+
} = data;
269292
const [total, setTotal] = useState(limits.used);
270293
const organization = useOrganization();
271294
const lastSubmission = useActionData();

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@ import { Form, useActionData, type MetaFunction } from "@remix-run/react";
44
import { json } from "@remix-run/server-runtime";
55
import { tryCatch } from "@trigger.dev/core";
66
import { Fragment, useEffect, useRef, useState } from "react";
7-
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
7+
import {
8+
type UseDataFunctionReturn,
9+
redirect,
10+
typedjson,
11+
useTypedLoaderData,
12+
} from "remix-typedjson";
813
import { z } from "zod";
914
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
1015
import {
16+
MainCenteredContainer,
1117
MainHorizontallyCenteredContainer,
1218
PageBody,
1319
PageContainer,
1420
} from "~/components/layout/AppLayout";
21+
import { PermissionDenied } from "~/components/PermissionDenied";
1522
import { Button } from "~/components/primitives/Buttons";
1623
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1724
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -60,9 +67,11 @@ export const loader = dashboardLoader(
6067
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
6168
return organizationId ? { organizationId } : {};
6269
},
63-
authorization: { action: "manage", resource: { type: "billing" } },
70+
// No hard authorization block: a denial renders a PermissionDenied panel
71+
// instead of blindly redirecting. Enforced via canManageBilling below (the
72+
// form mutations are gated independently in the action).
6473
},
65-
async ({ params, request, user }) => {
74+
async ({ params, request, user, ability }) => {
6675
const userId = user.id;
6776
const { organizationSlug } = params;
6877

@@ -71,6 +80,10 @@ export const loader = dashboardLoader(
7180
return redirect(organizationPath({ slug: organizationSlug }));
7281
}
7382

83+
if (!ability.can("manage", { type: "billing" })) {
84+
return typedjson({ canManageBilling: false as const });
85+
}
86+
7487
const organization = await prisma.organization.findFirst({
7588
where: { slug: organizationSlug, members: { some: { userId } } },
7689
});
@@ -94,6 +107,7 @@ export const loader = dashboardLoader(
94107
}
95108

96109
return typedjson({
110+
canManageBilling: true as const,
97111
alerts: {
98112
...alerts,
99113
amount: alerts.amount / 100,
@@ -102,6 +116,8 @@ export const loader = dashboardLoader(
102116
}
103117
);
104118

119+
type BillingAlertsData = Extract<UseDataFunctionReturn<typeof loader>, { canManageBilling: true }>;
120+
105121
const schema = z.object({
106122
amount: z
107123
.number({ invalid_type_error: "Not a valid amount" })
@@ -197,7 +213,27 @@ export const action = dashboardAction(
197213
);
198214

199215
export default function Page() {
200-
const { alerts } = useTypedLoaderData<typeof loader>();
216+
const loaderData = useTypedLoaderData<typeof loader>();
217+
218+
if (!loaderData.canManageBilling) {
219+
return (
220+
<PageContainer>
221+
<NavBar>
222+
<PageTitle title="Billing alerts" />
223+
</NavBar>
224+
<PageBody>
225+
<MainCenteredContainer>
226+
<PermissionDenied message="With your current role, you can't manage billing alerts." />
227+
</MainCenteredContainer>
228+
</PageBody>
229+
</PageContainer>
230+
);
231+
}
232+
233+
return <BillingAlerts alerts={loaderData.alerts} />;
234+
}
235+
236+
function BillingAlerts({ alerts }: { alerts: BillingAlertsData["alerts"] }) {
201237
const plan = useCurrentPlan();
202238
const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2));
203239

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type PlanDefinition } from "@trigger.dev/platform";
33
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
44
import { Feedback } from "~/components/Feedback";
55
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
6+
import { PermissionDenied } from "~/components/PermissionDenied";
67
import { Button, LinkButton } from "~/components/primitives/Buttons";
78
import { DateTime } from "~/components/primitives/DateTime";
89
import { InfoPanel } from "~/components/primitives/InfoPanel";
@@ -42,9 +43,11 @@ export const loader = dashboardLoader(
4243
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
4344
return organizationId ? { organizationId } : {};
4445
},
45-
authorization: { action: "manage", resource: { type: "billing" } },
46+
// No hard authorization block here: a denial should render the page with a
47+
// PermissionDenied panel, not blindly redirect away. Enforced via the
48+
// canManageBilling check below (the billing mutations are gated separately).
4649
},
47-
async ({ params, request, user }) => {
50+
async ({ params, request, user, ability }) => {
4851
const userId = user.id;
4952
const { organizationSlug } = params;
5053

@@ -53,6 +56,10 @@ export const loader = dashboardLoader(
5356
return redirect(organizationPath({ slug: organizationSlug }));
5457
}
5558

59+
if (!ability.can("manage", { type: "billing" })) {
60+
return typedjson({ canManageBilling: false as const });
61+
}
62+
5663
const organization = await prisma.organization.findFirst({
5764
where: { slug: organizationSlug, members: { some: { userId } } },
5865
});
@@ -84,6 +91,7 @@ export const loader = dashboardLoader(
8491

8592
if (!showSelfServe) {
8693
return typedjson({
94+
canManageBilling: true as const,
8795
showSelfServe: false as const,
8896
...currentPlan,
8997
organizationSlug,
@@ -100,6 +108,7 @@ export const loader = dashboardLoader(
100108
}
101109

102110
return typedjson({
111+
canManageBilling: true as const,
103112
showSelfServe: true as const,
104113
...plans,
105114
...currentPlan,
@@ -114,6 +123,22 @@ export const loader = dashboardLoader(
114123

115124
export default function ChoosePlanPage() {
116125
const loaderData = useTypedLoaderData<typeof loader>();
126+
127+
if (!loaderData.canManageBilling) {
128+
return (
129+
<PageContainer>
130+
<NavBar>
131+
<PageTitle title="Billing" />
132+
</NavBar>
133+
<PageBody>
134+
<MainCenteredContainer>
135+
<PermissionDenied message="With your current role, you can't manage billing." />
136+
</MainCenteredContainer>
137+
</PageBody>
138+
</PageContainer>
139+
);
140+
}
141+
117142
const {
118143
showSelfServe,
119144
v3Subscription,

0 commit comments

Comments
 (0)