From 4821d0db52d183fb4218c35ab9ce4acd7dbb9174 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 11 Jun 2026 11:45:58 +0100 Subject: [PATCH 01/26] feat(webapp): add permission-gating primitives Add checkPermissions(ability, checks) which maps a set of action/resource checks to a boolean record using the injected ability, so loaders can compute display-only permission flags server-side and pass them to the client. Add PermissionButton and PermissionLink wrappers that disable the underlying control and show an explanatory tooltip when a server-computed hasPermission flag is false. No permission logic ships to the client; the route builder authorization block remains the security boundary. --- .../primitives/PermissionButton.tsx | 36 ++++++++++ .../components/primitives/PermissionLink.tsx | 43 +++++++++++ .../routeBuilders/permissions.server.ts | 33 +++++++++ apps/webapp/test/checkPermissions.test.ts | 71 +++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 apps/webapp/app/components/primitives/PermissionButton.tsx create mode 100644 apps/webapp/app/components/primitives/PermissionLink.tsx create mode 100644 apps/webapp/app/services/routeBuilders/permissions.server.ts create mode 100644 apps/webapp/test/checkPermissions.test.ts diff --git a/apps/webapp/app/components/primitives/PermissionButton.tsx b/apps/webapp/app/components/primitives/PermissionButton.tsx new file mode 100644 index 00000000000..22b6baa354c --- /dev/null +++ b/apps/webapp/app/components/primitives/PermissionButton.tsx @@ -0,0 +1,36 @@ +import { forwardRef, type ReactNode } from "react"; +import { Button } from "./Buttons"; + +export const DEFAULT_NO_PERMISSION_TOOLTIP = "You don't have permission to do this"; + +type PermissionButtonProps = React.ComponentProps & { + /** Server-computed flag (see `checkPermissions`). When false the button is disabled with a tooltip. */ + hasPermission: boolean; + noPermissionTooltip?: ReactNode; +}; + +/** + * A `Button` that disables itself and shows an explanatory tooltip when the + * user lacks permission. Display only — the server route builder's + * `authorization` block is the real gate. `Button` already renders its + * `tooltip` while disabled (it wraps the disabled button in a hoverable span), + * so we reuse that path. + */ +export const PermissionButton = forwardRef( + ({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => { + if (hasPermission) { + return + ) : null} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index ab216bcab7e..74f05ccda7d 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -2,7 +2,6 @@ import { parse } from "@conform-to/zod"; import { ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; import { tryCatch } from "@trigger.dev/core"; import { useEffect, useState } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; @@ -52,37 +51,58 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList"; +import { $replica } from "~/db.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; -export async function loader({ request, params }: LoaderFunctionArgs) { - const userId = await requireUserId(request); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "read", resource: { type: "runs" } }, + }, + async ({ request, params, user, ability }) => { + const { organizationSlug, projectParam, envParam } = params; - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const presenter = new CreateBulkActionPresenter(); + const data = await presenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + request, + }); - const presenter = new CreateBulkActionPresenter(); - const data = await presenter.call({ - organizationId: project.organizationId, - projectId: project.id, - environmentId: environment.id, - request, - }); + // Display flag for the inspector's Cancel/Replay controls — the action + // below enforces write:runs independently. + const { canCreateBulkAction } = checkPermissions(ability, { + canCreateBulkAction: { action: "write", resource: { type: "runs" } }, + }); - return typedjson(data); -} + return typedjson({ ...data, canCreateBulkAction }); + } +); export const CreateBulkActionSearchParams = z.object({ mode: BulkActionMode.default("filter"), @@ -112,67 +132,75 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ ]); export type CreateBulkActionPayload = z.infer; -export async function action({ params, request }: ActionFunctionArgs) { - const userId = await requireUserId(request); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "runs" } }, + }, + async ({ request, params, user }) => { + const { organizationSlug, projectParam, envParam } = params; - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: CreateBulkActionPayload }); - const formData = await request.formData(); - const submission = parse(formData, { schema: CreateBulkActionPayload }); + if (!submission.value) { + logger.error("Invalid bulk action", { + submission, + formData: Object.fromEntries(formData), + }); + return redirectWithErrorMessage("/", request, "Invalid bulk action"); + } - if (!submission.value) { - logger.error("Invalid bulk action", { - submission, - formData: Object.fromEntries(formData), - }); - return redirectWithErrorMessage("/", request, "Invalid bulk action"); - } + const service = new BulkActionService(); + const [error, result] = await tryCatch( + service.create( + project.organizationId, + project.id, + environment.id, + user.id, + submission.value, + request + ) + ); - const service = new BulkActionService(); - const [error, result] = await tryCatch( - service.create( - project.organizationId, - project.id, - environment.id, - userId, - submission.value, - request - ) - ); + if (error) { + logger.error("Failed to create bulk action", { + error, + }); - if (error) { - logger.error("Failed to create bulk action", { - error, - }); + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + `Failed to create bulk action: ${error.message}` + ); + } - return redirectWithErrorMessage( - submission.value.failedRedirect, + return redirectWithSuccessMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: result.bulkActionId } + ), request, - `Failed to create bulk action: ${error.message}` + "Bulk action started" ); } - - return redirectWithSuccessMessage( - v3BulkActionPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: result.bulkActionId } - ), - request, - "Bulk action started" - ); -} +); export function CreateBulkActionInspector({ filters, @@ -209,6 +237,9 @@ export function CreateBulkActionInspector({ const data = fetcher.data != null ? fetcher.data : undefined; + // Permissive while the fetcher is loading; the action enforces write:runs. + const canCreateBulkAction = data?.canCreateBulkAction ?? true; + const impactedCountElement = mode === "selected" ? selectedItems.size : ; @@ -369,7 +400,12 @@ export function CreateBulkActionInspector({ key: "enter", enabledOnInputElements: true, }} - disabled={impactedCountElement === 0 || isDialogOpen} + disabled={impactedCountElement === 0 || isDialogOpen || !canCreateBulkAction} + tooltip={ + canCreateBulkAction + ? undefined + : "You don't have permission to create bulk actions" + } > {action === "replay" ? ( Replay {impactedCountElement} runs… From dc118ebd2d49a5719c39726a261d4000dd388106 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 11 Jun 2026 14:39:47 +0100 Subject: [PATCH 05/26] fix(webapp): gate run-detail Replay and Cancel buttons on write:runs Surface write:runs as canReplayRun/canCancelRun from the run-detail loader (via the injected RBAC ability) and disable the Replay and Cancel controls with an explanatory tooltip when the role lacks it. Display only; the cancel/replay action routes are the enforcement boundary. --- .../route.tsx | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 45d2a06b15a..de23b935cd6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -104,6 +104,7 @@ import { getImpersonationId } from "~/services/impersonation.server"; import { logger } from "~/services/logger.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; import { cn } from "~/utils/cn"; import { lerp } from "~/utils/lerp"; import { @@ -189,7 +190,10 @@ async function getRunsListFromTableState({ return null; } - const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard"); + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); const runsListPresenter = new NextRunListPresenter($replica, clickhouse); const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { userId, @@ -253,6 +257,15 @@ async function getRunsListFromTableState({ } } +// Display-only write:runs flags for the Replay/Cancel controls. The cancel +// and replay action routes enforce write:runs independently; this mirrors the +// result so the buttons disable for roles that lack it. Permissive in OSS. +async function runWritePermissions(request: Request, userId: string, organizationId: string) { + const auth = await rbac.authenticateSession(request, { userId, organizationId }); + const canWriteRun = auth.ok ? auth.ability.can("write", { type: "runs" }) : true; + return { canReplayRun: canWriteRun, canCancelRun: canWriteRun }; +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const impersonationId = await getImpersonationId(request); @@ -318,11 +331,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Skip on `_data` requests (Remix data fetches): they're // client-driven follow-ups and the client URL is what matters, // not the loader's view of it. - if ( - !url.searchParams.has("span") && - !url.searchParams.has("_data") && - buffered.run.spanId - ) { + if (!url.searchParams.has("span") && !url.searchParams.has("_data") && buffered.run.spanId) { url.searchParams.set("span", buffered.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } @@ -336,6 +345,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS, resizable: { parent, tree }, runsList: null, + ...(await runWritePermissions(request, userId, buffered.run.environment.organizationId)), }); } @@ -347,11 +357,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // block in the buffered fallback above — the sibling redirect routes // do this, but direct navigation to the canonical project-scoped URL // never hits them, leaving the right detail panel collapsed. - if ( - !url.searchParams.has("span") && - !url.searchParams.has("_data") && - result.run.spanId - ) { + if (!url.searchParams.has("span") && !url.searchParams.has("_data") && result.run.spanId) { url.searchParams.set("span", result.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } @@ -378,6 +384,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { tree, }, runsList, + ...(await runWritePermissions(request, userId, result.run.environment.organizationId)), }); }; @@ -417,8 +424,15 @@ async function tryMollifiedRunFallback(args: { type LoaderData = SerializeFrom; export default function Page() { - const { run, trace, maximumLiveReloadingSetting, runsList, resizable } = - useLoaderData(); + const { + run, + trace, + maximumLiveReloadingSetting, + runsList, + resizable, + canReplayRun, + canCancelRun, + } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -500,6 +514,8 @@ export default function Page() { LeadingIcon={ArrowUturnLeftIcon} shortcut={{ key: "R" }} className="pr-2" + disabled={!canReplayRun} + tooltip={canReplayRun ? undefined : "You don't have permission to replay runs"} > Replay run @@ -518,6 +534,7 @@ export default function Page() { {run.isFinished ? null : ( - From c5f5a44c4d301e8e41cc29e050df849bc7e252b9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 12 Jun 2026 12:20:40 +0100 Subject: [PATCH 06/26] fix(webapp): make RBAC role assignment on invite accept non-fatal The setUserRole call in acceptInvite ran outside a try/catch, so a thrown error from the RBAC plugin escaped and turned the whole invite-accept into a 400 (the membership was already created in the transaction). Wrap it so both a returned {ok:false} and a thrown error are logged, including the stack, and never block joining the org. --- apps/webapp/app/models/member.server.ts | 34 ++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index b88fc7e11c0..09ceed523ef 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -227,19 +227,35 @@ export async function acceptInvite({ }; }); - // If the invite carried an explicit RBAC role. Errors are logged, not fatal. + // If the invite carried an explicit RBAC role, assign it. Best-effort: the + // invite is already consumed and membership created above, so a failure here + // — a returned {ok:false} or a thrown error from the plugin — must not block + // joining the org. Swallow and log either way; without the catch a plugin + // throw escapes and turns the whole invite-accept into a 400. if (result.rbacRoleId) { - const roleResult = await rbac.setUserRole({ - userId: user.id, - organizationId: result.organization.id, - roleId: result.rbacRoleId, - }); - if (!roleResult.ok) { - logger.error("acceptInvite: skipped RBAC role assignment", { + try { + const roleResult = await rbac.setUserRole({ + userId: user.id, + organizationId: result.organization.id, + roleId: result.rbacRoleId, + }); + if (!roleResult.ok) { + logger.error("acceptInvite: skipped RBAC role assignment", { + organizationId: result.organization.id, + userId: user.id, + rbacRoleId: result.rbacRoleId, + reason: roleResult.error, + }); + } + } catch (error) { + logger.error("acceptInvite: RBAC role assignment threw", { organizationId: result.organization.id, userId: user.id, rbacRoleId: result.rbacRoleId, - reason: roleResult.error, + error: + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : String(error), }); } } From 0280e01f623592a695b0c82fe2e5d7b9f8cc85da Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 12 Jun 2026 14:16:15 +0100 Subject: [PATCH 07/26] fix(webapp): enforce write:prompts / update:prompts on prompt detail route + UI Migrate the prompt detail action to dashboardAction and check the right permission per intent: promote -> update:prompts, create/edit/remove/ reactivate override -> write:prompts. Surface canPromote / canWritePrompts display flags from the loader (via the injected ability) and gate the Promote, Reactivate, Create override, Edit, and Remove buttons. Tenancy queries unchanged; permissive in OSS, enforced under the enterprise plugin. --- .../route.tsx | 224 +++++++++++------- 1 file changed, 140 insertions(+), 84 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx index 29753dd1133..254950b683a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx @@ -2,12 +2,7 @@ import * as Ariakit from "@ariakit/react"; import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { type MetaFunction, useFetcher } from "@remix-run/react"; -import { - type ActionFunctionArgs, - json, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { AnimatePresence, motion } from "framer-motion"; import { ClipboardCheckIcon, ClipboardIcon, GitBranchPlusIcon } from "lucide-react"; @@ -22,6 +17,7 @@ import { ProvidersFilter } from "~/components/metrics/ProvidersFilter"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionButton } from "~/components/primitives/PermissionButton"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Header3 } from "~/components/primitives/Headers"; @@ -60,7 +56,7 @@ import { Spinner } from "~/components/primitives/Spinner"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextArea } from "~/components/primitives/TextArea"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useInterval } from "~/hooks/useInterval"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -73,6 +69,9 @@ import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$pr import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { PromptService } from "~/v3/services/promptService.server"; import { z } from "zod"; @@ -122,85 +121,107 @@ const ActionSchema = z.discriminatedUnion("intent", [ }), ]); -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) return json({ error: "Project not found" }, { status: 404 }); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + }, + async ({ request, params, user, ability }) => { + const { organizationSlug, projectParam, envParam, promptSlug } = params; + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) return json({ error: "Project not found" }, { status: 404 }); + + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) return json({ error: "Environment not found" }, { status: 404 }); + + const formData = Object.fromEntries(await request.formData()); + const parsed = ActionSchema.safeParse(formData); + if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 }); + + const prompt = await prisma.prompt.findUnique({ + where: { + projectId_runtimeEnvironmentId_slug: { + projectId: project.id, + runtimeEnvironmentId: environment.id, + slug: promptSlug, + }, + }, + }); - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) return json({ error: "Environment not found" }, { status: 404 }); + if (!prompt) return json({ error: "Prompt not found" }, { status: 404 }); - const formData = Object.fromEntries(await request.formData()); - const parsed = ActionSchema.safeParse(formData); - if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 }); + const data = parsed.data; - const prompt = await prisma.prompt.findUnique({ - where: { - projectId_runtimeEnvironmentId_slug: { - projectId: project.id, - runtimeEnvironmentId: environment.id, - slug: promptSlug, - }, - }, - }); + // Promoting a version to production is `update:prompts`; creating or + // editing override versions is `write:prompts`. Check the right one per + // intent — a single authorization block can't express both. + const requiredAction = data.intent === "promote" ? "update" : "write"; + if (!ability.can(requiredAction, { type: "prompts" })) { + return json({ error: "Unauthorized" }, { status: 403 }); + } - if (!prompt) return json({ error: "Prompt not found" }, { status: 404 }); + const service = new PromptService(); - const data = parsed.data; - const service = new PromptService(); + if (data.intent === "promote") { + await service.promoteVersion(prompt.id, data.versionId); + return json({ ok: true }); + } - if (data.intent === "promote") { - await service.promoteVersion(prompt.id, data.versionId); - return json({ ok: true }); - } + const url = new URL(request.url); - const url = new URL(request.url); + if (data.intent === "saveVersion") { + const result = await service.createOverride(prompt.id, { + textContent: data.textContent ?? "", + model: data.model, + commitMessage: data.commitMessage, + source: "dashboard", + createdBy: user.id, + }); + url.searchParams.set("version", String(result.version)); + return redirect(url.pathname + url.search); + } - if (data.intent === "saveVersion") { - const result = await service.createOverride(prompt.id, { - textContent: data.textContent ?? "", - model: data.model, - commitMessage: data.commitMessage, - source: "dashboard", - createdBy: userId, - }); - url.searchParams.set("version", String(result.version)); - return redirect(url.pathname + url.search); - } + if (data.intent === "updateOverride") { + await service.updateOverride(prompt.id, { + textContent: data.textContent, + model: data.model, + commitMessage: data.commitMessage, + }); + return json({ ok: true }); + } - if (data.intent === "updateOverride") { - await service.updateOverride(prompt.id, { - textContent: data.textContent, - model: data.model, - commitMessage: data.commitMessage, - }); - return json({ ok: true }); - } + if (data.intent === "removeOverride") { + await service.removeOverride(prompt.id); + // Navigate back to current version + const currentVersion = await prisma.promptVersion.findFirst({ + where: { promptId: prompt.id, labels: { has: "current" } }, + select: { version: true }, + }); + if (currentVersion) { + url.searchParams.set("version", String(currentVersion.version)); + } else { + url.searchParams.delete("version"); + } + return redirect(url.pathname + url.search); + } - if (data.intent === "removeOverride") { - await service.removeOverride(prompt.id); - // Navigate back to current version - const currentVersion = await prisma.promptVersion.findFirst({ - where: { promptId: prompt.id, labels: { has: "current" } }, - select: { version: true }, - }); - if (currentVersion) { - url.searchParams.set("version", String(currentVersion.version)); - } else { - url.searchParams.delete("version"); + if (data.intent === "reactivateOverride") { + await service.reactivateOverride(prompt.id, data.versionId); + return json({ ok: true }); } - return redirect(url.pathname + url.search); - } - if (data.intent === "reactivateOverride") { - await service.reactivateOverride(prompt.id, data.versionId); - return json({ ok: true }); + return json({ error: "Unknown intent" }, { status: 400 }); } - - return json({ error: "Unknown intent" }, { status: 400 }); -} +); // ─── Loader ────────────────────────────────────────────── @@ -242,7 +263,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs); const endTime = toTime ? new Date(toTime) : new Date(); - const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard"); + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); const presenter = new PromptPresenter(clickhouse); let generations: Awaited>["generations"] = []; let generationsPagination: { next?: string } = {}; @@ -301,6 +325,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const possibleOperations = opsErr ? [] : opsRows.map((r) => r.val); const possibleProviders = provsErr ? [] : provsRows.map((r) => r.val); + // Display flags for the promote / override controls — the action enforces + // update:prompts and write:prompts independently. Permissive in OSS. + const promptAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const promptPermissions = promptAuth.ok + ? checkPermissions(promptAuth.ability, { + canWritePrompts: { action: "write", resource: { type: "prompts" } }, + canPromote: { action: "update", resource: { type: "prompts" } }, + }) + : { canWritePrompts: true, canPromote: true }; + return typedjson({ resizable: { outer: resizableOuter, @@ -353,6 +390,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { possibleModels, possibleOperations, possibleProviders, + ...promptPermissions, }); }; @@ -437,6 +475,8 @@ export default function PromptDetailPage() { possibleModels, possibleOperations, possibleProviders, + canWritePrompts, + canPromote, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -518,18 +558,22 @@ export default function PromptDetailPage() { )} {selectedVersion && !isCurrent && selectedVersion.source === "code" && ( - + )} {selectedVersion && selectedVersion.source !== "code" && !selectedVersion.labels.includes("override") && ( - + )} {!overrideVersion && ( - + )} @@ -565,21 +614,25 @@ export default function PromptDetailPage() { instead of the deployed prompt.
- - +
)} @@ -1502,7 +1555,10 @@ function GenerationsTab({ {gen.operation_id || gen.task_identifier} v{gen.prompt_version} From 0cdd98eecff0adc4789b79d82c022a44a658b288 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 12 Jun 2026 14:22:15 +0100 Subject: [PATCH 08/26] fix(webapp): enforce manage:members on invite/resend/revoke routes + UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the invite, invite-resend, and invite-revoke routes to dashboardLoader/dashboardAction with a manage:members authorization block. The resend/revoke routes have no URL params, so the org for the auth scope is resolved from the form body (read via a cloned request) — from the invite's organization (resend) or the slug field (revoke). Gate the Resend/Revoke buttons on the team page with the existing canManageMembers flag. Existing tenancy/inviter checks in the model layer are unchanged. --- .../route.tsx | 277 +++++++++--------- .../route.tsx | 22 +- apps/webapp/app/routes/invite-resend.tsx | 89 +++--- apps/webapp/app/routes/invite-revoke.tsx | 65 ++-- 4 files changed, 248 insertions(+), 205 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index f77c19ffbdd..e2c6fe2f531 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -6,13 +6,11 @@ import { LockOpenIcon, UserPlusIcon, } from "@heroicons/react/20/solid"; -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { Fragment, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import simplur from "simplur"; -import invariant from "tiny-invariant"; import { z } from "zod"; import { MainCenteredContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -34,7 +32,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; import { rbac } from "~/services/rbac.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; @@ -42,55 +40,63 @@ const Params = z.object({ organizationSlug: z.string(), }); -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = Params.parse(params); - - const organization = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - if (!organization) { - throw new Response("Not Found", { status: 404 }); - } +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "members" } }, + }, + async ({ user, context }) => { + const organizationId = context.organizationId; + if (!organizationId) { + throw new Response("Not Found", { status: 404 }); + } + const userId = user.id; - const presenter = new TeamPresenter(); - const result = await presenter.call({ - userId, - organizationId: organization.id, - }); + const presenter = new TeamPresenter(); + const result = await presenter.call({ + userId, + organizationId, + }); - if (!result) { - throw new Response("Not Found", { status: 404 }); - } + if (!result) { + throw new Response("Not Found", { status: 404 }); + } - // Inviter's own role drives the "below their level" filter on the - // dropdown. Plus assignable role IDs already encode the org's plan - // tier — the intersection is what we offer. - const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ - rbac.getUserRole({ userId, organizationId: organization.id }), - rbac.getAssignableRoleIds(organization.id), - rbac.systemRoles(organization.id), - ]); + // Inviter's own role drives the "below their level" filter on the + // dropdown. Plus assignable role IDs already encode the org's plan + // tier — the intersection is what we offer. + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId }), + rbac.getAssignableRoleIds(organizationId), + rbac.systemRoles(organizationId), + ]); - // Build the dropdown's offerable set server-side: roles that are - // (a) assignable on the current plan AND (b) at or below the - // inviter's own level. The client just renders these — it doesn't - // need to know about the system-role catalogue or the ladder. - const assignableSet = new Set(assignableRoleIds); - const offerableRoleIds = systemRoles - ? result.roles - .filter( - (r) => - assignableSet.has(r.id) && - isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) - ) - .map((r) => r.id) - : []; + // Build the dropdown's offerable set server-side: roles that are + // (a) assignable on the current plan AND (b) at or below the + // inviter's own level. The client just renders these — it doesn't + // need to know about the system-role catalogue or the ladder. + const assignableSet = new Set(assignableRoleIds); + const offerableRoleIds = systemRoles + ? result.roles + .filter( + (r) => + assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) + ) + .map((r) => r.id) + : []; - return typedjson({ ...result, offerableRoleIds }); -}; + return typedjson({ ...result, offerableRoleIds }); + } +); // Sentinel for "no RBAC role attached to invite" — the runtime // fallback will derive a role from the legacy OrgMember.role write at @@ -153,101 +159,101 @@ const schema = z.object({ rbacRoleId: z.string().optional(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug } = params; - invariant(organizationSlug, "organizationSlug is required"); - - const formData = await request.formData(); - const submission = parse(formData, { schema }); +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "members" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug } = params; - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema }); - // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown - // role → don't pass one through; the runtime fallback handles it. - // Validation: the chosen role must be in the org's assignable set - // (plan-tier) and at or below the inviter's own level. - let resolvedRbacRoleId: string | null = null; - const submittedRbacRoleId = submission.value.rbacRoleId; - if ( - submittedRbacRoleId && - submittedRbacRoleId !== NO_RBAC_ROLE - ) { - const org = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); - if (!org) { - return json({ errors: { body: "Organization not found" } }, { status: 404 }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); } - const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ - rbac.getUserRole({ userId, organizationId: org.id }), - rbac.getAssignableRoleIds(org.id), - rbac.systemRoles(org.id), - ]); - if (!systemRoles) { - // No plugin installed but the form somehow submitted a role id — - // ignore it (fall through to legacy behaviour rather than 400). - resolvedRbacRoleId = null; - } else { - const assignable = new Set(assignableRoleIds); - if (!assignable.has(submittedRbacRoleId)) { - return json( - { errors: { body: "You can't invite someone with this role on your current plan" } }, - { status: 400 } - ); + + // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown + // role → don't pass one through; the runtime fallback handles it. + // Validation: the chosen role must be in the org's assignable set + // (plan-tier) and at or below the inviter's own level. + let resolvedRbacRoleId: string | null = null; + const submittedRbacRoleId = submission.value.rbacRoleId; + if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) { + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); + if (!org) { + return json({ errors: { body: "Organization not found" } }, { status: 404 }); } - if ( - !isAtOrBelow( - systemRoles, - inviterRole?.id ?? null, - submittedRbacRoleId - ) - ) { - return json( - { errors: { body: "You can only invite members at or below your own role" } }, - { status: 403 } - ); + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: org.id }), + rbac.getAssignableRoleIds(org.id), + rbac.systemRoles(org.id), + ]); + if (!systemRoles) { + // No plugin installed but the form somehow submitted a role id — + // ignore it (fall through to legacy behaviour rather than 400). + resolvedRbacRoleId = null; + } else { + const assignable = new Set(assignableRoleIds); + if (!assignable.has(submittedRbacRoleId)) { + return json( + { errors: { body: "You can't invite someone with this role on your current plan" } }, + { status: 400 } + ); + } + if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) { + return json( + { errors: { body: "You can only invite members at or below your own role" } }, + { status: 403 } + ); + } + resolvedRbacRoleId = submittedRbacRoleId; } - resolvedRbacRoleId = submittedRbacRoleId; } - } - try { - const invites = await inviteMembers({ - slug: organizationSlug, - emails: submission.value.emails, - userId, - rbacRoleId: resolvedRbacRoleId, - }); + try { + const invites = await inviteMembers({ + slug: organizationSlug, + emails: submission.value.emails, + userId, + rbacRoleId: resolvedRbacRoleId, + }); - for (const invite of invites) { - try { - await scheduleEmail({ - email: "invite", - to: invite.email, - orgName: invite.organization.title, - inviterName: invite.inviter.name ?? undefined, - inviterEmail: invite.inviter.email, - inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, - }); - } catch (error) { - console.error("Failed to send invite email"); - console.error(error); + for (const invite of invites) { + try { + await scheduleEmail({ + email: "invite", + to: invite.email, + orgName: invite.organization.title, + inviterName: invite.inviter.name ?? undefined, + inviterEmail: invite.inviter.email, + inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + }); + } catch (error) { + console.error("Failed to send invite email"); + console.error(error); + } } - } - return redirectWithSuccessMessage( - organizationTeamPath(invites[0].organization), - request, - simplur`${submission.value.emails.length} member[|s] invited` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + return redirectWithSuccessMessage( + organizationTeamPath(invites[0].organization), + request, + simplur`${submission.value.emails.length} member[|s] invited` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } } -}; +); export default function Page() { const { @@ -274,9 +280,7 @@ export default function Page() { // Default to the lowest-tier offered role (the loader returns roles // in its allRoles order, which the plugin emits Owner→Member; the // last entry is the most restrictive). - const defaultRoleId = showRolePicker - ? offerable[offerable.length - 1].id - : NO_RBAC_ROLE; + const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE; const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId); const [form, { emails }] = useForm({ @@ -386,9 +390,7 @@ export default function Page() { items={offerable} variant="tertiary/medium" dropdownIcon - text={(v) => - offerable.find((r) => r.id === v)?.name ?? "Pick a role" - } + text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"} setValue={(next) => { if (typeof next === "string") setSelectedRoleId(next); }} @@ -402,8 +404,7 @@ export default function Page() { } - Invitees join with this role. They can be promoted later - from the Team page. + Invitees join with this role. They can be promoted later from the Team page. ) : null} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index f9e7c0b0ee1..8ebcfb80f29 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -423,8 +423,8 @@ export default function Page() {
- - + +
))} @@ -772,7 +772,7 @@ function initialCooldown(updatedAt: Date | string): number { return remaining > 0 ? remaining : 0; } -function ResendButton({ invite }: { invite: Invite }) { +function ResendButton({ invite, canManageMembers }: { invite: Invite; canManageMembers: boolean }) { const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting" && @@ -806,12 +806,17 @@ function ResendButton({ invite }: { invite: Invite }) { return () => clearInterval(intervalRef.current); }, [cooldownActive]); - const isDisabled = isSubmitting || cooldown > 0; + const isDisabled = isSubmitting || cooldown > 0 || !canManageMembers; return (
- - - - - )} - {run.isReplayable && ( - - + Cancel run + + + + + ) : ( + - - - - )} + + + + + ) : ( + + ))} } hiddenButtons={ <> - {run.isCancellable && ( + {run.isCancellable && canCancelRuns && ( @@ -617,10 +670,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) { disableHoverableContent /> )} - {run.isCancellable && run.isReplayable && ( + {run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
)} - {run.isReplayable && ( + {run.isReplayable && canReplayRuns && ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 91a3f083e48..a84e445539d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -36,6 +36,7 @@ import { PageBody } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; import { LinkButton } from "~/components/primitives/Buttons"; +import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Callout } from "~/components/primitives/Callout"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; @@ -74,6 +75,8 @@ import { import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser, requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, @@ -282,6 +285,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ) .catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] })); + // Display flags for the row-menu and bulk-replay controls — the cancel/ + // replay action routes enforce write:runs independently. Permissive in OSS. + const runAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const runPermissions = runAuth.ok + ? checkPermissions(runAuth.ability, { + canCancelRuns: { action: "write", resource: { type: "runs" } }, + canReplayRuns: { action: "write", resource: { type: "runs" } }, + }) + : { canCancelRuns: true, canReplayRuns: true }; + return typeddefer({ data: detailPromise, activity: activityPromise, @@ -289,12 +305,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectParam, envParam, fingerprint, + ...runPermissions, }); }; export default function Page() { - const { data, activity, organizationSlug, projectParam, envParam, fingerprint } = - useTypedLoaderData(); + const { + data, + activity, + organizationSlug, + projectParam, + envParam, + fingerprint, + canCancelRuns, + canReplayRuns, + } = useTypedLoaderData(); const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); @@ -387,6 +412,8 @@ export default function Page() { projectParam={projectParam} envParam={envParam} fingerprint={fingerprint} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ); }} @@ -405,6 +432,8 @@ function ErrorGroupDetail({ projectParam, envParam, fingerprint, + canCancelRuns, + canReplayRuns, }: { errorGroup: ErrorGroupSummary | undefined; runList: NextRunList | undefined; @@ -413,6 +442,8 @@ function ErrorGroupDetail({ projectParam: string; envParam: string; fingerprint: string; + canCancelRuns: boolean; + canReplayRuns: boolean; }) { const { value, values } = useSearchParams(); const organization = useOrganization(); @@ -482,7 +513,9 @@ function ErrorGroupDetail({ > View all runs - Bulk replay… - +
)} @@ -515,6 +548,8 @@ function ErrorGroupDetail({ isLoading={false} variant="dimmed" additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ) : (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 78c60904a6b..f00a4548167 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -55,6 +55,8 @@ import { uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { docsPath, @@ -67,7 +69,11 @@ import { throwNotFound } from "~/utils/httpErrors"; import { ListPagination } from "../../components/ListPagination"; import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; import { Callout } from "~/components/primitives/Callout"; -import { isRunsListLoading, RUNS_BULK_INSPECTOR_OPEN_VALUE, shouldRevalidateRunsList } from "./shouldRevalidateRunsList"; +import { + isRunsListLoading, + RUNS_BULK_INSPECTOR_OPEN_VALUE, + shouldRevalidateRunsList, +} from "./shouldRevalidateRunsList"; import { useRunsLiveReload } from "./useRunsLiveReload"; export { shouldRevalidateRunsList as shouldRevalidate }; @@ -120,18 +126,33 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } : undefined; + // Display flags for the row-menu and bulk-action controls — the cancel/ + // replay action routes enforce write:runs independently. Permissive in OSS. + const runAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const runPermissions = runAuth.ok + ? checkPermissions(runAuth.ability, { + canCancelRuns: { action: "write", resource: { type: "runs" } }, + canReplayRuns: { action: "write", resource: { type: "runs" } }, + }) + : { canCancelRuns: true, canReplayRuns: true }; + return typeddefer( { data: list, rootOnlyDefault: filters.rootOnly, filters, + ...runPermissions, }, headers ? { headers } : undefined ); }; export default function Page() { - const { data, rootOnlyDefault, filters } = useTypedLoaderData(); + const { data, rootOnlyDefault, filters, canCancelRuns, canReplayRuns } = + useTypedLoaderData(); const { isConnected } = useDevPresence(); const project = useProject(); const environment = useEnvironment(); @@ -190,6 +211,8 @@ export default function Page() { selectedItems={selectedItems} rootOnlyDefault={rootOnlyDefault} filters={filters} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ); }} @@ -207,11 +230,15 @@ function RunsList({ selectedItems, rootOnlyDefault, filters, + canCancelRuns, + canReplayRuns, }: { list: Awaited["data"]>; selectedItems: Set; rootOnlyDefault: boolean; filters: TaskRunListSearchFilters; + canCancelRuns: boolean; + canReplayRuns: boolean; }) { const revalidator = useRevalidator(); const location = useLocation(); @@ -245,9 +272,10 @@ function RunsList({ revalidator.revalidate(); }; - // Shortcut keys for bulk actions + // Shortcut keys for bulk actions — disabled when the role can't perform them. useShortcutKeys({ shortcut: { key: "r" }, + disabled: !canReplayRuns, action: (e) => { replace({ bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE, @@ -258,6 +286,7 @@ function RunsList({ }); useShortcutKeys({ shortcut: { key: "c" }, + disabled: !canCancelRuns, action: (e) => { replace({ bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE, @@ -272,8 +301,7 @@ function RunsList({ !isShowingBulkActionInspector ); // Keep content mounted until onCollapseChange reports the panel is fully collapsed. - const showBulkInspectorContent = - isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed; + const showBulkInspectorContent = isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed; return ( @@ -326,7 +354,7 @@ function RunsList({ {/* Stay mounted while the inspector is open to avoid toolbar layout shift. */} - - - - )} - {canBePromoted && ( - - - - - - - )} - {canBeCanceled && ( - - - - - - - )} + {canBeRolledBack && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} + {canBePromoted && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} + {canBeCanceled && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} } /> diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts index c802d115ad1..ffc7e4315c6 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -1,11 +1,11 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { errAsync, fromPromise, okAsync } from "neverthrow"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { DeploymentService } from "~/v3/services/deployment.server"; export const cancelSchema = z.object({ @@ -17,117 +17,142 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - const formData = await request.formData(); - const submission = parse(formData, { schema: cancelSchema }); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; + const userId = user.id; - if (!submission.value) { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: cancelSchema }); + + if (!submission.value) { + return json(submission); + } - const verifyProjectMembership = () => - fromPromise( - prisma.project.findFirst({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + const verifyProjectMembership = () => + fromPromise( + prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }), - (error) => ({ type: "other" as const, cause: error }) - ).andThen((project) => { - if (!project) { - return errAsync({ type: "project_not_found" as const }); - } - return okAsync(project); - }); + select: { + id: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((project) => { + if (!project) { + return errAsync({ type: "project_not_found" as const }); + } + return okAsync(project); + }); - const findDeploymentFriendlyId = ({ id }: { id: string }) => - fromPromise( - prisma.workerDeployment.findUnique({ - select: { - friendlyId: true, - projectId: true, - }, - where: { - projectId_shortCode: { - projectId: id, - shortCode: deploymentShortCode, + const findDeploymentFriendlyId = ({ id }: { id: string }) => + fromPromise( + prisma.workerDeployment.findUnique({ + select: { + friendlyId: true, + projectId: true, }, - }, - }), - (error) => ({ type: "other" as const, cause: error }) - ).andThen((deployment) => { - if (!deployment) { - return errAsync({ type: "deployment_not_found" as const }); - } - return okAsync(deployment); - }); + where: { + projectId_shortCode: { + projectId: id, + shortCode: deploymentShortCode, + }, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((deployment) => { + if (!deployment) { + return errAsync({ type: "deployment_not_found" as const }); + } + return okAsync(deployment); + }); - const deploymentService = new DeploymentService(); - const result = await verifyProjectMembership() - .andThen(findDeploymentFriendlyId) - .andThen((deployment) => - deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId) - ); + const deploymentService = new DeploymentService(); + const result = await verifyProjectMembership() + .andThen(findDeploymentFriendlyId) + .andThen((deployment) => + deploymentService.cancelDeployment( + { projectId: deployment.projectId }, + deployment.friendlyId + ) + ); - if (result.isErr()) { - logger.error( - `Failed to cancel deployment: ${result.error.type}`, - result.error.type === "other" - ? { - cause: result.error.cause, - } - : undefined - ); + if (result.isErr()) { + logger.error( + `Failed to cancel deployment: ${result.error.type}`, + result.error.type === "other" + ? { + cause: result.error.cause, + } + : undefined + ); - switch (result.error.type) { - case "project_not_found": - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - case "deployment_not_found": - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment not found" - ); - case "deployment_cannot_be_cancelled": - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment is already in a final state and cannot be canceled" - ); - case "failed_to_delete_deployment_timeout": - // not a critical error, ignore - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Canceled deployment ${deploymentShortCode}.` - ); - case "other": - default: - result.error.type satisfies "other"; - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Internal server error" - ); + switch (result.error.type) { + case "project_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Project not found" + ); + case "deployment_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + case "deployment_cannot_be_cancelled": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment is already in a final state and cannot be canceled" + ); + case "failed_to_delete_deployment_timeout": + // not a critical error, ignore + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); + case "other": + default: + result.error.type satisfies "other"; + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Internal server error" + ); + } } - } - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Canceled deployment ${deploymentShortCode}.` - ); -}; + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); + } +); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts index 1d96df89d1e..f5c5cbc6001 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts @@ -1,10 +1,10 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const promoteSchema = z.object({ @@ -16,75 +16,90 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - const formData = await request.formData(); - const submission = parse(formData, { schema: promoteSchema }); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; - if (!submission.value) { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: promoteSchema }); - try { - const project = await prisma.project.findUnique({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + if (!submission.value) { + return json(submission); + } + + try { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, - }, - }); + }); - if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - } + if (!project) { + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + } - const deployment = await prisma.workerDeployment.findUnique({ - where: { - projectId_shortCode: { + const deployment = await prisma.workerDeployment.findFirst({ + where: { projectId: project.id, shortCode: deploymentShortCode, }, - }, - }); + }); + + if (!deployment) { + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + } + + const promoteService = new ChangeCurrentDeploymentService(); + await promoteService.call(deployment, "promote"); - if (!deployment) { - return redirectWithErrorMessage( + return redirectWithSuccessMessage( submission.value.redirectUrl, request, - "Deployment not found" + `Promoted deployment version ${deployment.version} to current.` ); - } - - const promoteService = new ChangeCurrentDeploymentService(); - await promoteService.call(deployment, "promote"); - - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Promoted deployment version ${deployment.version} to current.` - ); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to promote deployment", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }); - submission.error = { runParam: [error.message] }; - return json(submission); - } else { - logger.error("Failed to promote deployment", { error }); - submission.error = { runParam: [JSON.stringify(error)] }; - return json(submission); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to promote deployment", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + submission.error = { runParam: [error.message] }; + return json(submission); + } else { + logger.error("Failed to promote deployment", { error }); + submission.error = { runParam: [JSON.stringify(error)] }; + return json(submission); + } } } -}; +); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts index 9995ba4c063..5b81aeaf957 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts @@ -1,10 +1,10 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const rollbackSchema = z.object({ @@ -16,78 +16,90 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - console.log("projectId", projectId); - console.log("deploymentShortCode", deploymentShortCode); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; - const formData = await request.formData(); - const submission = parse(formData, { schema: rollbackSchema }); + const formData = await request.formData(); + const submission = parse(formData, { schema: rollbackSchema }); - if (!submission.value) { - return json(submission); - } + if (!submission.value) { + return json(submission); + } - try { - const project = await prisma.project.findUnique({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + try { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, - }, - }); + }); - if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - } + if (!project) { + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + } - const deployment = await prisma.workerDeployment.findUnique({ - where: { - projectId_shortCode: { + const deployment = await prisma.workerDeployment.findFirst({ + where: { projectId: project.id, shortCode: deploymentShortCode, }, - }, - }); + }); + + if (!deployment) { + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + } + + const rollbackService = new ChangeCurrentDeploymentService(); + await rollbackService.call(deployment, "rollback"); - if (!deployment) { - return redirectWithErrorMessage( + return redirectWithSuccessMessage( submission.value.redirectUrl, request, - "Deployment not found" + "Rolled back deployment" ); - } - - const rollbackService = new ChangeCurrentDeploymentService(); - await rollbackService.call(deployment, "rollback"); - - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - "Rolled back deployment" - ); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to roll back deployment", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }); - submission.error = { runParam: [error.message] }; - return json(submission); - } else { - logger.error("Failed to roll back deployment", { error }); - submission.error = { runParam: [JSON.stringify(error)] }; - return json(submission); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to roll back deployment", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + submission.error = { runParam: [error.message] }; + return json(submission); + } else { + logger.error("Failed to roll back deployment", { error }); + submission.error = { runParam: [JSON.stringify(error)] }; + return json(submission); + } } } -}; +); From e3d78f4e70cb4491c9458a8c0669fa68be3cbd89 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 15 Jun 2026 16:22:26 +0100 Subject: [PATCH 14/26] fix(webapp): enforce write:github on the GitHub integration route Migrate the GitHub settings resource-route action (connect-repo / disconnect-repo / update-git-settings) to dashboardAction with a write:github authorization block, and surface canManageGithub from the loader for UI gating. Project membership checks unchanged; permissive in OSS. --- ...cts.$projectParam.env.$envParam.github.tsx | 307 ++++++++++-------- 1 file changed, 173 insertions(+), 134 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index fe1b32f8925..d9001e74887 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -1,10 +1,16 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { redirect, -typedjson, useTypedFetcher } from "remix-typedjson"; +import { + Form, + useActionData, + useNavigation, + useNavigate, + useSearchParams, + useLocation, +} from "@remix-run/react"; +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { redirect, typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; @@ -43,6 +49,9 @@ import { logger } from "~/services/logger.server"; import { triggerInitialDeployment } from "~/services/platform.v3.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { requireUserId } from "~/services/session.server"; +import { $replica } from "~/db.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { githubAppInstallPath, EnvironmentParamSchema, @@ -141,7 +150,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Failed to load GitHub settings", { status: 500 }); } - return typedjson(resultOrFail.value); + // Display flag for the connect/disconnect/configure controls — the action + // enforces write:github independently. Permissive in OSS. + const sessionAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const canManageGithub = sessionAuth.ok + ? sessionAuth.ability.can("write", { type: "github" }) + : true; + + return typedjson({ ...resultOrFail.value, canManageGithub }); } // ============================================================================ @@ -164,170 +183,188 @@ function redirectWithMessage( : redirectBackWithErrorMessage(request, message); } -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "github" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const formData = await request.formData(); - const submission = parse(formData, { schema: GitHubActionSchema }); + const formData = await request.formData(); + const submission = parse(formData, { schema: GitHubActionSchema }); - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); - } + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } - const { projectId, organizationId } = membershipResultOrFail.value; - const { action: actionType } = submission.value; + const { projectId, organizationId } = membershipResultOrFail.value; + const { action: actionType } = submission.value; - // Handle connect-repo action - if (actionType === "connect-repo") { - const { repositoryId, installationId, redirectUrl } = submission.value; + // Handle connect-repo action + if (actionType === "connect-repo") { + const { repositoryId, installationId, redirectUrl } = submission.value; - const resultOrFail = await projectSettingsService.connectGitHubRepo( - projectId, - organizationId, - repositoryId, - installationId - ); + const resultOrFail = await projectSettingsService.connectGitHubRepo( + projectId, + organizationId, + repositoryId, + installationId + ); - if (resultOrFail.isOk()) { - // Trigger initial deployment for marketplace flows now that GitHub is connected. - // We check the persisted onboardingOrigin on the Vercel integration rather than - // the redirectUrl, because the redirect URL loses the marketplace context when - // the user installs the GitHub App for the first time (full-page redirect cycle). - try { - const vercelService = new VercelIntegrationService(); - const vercelIntegration = await vercelService.getVercelProjectIntegration(projectId); - if ( - vercelIntegration?.parsedIntegrationData.onboardingCompleted && - vercelIntegration.parsedIntegrationData.onboardingOrigin === "marketplace" - ) { - logger.info("Marketplace flow detected, triggering initial deployment", { projectId }); - await triggerInitialDeployment(projectId, { environment: "prod" }); + if (resultOrFail.isOk()) { + // Trigger initial deployment for marketplace flows now that GitHub is connected. + // We check the persisted onboardingOrigin on the Vercel integration rather than + // the redirectUrl, because the redirect URL loses the marketplace context when + // the user installs the GitHub App for the first time (full-page redirect cycle). + try { + const vercelService = new VercelIntegrationService(); + const vercelIntegration = await vercelService.getVercelProjectIntegration(projectId); + if ( + vercelIntegration?.parsedIntegrationData.onboardingCompleted && + vercelIntegration.parsedIntegrationData.onboardingOrigin === "marketplace" + ) { + logger.info("Marketplace flow detected, triggering initial deployment", { projectId }); + await triggerInitialDeployment(projectId, { environment: "prod" }); + } + } catch (error) { + logger.error("Failed to check Vercel integration or trigger initial deployment", { + projectId, + error, + }); } - } catch (error) { - logger.error("Failed to check Vercel integration or trigger initial deployment", { projectId, error }); + + return redirectWithMessage( + request, + redirectUrl, + "GitHub repository connected successfully", + "success" + ); } - return redirectWithMessage( - request, - redirectUrl, - "GitHub repository connected successfully", - "success" - ); - } + const errorType = resultOrFail.error.type; - const errorType = resultOrFail.error.type; + if (errorType === "gh_repository_not_found") { + return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error"); + } - if (errorType === "gh_repository_not_found") { - return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error"); - } + if (errorType === "project_already_has_connected_repository") { + return redirectWithMessage( + request, + redirectUrl, + "Project already has a connected repository", + "error" + ); + } - if (errorType === "project_already_has_connected_repository") { + logger.error("Failed to connect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, - "Project already has a connected repository", + "Failed to connect GitHub repository", "error" ); } - logger.error("Failed to connect GitHub repository", { error: resultOrFail.error }); - return redirectWithMessage( - request, - redirectUrl, - "Failed to connect GitHub repository", - "error" - ); - } + // Handle disconnect-repo action + if (actionType === "disconnect-repo") { + const { redirectUrl } = submission.value; - // Handle disconnect-repo action - if (actionType === "disconnect-repo") { - const { redirectUrl } = submission.value; + const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); - const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); + if (resultOrFail.isOk()) { + return redirectWithMessage( + request, + redirectUrl, + "GitHub repository disconnected successfully", + "success" + ); + } - if (resultOrFail.isOk()) { + logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, - "GitHub repository disconnected successfully", - "success" + "Failed to disconnect GitHub repository", + "error" ); } - logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error }); - return redirectWithMessage( - request, - redirectUrl, - "Failed to disconnect GitHub repository", - "error" - ); - } + // Handle update-git-settings action + if (actionType === "update-git-settings") { + const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = + submission.value; - // Handle update-git-settings action - if (actionType === "update-git-settings") { - const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = - submission.value; + const resultOrFail = await projectSettingsService.updateGitSettings( + projectId, + productionBranch, + stagingBranch, + previewDeploymentsEnabled + ); - const resultOrFail = await projectSettingsService.updateGitSettings( - projectId, - productionBranch, - stagingBranch, - previewDeploymentsEnabled - ); + if (resultOrFail.isOk()) { + return redirectWithMessage( + request, + redirectUrl, + "Git settings updated successfully", + "success" + ); + } - if (resultOrFail.isOk()) { - return redirectWithMessage( - request, - redirectUrl, - "Git settings updated successfully", - "success" - ); - } + const errorType = resultOrFail.error.type; - const errorType = resultOrFail.error.type; + const errorMessages: Record = { + github_app_not_enabled: "GitHub app is not enabled", + connected_gh_repository_not_found: "Connected GitHub repository not found", + production_tracking_branch_not_found: "Production tracking branch not found", + staging_tracking_branch_not_found: "Staging tracking branch not found", + }; - const errorMessages: Record = { - github_app_not_enabled: "GitHub app is not enabled", - connected_gh_repository_not_found: "Connected GitHub repository not found", - production_tracking_branch_not_found: "Production tracking branch not found", - staging_tracking_branch_not_found: "Staging tracking branch not found", - }; + const message = errorMessages[errorType]; + if (message) { + return redirectWithMessage(request, redirectUrl, message, "error"); + } - const message = errorMessages[errorType]; - if (message) { - return redirectWithMessage(request, redirectUrl, message, "error"); + logger.error("Failed to update Git settings", { error: resultOrFail.error }); + return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error"); } - logger.error("Failed to update Git settings", { error: resultOrFail.error }); - return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error"); + // Exhaustive check - this should never be reached + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } - - // Exhaustive check - this should never be reached - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); -} +); // ============================================================================ // Helper: Build resource URL for fetching GitHub data @@ -587,8 +624,13 @@ export function GitHubConnectionPrompt({ environmentSlug: string; redirectUrl?: string; }) { - - const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); + const githubInstallationRedirect = + redirectUrl || + v3ProjectSettingsIntegrationsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ); return (
@@ -920,11 +962,8 @@ export function GitHubSettingsPanel({ redirectUrl={effectiveRedirectUrl} /> {!data.connectedRepository && ( - - Connect your GitHub repository to automatically deploy your changes. - + Connect your GitHub repository to automatically deploy your changes. )}
- ); } From 383cbf553f53dd5571817bb1bf43448d59a3ebe2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 15 Jun 2026 16:35:56 +0100 Subject: [PATCH 15/26] fix(webapp): gate GitHub integration UI + install entry on write:github Gate the GitHub settings panel controls (Install / Connect repo / Disconnect / Save) on the canManageGithub flag, and wrap the GitHub app install entry route in dashboardLoader with a write:github authorization block (org resolved from the org_slug query param). Membership queries unchanged; permissive in OSS. --- .../app/routes/_app.github.install/route.tsx | 77 +++++++++++-------- ...cts.$projectParam.env.$envParam.github.tsx | 47 +++++++++-- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/routes/_app.github.install/route.tsx b/apps/webapp/app/routes/_app.github.install/route.tsx index 42d68e5bec1..47eaca68c33 100644 --- a/apps/webapp/app/routes/_app.github.install/route.tsx +++ b/apps/webapp/app/routes/_app.github.install/route.tsx @@ -1,9 +1,8 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "remix-typedjson"; import { z } from "zod"; import { $replica } from "~/db.server"; import { createGitHubAppInstallSession } from "~/services/gitHubSession.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { newOrganizationPath } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,38 +14,54 @@ const QuerySchema = z.object({ }), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const searchParams = new URL(request.url).searchParams; - const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - if (!parsed.success) { - logger.warn("GitHub App installation redirect with invalid params", { - searchParams, - error: parsed.error, - }); - throw redirect("/"); - } +export const loader = dashboardLoader( + { + // The org for the auth scope comes from the `org_slug` query param. + context: async (_params, request) => { + const orgSlug = new URL(request.url).searchParams.get("org_slug"); + if (!orgSlug) return {}; + const organizationId = await resolveOrgIdFromSlug(orgSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "github" } }, + }, + async ({ request, user }) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); - const { org_slug, redirect_to } = parsed.data; - const user = await requireUser(request); + if (!parsed.success) { + logger.warn("GitHub App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } - const org = await $replica.organization.findFirst({ - where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, - }, - }); + const { org_slug, redirect_to } = parsed.data; - if (!org) { - throw redirect(newOrganizationPath()); - } + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); - const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); + if (!org) { + throw redirect(newOrganizationPath()); + } - return redirect(url, { - headers: { - "Set-Cookie": cookieHeader, - }, - }); -}; + const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); + + return redirect(url, { + headers: { + "Set-Cookie": cookieHeader, + }, + }); + } +); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index d9001e74887..3098b2c1cbe 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -16,6 +16,7 @@ import { OctoKitty } from "~/components/GitHubLoginButton"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { DialogClose } from "@radix-ui/react-dialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -389,6 +390,7 @@ export function ConnectGitHubRepoModal({ environmentSlug, redirectUrl, preventDismiss, + canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; @@ -397,6 +399,7 @@ export function ConnectGitHubRepoModal({ redirectUrl?: string; /** When true, prevents closing the modal via Escape key or clicking outside */ preventDismiss?: boolean; + canManageGithub?: boolean; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -457,7 +460,17 @@ export function ConnectGitHubRepoModal({ }} > - @@ -617,12 +630,14 @@ export function GitHubConnectionPrompt({ projectSlug, environmentSlug, redirectUrl, + canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; + canManageGithub?: boolean; }) { const githubInstallationRedirect = redirectUrl || @@ -635,7 +650,9 @@ export function GitHubConnectionPrompt({
{gitHubAppInstallations.length === 0 && ( - Install GitHub app - + )} {gitHubAppInstallations.length !== 0 && (
@@ -654,6 +671,7 @@ export function GitHubConnectionPrompt({ projectSlug={projectSlug} environmentSlug={environmentSlug} redirectUrl={redirectUrl} + canManageGithub={canManageGithub} /> GitHub app is installed @@ -673,6 +691,7 @@ export function ConnectedGitHubRepoForm({ environmentSlug, billingPath, redirectUrl, + canManageGithub = true, }: { connectedGitHubRepo: ConnectedGitHubRepo; previewEnvironmentEnabled?: boolean; @@ -681,6 +700,7 @@ export function ConnectedGitHubRepoForm({ environmentSlug: string; billingPath: string; redirectUrl?: string; + canManageGithub?: boolean; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); @@ -747,7 +767,17 @@ export function ConnectedGitHubRepoForm({
- + Disconnect GitHub repository @@ -876,7 +906,12 @@ export function ConnectedGitHubRepoForm({ name="action" value="update-git-settings" variant="secondary/small" - disabled={isGitSettingsLoading || !hasGitSettingsChanges} + disabled={isGitSettingsLoading || !hasGitSettingsChanges || !canManageGithub} + tooltip={ + canManageGithub + ? undefined + : "You don't have permission to manage the GitHub integration" + } LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined} > Save @@ -947,6 +982,7 @@ export function GitHubSettingsPanel({ environmentSlug={environmentSlug} billingPath={billingPath} redirectUrl={effectiveRedirectUrl} + canManageGithub={data.canManageGithub} /> ); } @@ -960,6 +996,7 @@ export function GitHubSettingsPanel({ projectSlug={projectSlug} environmentSlug={environmentSlug} redirectUrl={effectiveRedirectUrl} + canManageGithub={data.canManageGithub} /> {!data.connectedRepository && ( Connect your GitHub repository to automatically deploy your changes. From af7097fdf94e225dcf095ceca7a5463fc674106c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 15 Jun 2026 17:02:08 +0100 Subject: [PATCH 16/26] fix(webapp): enforce write:vercel on Vercel integration routes + UI Migrate the Vercel settings resource action, the Vercel app install entry, and the org-level uninstall action to dashboardLoader/dashboardAction with a write:vercel authorization block. Surface canManageVercel from the loaders and gate the Connect / Install / Reconnect / Disconnect / Save / Remove controls. Membership queries unchanged; permissive in OSS. --- ...ationSlug.settings.integrations.vercel.tsx | 244 +++--- ...cts.$projectParam.env.$envParam.vercel.tsx | 727 ++++++++++-------- apps/webapp/app/routes/vercel.install.tsx | 114 +-- 3 files changed, 624 insertions(+), 461 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index df6f5b9859a..0320ef5bd4e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -1,7 +1,4 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, -} from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { fromPromise } from "neverthrow"; import { Form, useActionData, useNavigation } from "@remix-run/react"; @@ -21,10 +18,19 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { Header1 } from "~/components/primitives/Headers"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; -import { $transaction, prisma } from "~/db.server"; +import { $replica, $transaction, prisma } from "~/db.server"; import { requireOrganization } from "~/services/org.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; @@ -47,8 +53,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); const url = new URL(request.url); const configurationId = url.searchParams.get("configurationId") ?? undefined; - const { organization } = await requireOrganization(request, organizationSlug); - + const { organization, userId } = await requireOrganization(request, organizationSlug); + + // Display flag for the Remove Integration control — the action enforces + // write:vercel independently. Permissive in OSS. + const sessionAuth = await rbac.authenticateSession(request, { + userId, + organizationId: organization.id, + }); + const canManageVercel = sessionAuth.ok + ? sessionAuth.ability.can("write", { type: "vercel" }) + : true; + // Find Vercel integration for this organization let vercelIntegration = await prisma.organizationIntegration.findFirst({ where: { @@ -75,6 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { connectedProjects: [], teamId: null, installationId: null, + canManageVercel, }); } @@ -109,6 +126,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { connectedProjects, teamId, installationId, + canManageVercel, }); }; @@ -116,111 +134,134 @@ const ActionSchema = z.object({ intent: z.literal("uninstall"), }); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const { organizationSlug } = OrganizationParamsSchema.parse(params); - const { organization, userId } = await requireOrganization(request, organizationSlug); - - const formData = await request.formData(); - const result = ActionSchema.safeParse({ intent: formData.get("intent") }); - if (!result.success) { - return json({ error: "Invalid action" }, { status: 400 }); - } +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - // Find Vercel integration - const vercelIntegration = await prisma.organizationIntegration.findFirst({ - where: { - organizationId: organization.id, - service: "VERCEL", - deletedAt: null, +export const action = dashboardAction( + { + params: OrganizationParamsSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; }, - include: { - tokenReference: true, - }, - }); - - if (!vercelIntegration) { - return json({ error: "Vercel integration not found" }, { status: 404 }); - } + authorization: { action: "write", resource: { type: "vercel" } }, + }, + async ({ request, params }) => { + const { organizationSlug } = params; + const { organization, userId } = await requireOrganization(request, organizationSlug); - // Uninstall from Vercel side - const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); + const formData = await request.formData(); + const result = ActionSchema.safeParse({ intent: formData.get("intent") }); + if (!result.success) { + return json({ error: "Invalid action" }, { status: 400 }); + } - if (uninstallResult.isErr()) { - logger.error("Failed to uninstall Vercel integration", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - error: uninstallResult.error.message, + // Find Vercel integration + const vercelIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, }); - return json( - { error: "Failed to uninstall Vercel integration. Please try again." }, - { status: 500 } + if (!vercelIntegration) { + return json({ error: "Vercel integration not found" }, { status: 404 }); + } + + // Uninstall from Vercel side + const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration( + vercelIntegration ); - } - // Soft-delete the integration and all connected projects in a transaction - const txResult = await fromPromise( - $transaction(prisma, async (tx) => { - await tx.organizationProjectIntegration.updateMany({ - where: { - organizationIntegrationId: vercelIntegration.id, - deletedAt: null, - }, - data: { deletedAt: new Date() }, + if (uninstallResult.isErr()) { + logger.error("Failed to uninstall Vercel integration", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + error: uninstallResult.error.message, }); - await tx.organizationIntegration.update({ - where: { id: vercelIntegration.id }, - data: { deletedAt: new Date() }, - }); - }), - (error) => error - ); + return json( + { error: "Failed to uninstall Vercel integration. Please try again." }, + { status: 500 } + ); + } - if (txResult.isErr()) { - logger.error("Failed to soft-delete Vercel integration records", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error), - }); + // Soft-delete the integration and all connected projects in a transaction + const txResult = await fromPromise( + $transaction(prisma, async (tx) => { + await tx.organizationProjectIntegration.updateMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }); - return json( - { error: "Failed to uninstall Vercel integration. Please try again." }, - { status: 500 } + await tx.organizationIntegration.update({ + where: { id: vercelIntegration.id }, + data: { deletedAt: new Date() }, + }); + }), + (error) => error ); - } - if (uninstallResult.value.authInvalid) { - logger.warn("Vercel integration uninstalled with auth error - token invalid", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - }); - } else { - logger.info("Vercel integration uninstalled successfully", { - organizationId: organization.id, - organizationSlug, - userId, - integrationId: vercelIntegration.id, - }); - } + if (txResult.isErr()) { + logger.error("Failed to soft-delete Vercel integration records", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error), + }); - // Redirect back to organization settings - return redirect(`/orgs/${organizationSlug}/settings`); -}; + return json( + { error: "Failed to uninstall Vercel integration. Please try again." }, + { status: 500 } + ); + } + + if (uninstallResult.value.authInvalid) { + logger.warn("Vercel integration uninstalled with auth error - token invalid", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } else { + logger.info("Vercel integration uninstalled successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } + + // Redirect back to organization settings + return redirect(`/orgs/${organizationSlug}/settings`); + } +); export default function VercelIntegrationPage() { - const { organization, vercelIntegration, connectedProjects, teamId, installationId } = - useTypedLoaderData(); + const { + organization, + vercelIntegration, + connectedProjects, + teamId, + installationId, + canManageVercel, + } = useTypedLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); - const isUninstalling = navigation.state === "submitting" && - navigation.formData?.get("intent") === "uninstall"; + const isUninstalling = + navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall"; if (!vercelIntegration) { return ( @@ -275,7 +316,12 @@ export default function VercelIntegrationPage() { @@ -285,7 +331,7 @@ export default function VercelIntegrationPage() { Remove Vercel Integration - This will permanently remove the Vercel integration and disconnect all projects. + This will permanently remove the Vercel integration and disconnect all projects. This action cannot be undone. Connected Projects ({connectedProjects.length}) - + {connectedProjects.length === 0 ? (
@@ -348,9 +394,7 @@ export default function VercelIntegrationPage() { {projectIntegration.externalEntityId} - - {formatDate(new Date(projectIntegration.createdAt))} - + {formatDate(new Date(projectIntegration.createdAt))} val !== "false"), + autoPromote: z + .string() + .optional() + .transform((val) => val !== "false"), clearTriggerVersion: z .string() .optional() @@ -123,7 +122,10 @@ const CompleteOnboardingFormSchema = z.object({ discoverEnvVars: envSlugArrayField, syncEnvVarsMapping: z.string().optional(), next: z.string().optional(), - skipRedirect: z.string().optional().transform((val) => val === "true"), + skipRedirect: z + .string() + .optional() + .transform((val) => val === "true"), origin: z.string().optional(), }); @@ -202,6 +204,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const authInvalid = onboardingData?.authInvalid || result.authInvalid || false; const authError = onboardingData?.authError || result.authError; + // Display flag for the connect/disconnect/configure controls — the action + // enforces write:vercel independently. Permissive in OSS. + const sessionAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const canManageVercel = sessionAuth.ok + ? sessionAuth.ability.can("write", { type: "vercel" }) + : true; + return typedjson({ ...result, authInvalid, @@ -212,235 +224,269 @@ export async function loader({ request, params }: LoaderFunctionArgs) { environmentSlug: envParam, projectId: project.id, organizationId: project.organizationId, + canManageVercel, }); } -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "vercel" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const formData = await request.formData(); - const submission = parse(formData, { schema: VercelActionSchema }); + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: VercelActionSchema }); - const settingsPath = v3ProjectSettingsIntegrationsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - const vercelService = new VercelIntegrationService(); - const { action: actionType } = submission.value; - - switch (actionType) { - case "update-config": { - const { - atomicBuilds, - pullEnvVarsBeforeBuild, - discoverEnvVars, - vercelStagingEnvironment, - autoPromote, - clearTriggerVersion, - } = submission.value; - - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - - // Get the previous staging environment before updating - const previousIntegration = await vercelService.getVercelProjectIntegration(project.id); - const previousStagingEnvId = - previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment?.environmentId ?? null; - const newStagingEnvId = parsedStagingEnv?.environmentId ?? null; - - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - atomicBuilds, - pullEnvVarsBeforeBuild, - discoverEnvVars, - vercelStagingEnvironment: parsedStagingEnv, - autoPromote, - }); + const settingsPath = v3ProjectSettingsIntegrationsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); - if (result) { - // Sync staging TRIGGER_SECRET_KEY if the custom environment changed - if (previousStagingEnvId !== newStagingEnvId) { - await vercelService.syncStagingKeyForCustomEnvironment( - project.id, - previousStagingEnvId, - newStagingEnvId - ); - } + const vercelService = new VercelIntegrationService(); + const { action: actionType } = submission.value; + + switch (actionType) { + case "update-config": { + const { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment, + autoPromote, + clearTriggerVersion, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + // Get the previous staging environment before updating + const previousIntegration = await vercelService.getVercelProjectIntegration(project.id); + const previousStagingEnvId = + previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment + ?.environmentId ?? null; + const newStagingEnvId = parsedStagingEnv?.environmentId ?? null; + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment: parsedStagingEnv, + autoPromote, + }); - // When atomic deployments are being disabled and the user confirmed clearing the pin, - // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. - // If the Vercel API call fails we still consider the settings save itself successful, - // but tell the user so they can clear the env var manually from the Vercel dashboard. - if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { - const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id); - if (!cleared) { - return redirectWithErrorMessage( - settingsPath, - request, - "Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings." + if (result) { + // Sync staging TRIGGER_SECRET_KEY if the custom environment changed + if (previousStagingEnvId !== newStagingEnvId) { + await vercelService.syncStagingKeyForCustomEnvironment( + project.id, + previousStagingEnvId, + newStagingEnvId ); } + + // When atomic deployments are being disabled and the user confirmed clearing the pin, + // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. + // If the Vercel API call fails we still consider the settings save itself successful, + // but tell the user so they can clear the env var manually from the Vercel dashboard. + if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { + const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id); + if (!cleared) { + return redirectWithErrorMessage( + settingsPath, + request, + "Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings." + ); + } + } + + return redirectWithSuccessMessage( + settingsPath, + request, + "Vercel settings updated successfully" + ); } - return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); - } + case "disconnect": { + const success = await vercelService.disconnectVercelProject(project.id); - case "disconnect": { - const success = await vercelService.disconnectVercelProject(project.id); + if (success) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + } - if (success) { - return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + return redirectWithErrorMessage( + settingsPath, + request, + "Failed to disconnect Vercel project" + ); } - return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); - } - - case "complete-onboarding": { - const { - vercelStagingEnvironment, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMapping, - next, - skipRedirect, - origin, - } = submission.value; - - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const parsedSyncEnvVarsMapping = syncEnvVarsMapping - ? safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as SyncEnvVarsMapping | undefined - : undefined; - - const result = await vercelService.completeOnboarding(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMapping: parsedSyncEnvVarsMapping, - origin: origin === "marketplace" ? "marketplace" : "dashboard", - }); + case "complete-onboarding": { + const { + vercelStagingEnvironment, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping, + next, + skipRedirect, + origin, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const parsedSyncEnvVarsMapping = syncEnvVarsMapping + ? (safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as + | SyncEnvVarsMapping + | undefined) + : undefined; + + const result = await vercelService.completeOnboarding(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping: parsedSyncEnvVarsMapping, + origin: origin === "marketplace" ? "marketplace" : "dashboard", + }); - if (result) { - if (skipRedirect) { - return json({ success: true }); - } + if (result) { + if (skipRedirect) { + return json({ success: true }); + } - if (next) { - const sanitizedNext = sanitizeVercelNextUrl(next); - if (sanitizedNext) { - return json({ success: true, redirectTo: sanitizedNext }); + if (next) { + const sanitizedNext = sanitizeVercelNextUrl(next); + if (sanitizedNext) { + return json({ success: true, redirectTo: sanitizedNext }); + } + logger.warn("Rejected next URL - not same-origin or vercel.com", { next }); } - logger.warn("Rejected next URL - not same-origin or vercel.com", { next }); + + return json({ success: true, redirectTo: settingsPath }); } - return json({ success: true, redirectTo: settingsPath }); + return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); - } + case "update-env-mapping": { + const { vercelStagingEnvironment } = submission.value; - case "update-env-mapping": { - const { vercelStagingEnvironment } = submission.value; + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + }); - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - }); + if (result) { + // During onboarding there's no previous custom environment — just upsert + await vercelService.syncStagingKeyForCustomEnvironment( + project.id, + null, + parsedStagingEnv?.environmentId ?? null + ); + return json({ success: true }); + } - if (result) { - // During onboarding there's no previous custom environment — just upsert - await vercelService.syncStagingKeyForCustomEnvironment( - project.id, - null, - parsedStagingEnv?.environmentId ?? null + return json( + { success: false, error: "Failed to update environment mapping" }, + { status: 400 } ); - return json({ success: true }); } - return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); - } + case "skip-onboarding": { + return redirectWithSuccessMessage( + settingsPath, + request, + "Vercel integration setup skipped" + ); + } - case "skip-onboarding": { - return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); - } + case "select-vercel-project": { + const { vercelProjectId, vercelProjectName } = submission.value; + + const selectResult = await fromPromise( + vercelService.selectVercelProject({ + organizationId: project.organizationId, + projectId: project.id, + vercelProjectId, + vercelProjectName, + userId, + }), + (error) => error + ); - case "select-vercel-project": { - const { vercelProjectId, vercelProjectName } = submission.value; - - const selectResult = await fromPromise( - vercelService.selectVercelProject({ - organizationId: project.organizationId, - projectId: project.id, - vercelProjectId, - vercelProjectName, - userId, - }), - (error) => error - ); - - if (selectResult.isErr()) { - logger.error("Failed to select Vercel project", { error: selectResult.error }); - return json({ - error: "Failed to connect Vercel project. Please try again.", - }); - } + if (selectResult.isErr()) { + logger.error("Failed to select Vercel project", { error: selectResult.error }); + return json({ + error: "Failed to connect Vercel project. Please try again.", + }); + } - const { integration, syncResult } = selectResult.value; + const { integration, syncResult } = selectResult.value; + + if (!syncResult.success && syncResult.errors.length > 0) { + logger.warn("Failed to send trigger secrets to Vercel", { + projectId: project.id, + vercelProjectId, + errors: syncResult.errors, + }); + } - if (!syncResult.success && syncResult.errors.length > 0) { - logger.warn("Failed to send trigger secrets to Vercel", { - projectId: project.id, - vercelProjectId, - errors: syncResult.errors, + return json({ + success: true, + integrationId: integration.id, + syncErrors: syncResult.errors, }); } - return json({ - success: true, - integrationId: integration.id, - syncErrors: syncResult.errors, - }); - } - - case "disable-auto-assign": { - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( - project.id - ); + case "disable-auto-assign": { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + project.id + ); - if (!orgIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); - } + if (!orgIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); + } - const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); + const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); - if (!projectIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); - } + if (!projectIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); + } - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration) - .andThen((client) => + const disableResult = await VercelIntegrationRepository.getVercelClient( + orgIntegration + ).andThen((client) => VercelIntegrationRepository.disableAutoAssignCustomDomains( client, projectIntegration.parsedIntegrationData.vercelProjectId, @@ -448,20 +494,31 @@ export async function action({ request, params }: ActionFunctionArgs) { ) ); - if (disableResult.isErr()) { - logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error }); - return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); - } + if (disableResult.isErr()) { + logger.error("Failed to disable auto-assign custom domains", { + error: disableResult.error, + }); + return redirectWithErrorMessage( + settingsPath, + request, + "Failed to disable auto-assign custom domains" + ); + } - return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled"); - } + return redirectWithSuccessMessage( + settingsPath, + request, + "Auto-assign custom domains disabled" + ); + } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); + } } } -} +); function VercelConnectionPrompt({ organizationSlug, @@ -471,6 +528,7 @@ function VercelConnectionPrompt({ isGitHubConnected, onOpenModal, isLoading, + canManageVercel = true, }: { organizationSlug: string; projectSlug: string; @@ -479,6 +537,7 @@ function VercelConnectionPrompt({ isGitHubConnected: boolean; onOpenModal?: () => void; isLoading?: boolean; + canManageVercel?: boolean; }) { const installPath = vercelAppInstallPath(organizationSlug, projectSlug); @@ -501,11 +560,16 @@ function VercelConnectionPrompt({
@@ -537,12 +603,14 @@ function VercelConnectionPrompt({ ); } -function VercelAuthInvalidBanner({ +function VercelAuthInvalidBanner({ organizationSlug, projectSlug, -}: { + canManageVercel = true, +}: { organizationSlug: string; projectSlug: string; + canManageVercel?: boolean; }) { const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); @@ -550,19 +618,22 @@ function VercelAuthInvalidBanner({
-

+

Vercel connection expired

-

- Your Vercel access token has expired or been revoked. Please reconnect to restore functionality. +

+ Your Vercel access token has expired or been revoked. Please reconnect to restore + functionality.

- Reconnect Vercel - +
@@ -573,8 +644,8 @@ function VercelGitHubWarning() { return (

- GitHub integration is not connected. Vercel integration cannot sync environment variables and - link deployments without a properly installed GitHub integration. + GitHub integration is not connected. Vercel integration cannot sync environment variables + and link deployments without a properly installed GitHub integration.

); @@ -604,6 +675,7 @@ function ConnectedVercelProjectForm({ organizationSlug, projectSlug, environmentSlug, + canManageVercel = true, }: { connectedProject: ConnectedVercelProject; hasStagingEnvironment: boolean; @@ -615,6 +687,7 @@ function ConnectedVercelProjectForm({ organizationSlug: string; projectSlug: string; environmentSlug: string; + canManageVercel?: boolean; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); @@ -632,7 +705,8 @@ function ConnectedVercelProjectForm({ const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? []; - const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null; + const originalStagingEnv = + connectedProject.integrationData.config.vercelStagingEnvironment ?? null; const originalAutoPromote = connectedProject.integrationData.config.autoPromote ?? true; useEffect(() => { @@ -645,7 +719,8 @@ function ConnectedVercelProjectForm({ const discoverEnvVarsChanged = JSON.stringify([...configValues.discoverEnvVars].sort()) !== JSON.stringify([...originalDiscoverEnvVars].sort()); - const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; + const stagingEnvChanged = + configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote; setHasConfigChanges( @@ -710,14 +785,20 @@ function ConnectedVercelProjectForm({ const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); - const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment); + const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings( + hasStagingEnvironment, + hasPreviewEnvironment + ); const disabledEnvSlugsForBuildSettings: Partial> | undefined = hasStagingEnvironment && !configValues.vercelStagingEnvironment ? { stg: "Map a custom Vercel environment to Staging to enable this" } : undefined; - const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => { + const formatSelectedEnvs = ( + selected: EnvSlug[], + availableSlugs: EnvSlug[] = availableEnvSlugs + ): string => { if (selected.length === 0) return "None selected"; if (selected.length === availableSlugs.length) return "All environments"; return selected.map(envSlugLabel).join(", "); @@ -743,15 +824,25 @@ function ConnectedVercelProjectForm({ - + Disconnect Vercel project
Are you sure you want to disconnect{" "} - {connectedProject.vercelProjectName}? - This will stop pulling environment variables and disable atomic deployments. + {connectedProject.vercelProjectName}? This + will stop pulling environment variables and disable atomic deployments. - + {/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */} s !== "stg" ); - next.discoverEnvVars = prev.discoverEnvVars.filter( - (s) => s !== "stg" - ); + next.discoverEnvVars = prev.discoverEnvVars.filter((s) => s !== "stg"); } return next; }); @@ -890,37 +979,36 @@ function ConnectedVercelProjectForm({ /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} - {autoAssignCustomDomains !== false && - configValues.atomicBuilds.includes("prod") && ( - -
-

- Atomic deployments require the "Auto-assign Custom Domains" setting to be - disabled on your Vercel project. Without this, Vercel will promote - deployments before Trigger.dev is ready. -

- - - - -
-
- )} + {autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && ( + +
+

+ Atomic deployments require the "Auto-assign Custom Domains" setting to be + disabled on your Vercel project. Without this, Vercel will promote deployments + before Trigger.dev is ready. +

+
+ + +
+
+
+ )}
{configForm.error} @@ -934,7 +1022,12 @@ function ConnectedVercelProjectForm({ name="action" value="update-config" variant="secondary/small" - disabled={isConfigLoading || !hasConfigChanges} + disabled={isConfigLoading || !hasConfigChanges || !canManageVercel} + tooltip={ + canManageVercel + ? undefined + : "You don't have permission to manage the Vercel integration" + } LeadingIcon={isConfigLoading ? SpinnerWhite : undefined} onClick={(event) => { if (shouldPromptClearOnSave) { @@ -957,17 +1050,15 @@ function ConnectedVercelProjectForm({ {currentTriggerVersion ? ( Atomic deployments are being turned off. The{" "} - TRIGGER_VERSION env var on - your Vercel production environment is currently set to{" "} + TRIGGER_VERSION env var on your + Vercel production environment is currently set to{" "} {currentTriggerVersion}. ) : ( - Atomic deployments are being turned off. We couldn't reach Vercel to confirm - whether{" "} - TRIGGER_VERSION is currently - set on your Vercel production environment, so please verify in the Vercel - dashboard. + Atomic deployments are being turned off. We couldn't reach Vercel to confirm whether{" "} + TRIGGER_VERSION is currently set + on your Vercel production environment, so please verify in the Vercel dashboard. )} @@ -978,16 +1069,10 @@ function ConnectedVercelProjectForm({ - - @@ -1029,17 +1114,26 @@ function VercelSettingsPanel({ fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); setHasFetched(true); } - }, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]); + }, [ + organizationSlug, + projectSlug, + environmentSlug, + data?.authInvalid, + hasError, + data, + hasFetched, + ]); if (hasError) { return (
- +

Failed to load Vercel settings

-

- There was an error loading the Vercel integration settings. Please refresh the page to try again. +

+ There was an error loading the Vercel integration settings. Please refresh the page to + try again.

@@ -1066,27 +1160,38 @@ function VercelSettingsPanel({ if (data.connectedProject) { return ( <> - {showAuthInvalid && } + {showAuthInvalid && ( + + )} {showGitHubWarning && } - {!showAuthInvalid && ()} + {!showAuthInvalid && ( + + )} ); } return (
- {showAuthInvalid && } + {showAuthInvalid && ( + + )} {!showAuthInvalid && ( <> {data.hasOrgIntegration @@ -1105,8 +1211,8 @@ function VercelSettingsPanel({ {!data.isGitHubConnected && ( - GitHub integration is not connected. Vercel integration cannot sync environment variables and - link deployments without a properly installed GitHub integration. + GitHub integration is not connected. Vercel integration cannot sync environment + variables and link deployments without a properly installed GitHub integration. )} @@ -1115,7 +1221,6 @@ function VercelSettingsPanel({ ); } - import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal"; export { VercelSettingsPanel, VercelOnboardingModal }; diff --git a/apps/webapp/app/routes/vercel.install.tsx b/apps/webapp/app/routes/vercel.install.tsx index 6a1ca4d7a64..b3d66cdf5f2 100644 --- a/apps/webapp/app/routes/vercel.install.tsx +++ b/apps/webapp/app/routes/vercel.install.tsx @@ -1,8 +1,7 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { logger } from "~/services/logger.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; @@ -13,61 +12,76 @@ const QuerySchema = z.object({ project_slug: z.string(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const searchParams = new URL(request.url).searchParams; - const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - if (!parsed.success) { - logger.warn("Vercel App installation redirect with invalid params", { - searchParams, - error: parsed.error, - }); - throw redirect("/"); - } - - const { org_slug, project_slug } = parsed.data; - const user = await requireUser(request); - - // Find the organization - const org = await $replica.organization.findFirst({ - where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, +export const loader = dashboardLoader( + { + // The org for the auth scope comes from the `org_slug` query param. + context: async (_params, request) => { + const orgSlug = new URL(request.url).searchParams.get("org_slug"); + if (!orgSlug) return {}; + const organizationId = await resolveOrgIdFromSlug(orgSlug); + return organizationId ? { organizationId } : {}; }, - }); + authorization: { action: "write", resource: { type: "vercel" } }, + }, + async ({ request, user }) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); - if (!org) { - throw redirect("/"); - } + if (!parsed.success) { + logger.warn("Vercel App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } - // Find the project - const project = await findProjectBySlug(org_slug, project_slug, user.id); - if (!project) { - logger.warn("Vercel App installation attempt for non-existent project", { - org_slug, - project_slug, - userId: user.id, + const { org_slug, project_slug } = parsed.data; + + // Find the organization + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, }); - throw redirect("/"); - } - // Use "prod" as the default environment slug for the redirect - // The callback will redirect to the settings page for this environment - const environmentSlug = "prod"; + if (!org) { + throw redirect("/"); + } - // Generate JWT state token - const stateToken = await generateVercelOAuthState({ - organizationId: org.id, - projectId: project.id, - environmentSlug, - organizationSlug: org_slug, - projectSlug: project_slug, - }); + // Find the project + const project = await findProjectBySlug(org_slug, project_slug, user.id); + if (!project) { + logger.warn("Vercel App installation attempt for non-existent project", { + org_slug, + project_slug, + userId: user.id, + }); + throw redirect("/"); + } - // Generate Vercel install URL - const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + // Use "prod" as the default environment slug for the redirect + // The callback will redirect to the settings page for this environment + const environmentSlug = "prod"; - return redirect(vercelInstallUrl); -}; + // Generate JWT state token + const stateToken = await generateVercelOAuthState({ + organizationId: org.id, + projectId: project.id, + environmentSlug, + organizationSlug: org_slug, + projectSlug: project_slug, + }); + // Generate Vercel install URL + const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + + return redirect(vercelInstallUrl); + } +); From 53e594768d7aec3f31f16f90a11c6ddc224bb563 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 10:34:57 +0100 Subject: [PATCH 17/26] fix(webapp): disable seat purchase without manage:billing The team page's seat purchase button now disables itself with an explanatory tooltip when the current role can't manage billing, matching the server-side check the action already enforces. --- .../route.tsx | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 8ebcfb80f29..fd0530ba4b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -11,7 +11,7 @@ import { } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; -import { useEffect, useRef, useState } from "react"; +import { cloneElement, useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { AlertTrigger, } from "~/components/primitives/Alert"; import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionButton } from "~/components/primitives/PermissionButton"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -119,10 +120,12 @@ export const loader = dashboardLoader( } // Pre-compute manage authority server-side so the UI gating matches - // the action gating (the action enforces it independently). + // the action gating (the action enforces it independently). Seat + // purchases are a billing operation, so they gate on manage:billing. const canManageMembers = ability.can("manage", { type: "members" }); + const canManageBilling = ability.can("manage", { type: "billing" }); - return typedjson({ ...result, canManageMembers }); + return typedjson({ ...result, canManageMembers, canManageBilling }); } ); @@ -318,6 +321,7 @@ export default function Page() { assignableRoleIds, memberRoles, canManageMembers, + canManageBilling, } = useTypedLoaderData(); // Build a userId → roleId map so the dropdown's defaultValue matches // each member's current assignment without re-querying. @@ -532,6 +536,7 @@ export default function Page() { usedSeats={limits.used} maxQuota={maxSeatQuota} planSeatLimit={planSeatLimit} + canManageBilling={canManageBilling} /> ) : canUpgrade ? ( showSelfServe ? ( @@ -864,6 +869,7 @@ export function PurchaseSeatsModal({ maxQuota, planSeatLimit, triggerButton, + canManageBilling = true, }: { seatPricing: { stepSize: number; @@ -874,6 +880,7 @@ export function PurchaseSeatsModal({ maxQuota: number; planSeatLimit: number; triggerButton?: React.ReactElement; + canManageBilling?: boolean; }) { const showSelfServe = useShowSelfServe(); const fetcher = useFetcher(); @@ -933,15 +940,30 @@ export function PurchaseSeatsModal({ ); } + // Buying seats is a billing action — disable the trigger (and explain why) + // when the role can't manage billing. The action enforces it independently. + const noBillingTooltip = "You don't have permission to manage billing"; + const trigger = canManageBilling ? ( + triggerButton ?? ( + + ) + ) : triggerButton ? ( + cloneElement(triggerButton, { disabled: true, tooltip: noBillingTooltip }) + ) : ( + + {title} + + ); + return ( - - {triggerButton ?? ( - - )} - + {trigger} {title} From dbca7f4b3ec3150d08a35c6c9702cd45479a5771 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 14:05:40 +0100 Subject: [PATCH 18/26] feat(webapp): gate API keys page on env-tier read access Add a reusable PermissionDenied panel and use it on the API keys page: when a role can't read a given environment's secret key (e.g. deployed environments for a restricted role), the secret is withheld server-side and the page renders the panel instead. Regenerating an API key is gated the same way, enforced on the POST so a disabled button isn't the only guard. --- .../app/components/PermissionDenied.tsx | 25 +++ .../route.tsx | 187 ++++++++++-------- ...ents.$environmentId.regenerate-api-key.tsx | 100 ++++++---- 3 files changed, 199 insertions(+), 113 deletions(-) create mode 100644 apps/webapp/app/components/PermissionDenied.tsx diff --git a/apps/webapp/app/components/PermissionDenied.tsx b/apps/webapp/app/components/PermissionDenied.tsx new file mode 100644 index 00000000000..a3212d82662 --- /dev/null +++ b/apps/webapp/app/components/PermissionDenied.tsx @@ -0,0 +1,25 @@ +import { organizationRolesPath } from "~/utils/pathBuilder"; +import { LinkButton } from "./primitives/Buttons"; +import { InfoPanel } from "./primitives/InfoPanel"; +import { LockClosedIcon } from "@heroicons/react/20/solid"; +import { useOrganization } from "~/hooks/useOrganizations"; +import React from "react"; + +export function PermissionDenied({ message }: { message: React.ReactNode }) { + const organization = useOrganization(); + + return ( + + View roles + + } + > + {message} + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 897687f4ec9..83eb3b1248c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1,6 +1,5 @@ import { BookOpenIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CodeBlock } from "~/components/code/CodeBlock"; @@ -16,6 +15,7 @@ import { PageBody, PageContainer, } from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; import { Accordion, AccordionContent, @@ -31,9 +31,10 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import * as Property from "~/components/primitives/PropertyTable"; +import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; @@ -45,33 +46,55 @@ export const meta: MetaFunction = () => { ]; }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, envParam } = EnvironmentParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - try { - const presenter = new ApiKeysPresenter(); - const { environment, hasVercelIntegration } = await presenter.call({ - userId, - projectSlug: projectParam, - environmentSlug: envParam, - }); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // No hard authorization: anyone with project access can open the page. + // Reading the secret key is gated per environment tier below — a role + // that can't read this tier's keys gets the info panel, not the key. + }, + async ({ params, user, ability }) => { + const { projectParam, envParam } = params; - return typedjson({ - environment, - hasVercelIntegration, - }); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); + try { + const presenter = new ApiKeysPresenter(); + const { environment, hasVercelIntegration } = await presenter.call({ + userId: user.id, + projectSlug: projectParam, + environmentSlug: envParam, + }); + + const canReadApiKeys = + !environment || ability.can("read", { type: "apiKeys", envType: environment.type }); + + return typedjson({ + // Never serialize the secret key to the client when the role can't + // read it for this environment tier. + environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment, + hasVercelIntegration, + canReadApiKeys, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } } -}; +); export default function Page() { - const { environment, hasVercelIntegration } = useTypedLoaderData(); + const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData(); const organization = useOrganization(); if (!environment) { @@ -126,70 +149,78 @@ export default function Page() { API keys
-
- -
- - -
- - - Set this as your TRIGGER_SECRET_KEY{" "} - env var in your backend. - -
- {environment.branchName && ( + {canReadApiKeys ? ( +
- +
+ + +
- Set this as your{" "} - TRIGGER_PREVIEW_BRANCH env var in - your backend. + Set this as your TRIGGER_SECRET_KEY{" "} + env var in your backend.
- )} - {environment.type === "DEVELOPMENT" && ( - - Every team member gets their own dev Secret key. Make sure you're using the one - above otherwise you will trigger runs on your team member's machine. - - )} + {environment.branchName && ( + + + + + Set this as your{" "} + TRIGGER_PREVIEW_BRANCH env var in + your backend. + + + )} + {environment.type === "DEVELOPMENT" && ( + + Every team member gets their own dev Secret key. Make sure you're using the one + above otherwise you will trigger runs on your team member's machine. + + )} - - - How to set these environment variables - -
-
- You need to set these environment variables in your backend. This allows the - SDK to authenticate with Trigger.dev. + + + How to set these environment variables + +
+
+ You need to set these environment variables in your backend. This allows the + SDK to authenticate with Trigger.dev. +
+
- -
- - - -
+
+
+
+
+ ) : ( + + )} diff --git a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx index 5efb69bc723..ced2145f212 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +++ b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx @@ -1,56 +1,86 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; +import { $replica } from "~/db.server"; import { regenerateApiKey } from "~/models/api-key.server"; -import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server"; -import { requireUserId } from "~/services/session.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { logger } from "~/services/logger.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; const ParamsSchema = z.object({ environmentId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - const userId = await requireUserId(request); - - const { environmentId } = ParamsSchema.parse(params); +export const action = dashboardAction( + { + params: ParamsSchema, + context: async (params) => { + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { id: params.environmentId }, + select: { organizationId: true }, + }); + return environment ? { organizationId: environment.organizationId } : {}; + }, + // Env-tier write:apiKeys is enforced in the handler — the target + // environment's tier isn't known until we resolve it from the id. + }, + async ({ request, params, user, ability }) => { + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - const formData = await request.formData(); - const syncToVercel = formData.get("syncToVercel") === "on"; + const { environmentId } = params; - try { - const updatedEnvironment = await regenerateApiKey({ userId, environmentId }); + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { id: environmentId }, + select: { type: true }, + }); + if (!environment) { + return jsonWithErrorMessage({ ok: false }, request, "Environment not found"); + } - // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT - if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") { - await syncApiKeyToVercel( - updatedEnvironment.projectId, - updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW", - updatedEnvironment.apiKey + // Gate the regenerate even on a direct POST: a role that can't write + // this tier's API keys can't rotate them. The disabled UI control is + // not the boundary; this check is. + if (!ability.can("write", { type: "apiKeys", envType: environment.type })) { + return jsonWithErrorMessage( + { ok: false }, + request, + "You don't have permission to regenerate API keys for this environment." ); } - return jsonWithSuccessMessage( - { ok: true }, - request, - `API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment` - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; + const formData = await request.formData(); + const syncToVercel = formData.get("syncToVercel") === "on"; - return jsonWithErrorMessage( - { ok: false }, - request, - `API keys could not be regenerated: ${message}` - ); + try { + const updatedEnvironment = await regenerateApiKey({ userId: user.id, environmentId }); + + // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT + if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") { + await syncApiKeyToVercel( + updatedEnvironment.projectId, + updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW", + updatedEnvironment.apiKey + ); + } + + return jsonWithSuccessMessage( + { ok: true }, + request, + `API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment` + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + return jsonWithErrorMessage( + { ok: false }, + request, + `API keys could not be regenerated: ${message}` + ); + } } -} +); /** * Sync the API key to Vercel. From c6cb229df23da15b35e8121f88ac539121e013f6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 14:05:51 +0100 Subject: [PATCH 19/26] 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. --- .../route.tsx | 33 +++++++++++--- .../route.tsx | 44 +++++++++++++++++-- .../route.tsx | 29 +++++++++++- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index e2c6fe2f531..822a4d7f3f3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -9,10 +9,11 @@ import { import { json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { Fragment, useRef, useState } from "react"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import simplur from "simplur"; import { z } from "zod"; import { MainCenteredContainer } from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -52,15 +53,21 @@ export const loader = dashboardLoader( const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, - authorization: { action: "manage", resource: { type: "members" } }, + // No hard authorization block: a denial renders a PermissionDenied panel + // rather than blindly redirecting. Enforced via canManageMembers below + // (the invite action gates manage:members independently). }, - async ({ user, context }) => { + async ({ user, context, ability }) => { const organizationId = context.organizationId; if (!organizationId) { throw new Response("Not Found", { status: 404 }); } const userId = user.id; + if (!ability.can("manage", { type: "members" })) { + return typedjson({ canManageMembers: false as const }); + } + const presenter = new TeamPresenter(); const result = await presenter.call({ userId, @@ -94,7 +101,7 @@ export const loader = dashboardLoader( .map((r) => r.id) : []; - return typedjson({ ...result, offerableRoleIds }); + return typedjson({ canManageMembers: true as const, ...result, offerableRoleIds }); } ); @@ -255,7 +262,23 @@ export const action = dashboardAction( } ); +type InviteData = Extract, { canManageMembers: true }>; + export default function Page() { + const loaderData = useTypedLoaderData(); + + if (!loaderData.canManageMembers) { + return ( + + + + ); + } + + return ; +} + +function InvitePage({ data }: { data: InviteData }) { const { limits, canPurchaseSeats, @@ -265,7 +288,7 @@ export default function Page() { planSeatLimit, roles, offerableRoleIds, - } = useTypedLoaderData(); + } = data; const [total, setTotal] = useState(limits.used); const organization = useOrganization(); const lastSubmission = useActionData(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx index 3913d6a6707..67151752876 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx @@ -4,14 +4,21 @@ import { Form, useActionData, type MetaFunction } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { Fragment, useEffect, useRef, useState } from "react"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + type UseDataFunctionReturn, + redirect, + typedjson, + useTypedLoaderData, +} from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { + MainCenteredContainer, MainHorizontallyCenteredContainer, PageBody, PageContainer, } from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -60,9 +67,11 @@ export const loader = dashboardLoader( const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, - authorization: { action: "manage", resource: { type: "billing" } }, + // No hard authorization block: a denial renders a PermissionDenied panel + // instead of blindly redirecting. Enforced via canManageBilling below (the + // form mutations are gated independently in the action). }, - async ({ params, request, user }) => { + async ({ params, request, user, ability }) => { const userId = user.id; const { organizationSlug } = params; @@ -71,6 +80,10 @@ export const loader = dashboardLoader( return redirect(organizationPath({ slug: organizationSlug })); } + if (!ability.can("manage", { type: "billing" })) { + return typedjson({ canManageBilling: false as const }); + } + const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } } }, }); @@ -94,6 +107,7 @@ export const loader = dashboardLoader( } return typedjson({ + canManageBilling: true as const, alerts: { ...alerts, amount: alerts.amount / 100, @@ -102,6 +116,8 @@ export const loader = dashboardLoader( } ); +type BillingAlertsData = Extract, { canManageBilling: true }>; + const schema = z.object({ amount: z .number({ invalid_type_error: "Not a valid amount" }) @@ -197,7 +213,27 @@ export const action = dashboardAction( ); export default function Page() { - const { alerts } = useTypedLoaderData(); + const loaderData = useTypedLoaderData(); + + if (!loaderData.canManageBilling) { + return ( + + + + + + + + + + + ); + } + + return ; +} + +function BillingAlerts({ alerts }: { alerts: BillingAlertsData["alerts"] }) { const plan = useCurrentPlan(); const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2)); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index 16c1a60cc1c..aeb31b38c6a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -3,6 +3,7 @@ import { type PlanDefinition } from "@trigger.dev/platform"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { Feedback } from "~/components/Feedback"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; import { InfoPanel } from "~/components/primitives/InfoPanel"; @@ -42,9 +43,11 @@ export const loader = dashboardLoader( const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, - authorization: { action: "manage", resource: { type: "billing" } }, + // No hard authorization block here: a denial should render the page with a + // PermissionDenied panel, not blindly redirect away. Enforced via the + // canManageBilling check below (the billing mutations are gated separately). }, - async ({ params, request, user }) => { + async ({ params, request, user, ability }) => { const userId = user.id; const { organizationSlug } = params; @@ -53,6 +56,10 @@ export const loader = dashboardLoader( return redirect(organizationPath({ slug: organizationSlug })); } + if (!ability.can("manage", { type: "billing" })) { + return typedjson({ canManageBilling: false as const }); + } + const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } } }, }); @@ -84,6 +91,7 @@ export const loader = dashboardLoader( if (!showSelfServe) { return typedjson({ + canManageBilling: true as const, showSelfServe: false as const, ...currentPlan, organizationSlug, @@ -100,6 +108,7 @@ export const loader = dashboardLoader( } return typedjson({ + canManageBilling: true as const, showSelfServe: true as const, ...plans, ...currentPlan, @@ -114,6 +123,22 @@ export const loader = dashboardLoader( export default function ChoosePlanPage() { const loaderData = useTypedLoaderData(); + + if (!loaderData.canManageBilling) { + return ( + + + + + + + + + + + ); + } + const { showSelfServe, v3Subscription, From bd2a6686571560d50a32ee92824407a3aac17b72 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 14:06:00 +0100 Subject: [PATCH 20/26] feat(webapp): roles page full-height sticky table + current role + env chips The roles comparison table now fills the remaining height with a sticky header and scrolls internally. A line under the description states the viewer's own role, and env-tier permission conditions render as environment chips for the environments they apply to instead of raw text. --- .../route.tsx | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx index 6b3d037fbfe..53eb77289a1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react"; import { useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; @@ -57,13 +58,13 @@ export const loader = dashboardLoader( }, authorization: { action: "read", resource: { type: "members" } }, }, - async ({ context }) => { + async ({ context, user }) => { const orgId = context.organizationId; if (!orgId) { throw new Response("Not Found", { status: 404 }); } - const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] = + const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] = await Promise.all([ rbac.allRoles(orgId), rbac.getAssignableRoleIds(orgId), @@ -71,6 +72,7 @@ export const loader = dashboardLoader( rbac.systemRoles(orgId), // OSS self-host has no RBAC plugin. rbac.isUsingPlugin(), + rbac.getUserRole({ userId: user.id, organizationId: orgId }), ]); return typedjson({ @@ -79,6 +81,7 @@ export const loader = dashboardLoader( allPermissions, systemRoles, isUsingPlugin, + currentRoleName: currentRole?.name ?? null, }); } ); @@ -92,7 +95,7 @@ type RolePermission = LoaderRole["permissions"][number]; const FALLBACK_GROUP = "Other"; export default function Page() { - const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } = + const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } = useTypedLoaderData(); const organization = useOrganization(); const showSelfServe = useShowSelfServe(); @@ -122,19 +125,24 @@ export default function Page() { {isUsingPlugin && showSelfServe ? : null} -
+
Roles control what each team member can do in {organization.title}. Compare what each role grants below; assign a role to a team member from the{" "} Team page. + {currentRoleName ? ( + + Your role is {currentRoleName}. + + ) : null}
-
+
{columns.length === 0 ? ( ) : ( - +
Permission @@ -287,6 +295,18 @@ function RoleCell({ const conditionalDeny = denied.find((p) => p.conditions); if (conditionalDeny?.conditions) { + const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions); + if (allowedEnvTypes) { + // Conditional grant: show the environments the permission is allowed in. + return ( +
+ {allowedEnvTypes.map((type) => ( + + ))} +
+ ); + } + // Conditions we can't map to environments fall back to a text label. return ( {conditionLabel(conditionalDeny.conditions)} ); @@ -298,6 +318,30 @@ function RoleCell({ ); } +const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const; +type EnvType = (typeof ENV_TYPES)[number]; + +// A conditional `cannot` rule denies the permission where the resource matches +// its condition, so the permission stays allowed everywhere else. Translate the +// envType condition into the set of environments where it's still allowed, or +// null when we can't interpret it (caller falls back to a text label). +function allowedEnvTypesFromDeny(conditions: Record): EnvType[] | null { + const envType = conditions.envType; + // Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere. + if (typeof envType === "string") { + return ENV_TYPES.includes(envType as EnvType) + ? ENV_TYPES.filter((t) => t !== envType) + : null; + } + // Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except + // DEVELOPMENT, so allowed only in DEVELOPMENT. + if (envType && typeof envType === "object" && "$ne" in envType) { + const ne = (envType as { $ne: unknown }).$ne; + return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null; + } + return null; +} + // Only `envType` is supported today. function conditionLabel(conditions: Record): string { if (typeof conditions.envType === "string") { From 2149d039ac69bf16c2d905b26ee3eba48a083fdb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 15:07:16 +0100 Subject: [PATCH 21/26] feat(webapp): enforce env var permissions on the dashboard The environment variables list now withholds values for environments the current role can't read and shows a permission-denied state in their place. The create dialog disables the environment targets the role can't write (with a tooltip) and its action rejects those targets server-side. The permission-denied states use the no-entry icon. --- .../app/components/PermissionDenied.tsx | 4 +- .../route.tsx | 208 ++++++---- .../route.tsx | 373 +++++++++++------- 3 files changed, 363 insertions(+), 222 deletions(-) diff --git a/apps/webapp/app/components/PermissionDenied.tsx b/apps/webapp/app/components/PermissionDenied.tsx index a3212d82662..9e59c9c21c3 100644 --- a/apps/webapp/app/components/PermissionDenied.tsx +++ b/apps/webapp/app/components/PermissionDenied.tsx @@ -1,7 +1,7 @@ import { organizationRolesPath } from "~/utils/pathBuilder"; import { LinkButton } from "./primitives/Buttons"; import { InfoPanel } from "./primitives/InfoPanel"; -import { LockClosedIcon } from "@heroicons/react/20/solid"; +import { NoSymbolIcon } from "@heroicons/react/20/solid"; import { useOrganization } from "~/hooks/useOrganizations"; import React from "react"; @@ -10,7 +10,7 @@ export function PermissionDenied({ message }: { message: React.ReactNode }) { return ( { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // Per-environment write:envvars is enforced in the handler — the target + // environments come from the submission, not the route params. + }, + async ({ request, params, user, ability }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - const formData = await request.formData(); - const submission = parse(formData, { schema }); + const formData = await request.formData(); + const submission = parse(formData, { schema }); - if (!submission.value) { - return json(submission); - } + if (!submission.value) { + return json(submission); + } + + // Enforce env-tier write:envvars for every targeted environment, so a role + // that can't write a deployed tier can't create vars there via a direct + // POST (the disabled checkboxes are not the boundary). + const targetEnvironments = await prisma.runtimeEnvironment.findMany({ + where: { id: { in: submission.value.environmentIds } }, + select: { type: true }, + }); + const hasDeniedEnvironment = targetEnvironments.some( + (env) => !ability.can("write", { type: "envvars", envType: env.type }) + ); + if (hasDeniedEnvironment) { + submission.error.environmentIds = [ + "You don't have permission to manage environment variables in one of the selected environments.", + ]; + return json(submission); + } - const project = await prisma.project.findUnique({ - where: { - slug: params.projectParam, - organization: { - members: { - some: { - userId, + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }); - if (!project) { - submission.error.key = ["Project not found"]; - return json(submission); - } + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = ["Project not found"]; + return json(submission); + } - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.create(project.id, { - ...submission.value, - lastUpdatedBy: { - type: "user", - userId, - }, - }); + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.create(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); - if (!result.success) { - if (result.variableErrors) { - for (const { key, error } of result.variableErrors) { - const index = submission.value.variables.findIndex((v) => v.key === key); + if (!result.success) { + if (result.variableErrors) { + for (const { key, error } of result.variableErrors) { + const index = submission.value.variables.findIndex((v) => v.key === key); - if (index !== -1) { - submission.error[`variables[${index}].key`] = [error]; + if (index !== -1) { + submission.error[`variables[${index}].key`] = [error]; + } } + } else { + submission.error.variables = [result.error]; } - } else { - submission.error.variables = [result.error]; + + return json(submission); } - return json(submission); + return redirect( + v3EnvironmentVariablesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ) + ); } - - return redirect( - v3EnvironmentVariablesPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ) - ); -}; +); export default function Page() { const [isOpen, setIsOpen] = useState(true); @@ -173,7 +212,8 @@ export default function Page() { parentData, "Environment variables page loader data must be defined when rendering the create dialog" ); - const { environments, hasStaging } = parentData; + const { environments, hasStaging, accessibleEnvironmentIds } = parentData; + const accessibleEnvironmentIdSet = new Set(accessibleEnvironmentIds); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); @@ -269,19 +309,45 @@ export default function Page() { )) )}
- {nonBranchEnvironments.map((environment) => ( - - handleEnvironmentChange(environment.id, isChecked, environment.type) - } - label={} - variant="button" - /> - ))} + {nonBranchEnvironments.map((environment) => + accessibleEnvironmentIdSet.has(environment.id) ? ( + + handleEnvironmentChange(environment.id, isChecked, environment.type) + } + label={} + variant="button" + /> + ) : ( + + + +
+ + } + variant="button" + /> +
+
+ + + With your current role, you can't manage{" "} + {environmentFullTitle(environment)} environment variables. + +
+
+ ) + )} {!hasStaging && ( <> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 389551fc75f..cae6d2eeed2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -4,6 +4,7 @@ import { BookOpenIcon, InformationCircleIcon, LockClosedIcon, + NoSymbolIcon, PencilSquareIcon, PlusIcon, TrashIcon, @@ -17,16 +18,9 @@ import { useNavigation, useRevalidator, } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type RefObject, -} from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -70,11 +64,10 @@ import { EnvironmentVariablesPresenter, } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, - ProjectParamSchema, docsPath, v3EnvironmentVariablesPath, v3NewEnvironmentVariablesPath, @@ -107,42 +100,86 @@ type PageVercelIntegration = NonNullable< Awaited>["vercelIntegration"] >; +// A value the current role can't read for its environment tier is masked +// server-side: the value is withheld and the cell renders "Permission denied". +export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & { + permissionDenied?: boolean; +}; + export type EnvironmentVariablesPageLoaderData = { - environmentVariables: EnvironmentVariableWithSetValues[]; + environmentVariables: MaskedEnvironmentVariable[]; environments: EnvironmentVariablesEnvironment[]; hasStaging: boolean; vercelIntegration: PageVercelIntegration | null; + // Environment ids whose env vars the current role can read. + accessibleEnvironmentIds: string[]; }; export const environmentVariablesRouteId = "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - try { - const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration } = - await presenter.call({ - userId, - projectSlug: projectParam, - }); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // No hard authorization: the page lists every environment. Values in + // environments the role can't read are masked per-tier below. + }, + async ({ params, user, ability }) => { + const { projectParam } = params; + + try { + const presenter = new EnvironmentVariablesPresenter(); + const { environmentVariables, environments, hasStaging, vercelIntegration } = + await presenter.call({ + userId: user.id, + projectSlug: projectParam, + }); + + const accessibleEnvironmentIds = environments + .filter((env) => ability.can("read", { type: "envvars", envType: env.type })) + .map((env) => env.id); + const accessible = new Set(accessibleEnvironmentIds); + + // Withhold values (and the "who/when" metadata) for environments the + // role can't read — never serialize them to the client. + const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) => + accessible.has(variable.environment.id) + ? variable + : { + ...variable, + value: "", + isSecret: false, + permissionDenied: true, + lastUpdatedBy: null, + updatedByUser: null, + } + ); - return typedjson({ - environmentVariables, - environments, - hasStaging, - vercelIntegration, - }); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); + return typedjson({ + environmentVariables: masked, + environments, + hasStaging, + vercelIntegration, + accessibleEnvironmentIds, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } } -}; +); const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), @@ -161,131 +198,160 @@ const schema = z.discriminatedUnion("action", [ }), ]); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // Per-environment write:envvars is enforced in the handler — the target + // environment tier comes from the submission, not the route params. + }, + async ({ request, params, user, ability }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } + const formData = await request.formData(); + const submission = parse(formData, { schema }); - const formData = await request.formData(); - const submission = parse(formData, { schema }); + if (!submission.value) { + return json(submission); + } - if (!submission.value) { - return json(submission); - } + // Enforce env-tier write:envvars on the targeted environment, so a role + // that can't write a deployed tier can't mutate it via a direct POST. + const targetEnvType = + submission.value.action === "update-vercel-sync" + ? submission.value.environmentType + : ( + await prisma.runtimeEnvironment.findFirst({ + where: { id: submission.value.environmentId }, + select: { type: true }, + }) + )?.type; + if (targetEnvType && !ability.can("write", { type: "envvars", envType: targetEnvType })) { + submission.error.key = [ + "You don't have permission to manage environment variables in this environment.", + ]; + return json(submission); + } - const project = await prisma.project.findUnique({ - where: { - slug: params.projectParam, - organization: { - members: { - some: { - userId, + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }); - if (!project) { - submission.error.key = ["Project not found"]; - return json(submission); - } + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = ["Project not found"]; + return json(submission); + } - switch (submission.value.action) { - case "edit": { - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.editValue(project.id, { - ...submission.value, - lastUpdatedBy: { - type: "user", - userId, - }, - }); + switch (submission.value.action) { + case "edit": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.editValue(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); - if (!result.success) { - submission.error.key = [result.error]; - return json(submission); + if (!result.success) { + submission.error.key = [result.error]; + return json(submission); + } + + return json({ ...submission, success: true }); } + case "delete": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.deleteValue(project.id, submission.value); - return json({ ...submission, success: true }); - } - case "delete": { - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.deleteValue(project.id, submission.value); + if (!result.success) { + submission.error.key = [result.error]; + return json(submission); + } - if (!result.success) { - submission.error.key = [result.error]; - return json(submission); + // Clean up syncEnvVarsMapping if Vercel integration exists (best-effort) + const { environmentId, key } = submission.value; + const vercelService = new VercelIntegrationService(); + await fromPromise( + (async () => { + const integration = await vercelService.getVercelProjectIntegration(project.id); + if (integration) { + const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ + where: { id: environmentId }, + select: { type: true }, + }); + if (runtimeEnv) { + await vercelService.removeSyncEnvVarForEnvironment( + project.id, + key, + runtimeEnv.type as TriggerEnvironmentType + ); + } + } + })(), + (error) => error + ).mapErr((error) => { + logger.error("Failed to remove Vercel sync mapping", { error }); + return error; + }); + + return redirectWithSuccessMessage( + v3EnvironmentVariablesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ), + request, + `Deleted ${submission.value.key} environment variable` + ); } + case "update-vercel-sync": { + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); - // Clean up syncEnvVarsMapping if Vercel integration exists (best-effort) - const { environmentId, key } = submission.value; - const vercelService = new VercelIntegrationService(); - await fromPromise( - (async () => { - const integration = await vercelService.getVercelProjectIntegration(project.id); - if (integration) { - const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ - where: { id: environmentId }, - select: { type: true }, - }); - if (runtimeEnv) { - await vercelService.removeSyncEnvVarForEnvironment( - project.id, - key, - runtimeEnv.type as TriggerEnvironmentType - ); - } - } - })(), - (error) => error - ).mapErr((error) => { - logger.error("Failed to remove Vercel sync mapping", { error }); - return error; - }); + if (!integration) { + submission.error.key = ["Vercel integration not found"]; + return json(submission); + } - return redirectWithSuccessMessage( - v3EnvironmentVariablesPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ), - request, - `Deleted ${submission.value.key} environment variable` - ); - } - case "update-vercel-sync": { - const vercelService = new VercelIntegrationService(); - const integration = await vercelService.getVercelProjectIntegration(project.id); + // Update the sync mapping for the specific env var and environment + await vercelService.updateSyncEnvVarForEnvironment( + project.id, + submission.value.key, + submission.value.environmentType, + submission.value.syncEnabled + ); - if (!integration) { - submission.error.key = ["Vercel integration not found"]; - return json(submission); + return json({ success: true }); } - - // Update the sync mapping for the specific env var and environment - await vercelService.updateSyncEnvVarForEnvironment( - project.id, - submission.value.key, - submission.value.environmentType, - submission.value.syncEnabled - ); - - return json({ success: true }); } } -}; +); const SSR_ROW_WINDOW = 50; const ROW_ESTIMATE_HEIGHT = 44; const VIRTUAL_OVERSCAN = 10; -type GroupedEnvironmentVariable = EnvironmentVariableWithSetValues & { +type GroupedEnvironmentVariable = MaskedEnvironmentVariable & { isFirstTime: boolean; isLastTime: boolean; occurences: number; @@ -538,16 +604,22 @@ function EnvironmentVariableTableRow({ const borderedCellClassName = getBorderedCellClassName(variable); return ( - + - {variable.isFirstTime ? ( - - ) : null} + {variable.isFirstTime ? : null} - {variable.isSecret ? ( + {variable.permissionDenied ? ( + + + Permission denied +
+ } + content="With your current role, you can't view this environment's variables." + /> + ) : variable.isSecret ? ( @@ -620,10 +692,14 @@ function EnvironmentVariableTableRow({ isSticky className="[&:has(.group-hover/table-row:block)]:w-auto w-0" hiddenButtons={ - <> - - - + // No edit/delete for environments the role can't manage — the value + // is withheld, and the action enforces write:envvars independently. + variable.permissionDenied ? undefined : ( + <> + + + + ) } />
@@ -652,8 +728,7 @@ function EnvironmentVariablesVirtualTableBody({ const virtualItems = rowVirtualizer.getVirtualItems(); const topSpacerHeight = virtualItems[0]?.start ?? 0; - const bottomSpacerHeight = - rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); + const bottomSpacerHeight = rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); return ( From 9da9b1b8e9e888025491992d3377c17b7f72a5e2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 15:07:33 +0100 Subject: [PATCH 22/26] feat(webapp): enforce env var permissions on the API routes The environment variable API routes now apply the caller's role to the targeted environment tier when authenticated with a personal access token, so a restricted role can't read or write deployed env vars via the API. Environment API keys are scoped to a single environment already, so they are unaffected. --- ...rojects.$projectRef.envvars.$slug.$name.ts | 33 ++++++++++++- ...ojects.$projectRef.envvars.$slug.import.ts | 17 ++++++- ...i.v1.projects.$projectRef.envvars.$slug.ts | 33 ++++++++++++- .../environmentVariableApiAccess.server.ts | 49 +++++++++++++++++++ 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/app/services/environmentVariableApiAccess.server.ts diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 00e155622ce..4c0fa99185a 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -8,6 +8,7 @@ import { branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -24,7 +25,11 @@ export async function action({ params, request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -37,6 +42,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + // Find the environment variable const variable = await prisma.environmentVariable.findFirst({ where: { @@ -110,7 +125,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -123,6 +142,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "read", + }); + if (denied) return denied; + // Find the environment variable const variable = await prisma.environmentVariable.findFirst({ where: { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 313ecdc8538..177fbd6848f 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -7,6 +7,7 @@ import { authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -21,7 +22,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -34,6 +39,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + const repository = new EnvironmentVariablesRepository(); const body = await parseImportBody(request); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index 188290ae8ab..95e1d480fb8 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -6,6 +6,7 @@ import { authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -20,7 +21,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -33,6 +38,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + const jsonBody = await request.json(); const body = CreateEnvironmentVariableRequestBody.safeParse(jsonBody); @@ -68,7 +83,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -81,6 +100,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "read", + }); + if (denied) return denied; + const repository = new EnvironmentVariablesRepository(); const variables = await repository.getEnvironmentWithRedactedSecrets( diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts new file mode 100644 index 00000000000..43751239e42 --- /dev/null +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -0,0 +1,49 @@ +import { json } from "@remix-run/server-runtime"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { rbac } from "~/services/rbac.server"; + +/** + * Env-tier RBAC for the environment-variable API routes. + * + * Machine credentials (an environment's secret/public API key) are already + * scoped to a single environment, so they pass through unchanged. A personal + * access token carries a user, so enforce that user's role for the targeted + * environment tier — e.g. a Developer can't read or write deployed env vars + * via the API, matching the dashboard restriction. + * + * Returns a `Response` to short-circuit with when access is denied, or + * `undefined` when the request may proceed. + */ +export async function authorizeEnvVarApiRequest({ + request, + authType, + organizationId, + projectId, + envType, + action, +}: { + request: Request; + authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; + organizationId: string; + projectId: string; + envType: RuntimeEnvironmentType; + action: "read" | "write"; +}): Promise { + if (authType !== "personalAccessToken") { + return undefined; + } + + const patAuth = await rbac.authenticatePat(request, { organizationId, projectId }); + if (!patAuth.ok) { + return json({ error: patAuth.error }, { status: patAuth.status }); + } + + if (!patAuth.ability.can(action, { type: "envvars", envType })) { + return json( + { error: "You don't have permission to manage environment variables in this environment." }, + { status: 403 } + ); + } + + return undefined; +} From 2346a72568109843f7f042daca2a6955f3c1ab9a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 15:07:42 +0100 Subject: [PATCH 23/26] docs(webapp): drop tracker reference from invite role comment --- .../app/routes/_app.orgs.$organizationSlug.invite/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 822a4d7f3f3..bc515667458 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -116,7 +116,7 @@ const NO_RBAC_ROLE = "__no_rbac_role__"; // first), so array index drives the ladder — earlier index = higher // rank. Plan-tier filtering happens separately via assignableRoleIds; // the ladder is the absolute hierarchy. Custom roles aren't in the -// table and are refused (TRI-8747's follow-up will handle them). +// ladder yet, so they're refused for now. type LadderRole = { id: string }; function buildRoleLevel(roles: ReadonlyArray): Record { From c8e7e0d1f0c0a706a5786e0e2c545b91093b0eb3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 17:36:35 +0100 Subject: [PATCH 24/26] feat(webapp): enforce env-tier access on environment credential endpoints The endpoints that hand a personal access token an environment's secret key or a key-signed JWT now apply the caller's role for that environment tier. A restricted role can't pull deployed-environment credentials, which is what stops it deploying via the CLI (deploy authenticates with the environment secret key). Environment API keys are scoped to a single environment already, so they are unaffected. --- .../api.v1.projects.$projectRef.$env.jwt.ts | 15 ++++++++ .../api.v1.projects.$projectRef.$env.ts | 21 +++++++++- .../environmentVariableApiAccess.server.ts | 38 ++++++++++++++++--- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts index b9cb61e1f20..1f455a6b51c 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts @@ -6,6 +6,7 @@ import { authenticateRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -49,6 +50,20 @@ export async function action({ request, params }: ActionFunctionArgs) { triggerBranch ); + // This mints a JWT signed with the environment's secret key. For a PAT + // (a user), gate it on env-tier read:apiKeys so a restricted role can't + // obtain deployed-environment credentials (and therefore can't deploy). + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: runtimeEnv.organizationId, + projectId: runtimeEnv.project.id, + envType: runtimeEnv.type, + resource: "apiKeys", + action: "read", + }); + if (denied) return denied; + const parsedBody = RequestBodySchema.safeParse(await request.json()); if (!parsedBody.success) { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index 218cc580dd3..42ef412ec19 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -8,6 +8,7 @@ import { branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -26,7 +27,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -39,6 +44,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + // This endpoint hands the caller the environment's secret key. For a PAT + // (a user), gate it on env-tier read:apiKeys — so a restricted role can't + // pull deployed credentials (and therefore can't deploy) via the CLI. + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + resource: "apiKeys", + action: "read", + }); + if (denied) return denied; + const result: GetProjectEnvResponse = { apiKey: environment.apiKey, name: environment.project.name, diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index 43751239e42..2ba7b2b91f2 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -2,24 +2,35 @@ import { json } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { rbac } from "~/services/rbac.server"; +type EnvironmentScopedResource = "envvars" | "apiKeys"; + +const RESOURCE_LABELS: Record = { + envvars: "environment variables", + apiKeys: "API keys", +}; + /** - * Env-tier RBAC for the environment-variable API routes. + * Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints + * that hand out an environment's secret credentials). * * Machine credentials (an environment's secret/public API key) are already * scoped to a single environment, so they pass through unchanged. A personal * access token carries a user, so enforce that user's role for the targeted - * environment tier — e.g. a Developer can't read or write deployed env vars - * via the API, matching the dashboard restriction. + * environment tier — e.g. a Developer can't read deployed env vars or API keys + * via the API, matching the dashboard restriction. Blocking the credential read + * for deployed tiers is also what stops a restricted role deploying via the CLI + * (deploy needs the environment's secret key). * * Returns a `Response` to short-circuit with when access is denied, or * `undefined` when the request may proceed. */ -export async function authorizeEnvVarApiRequest({ +export async function authorizePatEnvironmentAccess({ request, authType, organizationId, projectId, envType, + resource, action, }: { request: Request; @@ -27,6 +38,7 @@ export async function authorizeEnvVarApiRequest({ organizationId: string; projectId: string; envType: RuntimeEnvironmentType; + resource: EnvironmentScopedResource; action: "read" | "write"; }): Promise { if (authType !== "personalAccessToken") { @@ -38,12 +50,26 @@ export async function authorizeEnvVarApiRequest({ return json({ error: patAuth.error }, { status: patAuth.status }); } - if (!patAuth.ability.can(action, { type: "envvars", envType })) { + if (!patAuth.ability.can(action, { type: resource, envType })) { return json( - { error: "You don't have permission to manage environment variables in this environment." }, + { + error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`, + }, { status: 403 } ); } return undefined; } + +/** Env-tier env var access for the env var API routes. */ +export function authorizeEnvVarApiRequest(opts: { + request: Request; + authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; + organizationId: string; + projectId: string; + envType: RuntimeEnvironmentType; + action: "read" | "write"; +}): Promise { + return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" }); +} From 14ff180c380d45520e11e4954f65e5231a8f14b2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 16 Jun 2026 18:18:45 +0100 Subject: [PATCH 25/26] chore(webapp): add server-changes note for RBAC permission enforcement --- .server-changes/rbac-permission-enforcement.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/rbac-permission-enforcement.md diff --git a/.server-changes/rbac-permission-enforcement.md b/.server-changes/rbac-permission-enforcement.md new file mode 100644 index 00000000000..54648c7de0e --- /dev/null +++ b/.server-changes/rbac-permission-enforcement.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Enforce role-based permissions across the dashboard and API. Roles without access to a resource (environment variables, API keys, deployments, integrations, members, billing) can no longer read or change it, and gated pages now show a permission-denied panel instead of redirecting away. From cc6a02148a2897fe3a4a2712f4c5c2807dc76430 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 17 Jun 2026 14:01:55 +0100 Subject: [PATCH 26/26] 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. --- .../route.tsx | 281 ++++++++++++------ 1 file changed, 189 insertions(+), 92 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx index 2178d19f99b..8158634922a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -1,10 +1,21 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { Form, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson"; +import { json } from "@remix-run/server-runtime"; +import { + type UseDataFunctionReturn, + typedjson, + useTypedLoaderData, + useTypedFetcher, +} from "remix-typedjson"; import { z } from "zod"; -import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { + MainCenteredContainer, + MainHorizontallyCenteredContainer, +} from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; +import { $replica } from "~/db.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -26,7 +37,6 @@ import { import { ProjectSettingsService } from "~/services/projectSettings.server"; import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; import React, { useEffect, useState, useCallback, useRef } from "react"; import { useSearchParams } from "@remix-run/react"; @@ -39,48 +49,71 @@ import { import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } }); + return org?.id ?? null; +} - const projectSettingsPresenter = new ProjectSettingsPresenter(); - const resultOrFail = await projectSettingsPresenter.getProjectSettings( - organizationSlug, - projectParam, - userId - ); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // No hard authorization: the page renders a PermissionDenied panel for + // roles that can't manage any integration (see canManageIntegrations). + }, + async ({ params, user, ability }) => { + const { projectParam, organizationSlug } = params; - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "project_not_found": { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed loading project settings", { - error: resultOrFail.error, - }); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, please try again!", - }); + const canManageIntegrations = + ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" }); + + if (!canManageIntegrations) { + return typedjson({ canManageIntegrations: false as const }); + } + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + user.id + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "project_not_found": { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed loading project settings", { + error: resultOrFail.error, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, please try again!", + }); + } } } - } - const { gitHubApp, buildSettings } = resultOrFail.value; + const { gitHubApp, buildSettings } = resultOrFail.value; - return typedjson({ - githubAppEnabled: gitHubApp.enabled, - buildSettings, - vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, - }); -}; + return typedjson({ + canManageIntegrations: true as const, + githubAppEnabled: gitHubApp.enabled, + buildSettings, + vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, + }); + } +); const UpdateBuildSettingsFormSchema = z.object({ action: z.literal("update-build-settings"), @@ -118,63 +151,89 @@ const UpdateBuildSettingsFormSchema = z.object({ .transform((val) => val === "on"), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam } = params; - if (!organizationSlug || !projectParam) { - return json({ errors: { body: "organizationSlug and projectParam are required" } }, { status: 400 }); - } - - const formData = await request.formData(); - const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema }); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // Build settings configure the Git-based deploy, so gate on write:github + // (a restricted role can view neither this page nor mutate via a POST). + authorization: { action: "write", resource: { type: "github" } }, + }, + async ({ request, params, user }) => { + const { organizationSlug, projectParam } = params; + + const formData = await request.formData(); + const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + user.id + ); - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); - } + const { projectId } = membershipResultOrFail.value; - const { projectId } = membershipResultOrFail.value; + const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = + submission.value; - const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = - submission.value; + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + installCommand: installCommand || undefined, + preBuildCommand: preBuildCommand || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, + useNativeBuildServer: useNativeBuildServer, + }); - const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { - installCommand: installCommand || undefined, - preBuildCommand: preBuildCommand || undefined, - triggerConfigFilePath: triggerConfigFilePath || undefined, - useNativeBuildServer: useNativeBuildServer, - }); + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to update build settings", { - error: resultOrFail.error, - }); - return redirectBackWithErrorMessage(request, "Failed to update build settings"); + logger.error("Failed to update build settings", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to update build settings"); + } } } + + return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); } +); - return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); -}; +type IntegrationsData = Extract< + UseDataFunctionReturn, + { canManageIntegrations: true } +>; export default function IntegrationsSettingsPage() { - const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = - useTypedLoaderData(); + const data = useTypedLoaderData(); + + if (!data.canManageIntegrations) { + return ( + + + + ); + } + + return ; +} + +function IntegrationsSettings({ data }: { data: IntegrationsData }) { + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = data; const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); @@ -223,14 +282,28 @@ export default function IntegrationsSettingsPage() { } else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) { // Load onboarding data vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + `${vercelResourcePath( + organization.slug, + project.slug, + environment.slug + )}?vercelOnboarding=true` ); } } else if (!hasQueryParam && isModalOpen) { // Query param removed but modal is open, close modal setIsModalOpen(false); } - }, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + }, [ + hasQueryParam, + vercelIntegrationEnabled, + organization.slug, + project.slug, + environment.slug, + vercelFetcher.data, + vercelFetcher.state, + isModalOpen, + openVercelOnboarding, + ]); // Ensure modal stays open when query param is present (even after data reloads) // This is a safeguard to prevent the modal from closing during form submissions @@ -272,14 +345,30 @@ export default function IntegrationsSettingsPage() { // Need to load data first, mark that we're waiting for button click waitingForButtonClickRef.current = true; vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + `${vercelResourcePath( + organization.slug, + project.slug, + environment.slug + )}?vercelOnboarding=true` ); } - }, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]); + }, [ + organization.slug, + project.slug, + environment.slug, + vercelFetcher, + setSearchParams, + hasQueryParam, + openVercelOnboarding, + ]); // When data loads from button click, open modal useEffect(() => { - if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + if ( + waitingForButtonClickRef.current && + vercelFetcher.data?.onboardingData && + vercelFetcher.state === "idle" + ) { // Data loaded from button click, open modal and ensure query param is present waitingForButtonClickRef.current = false; openVercelOnboarding(); @@ -313,7 +402,9 @@ export default function IntegrationsSettingsPage() { projectSlug={project.slug} environmentSlug={environment.slug} onOpenVercelModal={handleOpenVercelModal} - isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"} + isLoadingVercelData={ + vercelFetcher.state === "loading" || vercelFetcher.state === "submitting" + } /> @@ -346,8 +437,14 @@ export default function IntegrationsSettingsPage() { vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl} onDataReload={(vercelEnvironmentId) => { vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ - vercelEnvironmentId ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` : "" + `${vercelResourcePath( + organization.slug, + project.slug, + environment.slug + )}?vercelOnboarding=true${ + vercelEnvironmentId + ? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}` + : "" }` ); }}