Skip to content

Commit 37d1f9f

Browse files
committed
fix(webapp): show a permission panel on the integrations page for restricted roles
The project integrations page (Git, Vercel, and build settings) rendered an empty page for roles that can't manage integrations. It now shows a permission-denied panel, and the build-settings action is gated server-side.
1 parent 97ff6f9 commit 37d1f9f

1 file changed

Lines changed: 189 additions & 92 deletions

File tree

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

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

Lines changed: 189 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
33
import { Form, useActionData, useNavigation } from "@remix-run/react";
4-
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
5-
import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson";
4+
import { json } from "@remix-run/server-runtime";
5+
import {
6+
type UseDataFunctionReturn,
7+
typedjson,
8+
useTypedLoaderData,
9+
useTypedFetcher,
10+
} from "remix-typedjson";
611
import { z } from "zod";
7-
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
12+
import {
13+
MainCenteredContainer,
14+
MainHorizontallyCenteredContainer,
15+
} from "~/components/layout/AppLayout";
16+
import { PermissionDenied } from "~/components/PermissionDenied";
17+
import { $replica } from "~/db.server";
18+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
819
import { Button } from "~/components/primitives/Buttons";
920
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1021
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -26,7 +37,6 @@ import {
2637
import { ProjectSettingsService } from "~/services/projectSettings.server";
2738
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
2839
import { logger } from "~/services/logger.server";
29-
import { requireUserId } from "~/services/session.server";
3040
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
3141
import React, { useEffect, useState, useCallback, useRef } from "react";
3242
import { useSearchParams } from "@remix-run/react";
@@ -39,48 +49,71 @@ import {
3949
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
4050
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
4151

42-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
43-
const userId = await requireUserId(request);
44-
const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params);
52+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
53+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
54+
return org?.id ?? null;
55+
}
4556

46-
const projectSettingsPresenter = new ProjectSettingsPresenter();
47-
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
48-
organizationSlug,
49-
projectParam,
50-
userId
51-
);
57+
export const loader = dashboardLoader(
58+
{
59+
params: EnvironmentParamSchema,
60+
context: async (params) => {
61+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
62+
return organizationId ? { organizationId } : {};
63+
},
64+
// No hard authorization: the page renders a PermissionDenied panel for
65+
// roles that can't manage any integration (see canManageIntegrations).
66+
},
67+
async ({ params, user, ability }) => {
68+
const { projectParam, organizationSlug } = params;
5269

53-
if (resultOrFail.isErr()) {
54-
switch (resultOrFail.error.type) {
55-
case "project_not_found": {
56-
throw new Response(undefined, {
57-
status: 404,
58-
statusText: "Project not found",
59-
});
60-
}
61-
case "other":
62-
default: {
63-
resultOrFail.error.type satisfies "other";
64-
65-
logger.error("Failed loading project settings", {
66-
error: resultOrFail.error,
67-
});
68-
throw new Response(undefined, {
69-
status: 400,
70-
statusText: "Something went wrong, please try again!",
71-
});
70+
const canManageIntegrations =
71+
ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" });
72+
73+
if (!canManageIntegrations) {
74+
return typedjson({ canManageIntegrations: false as const });
75+
}
76+
77+
const projectSettingsPresenter = new ProjectSettingsPresenter();
78+
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
79+
organizationSlug,
80+
projectParam,
81+
user.id
82+
);
83+
84+
if (resultOrFail.isErr()) {
85+
switch (resultOrFail.error.type) {
86+
case "project_not_found": {
87+
throw new Response(undefined, {
88+
status: 404,
89+
statusText: "Project not found",
90+
});
91+
}
92+
case "other":
93+
default: {
94+
resultOrFail.error.type satisfies "other";
95+
96+
logger.error("Failed loading project settings", {
97+
error: resultOrFail.error,
98+
});
99+
throw new Response(undefined, {
100+
status: 400,
101+
statusText: "Something went wrong, please try again!",
102+
});
103+
}
72104
}
73105
}
74-
}
75106

76-
const { gitHubApp, buildSettings } = resultOrFail.value;
107+
const { gitHubApp, buildSettings } = resultOrFail.value;
77108

78-
return typedjson({
79-
githubAppEnabled: gitHubApp.enabled,
80-
buildSettings,
81-
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
82-
});
83-
};
109+
return typedjson({
110+
canManageIntegrations: true as const,
111+
githubAppEnabled: gitHubApp.enabled,
112+
buildSettings,
113+
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
114+
});
115+
}
116+
);
84117

85118
const UpdateBuildSettingsFormSchema = z.object({
86119
action: z.literal("update-build-settings"),
@@ -118,63 +151,89 @@ const UpdateBuildSettingsFormSchema = z.object({
118151
.transform((val) => val === "on"),
119152
});
120153

121-
export const action: ActionFunction = async ({ request, params }) => {
122-
const userId = await requireUserId(request);
123-
const { organizationSlug, projectParam } = params;
124-
if (!organizationSlug || !projectParam) {
125-
return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 });
126-
}
127-
128-
const formData = await request.formData();
129-
const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema });
154+
export const action = dashboardAction(
155+
{
156+
params: EnvironmentParamSchema,
157+
context: async (params) => {
158+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
159+
return organizationId ? { organizationId } : {};
160+
},
161+
// Build settings configure the Git-based deploy, so gate on write:github
162+
// (a restricted role can view neither this page nor mutate via a POST).
163+
authorization: { action: "write", resource: { type: "github" } },
164+
},
165+
async ({ request, params, user }) => {
166+
const { organizationSlug, projectParam } = params;
167+
168+
const formData = await request.formData();
169+
const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema });
170+
171+
if (!submission.value || submission.intent !== "submit") {
172+
return json(submission);
173+
}
130174

