Skip to content

Commit e4fe85e

Browse files
committed
refactor(webapp): render the permission panel via the route error boundary
A failed `authorization` check in dashboardLoader/dashboardAction now throws a 403 that the shared RouteErrorDisplay turns into the permission panel, so a gated route only needs to declare `authorization`: no per-route error boundary or manual denial UI. Boundaries on the env and settings layouts keep the side nav visible alongside the panel. Billing and billing alerts now use the same declarative authorization instead of a hand-rolled flag.
1 parent b1b2d61 commit e4fe85e

15 files changed

Lines changed: 182 additions & 162 deletions

File tree

apps/webapp/app/components/ErrorDisplay.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { HomeIcon } from "@heroicons/react/20/solid";
22
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
33
import { friendlyErrorDisplay } from "~/utils/httpErrors";
4+
import { permissionDeniedMessage } from "~/utils/permissionDenied";
45
import { LinkButton } from "./primitives/Buttons";
56
import { Header1 } from "./primitives/Headers";
67
import { Paragraph } from "./primitives/Paragraph";
8+
import { PermissionDenied } from "./PermissionDenied";
79
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
810
import { type ReactNode } from "react";
911

@@ -17,6 +19,21 @@ type ErrorDisplayOptions = {
1719
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
1820
const error = useRouteError();
1921

22+
// A failed `authorization` check (or `throwPermissionDenied`) throws a 403
23+
// that bubbles to the nearest route ErrorBoundary. Every layout boundary
24+
// renders through here, so handling it once means a gated route only has to
25+
// declare `authorization` to get the permission panel: no per-route boundary.
26+
const permission = isRouteErrorResponse(error) ? permissionDeniedMessage(error.data) : null;
27+
if (permission) {
28+
return (
29+
<div className="flex min-h-screen w-full items-center justify-center p-4">
30+
<div className="w-full max-w-md">
31+
<PermissionDenied message={permission} />
32+
</div>
33+
</div>
34+
);
35+
}
36+
2037
return (
2138
<>
2239
{isRouteErrorResponse(error) ? (
Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,27 @@
11
import { NoSymbolIcon } from "@heroicons/react/20/solid";
2-
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
3-
import { json } from "@remix-run/server-runtime";
42
import React from "react";
5-
import { useOrganization } from "~/hooks/useOrganizations";
3+
import { useOptionalOrganization } from "~/hooks/useOrganizations";
64
import { organizationRolesPath } from "~/utils/pathBuilder";
7-
import { MainCenteredContainer } from "./layout/AppLayout";
8-
import { RouteErrorDisplay } from "./ErrorDisplay";
95
import { LinkButton } from "./primitives/Buttons";
106
import { InfoPanel } from "./primitives/InfoPanel";
117

128
export function PermissionDenied({ message }: { message: React.ReactNode }) {
13-
const organization = useOrganization();
9+
const organization = useOptionalOrganization();
1410

1511
return (
1612
<InfoPanel
1713
icon={NoSymbolIcon}
1814
iconClassName="text-text-dimmed"
1915
title="Permission denied"
2016
accessory={
21-
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
22-
View roles
23-
</LinkButton>
17+
organization ? (
18+
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
19+
View roles
20+
</LinkButton>
21+
) : undefined
2422
}
2523
>
2624
{message}
2725
</InfoPanel>
2826
);
2927
}
30-
31-
const PERMISSION_DENIED_MARKER = "rbac-permission-denied";
32-
33-
/**
34-
* Throw from a loader (or action) when the current role lacks access. The
35-
* thrown 403 routes to the nearest `PermissionDeniedBoundary`, which renders
36-
* the panel — so the loader stays the single enforcement point and the page
37-
* component only ever renders for users who are allowed.
38-
*/
39-
export function throwPermissionDenied(message: string): never {
40-
throw json({ [PERMISSION_DENIED_MARKER]: true, message }, { status: 403 });
41-
}
42-
43-
/**
44-
* Route `ErrorBoundary` that renders the permission panel for
45-
* `throwPermissionDenied`, and falls back to the default error display for
46-
* anything else.
47-
*/
48-
export function PermissionDeniedBoundary() {
49-
const error = useRouteError();
50-
51-
if (
52-
isRouteErrorResponse(error) &&
53-
error.status === 403 &&
54-
error.data?.[PERMISSION_DENIED_MARKER]
55-
) {
56-
return (
57-
<MainCenteredContainer>
58-
<PermissionDenied message={error.data.message ?? "You don't have permission to do this."} />
59-
</MainCenteredContainer>
60-
);
61-
}
62-
63-
return <RouteErrorDisplay />;
64-
}

apps/webapp/app/routes/_app.github.install/route.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export const loader = dashboardLoader(
2929
return organizationId ? { organizationId } : {};
3030
},
3131
authorization: { action: "write", resource: { type: "github" } },
32+
// Redirect endpoint (no UI): keep redirecting on denial rather than
33+
// throwing the permission panel.
34+
unauthorizedRedirect: "/",
3235
},
3336
async ({ request, user }) => {
3437
const searchParams = new URL(request.url).searchParams;

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

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@ 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 { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
12+
import { 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";
1716
import { Button, LinkButton } from "~/components/primitives/Buttons";
1817
import { Fieldset } from "~/components/primitives/Fieldset";
1918
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -53,21 +52,19 @@ export const loader = dashboardLoader(
5352
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
5453
return organizationId ? { organizationId } : {};
5554
},
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).
55+
authorization: {
56+
action: "manage",
57+
resource: { type: "members" },
58+
message: "With your current role, you can't invite team members.",
59+
},
5960
},
60-
async ({ user, context, ability }) => {
61+
async ({ user, context }) => {
6162
const organizationId = context.organizationId;
6263
if (!organizationId) {
6364
throw new Response("Not Found", { status: 404 });
6465
}
6566
const userId = user.id;
6667

67-
if (!ability.can("manage", { type: "members" })) {
68-
return typedjson({ canManageMembers: false as const });
69-
}
70-
7168
const presenter = new TeamPresenter();
7269
const result = await presenter.call({
7370
userId,
@@ -101,7 +98,7 @@ export const loader = dashboardLoader(
10198
.map((r) => r.id)
10299
: [];
103100

104-
return typedjson({ canManageMembers: true as const, ...result, offerableRoleIds });
101+
return typedjson({ ...result, offerableRoleIds });
105102
}
106103
);
107104

@@ -262,23 +259,7 @@ export const action = dashboardAction(
262259
}
263260
);
264261

265-
type InviteData = Extract<UseDataFunctionReturn<typeof loader>, { canManageMembers: true }>;
266-
267262
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 }) {
282263
const {
283264
limits,
284265
canPurchaseSeats,
@@ -288,7 +269,7 @@ function InvitePage({ data }: { data: InviteData }) {
288269
planSeatLimit,
289270
roles,
290271
offerableRoleIds,
291-
} = data;
272+
} = useTypedLoaderData<typeof loader>();
292273
const [total, setTotal] = useState(limits.used);
293274
const organization = useOrganization();
294275
const lastSubmission = useActionData();

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { json } from "@remix-run/server-runtime";
55
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
66
import { z } from "zod";
77
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
8-
import { PermissionDeniedBoundary, throwPermissionDenied } from "~/components/PermissionDenied";
8+
import { throwPermissionDenied } from "~/utils/permissionDenied";
99
import { $replica } from "~/db.server";
1010
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
1111
import { Button } from "~/components/primitives/Buttons";
@@ -204,8 +204,6 @@ export const action = dashboardAction(
204204
}
205205
);
206206

207-
export { PermissionDeniedBoundary as ErrorBoundary };
208-
209207
export default function IntegrationsSettingsPage() {
210208
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
211209
useTypedLoaderData<typeof loader>();

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Outlet } from "@remix-run/react";
22
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
3+
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
34
import { prisma } from "~/db.server";
45
import { redirectWithErrorMessage } from "~/models/message.server";
56
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
@@ -90,3 +91,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
9091
export default function Page() {
9192
return <Outlet />;
9293
}
94+
95+
// Caught here (inside the project SideMenu's Outlet) rather than at the project
96+
// layout, so a permission denial or error on any env-scoped page renders in the
97+
// content pane with the SideMenu intact. RouteErrorDisplay renders the
98+
// permission panel for a 403 and the generic error otherwise.
99+
export function ErrorBoundary() {
100+
return <RouteErrorDisplay />;
101+
}

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

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@ import {
1313
import { z } from "zod";
1414
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
1515
import {
16-
MainCenteredContainer,
1716
MainHorizontallyCenteredContainer,
1817
PageBody,
1918
PageContainer,
2019
} from "~/components/layout/AppLayout";
21-
import { PermissionDenied } from "~/components/PermissionDenied";
2220
import { Button } from "~/components/primitives/Buttons";
2321
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
2422
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -67,11 +65,13 @@ export const loader = dashboardLoader(
6765
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
6866
return organizationId ? { organizationId } : {};
6967
},
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).
68+
authorization: {
69+
action: "manage",
70+
resource: { type: "billing" },
71+
message: "With your current role, you can't manage billing alerts.",
72+
},
7373
},
74-
async ({ params, request, user, ability }) => {
74+
async ({ params, request, user }) => {
7575
const userId = user.id;
7676
const { organizationSlug } = params;
7777

@@ -80,10 +80,6 @@ export const loader = dashboardLoader(
8080
return redirect(organizationPath({ slug: organizationSlug }));
8181
}
8282

83-
if (!ability.can("manage", { type: "billing" })) {
84-
return typedjson({ canManageBilling: false as const });
85-
}
86-
8783
const organization = await prisma.organization.findFirst({
8884
where: { slug: organizationSlug, members: { some: { userId } } },
8985
});
@@ -107,7 +103,6 @@ export const loader = dashboardLoader(
107103
}
108104

109105
return typedjson({
110-
canManageBilling: true as const,
111106
alerts: {
112107
...alerts,
113108
amount: alerts.amount / 100,
@@ -116,7 +111,7 @@ export const loader = dashboardLoader(
116111
}
117112
);
118113

119-
type BillingAlertsData = Extract<UseDataFunctionReturn<typeof loader>, { canManageBilling: true }>;
114+
type BillingAlertsData = UseDataFunctionReturn<typeof loader>;
120115

121116
const schema = z.object({
122117
amount: z
@@ -215,21 +210,6 @@ export const action = dashboardAction(
215210
export default function Page() {
216211
const loaderData = useTypedLoaderData<typeof loader>();
217212

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-
233213
return <BillingAlerts alerts={loaderData.alerts} />;
234214
}
235215

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

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ 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";
76
import { Button, LinkButton } from "~/components/primitives/Buttons";
87
import { DateTime } from "~/components/primitives/DateTime";
98
import { InfoPanel } from "~/components/primitives/InfoPanel";
@@ -43,11 +42,13 @@ export const loader = dashboardLoader(
4342
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
4443
return organizationId ? { organizationId } : {};
4544
},
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).
45+
authorization: {
46+
action: "manage",
47+
resource: { type: "billing" },
48+
message: "With your current role, you can't manage billing.",
49+
},
4950
},
50-
async ({ params, request, user, ability }) => {
51+
async ({ params, request, user }) => {
5152
const userId = user.id;
5253
const { organizationSlug } = params;
5354

@@ -56,10 +57,6 @@ export const loader = dashboardLoader(
5657
return redirect(organizationPath({ slug: organizationSlug }));
5758
}
5859

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

9289
if (!showSelfServe) {
9390
return typedjson({
94-
canManageBilling: true as const,
9591
showSelfServe: false as const,
9692
...currentPlan,
9793
organizationSlug,
@@ -108,7 +104,6 @@ export const loader = dashboardLoader(
108104
}
109105

110106
return typedjson({
111-
canManageBilling: true as const,
112107
showSelfServe: true as const,
113108
...plans,
114109
...currentPlan,
@@ -124,21 +119,6 @@ export const loader = dashboardLoader(
124119
export default function ChoosePlanPage() {
125120
const loaderData = useTypedLoaderData<typeof loader>();
126121

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-
142122
const {
143123
showSelfServe,
144124
v3Subscription,

0 commit comments

Comments
 (0)