Skip to content

Commit fe15b66

Browse files
committed
refactor(webapp): render permission-denied via an error boundary
Add throwPermissionDenied + a PermissionDeniedBoundary so a gated loader can just throw, and the route's error boundary renders the panel (falling back to the normal error display otherwise). Switch the integrations page to this: the loader throws when the role can't manage integrations and the page component only renders for allowed users, dropping the flag-and-split boilerplate.
1 parent cc6a021 commit fe15b66

2 files changed

Lines changed: 49 additions & 35 deletions

File tree

  • apps/webapp/app

apps/webapp/app/components/PermissionDenied.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { NoSymbolIcon } from "@heroicons/react/20/solid";
2+
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
3+
import { json } from "@remix-run/server-runtime";
4+
import React from "react";
5+
import { useOrganization } from "~/hooks/useOrganizations";
16
import { organizationRolesPath } from "~/utils/pathBuilder";
7+
import { MainCenteredContainer } from "./layout/AppLayout";
8+
import { RouteErrorDisplay } from "./ErrorDisplay";
29
import { LinkButton } from "./primitives/Buttons";
310
import { InfoPanel } from "./primitives/InfoPanel";
4-
import { NoSymbolIcon } from "@heroicons/react/20/solid";
5-
import { useOrganization } from "~/hooks/useOrganizations";
6-
import React from "react";
711

812
export function PermissionDenied({ message }: { message: React.ReactNode }) {
913
const organization = useOrganization();
@@ -23,3 +27,38 @@ export function PermissionDenied({ message }: { message: React.ReactNode }) {
2327
</InfoPanel>
2428
);
2529
}
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.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,10 @@ import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
33
import { Form, useActionData, useNavigation } from "@remix-run/react";
44
import { json } from "@remix-run/server-runtime";
5-
import {
6-
type UseDataFunctionReturn,
7-
typedjson,
8-
useTypedLoaderData,
9-
useTypedFetcher,
10-
} from "remix-typedjson";
5+
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
116
import { z } from "zod";
12-
import {
13-
MainCenteredContainer,
14-
MainHorizontallyCenteredContainer,
15-
} from "~/components/layout/AppLayout";
16-
import { PermissionDenied } from "~/components/PermissionDenied";
7+
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
8+
import { PermissionDeniedBoundary, throwPermissionDenied } from "~/components/PermissionDenied";
179
import { $replica } from "~/db.server";
1810
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
1911
import { Button } from "~/components/primitives/Buttons";
@@ -71,7 +63,7 @@ export const loader = dashboardLoader(
7163
ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" });
7264

7365
if (!canManageIntegrations) {
74-
return typedjson({ canManageIntegrations: false as const });
66+
throwPermissionDenied("With your current role, you can't manage integrations.");
7567
}
7668

7769
const projectSettingsPresenter = new ProjectSettingsPresenter();
@@ -107,7 +99,6 @@ export const loader = dashboardLoader(
10799
const { gitHubApp, buildSettings } = resultOrFail.value;
108100

109101
return typedjson({
110-
canManageIntegrations: true as const,
111102
githubAppEnabled: gitHubApp.enabled,
112103
buildSettings,
113104
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
@@ -213,27 +204,11 @@ export const action = dashboardAction(
213204
}
214205
);
215206

216-
type IntegrationsData = Extract<
217-
UseDataFunctionReturn<typeof loader>,
218-
{ canManageIntegrations: true }
219-
>;
207+
export { PermissionDeniedBoundary as ErrorBoundary };
220208

221209
export default function IntegrationsSettingsPage() {
222-
const data = useTypedLoaderData<typeof loader>();
223-
224-
if (!data.canManageIntegrations) {
225-
return (
226-
<MainCenteredContainer>
227-
<PermissionDenied message="With your current role, you can't manage integrations." />
228-
</MainCenteredContainer>
229-
);
230-
}
231-
232-
return <IntegrationsSettings data={data} />;
233-
}
234-
235-
function IntegrationsSettings({ data }: { data: IntegrationsData }) {
236-
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = data;
210+
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
211+
useTypedLoaderData<typeof loader>();
237212
const project = useProject();
238213
const organization = useOrganization();
239214
const environment = useEnvironment();

0 commit comments

Comments
 (0)