From 4821d0db52d183fb4218c35ab9ce4acd7dbb9174 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 11 Jun 2026 11:45:58 +0100 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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); + } +);