From 480df2f85177893b868421e66da2e0a72ad02a27 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 21 Aug 2026 15:46:01 +0000 Subject: [PATCH 1/4] fix(site/src): use the browser-only bullets from the copy doc The browser-only connections paywall on /deployment/security fell back to the generic PREMIUM_FEATURES list. Use the bullets from the Premium Copy Updates doc, and add a paywalled story so the state is covered. --- .../SecuritySettingsPageView.stories.tsx | 24 +++++++++++++++++++ .../SecuritySettingsPageView.tsx | 8 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx index a27250bf267..1ac58ba7932 100644 --- a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import type { SerpentGroup, SerpentOption } from "#/api/typesGenerated"; import { SecuritySettingsPageView } from "./SecuritySettingsPageView"; @@ -56,6 +57,29 @@ type Story = StoryObj; export const Page: Story = {}; +export const BrowserOnlyPaywall: Story = { + args: { + featureBrowserOnlyEnabled: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText("Restrict access to web-based connections"), + ).toBeVisible(); + await expect( + canvas.getByText("Block SSH and port-forward entirely"), + ).toBeVisible(); + await expect( + canvas.getByText("Enforce browser-only compliance policies"), + ).toBeVisible(); + // The generic premium bullets must not leak back in. + await expect( + canvas.queryByText("24x7 global support with SLA"), + ).not.toBeInTheDocument(); + }, +}; + export const NoTLS = { args: { options: [ diff --git a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx index a8e95ffd29b..82e1a64465d 100644 --- a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx @@ -79,8 +79,12 @@ export const SecuritySettingsPageView: FC = ({ ) : null} From 3b049d3ccdd9aaad651c3f2b4c0dd677395ab1a7 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 21 Aug 2026 15:58:54 +0000 Subject: [PATCH 2/4] feat(site/src): show a premium paywall on the external auth page The multiple-providers notice was a plain info alert, so the copy doc bullets for external auth had nowhere to render and the surface was missing from premium funnel attribution. Replace it with PremiumPaywallSmall, shown only when the deployment lacks the multiple_external_auth entitlement, and add an external_auth premium funnel source so CTA clicks are attributable like every other paywall. --- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + codersdk/premiumfunnel.go | 2 + docs/reference/api/schemas.md | 6 +-- site/src/api/typesGenerated.ts | 2 + .../ExternalAuthSettingsPage.tsx | 10 ++++- .../ExternalAuthSettingsPageView.stories.tsx | 43 ++++++++++++++++--- .../ExternalAuthSettingsPageView.tsx | 39 +++++++++-------- 8 files changed, 78 insertions(+), 28 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f515fa9fff7..a8f25d7cce4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -23829,6 +23829,7 @@ const docTemplate = `{ "browser_only", "connection_log", "custom_roles", + "external_auth", "groups", "idp_org_sync", "idp_sync", @@ -23850,6 +23851,7 @@ const docTemplate = `{ "PremiumFunnelSourceBrowserOnly", "PremiumFunnelSourceConnectionLog", "PremiumFunnelSourceCustomRoles", + "PremiumFunnelSourceExternalAuth", "PremiumFunnelSourceGroups", "PremiumFunnelSourceIdpOrgSync", "PremiumFunnelSourceIdpSync", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6e4c74d90b0..29f75031056 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -21814,6 +21814,7 @@ "browser_only", "connection_log", "custom_roles", + "external_auth", "groups", "idp_org_sync", "idp_sync", @@ -21835,6 +21836,7 @@ "PremiumFunnelSourceBrowserOnly", "PremiumFunnelSourceConnectionLog", "PremiumFunnelSourceCustomRoles", + "PremiumFunnelSourceExternalAuth", "PremiumFunnelSourceGroups", "PremiumFunnelSourceIdpOrgSync", "PremiumFunnelSourceIdpSync", diff --git a/codersdk/premiumfunnel.go b/codersdk/premiumfunnel.go index f2901406e6a..13e8be226c3 100644 --- a/codersdk/premiumfunnel.go +++ b/codersdk/premiumfunnel.go @@ -22,6 +22,7 @@ const ( PremiumFunnelSourceBrowserOnly PremiumFunnelSource = "browser_only" PremiumFunnelSourceConnectionLog PremiumFunnelSource = "connection_log" PremiumFunnelSourceCustomRoles PremiumFunnelSource = "custom_roles" + PremiumFunnelSourceExternalAuth PremiumFunnelSource = "external_auth" PremiumFunnelSourceGroups PremiumFunnelSource = "groups" PremiumFunnelSourceIdpOrgSync PremiumFunnelSource = "idp_org_sync" PremiumFunnelSourceIdpSync PremiumFunnelSource = "idp_sync" @@ -49,6 +50,7 @@ func PremiumFunnelSources() []PremiumFunnelSource { PremiumFunnelSourceBrowserOnly, PremiumFunnelSourceConnectionLog, PremiumFunnelSourceCustomRoles, + PremiumFunnelSourceExternalAuth, PremiumFunnelSourceGroups, PremiumFunnelSourceIdpOrgSync, PremiumFunnelSourceIdpSync, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d8bbcbd6afe..97c7aa66928 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -10674,9 +10674,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_keys`, `ai_governance`, `aibridge_session_threads`, `aibridge_sessions`, `appearance`, `audit_log`, `browser_only`, `connection_log`, `custom_roles`, `direct`, `groups`, `idp_org_sync`, `idp_sync`, `multiple_organizations`, `observability`, `provisioner_keys`, `provisioners`, `template_permissions`, `workspace_proxies` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_keys`, `ai_governance`, `aibridge_session_threads`, `aibridge_sessions`, `appearance`, `audit_log`, `browser_only`, `connection_log`, `custom_roles`, `direct`, `external_auth`, `groups`, `idp_org_sync`, `idp_sync`, `multiple_organizations`, `observability`, `provisioner_keys`, `provisioners`, `template_permissions`, `workspace_proxies` | ## codersdk.PremiumFunnelVariant diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index aa6f9279cde..2e693d2cfc3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7412,6 +7412,7 @@ export type PremiumFunnelSource = | "connection_log" | "custom_roles" | "direct" + | "external_auth" | "groups" | "idp_org_sync" | "idp_sync" @@ -7433,6 +7434,7 @@ export const PremiumFunnelSources: PremiumFunnelSource[] = [ "connection_log", "custom_roles", "direct", + "external_auth", "groups", "idp_org_sync", "idp_sync", diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx index 6ac6a49f2ad..eb8dcab7b55 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage.tsx @@ -1,16 +1,24 @@ import type { FC } from "react"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { useDeploymentConfig } from "#/modules/management/DeploymentConfigProvider"; import { pageTitle } from "#/utils/page"; import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView"; const ExternalAuthSettingsPage: FC = () => { const { deploymentConfig } = useDeploymentConfig(); + const { permissions } = useAuthenticated(); + const { multiple_external_auth: isEntitled } = useFeatureVisibility(); return ( <> Codestin Search App - + ); }; diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 339a0a59974..98956324dde 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -34,6 +34,8 @@ const meta: Meta = { }, ], }, + isEntitled: false, + canViewPremium: true, }, }; @@ -44,13 +46,44 @@ export const Page: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const notice = canvas.getByRole("alert"); - await expect(within(notice).getByText("Premium")).toBeVisible(); await expect( - within(notice).getByRole("link", { - name: "Read the External Authentication documentation", - }), + canvas.getByText("Connect multiple Git and OAuth providers at once."), ).toBeVisible(); + await expect( + canvas.getByText("Match providers by regex per host"), + ).toBeVisible(); + await expect( + canvas.getByRole("link", { name: "Start trial for free" }), + ).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const Entitled: Story = { + args: { + isEntitled: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.queryByText("Connect multiple Git and OAuth providers at once."), + ).not.toBeInTheDocument(); + }, +}; + +export const PaywallWithoutLicenseAccess: Story = { + args: { + canViewPremium: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Start trial for free" }), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index bc67b6feee9..c27648515e2 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -3,7 +3,6 @@ import type { DeploymentValues, ExternalAuthConfig, } from "#/api/typesGenerated"; -import { Alert } from "#/components/Alert/Alert"; import { SettingsHeader, SettingsHeaderDescription, @@ -19,15 +18,19 @@ import { TableRow, } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { PremiumPaywallSmall } from "#/modules/paywall/PremiumPaywallSmall"; import { docs } from "#/utils/docs"; type ExternalAuthSettingsPageViewProps = { config: DeploymentValues; + /** True when the deployment may configure more than one provider. */ + isEntitled: boolean; + canViewPremium: boolean; }; export const ExternalAuthSettingsPageView: FC< ExternalAuthSettingsPageViewProps -> = ({ config }) => { +> = ({ config, isEntitled, canViewPremium }) => { return ( <> -
- - Integrating with multiple external authentication providers is a - Premium feature.{" "} - - Read the External Authentication documentation - - . - -
+ {!isEntitled && ( +
+ +
+ )} From 9f81c5d454ce2df354f3d9a600da941b65db33b7 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 21 Aug 2026 16:01:23 +0000 Subject: [PATCH 3/4] chore(site/src): drop the browser-only paywall story per review --- .../SecuritySettingsPageView.stories.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx index 1ac58ba7932..a27250bf267 100644 --- a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; import type { SerpentGroup, SerpentOption } from "#/api/typesGenerated"; import { SecuritySettingsPageView } from "./SecuritySettingsPageView"; @@ -57,29 +56,6 @@ type Story = StoryObj; export const Page: Story = {}; -export const BrowserOnlyPaywall: Story = { - args: { - featureBrowserOnlyEnabled: false, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect( - canvas.getByText("Restrict access to web-based connections"), - ).toBeVisible(); - await expect( - canvas.getByText("Block SSH and port-forward entirely"), - ).toBeVisible(); - await expect( - canvas.getByText("Enforce browser-only compliance policies"), - ).toBeVisible(); - // The generic premium bullets must not leak back in. - await expect( - canvas.queryByText("24x7 global support with SLA"), - ).not.toBeInTheDocument(); - }, -}; - export const NoTLS = { args: { options: [ From 3a5b114c0ddc5332703051fc3eb7a583661f2a68 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 21 Aug 2026 16:26:21 +0000 Subject: [PATCH 4/4] chore(site/src): assert links rather than copy in external auth stories --- .../ExternalAuthSettingsPageView.stories.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 98956324dde..0b5ac04cd8c 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -46,12 +46,6 @@ export const Page: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - canvas.getByText("Connect multiple Git and OAuth providers at once."), - ).toBeVisible(); - await expect( - canvas.getByText("Match providers by regex per host"), - ).toBeVisible(); await expect( canvas.getByRole("link", { name: "Start trial for free" }), ).toHaveAttribute("href", "/deployment/premium"); @@ -66,7 +60,7 @@ export const Entitled: Story = { const canvas = within(canvasElement); await expect( - canvas.queryByText("Connect multiple Git and OAuth providers at once."), + canvas.queryByRole("link", { name: "Start trial for free" }), ).not.toBeInTheDocument(); }, }; @@ -78,9 +72,6 @@ export const PaywallWithoutLicenseAccess: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - canvas.getByText(/contact your deployment administrator/i), - ).toBeVisible(); await expect( canvas.queryByRole("link", { name: "Start trial for free" }), ).not.toBeInTheDocument();