131-
if (!submission.value || submission.intent !== "submit") {
132-
return json(submission);
133-
}
175+
const projectSettingsService = new ProjectSettingsService();
176+
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
177+
organizationSlug,
178+
projectParam,
179+
user.id
180+
);
134181

135-
const projectSettingsService = new ProjectSettingsService();
136-
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
137-
organizationSlug,
138-
projectParam,
139-
userId
140-
);
182+
if (membershipResultOrFail.isErr()) {
183+
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
184+
}
141185

142-
if (membershipResultOrFail.isErr()) {
143-
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
144-
}
186+
const { projectId } = membershipResultOrFail.value;
145187

146-
const { projectId } = membershipResultOrFail.value;
188+
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
189+
submission.value;
147190

148-
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
149-
submission.value;
191+
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
192+
installCommand: installCommand || undefined,
193+
preBuildCommand: preBuildCommand || undefined,
194+
triggerConfigFilePath: triggerConfigFilePath || undefined,
195+
useNativeBuildServer: useNativeBuildServer,
196+
});
150197

151-
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
152-
installCommand: installCommand || undefined,
153-
preBuildCommand: preBuildCommand || undefined,
154-
triggerConfigFilePath: triggerConfigFilePath || undefined,
155-
useNativeBuildServer: useNativeBuildServer,
156-
});
198+
if (resultOrFail.isErr()) {
199+
switch (resultOrFail.error.type) {
200+
case "other":
201+
default: {
202+
resultOrFail.error.type satisfies "other";
157203

158-
if (resultOrFail.isErr()) {
159-
switch (resultOrFail.error.type) {
160-
case "other":
161-
default: {
162-
resultOrFail.error.type satisfies "other";
163-
164-
logger.error("Failed to update build settings", {
165-
error: resultOrFail.error,
166-
});
167-
return redirectBackWithErrorMessage(request, "Failed to update build settings");
204+
logger.error("Failed to update build settings", {
205+
error: resultOrFail.error,
206+
});
207+
return redirectBackWithErrorMessage(request, "Failed to update build settings");
208+
}
168209
}
169210
}
211+
212+
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
170213
}
214+
);
171215

172-
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
173-
};
216+
type IntegrationsData = Extract<
217+
UseDataFunctionReturn<typeof loader>,
218+
{ canManageIntegrations: true }
219+
>;
174220

175221
export default function IntegrationsSettingsPage() {
176-
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
177-
useTypedLoaderData<typeof loader>();
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;
178237
const project = useProject();
179238
const organization = useOrganization();
180239
const environment = useEnvironment();
@@ -223,14 +282,28 @@ export default function IntegrationsSettingsPage() {
223282
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
224283
// Load onboarding data
225284
vercelFetcher.load(
226-
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
285+
`${vercelResourcePath(
286+
organization.slug,
287+
project.slug,
288+
environment.slug
289+
)}?vercelOnboarding=true`
227290
);
228291
}
229292
} else if (!hasQueryParam && isModalOpen) {
230293
// Query param removed but modal is open, close modal
231294
setIsModalOpen(false);
232295
}
233-
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
296+
}, [
297+
hasQueryParam,
298+
vercelIntegrationEnabled,
299+
organization.slug,
300+
project.slug,
301+
environment.slug,
302+
vercelFetcher.data,
303+
vercelFetcher.state,
304+
isModalOpen,
305+
openVercelOnboarding,
306+
]);
234307

235308
// Ensure modal stays open when query param is present (even after data reloads)
236309
// This is a safeguard to prevent the modal from closing during form submissions
@@ -272,14 +345,30 @@ export default function IntegrationsSettingsPage() {
272345
// Need to load data first, mark that we're waiting for button click
273346
waitingForButtonClickRef.current = true;
274347
vercelFetcher.load(
275-
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
348+
`${vercelResourcePath(
349+
organization.slug,
350+
project.slug,
351+
environment.slug
352+
)}?vercelOnboarding=true`
276353
);
277354
}
278-
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
355+
}, [
356+
organization.slug,
357+
project.slug,
358+
environment.slug,
359+
vercelFetcher,
360+
setSearchParams,
361+
hasQueryParam,
362+
openVercelOnboarding,
363+
]);
279364

280365
// When data loads from button click, open modal
281366
useEffect(() => {
282-
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
367+
if (
368+
waitingForButtonClickRef.current &&
369+
vercelFetcher.data?.onboardingData &&
370+
vercelFetcher.state === "idle"
371+
) {
283372
// Data loaded from button click, open modal and ensure query param is present
284373
waitingForButtonClickRef.current = false;
285374
openVercelOnboarding();
@@ -313,7 +402,9 @@ export default function IntegrationsSettingsPage() {
313402
projectSlug={project.slug}
314403
environmentSlug={environment.slug}
315404
onOpenVercelModal={handleOpenVercelModal}
316-
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
405+
isLoadingVercelData={
406+
vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"
407+
}
317408
/>
318409
</div>
319410
</div>
@@ -346,8 +437,14 @@ export default function IntegrationsSettingsPage() {
346437
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
347438
onDataReload={(vercelEnvironmentId) => {
348439
vercelFetcher.load(
349-
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
350-
vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : ""
440+
`${vercelResourcePath(
441+
organization.slug,
442+
project.slug,
443+
environment.slug
444+
)}?vercelOnboarding=true${
445+
vercelEnvironmentId
446+
? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}`
447+
: ""
351448
}`
352449
);
353450
}}

0 commit comments

Comments
 (0)