From cdb1ba73022afd0fbdd1fe7ebbad79f8ddcbcf90 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Sat, 28 Mar 2026 15:54:15 +0100 Subject: [PATCH] feat(webapp): Org level feature flags for Private Links --- .../OrganizationSettingsSideMenu.tsx | 6 +++-- .../OrganizationsPresenter.server.ts | 9 +++++-- .../route.tsx | 6 ++--- .../route.tsx | 6 ++--- .../v3/canAccessPrivateConnections.server.ts | 27 +++++++++++++++++++ apps/webapp/app/v3/featureFlags.server.ts | 14 ++++++---- 6 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 apps/webapp/app/v3/canAccessPrivateConnections.server.ts diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 7f67236a587..b3cc17724a3 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -9,6 +9,7 @@ import { import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { SlackIcon } from "@trigger.dev/companyicons"; import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; @@ -48,7 +49,8 @@ export function OrganizationSettingsSideMenu({ organization: MatchedOrganization; buildInfo: BuildInfo; }) { - const { isManagedCloud, hasPrivateConnections } = useFeatures(); + const { isManagedCloud } = useFeatures(); + const featureFlags = useFeatureFlags(); const currentPlan = useCurrentPlan(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; @@ -105,7 +107,7 @@ export function OrganizationSettingsSideMenu({ /> )} - {hasPrivateConnections && ( + {featureFlags.hasPrivateConnections && ( { const orgFlagsResult = org.featureFlags diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx index 57a08674513..963969ddffc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx @@ -13,7 +13,7 @@ import { Header2 } from "~/components/primitives/Headers"; 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 { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; import { logger } from "~/services/logger.server"; import { getPrivateLinks } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; @@ -44,8 +44,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); - const { hasPrivateConnections } = featuresForRequest(request); - if (!hasPrivateConnections) { + const canAccess = await canAccessPrivateConnections({ organizationSlug, userId }); + if (!canAccess) { return redirect(organizationPath({ slug: organizationSlug })); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx index 730ab2290e2..649419b9c65 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx @@ -24,7 +24,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { featuresForRequest } from "~/features.server"; +import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage, @@ -56,8 +56,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); - const { hasPrivateConnections } = featuresForRequest(request); - if (!hasPrivateConnections) { + const canAccess = await canAccessPrivateConnections({ organizationSlug, userId }); + if (!canAccess) { return redirect(organizationPath({ slug: organizationSlug })); } diff --git a/apps/webapp/app/v3/canAccessPrivateConnections.server.ts b/apps/webapp/app/v3/canAccessPrivateConnections.server.ts new file mode 100644 index 00000000000..ffbd75b2ca5 --- /dev/null +++ b/apps/webapp/app/v3/canAccessPrivateConnections.server.ts @@ -0,0 +1,27 @@ +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; + +export async function canAccessPrivateConnections(options: { + organizationSlug: string; + userId: string; +}): Promise { + const { organizationSlug, userId } = options; + + const org = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + }, + select: { + featureFlags: true, + }, + }); + + const flag = makeFlag(); + return flag({ + key: FEATURE_FLAG.hasPrivateConnections, + defaultValue: env.PRIVATE_CONNECTIONS_ENABLED === "1", + overrides: (org?.featureFlags as Record) ?? {}, + }); +} diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index f32f34c64b8..749d3fa8374 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -9,6 +9,7 @@ export const FEATURE_FLAG = { hasLogsPageAccess: "hasLogsPageAccess", hasAiAccess: "hasAiAccess", hasAiModelsAccess: "hasAiModelsAccess", + hasPrivateConnections: "hasPrivateConnections", } as const; const FeatureFlagCatalog = { @@ -19,6 +20,7 @@ const FeatureFlagCatalog = { [FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), [FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(), + [FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(), }; type FeatureFlagKey = keyof typeof FeatureFlagCatalog; @@ -47,7 +49,7 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) { const flagSchema = FeatureFlagCatalog[opts.key]; - if (opts.overrides?.[opts.key]) { + if (opts.overrides?.[opts.key] !== undefined) { const parsed = flagSchema.safeParse(opts.overrides[opts.key]); if (parsed.success) { @@ -55,13 +57,15 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) { } } - const parsed = flagSchema.safeParse(value?.value); + if (value !== null) { + const parsed = flagSchema.safeParse(value.value); - if (!parsed.success) { - return opts.defaultValue; + if (parsed.success) { + return parsed.data; + } } - return parsed.data; + return opts.defaultValue; } return flag;