@@ -706,7 +738,9 @@ function FeaturesSection({
Feature
Status
- Upgrade
+ {showSelfServe ? (
+ Upgrade
+ ) : null}
@@ -714,16 +748,19 @@ function FeaturesSection({
feature={features.hasStagingEnvironment}
upgradeType={stagingUpgradeType}
billingPath={billingPath}
+ showSelfServe={showSelfServe}
/>
@@ -735,10 +772,12 @@ function FeatureRow({
feature,
upgradeType,
billingPath,
+ showSelfServe,
}: {
feature: FeatureInfo;
upgradeType: "view-plans" | "contact-us" | "none";
billingPath: string;
+ showSelfServe: boolean;
}) {
const displayValue = () => {
if (feature.name === "Included compute" && typeof feature.value === "number") {
@@ -795,7 +834,7 @@ function FeatureRow({
{displayValue()}
-
{renderUpgrade()}
+ {showSelfServe ?
{renderUpgrade()} : null}
);
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
index f90f3e829b0..c73fa253046 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
@@ -21,7 +21,7 @@ import {
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
-import { PurchaseSchedulesModal } from "~/components/schedules/PurchaseSchedulesModal";
+import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions";
import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { CopyableText } from "~/components/primitives/CopyableText";
@@ -439,21 +439,18 @@ function CreateScheduleButton({
You've used {limits.used}/{limits.limit} of your schedules.
- {canPurchaseSchedules && schedulePricing ? (
- Purchase more…}
- />
- ) : canUpgrade ? (
-
- Upgrade
-
- ) : null}
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx
index c4f8762639b..85f47ac9e4e 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx
@@ -28,7 +28,7 @@ import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
-import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
+import { getBillingAlerts, getCurrentPlan, setBillingAlert } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import {
@@ -36,6 +36,7 @@ import {
OrganizationParamsSchema,
organizationPath,
v3BillingAlertsPath,
+ v3BillingPath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
@@ -64,6 +65,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
+ const currentPlan = await getCurrentPlan(organization.id);
+ if (currentPlan?.v3Subscription?.showSelfServe === false) {
+ return redirect(v3BillingPath({ slug: organizationSlug }));
+ }
+
const [error, alerts] = await tryCatch(getBillingAlerts(organization.id));
if (error) {
throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` });
@@ -108,6 +114,23 @@ export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
+ const organization = await prisma.organization.findFirst({
+ where: { slug: organizationSlug, members: { some: { userId } } },
+ });
+
+ if (!organization) {
+ return redirectWithErrorMessage(
+ v3BillingPath({ slug: organizationSlug }),
+ request,
+ "You are not authorized to update billing alerts"
+ );
+ }
+
+ const currentPlan = await getCurrentPlan(organization.id);
+ if (currentPlan?.v3Subscription?.showSelfServe === false) {
+ return redirect(v3BillingPath({ slug: organizationSlug }));
+ }
+
const formData = await request.formData();
const submission = parse(formData, { schema });
@@ -116,18 +139,6 @@ export const action: ActionFunction = async ({ request, params }) => {
}
try {
- const organization = await prisma.organization.findFirst({
- where: { slug: organizationSlug, members: { some: { userId } } },
- });
-
- if (!organization) {
- return redirectWithErrorMessage(
- v3BillingAlertsPath({ slug: organizationSlug }),
- request,
- "You are not authorized to update billing alerts"
- );
- }
-
const [error, updatedAlert] = await tryCatch(
setBillingAlert(organization.id, {
...submission.value,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx
index 1e579908a92..e891efd087d 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx
@@ -1,11 +1,14 @@
-import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
+import { CalendarDaysIcon, CreditCardIcon, StarIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type PlanDefinition } from "@trigger.dev/platform";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
-import { PageBody, PageContainer } from "~/components/layout/AppLayout";
-import { LinkButton } from "~/components/primitives/Buttons";
+import { Feedback } from "~/components/Feedback";
+import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
+import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
@@ -14,6 +17,7 @@ import {
OrganizationParamsSchema,
organizationPath,
v3StripePortalPath,
+ v3UsagePath,
} from "~/utils/pathBuilder";
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
import { type MetaFunction } from "@remix-run/react";
@@ -36,11 +40,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return redirect(organizationPath({ slug: organizationSlug }));
}
- const plans = await getPlans();
- if (!plans) {
- throw new Response(null, { status: 404, statusText: "Plans not found" });
- }
-
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
@@ -50,6 +49,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
const currentPlan = await getCurrentPlan(organization.id);
+ const showSelfServe = currentPlan?.v3Subscription?.showSelfServe !== false;
//periods
const periodStart = new Date();
@@ -69,7 +69,25 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const message = url.searchParams.get("message");
+ if (!showSelfServe) {
+ return typedjson({
+ showSelfServe: false as const,
+ ...currentPlan,
+ organizationSlug,
+ periodStart,
+ periodEnd,
+ daysRemaining,
+ message,
+ });
+ }
+
+ const plans = await getPlans();
+ if (!plans) {
+ throw new Response(null, { status: 404, statusText: "Plans not found" });
+ }
+
return typedjson({
+ showSelfServe: true as const,
...plans,
...currentPlan,
organizationSlug,
@@ -81,22 +99,23 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
export default function ChoosePlanPage() {
+ const loaderData = useTypedLoaderData
();
const {
- plans,
- addOnPricing,
+ showSelfServe,
v3Subscription,
organizationSlug,
periodStart,
periodEnd,
daysRemaining,
message,
- } = useTypedLoaderData();
+ } = loaderData;
+
return (
- {v3Subscription?.isPaying && (
+ {v3Subscription?.isPaying && showSelfServe && (
<>
-
-
- {message && (
-
- {message}
-
- )}
-
-
-
- {planLabel(v3Subscription?.plan, v3Subscription?.canceledAt !== undefined, periodEnd)}
-
- {v3Subscription?.isPaying ? (
+
+ {showSelfServe ? (
+
+ {message && (
+
+ {message}
+
+ )}
+
-
- Billing period: {" "}
- to ({daysRemaining}{" "}
- days remaining)
+
+ {planLabel(
+ v3Subscription?.plan,
+ v3Subscription?.canceledAt !== undefined,
+ periodEnd
+ )}
- ) : null}
-
-
-
+ {v3Subscription?.isPaying ? (
+
+
+ Billing period: {" "}
+ to (
+ {daysRemaining} days remaining)
+
+ ) : null}
+
+
-
+ ) : (
+
+ Contact us}
+ />
+ }
+ >
+
+ Your billing is managed by our team.
+
+
+ Get in touch for invoices, plan changes, or other billing questions.
+
+
+
+ )}
);
@@ -161,7 +208,7 @@ function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEn
}
if (plan.type === "enterprise") {
- return `You're on the Enterprise plan`;
+ return "You're on the Enterprise plan";
}
const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`;
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
index 79f2356250a..6b3d037fbfe 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
@@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react";
import { useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
+import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
@@ -24,7 +25,7 @@ import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
-import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { TextLink } from "~/components/primitives/TextLink";
export const meta: MetaFunction = () => {
@@ -68,10 +69,7 @@ export const loader = dashboardLoader(
rbac.getAssignableRoleIds(orgId),
rbac.allPermissions(orgId),
rbac.systemRoles(orgId),
- // OSS self-host: no enterprise plugin → no role infrastructure to
- // show. Render a "roles aren't available" layout in that case
- // rather than the plan-upsell empty state (which assumes a cloud
- // plan and would be misleading).
+ // OSS self-host has no RBAC plugin.
rbac.isUsingPlugin(),
]);
@@ -90,33 +88,19 @@ type LoaderRole = LoaderData["roles"][number];
type LoaderPermission = LoaderData["allPermissions"][number];
type RolePermission = LoaderRole["permissions"][number];
-// Permissions are bucketed by `permission.group` from the plugin.
-// Section order = first-seen order in `allPermissions()`. Permissions
-// without a group fall into "Other" at the bottom.
+// Ungrouped permissions fall into "Other".
const FALLBACK_GROUP = "Other";
export default function Page() {
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } =
useTypedLoaderData
();
const organization = useOrganization();
- const plan = useCurrentPlan();
- const planCode = plan?.v3Subscription?.plan?.code;
- const isEnterprise = planCode === "enterprise";
+ const showSelfServe = useShowSelfServe();
- // Map role-id → role for fast cell lookup. Each role's permissions are
- // already the expanded `effectivePermissions` output (system roles
- // populated server-side; custom roles too) so cells just filter that
- // list by permission name.
const rolesById = new Map(roles.map((r) => [r.id, r]));
const assignable = new Set(assignableRoleIds);
- // Column ordering follows the plugin's canonical systemRoles order
- // (highest authority first), then any custom roles in the order
- // rbac.allRoles returned them. systemRoles is null when no plugin is
- // installed; fall through to whatever order rbac.allRoles returns.
- // Each entry's `available` flag reflects plan-tier eligibility — we
- // render unavailable system roles too, but PlanBadge tags them so
- // customers see the comparison and know what an upgrade unlocks.
+ // System roles first (plugin order), then custom roles.
const systemRoleOrder = systemRoles ?? [];
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
const systemColumns = systemRoleOrder.flatMap((meta) => {
@@ -134,12 +118,8 @@ export default function Page() {
- {/* Suppress the Enterprise-upsell button on OSS — there's no
- plan to upgrade to in a self-hosted deployment, and the
- dialog copy ("Available on the Enterprise plan") doesn't
- apply. The not-supported empty state below makes the
- absence of role infrastructure clear instead. */}
- {isUsingPlugin && !isEnterprise ? : null}
+ {/* Hide on OSS self-host and managed customers (!showSelfServe). */}
+ {isUsingPlugin && showSelfServe ? : null}
@@ -152,7 +132,7 @@ export default function Page() {
{columns.length === 0 ? (
-
+
) : (
@@ -223,17 +203,14 @@ export default function Page() {
);
}
-function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
- // Two distinct empty states:
- //
- // 1. Plugin loaded, but rbac.allRoles returned nothing the org can
- // use under its plan tier. The plan-upsell copy is correct —
- // upgrade unlocks the role infrastructure.
- // 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade
- // to. RBAC simply isn't part of this deployment; we use a
- // permissive ability for every authenticated user and rely on
- // org-membership for access control. Surface that honestly
- // instead of dangling a fake upgrade carrot.
+function EmptyState({
+ isUsingPlugin,
+ showSelfServe,
+}: {
+ isUsingPlugin: boolean;
+ showSelfServe: boolean;
+}) {
+ // OSS self-host vs plan-gated empty state.
if (!isUsingPlugin) {
return (
@@ -249,8 +226,16 @@ function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
No roles available on this plan.
- Upgrade to Pro to unlock RBAC.
+ {showSelfServe
+ ? "Upgrade to Pro to unlock RBAC."
+ : "Contact us to discuss RBAC for your organization."}
+ {!showSelfServe ? (
+
Contact us}
+ />
+ ) : null}
);
}
@@ -264,23 +249,14 @@ function PlanBadge({
assignable: ReadonlySet
;
systemRoleIdSet: ReadonlySet;
}) {
- // Roles the org's plan doesn't permit get a small upgrade-tier hint
- // in the column header. The cell rendering is identical regardless
- // — the comparison value is still useful even on Free/Hobby.
if (assignable.has(roleId)) return null;
- // System roles render as "Pro" (the gating tier where they unlock —
- // Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles
- // render as "Enterprise" — only Enterprise plans can create or assign
- // them.
+ // Unassignable system roles → Pro; custom roles → Enterprise.
if (systemRoleIdSet.has(roleId)) {
return Pro;
}
return Enterprise;
}
-// Render a single (role × permission) cell. Filters the role's
-// effectivePermissions list to entries matching this permission name
-// and emits an icon + optional condition badge based on the rules.
function RoleCell({
permissionName,
rolePermissions,
@@ -291,7 +267,6 @@ function RoleCell({
const matching = rolePermissions.filter((p) => p.name === permissionName);
if (matching.length === 0) {
- // No rule matches — the role denies this permission by omission.
return (
@@ -302,8 +277,6 @@ function RoleCell({
const allowed = matching.filter((p) => !p.inverted);
const denied = matching.filter((p) => p.inverted);
- // Only inverted rules apply — the role explicitly denies this
- // permission. Render as ✗ in error colour.
if (allowed.length === 0) {
return (
@@ -312,10 +285,6 @@ function RoleCell({
);
}
- // At least one allow rule applies. If there's a conditional cannot
- // rule, replace the ✓ with just the condition label so the user sees
- // the restriction without a misleading tick. Plain unconditional
- // allow keeps the ✓.
const conditionalDeny = denied.find((p) => p.conditions);
if (conditionalDeny?.conditions) {
return (
@@ -329,10 +298,7 @@ function RoleCell({
);
}
-// Render a CASL conditions object into a tier badge label. Only
-// `envType` is recognised today (the catalogue's only allowed condition);
-// extending this requires adding a new branch when ALLOWED_CONDITIONS
-// grows.
+// Only `envType` is supported today.
function conditionLabel(conditions: Record): string {
if (typeof conditions.envType === "string") {
if (conditions.envType === "PRODUCTION") return "Non-prod only";
@@ -344,9 +310,6 @@ function conditionLabel(conditions: Record): string {
function groupPermissions(
permissions: LoaderPermission[]
): { group: string; permissions: LoaderPermission[] }[] {
- // Insertion-ordered map: groups appear in the order their first
- // permission was seen. Plugins that want a specific section order
- // just emit permissions in that order from `allPermissions()`.
const buckets = new Map();
for (const permission of permissions) {
const group = permission.group ?? FALLBACK_GROUP;
@@ -357,7 +320,7 @@ function groupPermissions(
return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
}
-function CreateRoleUpsell() {
+function RequestCustomRoles() {
const [open, setOpen] = useState(false);
return (
@@ -841,6 +867,7 @@ export function PurchaseSeatsModal({
planSeatLimit: number;
triggerButton?: React.ReactElement;
}) {
+ const showSelfServe = useShowSelfServe();
const fetcher = useFetcher();
const organization = useOrganization();
const lastSubmission =
@@ -889,6 +916,15 @@ export function PurchaseSeatsModal({
const pricePerSeat = seatPricing.centsPerStep / seatPricing.stepSize / 100;
const title = extraSeats === 0 ? "Purchase extra seats…" : "Add/remove extra seats…";
+ if (!showSelfServe) {
+ return (
+ Request more}
+ />
+ );
+ }
+
return (