From 3a81b5c56f0384688463eca1cbe1548ee124b5b8 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 01:28:11 +0000 Subject: [PATCH 1/9] docs; premium badging and consistency --- docs/admin/external-auth/index.md | 5 +++++ docs/admin/networking/workspace-proxies.md | 7 ++++++- docs/admin/security/audit-logs.md | 2 +- docs/admin/users/groups-roles.md | 5 +++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/admin/external-auth/index.md b/docs/admin/external-auth/index.md index 94adc1e023e..61bd653cc4d 100644 --- a/docs/admin/external-auth/index.md +++ b/docs/admin/external-auth/index.md @@ -349,6 +349,11 @@ CODER_EXTERNAL_AUTH_0_SCOPES="repo:read repo:write write:gpg_key" ## Multiple External Providers (Premium) +> [!NOTE] +> Configuring more than one external authentication provider requires a +> [Premium license](https://coder.com/pricing#compare-plans). +> For more details, [contact your account team](https://coder.com/contact). + Below is an example configuration with multiple providers: > [!IMPORTANT] diff --git a/docs/admin/networking/workspace-proxies.md b/docs/admin/networking/workspace-proxies.md index 69aa2073deb..a85c6b98555 100644 --- a/docs/admin/networking/workspace-proxies.md +++ b/docs/admin/networking/workspace-proxies.md @@ -1,7 +1,12 @@ -# Workspace Proxies +# Workspace Proxies (Premium) Workspace proxies provide low-latency experiences for geo-distributed teams. +> [!NOTE] +> Workspace proxies require a +> [Premium license](https://coder.com/pricing#compare-plans). +> For more details, [contact your account team](https://coder.com/contact). + Coder's networking does a best effort to make direct connections to a workspace. In situations where this is not possible, such as connections via the web terminal and diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b5cb32ba168..e6d2f30ea14 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -1,4 +1,4 @@ -# Audit Logs +# Audit Logs (Premium) **Audit Logs** allows Auditors to monitor user operations in their deployment. diff --git a/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index 84f3c898efb..4fa8575ed51 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -5,6 +5,11 @@ these can also be [managed and synced by the identity provider](./idp-sync.md). ## Groups +> [!NOTE] +> Groups require a +> [Premium license](https://coder.com/pricing#compare-plans). +> For more details, [contact your account team](https://coder.com/contact). + Groups are logical segmentations of users in Coder and can be used to control which templates developers can use. For example: From 542e7ec1dbc20d07548f5d5a1e3eb8a9a800beac Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 13:44:26 +0000 Subject: [PATCH 2/9] remove PopoverPaywall, Paywall premium prop canViewPremium --- site/src/components/Paywall/Paywall.tsx | 33 +++++++++++++ .../Paywall/PaywallPremium.stories.tsx | 49 ++++++++++++++++--- .../src/components/Paywall/PaywallPremium.tsx | 34 ++++++++----- .../Paywall/PopoverPaywall.stories.tsx | 19 ------- .../src/components/Paywall/PopoverPaywall.tsx | 23 --------- 5 files changed, 99 insertions(+), 59 deletions(-) delete mode 100644 site/src/components/Paywall/PopoverPaywall.stories.tsx delete mode 100644 site/src/components/Paywall/PopoverPaywall.tsx diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx index 713a0fafa1c..f5a5eed8aa4 100644 --- a/site/src/components/Paywall/Paywall.tsx +++ b/site/src/components/Paywall/Paywall.tsx @@ -1,6 +1,7 @@ import { CircleCheckBigIcon } from "lucide-react"; import type React from "react"; import type { FC } from "react"; +import { Link as RouterLink, type LinkProps } from "react-router"; import { Button } from "#/components/Button/Button"; import { cn } from "#/utils/cn"; @@ -175,6 +176,38 @@ export const PaywallCTA: FC> = ({ ); }; +export const PaywallCTALink: FC = ({ + children, + className, + ...props +}) => { + return ( + + ); +}; + +export const PaywallGuidance: FC> = ({ + children, + className, + ...props +}) => { + return ( +

+ {children} +

+ ); +}; + const FeatureIcon: FC> = ({ className, ...props diff --git a/site/src/components/Paywall/PaywallPremium.stories.tsx b/site/src/components/Paywall/PaywallPremium.stories.tsx index b88104ed457..24cb5a02f67 100644 --- a/site/src/components/Paywall/PaywallPremium.stories.tsx +++ b/site/src/components/Paywall/PaywallPremium.stories.tsx @@ -1,19 +1,56 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { PaywallPremium } from "./PaywallPremium"; const meta: Meta = { title: "components/Paywall/Premium", component: PaywallPremium, + args: { + message: "Workspace Proxies", + description: + "Workspace proxies provide low-latency connections for geo-distributed teams. You need a Premium license to use this feature.", + documentationLink: + "https://coder.com/docs/admin/networking/workspace-proxies", + canViewPremium: false, + }, }; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - message: "Black Lotus", - description: - "Adds 3 mana of any single color of your choice to your mana pool, then is discarded. Tapping this artifact can be played as an interrupt.", - documentationLink: "https://coder.com/docs", +export const CanViewLicenses: Story = { + args: { canViewPremium: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toBeVisible(); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + await expect(cta).not.toHaveAttribute("target", "_blank"); + await expect( + canvas.queryByText(/contact your deployment administrator/i), + ).not.toBeInTheDocument(); }, }; + +export const CannotViewLicenses: Story = { + args: { canViewPremium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText("Premium")).toBeVisible(); + await expect( + canvas.getByRole("link", { name: "Read the documentation" }), + ).toBeVisible(); + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, +}; + +export const Compact: Story = { + args: { canViewPremium: true, compact: true }, +}; diff --git a/site/src/components/Paywall/PaywallPremium.tsx b/site/src/components/Paywall/PaywallPremium.tsx index d86b12ba697..04cd594cc1c 100644 --- a/site/src/components/Paywall/PaywallPremium.tsx +++ b/site/src/components/Paywall/PaywallPremium.tsx @@ -4,22 +4,34 @@ import { cn } from "#/utils/cn"; import { Paywall, PaywallContent, - PaywallCTA, + PaywallCTALink, PaywallDescription, PaywallDocumentationLink, PaywallFeature, PaywallFeatures, + PaywallGuidance, PaywallHeading, PaywallSeparator, PaywallStack, PaywallTitle, } from "./Paywall"; +const PREMIUM_FEATURES = [ + "High availability & workspace proxies", + "Multi-org & role-based access control", + "24x7 global support with SLA", + "Unlimited Git & external auth integrations", +]; + +const PREMIUM_PAGE_PATH = "/deployment/premium"; + type PaywallPremiumProps = React.ComponentProps<"div"> & { message: string; description: ReactNode; documentationLink: string; compact?: boolean; + /** Whether the viewer can reach the in-app Premium page. */ + canViewPremium: boolean; }; const PaywallPremium = ({ @@ -27,16 +39,10 @@ const PaywallPremium = ({ description, documentationLink, compact = false, + canViewPremium: canViewLicenses, className, ...props }: PaywallPremiumProps) => { - const PREMIUM_FEATURES = [ - "High availability & workspace proxies", - "Multi-org & role-based access control", - "24x7 global support with SLA", - "Unlimited Git & external auth integrations", - ]; - return ( ))} - - Learn about Premium - + {canViewLicenses ? ( + + Learn about Premium + + ) : ( + + Contact your deployment administrator for Premium. + + )} ); diff --git a/site/src/components/Paywall/PopoverPaywall.stories.tsx b/site/src/components/Paywall/PopoverPaywall.stories.tsx deleted file mode 100644 index 60f965a6e7b..00000000000 --- a/site/src/components/Paywall/PopoverPaywall.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PopoverPaywall } from "./PopoverPaywall"; - -const meta: Meta = { - title: "components/Paywall/PopoverPaywall", - component: PopoverPaywall, -}; - -export default meta; -type Story = StoryObj; - -export const Premium: Story = { - args: { - message: "Black Lotus", - description: - "Adds 3 mana of any single color of your choice to your mana pool, then is discarded. Tapping this artifact can be played as an interrupt.", - documentationLink: "https://coder.com/docs", - }, -}; diff --git a/site/src/components/Paywall/PopoverPaywall.tsx b/site/src/components/Paywall/PopoverPaywall.tsx deleted file mode 100644 index 59929e8b78a..00000000000 --- a/site/src/components/Paywall/PopoverPaywall.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { FC, ReactNode } from "react"; -import { PaywallPremium } from "./PaywallPremium"; - -interface PopoverPaywallProps { - message: string; - description?: ReactNode; - documentationLink: string; -} - -export const PopoverPaywall: FC = ({ - message, - description, - documentationLink, -}) => { - return ( - - ); -}; From 0fc37764db58dea3e41ed13bf908a41387873893 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 13:59:10 +0000 Subject: [PATCH 3/9] page view updates for premium --- .../GatewayKeysPage/GatewayKeysPageView.tsx | 6 ++- site/src/pages/AuditPage/AuditPageView.tsx | 6 ++- .../ConnectionLogPageView.tsx | 6 ++- .../AppearanceSettingsPageView.stories.tsx | 18 ++++++- .../AppearanceSettingsPageView.tsx | 31 +----------- .../ExternalAuthSettingsPageView.stories.tsx | 15 +++++- .../ExternalAuthSettingsPageView.tsx | 17 +++++-- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 5 +- .../ObservabilitySettingsPageView.stories.tsx | 39 ++++++++++++++- .../ObservabilitySettingsPageView.tsx | 47 +++++++------------ .../PremiumPage/PremiumPageView.stories.tsx | 17 +++++++ .../PremiumPage/PremiumPageView.tsx | 15 ++++-- site/src/pages/GroupsPage/GroupsPageView.tsx | 6 ++- .../CreateOrganizationPageView.stories.tsx | 20 +++++++- .../CreateOrganizationPageView.tsx | 39 ++++----------- .../CustomRolesPage/CustomRolesPageView.tsx | 6 ++- .../IdpSyncPage/IdpSyncPage.tsx | 5 +- .../OrganizationProvisionerKeysPageView.tsx | 6 ++- .../OrganizationProvisionersPageView.tsx | 6 ++- .../TemplatePermissionsPage.tsx | 5 +- .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 6 ++- 21 files changed, 209 insertions(+), 112 deletions(-) diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index 8d26234b1df..30c98e2981d 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -1,5 +1,5 @@ import { PlusIcon, TrashIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useContext } from "react"; import type { AIGatewayKey } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; @@ -20,6 +20,7 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { relativeTime } from "#/utils/time"; @@ -40,6 +41,8 @@ export const GatewayKeysPageView: FC = ({ onCreateKey, onDeleteKey, }) => { + const auth = useContext(AuthContext); + return (
= ({ message="AI Gateway" description="Authenticate standalone AI Gateway replicas to your deployment. You need a Premium license with AI Gateway enabled to use this feature." documentationLink={docs("/ai-coder/ai-gateway")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> )} diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index f6753b9aa89..e75f8dae8e5 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC } from "react"; +import { type ComponentProps, type FC, useContext } from "react"; import type { AuditLog } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Margins } from "#/components/Margins/Margins"; @@ -20,6 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { AuditFilter } from "./AuditFilter"; import { AuditHelpPopover } from "./AuditHelpPopover"; @@ -44,6 +45,8 @@ export const AuditPageView: FC = ({ auditsQuery: paginationResult, showOrgDetails, }) => { + const auth = useContext(AuthContext); + const isLoading = (auditLogs === undefined || paginationResult.totalRecords === undefined) && !error; @@ -89,6 +92,7 @@ export const AuditPageView: FC = ({ message="Audit logs" description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature." documentationLink={docs("/admin/security/audit-logs")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> )} diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx index c5d2a920d48..5a4ef7e09e7 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, FC } from "react"; +import { type ComponentProps, type FC, useContext } from "react"; import type { ConnectionLog } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Margins } from "#/components/Margins/Margins"; @@ -20,6 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { ConnectionLogFilter } from "./ConnectionLogFilter"; import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover"; @@ -42,6 +43,8 @@ export const ConnectionLogPageView: FC = ({ filterProps, connectionLogsQuery: paginationResult, }) => { + const auth = useContext(AuthContext); + const isLoading = (connectionLogs === undefined || paginationResult.totalRecords === undefined) && @@ -89,6 +92,7 @@ export const ConnectionLogPageView: FC = ({ message="Connection logs" description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature." documentationLink={docs("/admin/monitoring/connection-logs")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index 72c5bd4f59d..eb3e0fed5b6 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent } from "storybook/test"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; const meta: Meta = { @@ -28,10 +29,25 @@ const meta: Meta = { export default meta; type Story = StoryObj; +/** The badge is passive: hovering it must not surface a paywall. */ +const expectPassiveBadge = async (label: string) => { + await userEvent.hover(screen.getByText(label)); + await expect( + screen.queryByRole("link", { name: "Read the documentation" }), + ).not.toBeInTheDocument(); +}; + export const Entitled: Story = { args: { isEntitled: true, }, + play: async () => { + await expectPassiveBadge("Enterprise"); + }, }; -export const NotEntitled: Story = {}; +export const NotEntitled: Story = { + play: async () => { + await expectPassiveBadge("Premium"); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 74acf048df3..749d5a97ac3 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -13,18 +13,11 @@ import { InputGroupAddon, InputGroupInput, } from "#/components/InputGroup/InputGroup"; -import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { Fieldset } from "../Fieldset"; import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings"; @@ -71,29 +64,7 @@ export const AppearanceSettingsPageView: FC< - - {isEntitled && !isPremium ? ( - - ) : ( - - - - - - )} - - - - - + {isEntitled && !isPremium ? : }
= { @@ -38,4 +39,16 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Page: Story = {}; +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", + }), + ).toBeVisible(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index 29a3ac506a0..e10a6c85416 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -53,9 +53,20 @@ export const ExternalAuthSettingsPageView: FC< />
- }> - Integrating with multiple External authentication providers is an - Premium feature. + }> + Integrating with multiple external authentication providers is a + Premium feature.{" "} + + Read the External Authentication documentation + + .
diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index 3b0fa5f2e75..fb799c79023 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useContext, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -10,6 +10,7 @@ import { import { Link } from "#/components/Link/Link"; import { Loader } from "#/components/Loader/Loader"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { docs } from "#/utils/docs"; @@ -18,6 +19,7 @@ import { ExportPolicyButton } from "./ExportPolicyButton"; import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView"; const IdpOrgSyncPage: FC = () => { + const auth = useContext(AuthContext); const queryClient = useQueryClient(); // IdP sync does not have its own entitlement and is based on templace_rbac const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); @@ -80,6 +82,7 @@ const IdpOrgSyncPage: FC = () => { message="IdP Organization Sync" description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature." documentationLink={docs("/admin/users/idp-sync")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( = { }, ], featureAuditLogEnabled: true, + isPremium: false, }, }; @@ -53,4 +55,39 @@ type Story = StoryObj; export const Page: Story = {}; -export const Premium: Story = { args: { isPremium: true } }; +export const OSS: Story = { + args: { featureAuditLogEnabled: false, isPremium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const alert = canvas.getByRole("alert"); + await expect(alert).toBeVisible(); + await expect(within(alert).getByText("Premium")).toBeVisible(); + await expect( + within(alert).getByRole("link", { + name: "Read the Audit Logs documentation", + }), + ).toBeVisible(); + await expect(canvas.queryByText("Enterprise")).not.toBeInTheDocument(); + }, +}; + +export const Premium: Story = { + args: { featureAuditLogEnabled: true, isPremium: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + await expect(canvas.getByText("Premium")).toBeVisible(); + }, +}; + +export const EnterpriseAuditLogs: Story = { + args: { featureAuditLogEnabled: true, isPremium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + await expect(canvas.getByText("Enterprise")).toBeVisible(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx index 6970caf0591..5a2a803fb5b 100644 --- a/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ObservabilitySettingsPage/ObservabilitySettingsPageView.tsx @@ -1,22 +1,17 @@ import type { FC } from "react"; import type { SerpentOption } from "#/api/typesGenerated"; +import { Alert } from "#/components/Alert/Alert"; import { Badges, EnterpriseBadge, PremiumBadge, } from "#/components/Badges/Badges"; -import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderDocsLink, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; import { deploymentGroupHasParent } from "#/utils/deployOptions"; import { docs } from "#/utils/docs"; import OptionsTable from "../OptionsTable"; @@ -48,31 +43,23 @@ export const ObservabilitySettingsPageView: FC< - - - {featureAuditLogEnabled && !isPremium ? ( - - ) : ( - - - - - - )} - - {isPremium ? : } + ) : ( + }> + Audit logging lets auditors monitor user operations across your + deployment. It requires a Premium license.{" "} + - - - - + Read the Audit Logs documentation + + . + + )}
diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx index c59ffeb0a7d..b3cfdbdb19a 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { PremiumPageView } from "./PremiumPageView"; const meta: Meta = { @@ -10,14 +11,30 @@ export default meta; type Story = StoryObj; +const expectHeaderBadge = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("heading", { name: "Premium", level: 1 }), + ).toBeVisible(); + // The title and the badge beside it are the only exact "Premium" matches. + await expect(canvas.getAllByText("Premium")).toHaveLength(2); +}; + export const Enterprise: Story = { args: { isEnterprise: true, }, + play: async ({ canvasElement }) => { + await expectHeaderBadge(canvasElement); + }, }; export const OSS: Story = { args: { isEnterprise: false, }, + play: async ({ canvasElement }) => { + await expectHeaderBadge(canvasElement); + }, }; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx index a01a2c28840..b48f101027b 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx @@ -5,6 +5,7 @@ import { SquareArrowOutUpRightIcon, } from "lucide-react"; import type { FC } from "react"; +import { PremiumBadge } from "#/components/Badges/Badges"; import { Button } from "#/components/Button/Button"; import { Link } from "#/components/Link/Link"; import { docs } from "#/utils/docs"; @@ -20,7 +21,10 @@ const EnterpriseVersion: FC = () => {
-

Premium

+
+

Premium

+ +

As an Enterprise license holder, you already benefit from Coder’s features for secure, large-scale deployments. Upgrade to Coder @@ -86,9 +90,12 @@ const OSSVersion: FC = () => {

-

- Premium -

+
+

+ Premium +

+ +

Coder Premium is designed for enterprises that need to scale their Coder deployment efficiently, securely, and with full control. By diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 79e44597904..0749e8eeea0 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, PlusIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useContext } from "react"; import { Link as RouterLink, useNavigate } from "react-router"; import type { Group, OrganizationGroupsAISpend } from "#/api/typesGenerated"; import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; @@ -23,6 +23,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import { docs } from "#/utils/docs"; import { SpendEstimateDocsLink } from "./AICostControl"; @@ -67,12 +68,15 @@ export const GroupsPageView: FC = ({ groupsEnabled, showAIBudget, }) => { + const auth = useContext(AuthContext); + if (!groupsEnabled) { return ( ); } diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx index dfbff52a15e..34ba1cbed90 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, within } from "storybook/test"; import { mockApiError } from "#/testHelpers/entities"; import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; @@ -13,12 +14,29 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Example: Story = {}; +export const Example: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The badge is passive: hovering it must not surface a paywall. + await userEvent.hover(canvas.getByText("Premium")); + await expect( + screen.queryByRole("link", { name: "Read the documentation" }), + ).not.toBeInTheDocument(); + }, +}; export const NotEntitled: Story = { args: { isEntitled: false, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("link", { name: "Read the documentation" }), + ).toBeVisible(); + }, }; export const WithError: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 4b1bfedf219..4ba5808c6f8 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -1,7 +1,7 @@ import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; import { ArrowLeftIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useContext } from "react"; import { Link, useNavigate } from "react-router"; import * as Yup from "yup"; import { isApiValidationError } from "#/api/errors"; @@ -11,13 +11,8 @@ import { Badges, PremiumBadge } from "#/components/Badges/Badges"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; -import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall"; import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { displayNameValidator, @@ -47,6 +42,7 @@ interface CreateOrganizationPageViewProps { export const CreateOrganizationPageView: FC< CreateOrganizationPageViewProps > = ({ error, onSubmit, isEntitled }) => { + const auth = useContext(AuthContext); const form = useFormik({ initialValues: { name: "", @@ -79,29 +75,11 @@ export const CreateOrganizationPageView: FC<

)} - - - {isEntitled && ( - - - - - - )} - - - - - - + {isEntitled && ( + + + + )}

New Organization

@@ -117,6 +95,7 @@ export const CreateOrganizationPageView: FC< message="Organizations" description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure." documentationLink={docs("/admin/users/organizations")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} />
) : ( diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 2ebe55b3aa1..e7dcdc2629d 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -1,5 +1,5 @@ import { EllipsisVerticalIcon, PlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { type FC, useContext, useState } from "react"; import { Link as RouterLink, useNavigate } from "react-router"; import type { AssignableRoles, Organization, Role } from "#/api/typesGenerated"; import { PremiumBadge } from "#/components/Badges/Badges"; @@ -25,6 +25,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { PermissionPillsList } from "./PermissionPillsList"; @@ -62,6 +63,8 @@ export const CustomRolesPageView: FC = ({ onUpdateDefaultRoles, isUpdatingDefaultRoles, }) => { + const auth = useContext(AuthContext); + const showDefaultRoles = defaultRolesEnabled && canEditDefaultRoles && Boolean(onUpdateDefaultRoles); @@ -72,6 +75,7 @@ export const CustomRolesPageView: FC = ({ message="Custom Roles" description="Create custom roles to grant users a tailored set of granular permissions." documentationLink={docs("/admin/users/groups-roles")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> )} {showDefaultRoles && onUpdateDefaultRoles && ( diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 37ab7b1f3a3..af2c6e9de8f 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useContext, useEffect, useState } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; @@ -15,6 +15,7 @@ import { organizationRoles } from "#/api/queries/roles"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Link } from "#/components/Link/Link"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -23,6 +24,7 @@ import { pageTitle } from "#/utils/page"; import IdpSyncPageView from "./IdpSyncPageView"; const IdpSyncPage: FC = () => { + const auth = useContext(AuthContext); const queryClient = useQueryClient(); // IdP sync does not have its own entitlement and is based on templace_rbac const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); @@ -132,6 +134,7 @@ const IdpSyncPage: FC = () => { message="IdP Sync" description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature." documentationLink={docs("/admin/users/idp-sync")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => { + const auth = useContext(AuthContext); + const filteredProvisionerKeyDaemons = provisionerKeyDaemons?.filter( (pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id), ); @@ -63,6 +66,7 @@ export const OrganizationProvisionerKeysPageView: FC< message="Provisioners" description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations." documentationLink={docs("/admin/provisioners")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index dce453dbda3..0e48a6067a5 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -1,5 +1,5 @@ import { XIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useContext } from "react"; import type { ProvisionerDaemon } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; @@ -26,6 +26,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { docs } from "#/utils/docs"; import { LastConnectionHead } from "./LastConnectionHead"; import { ProvisionerRow } from "./ProvisionerRow"; @@ -56,6 +57,8 @@ export const OrganizationProvisionersPageView: FC< onFilterChange, onRetry, }) => { + const auth = useContext(AuthContext); + return (
@@ -99,6 +102,7 @@ export const OrganizationProvisionersPageView: FC< message="Provisioners" description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations." documentationLink={docs("/admin/provisioners")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( <> diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index d51dc43d3d2..c8ccd5bbbff 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { type FC, useContext } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -8,6 +8,7 @@ import { templateACL, } from "#/api/queries/templates"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { AuthContext } from "#/contexts/auth/AuthProvider"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { docs } from "#/utils/docs"; import { pageTitle } from "#/utils/page"; @@ -15,6 +16,7 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"; const TemplatePermissionsPage: FC = () => { + const auth = useContext(AuthContext); const { template, permissions } = useTemplateSettings(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); const templateACLQuery = useQuery(templateACL(template.id)); @@ -37,6 +39,7 @@ const TemplatePermissionsPage: FC = () => { message="Template permissions" description="Control users and groups access to templates. You need a Premium license to use this feature." documentationLink={docs("/admin/templates/template-permissions")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( = ({ selectProxyError, showPaywall, }) => { + const auth = useContext(AuthContext); + return (
= ({ message="Workspace Proxies" description="Workspace proxies provide low-latency connections for geo-distributed teams. You need a Premium license to use this feature." documentationLink={docs("/admin/networking/workspace-proxies")} + canViewPremium={auth?.permissions?.viewAllLicenses ?? false} /> ) : ( <> From cbb0a9f8ed39ca72e653dea5231e567623a07bb0 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 14:28:46 +0000 Subject: [PATCH 4/9] make fmt --- site/src/components/Paywall/Paywall.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx index f5a5eed8aa4..5c91d115ebd 100644 --- a/site/src/components/Paywall/Paywall.tsx +++ b/site/src/components/Paywall/Paywall.tsx @@ -1,7 +1,7 @@ import { CircleCheckBigIcon } from "lucide-react"; import type React from "react"; import type { FC } from "react"; -import { Link as RouterLink, type LinkProps } from "react-router"; +import { type LinkProps, Link as RouterLink } from "react-router"; import { Button } from "#/components/Button/Button"; import { cn } from "#/utils/cn"; From f9e9f07450d8d8adc3c579c2613854f653978ced Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 20:39:54 +0000 Subject: [PATCH 5/9] adjustments for replaced useAuthenticated --- .../GatewayKeysPage/GatewayKeysPageView.tsx | 8 ++++---- site/src/pages/AuditPage/AuditPageView.tsx | 8 ++++---- .../src/pages/ConnectionLogPage/ConnectionLogPageView.tsx | 8 ++++---- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 8 ++++---- site/src/pages/GroupsPage/GroupsPageView.tsx | 8 ++++---- .../CreateOrganizationPageView.tsx | 8 ++++---- .../CustomRolesPage/CustomRolesPageView.tsx | 8 ++++---- .../OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx | 8 ++++---- .../OrganizationProvisionerKeysPageView.tsx | 8 ++++---- .../OrganizationProvisionersPageView.tsx | 8 ++++---- .../TemplatePermissionsPage/TemplatePermissionsPage.tsx | 8 ++++---- .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 8 ++++---- 12 files changed, 48 insertions(+), 48 deletions(-) diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index 30c98e2981d..e729b685e8f 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -1,5 +1,5 @@ import { PlusIcon, TrashIcon } from "lucide-react"; -import { type FC, useContext } from "react"; +import type { FC } from "react"; import type { AIGatewayKey } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { relativeTime } from "#/utils/time"; @@ -41,7 +41,7 @@ export const GatewayKeysPageView: FC = ({ onCreateKey, onDeleteKey, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); return (
@@ -68,7 +68,7 @@ export const GatewayKeysPageView: FC = ({ message="AI Gateway" description="Authenticate standalone AI Gateway replicas to your deployment. You need a Premium license with AI Gateway enabled to use this feature." documentationLink={docs("/ai-coder/ai-gateway")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index e75f8dae8e5..8b87023dd67 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type FC, useContext } from "react"; +import type { ComponentProps, FC } from "react"; import type { AuditLog } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Margins } from "#/components/Margins/Margins"; @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { AuditFilter } from "./AuditFilter"; import { AuditHelpPopover } from "./AuditHelpPopover"; @@ -45,7 +45,7 @@ export const AuditPageView: FC = ({ auditsQuery: paginationResult, showOrgDetails, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const isLoading = (auditLogs === undefined || paginationResult.totalRecords === undefined) && @@ -92,7 +92,7 @@ export const AuditPageView: FC = ({ message="Audit logs" description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature." documentationLink={docs("/admin/security/audit-logs")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx index 5a4ef7e09e7..f2782c1bc9a 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type FC, useContext } from "react"; +import type { ComponentProps, FC } from "react"; import type { ConnectionLog } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Margins } from "#/components/Margins/Margins"; @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { ConnectionLogFilter } from "./ConnectionLogFilter"; import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover"; @@ -43,7 +43,7 @@ export const ConnectionLogPageView: FC = ({ filterProps, connectionLogsQuery: paginationResult, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const isLoading = (connectionLogs === undefined || @@ -92,7 +92,7 @@ export const ConnectionLogPageView: FC = ({ message="Connection logs" description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature." documentationLink={docs("/admin/monitoring/connection-logs")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index fb799c79023..185ced1813b 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useContext, useEffect, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -10,7 +10,7 @@ import { import { Link } from "#/components/Link/Link"; import { Loader } from "#/components/Loader/Loader"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { docs } from "#/utils/docs"; @@ -19,7 +19,7 @@ import { ExportPolicyButton } from "./ExportPolicyButton"; import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView"; const IdpOrgSyncPage: FC = () => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); // IdP sync does not have its own entitlement and is based on templace_rbac const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); @@ -82,7 +82,7 @@ const IdpOrgSyncPage: FC = () => { message="IdP Organization Sync" description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature." documentationLink={docs("/admin/users/idp-sync")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ) : ( = ({ groupsEnabled, showAIBudget, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); if (!groupsEnabled) { return ( @@ -76,7 +76,7 @@ export const GroupsPageView: FC = ({ message="Groups" description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature." documentationLink={docs("/admin/users/groups-roles")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ); } diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 4ba5808c6f8..16455558cdf 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -1,7 +1,7 @@ import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; import { ArrowLeftIcon } from "lucide-react"; -import { type FC, useContext } from "react"; +import type { FC } from "react"; import { Link, useNavigate } from "react-router"; import * as Yup from "yup"; import { isApiValidationError } from "#/api/errors"; @@ -12,7 +12,7 @@ import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Spinner } from "#/components/Spinner/Spinner"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { displayNameValidator, @@ -42,7 +42,7 @@ interface CreateOrganizationPageViewProps { export const CreateOrganizationPageView: FC< CreateOrganizationPageViewProps > = ({ error, onSubmit, isEntitled }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const form = useFormik({ initialValues: { name: "", @@ -95,7 +95,7 @@ export const CreateOrganizationPageView: FC< message="Organizations" description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure." documentationLink={docs("/admin/users/organizations")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} />
) : ( diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 3398bb83339..27d5f37d8c2 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -1,5 +1,5 @@ import { EllipsisVerticalIcon, PlusIcon } from "lucide-react"; -import { type FC, useContext, useState } from "react"; +import { type FC, useState } from "react"; import { Link as RouterLink, useNavigate } from "react-router"; import type { AssignableRoles, Organization, Role } from "#/api/typesGenerated"; import { PremiumBadge } from "#/components/Badges/Badges"; @@ -25,7 +25,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { PermissionPillsList } from "./PermissionPillsList"; @@ -61,7 +61,7 @@ export const CustomRolesPageView: FC = ({ onUpdateDefaultRoles, isUpdatingDefaultRoles, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); return (
@@ -70,7 +70,7 @@ export const CustomRolesPageView: FC = ({ message="Custom Roles" description="Create custom roles to grant users a tailored set of granular permissions." documentationLink={docs("/admin/users/groups-roles")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> )} {onUpdateDefaultRoles && ( diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index af2c6e9de8f..5b64d53cc6a 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useContext, useEffect, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; @@ -15,7 +15,7 @@ import { organizationRoles } from "#/api/queries/roles"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { Link } from "#/components/Link/Link"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -24,7 +24,7 @@ import { pageTitle } from "#/utils/page"; import IdpSyncPageView from "./IdpSyncPageView"; const IdpSyncPage: FC = () => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); // IdP sync does not have its own entitlement and is based on templace_rbac const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); @@ -134,7 +134,7 @@ const IdpSyncPage: FC = () => { message="IdP Sync" description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature." documentationLink={docs("/admin/users/idp-sync")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ) : ( = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); const filteredProvisionerKeyDaemons = provisionerKeyDaemons?.filter( (pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id), @@ -66,7 +66,7 @@ export const OrganizationProvisionerKeysPageView: FC< message="Provisioners" description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations." documentationLink={docs("/admin/provisioners")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ) : (
diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index 0e48a6067a5..3948ce5a852 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -1,5 +1,5 @@ import { XIcon } from "lucide-react"; -import { type FC, useContext } from "react"; +import type { FC } from "react"; import type { ProvisionerDaemon } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; @@ -26,7 +26,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { docs } from "#/utils/docs"; import { LastConnectionHead } from "./LastConnectionHead"; import { ProvisionerRow } from "./ProvisionerRow"; @@ -57,7 +57,7 @@ export const OrganizationProvisionersPageView: FC< onFilterChange, onRetry, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); return (
@@ -102,7 +102,7 @@ export const OrganizationProvisionersPageView: FC< message="Provisioners" description="Provisioners run your Terraform to create templates and workspaces. You need a Premium license to use this feature for multiple organizations." documentationLink={docs("/admin/provisioners")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ) : ( <> diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index c8ccd5bbbff..5a0da8bf110 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useContext } from "react"; +import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; @@ -8,7 +8,7 @@ import { templateACL, } from "#/api/queries/templates"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; -import { AuthContext } from "#/contexts/auth/AuthProvider"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { docs } from "#/utils/docs"; import { pageTitle } from "#/utils/page"; @@ -16,7 +16,7 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"; const TemplatePermissionsPage: FC = () => { - const auth = useContext(AuthContext); + const { permissions: authPermissions } = useAuthenticated(); const { template, permissions } = useTemplateSettings(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); const templateACLQuery = useQuery(templateACL(template.id)); @@ -39,7 +39,7 @@ const TemplatePermissionsPage: FC = () => { message="Template permissions" description="Control users and groups access to templates. You need a Premium license to use this feature." documentationLink={docs("/admin/templates/template-permissions")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={authPermissions.viewAllLicenses} /> ) : ( = ({ selectProxyError, showPaywall, }) => { - const auth = useContext(AuthContext); + const { permissions } = useAuthenticated(); return (
@@ -65,7 +65,7 @@ export const WorkspaceProxyView: FC = ({ message="Workspace Proxies" description="Workspace proxies provide low-latency connections for geo-distributed teams. You need a Premium license to use this feature." documentationLink={docs("/admin/networking/workspace-proxies")} - canViewPremium={auth?.permissions?.viewAllLicenses ?? false} + canViewPremium={permissions.viewAllLicenses} /> ) : ( <> From cd5b5222b5cc99ec48d8d4ae57055273efa4560b Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Tue, 4 Aug 2026 21:23:11 +0000 Subject: [PATCH 6/9] appearance adjustment --- .../AppearanceSettingsPageView.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 749d5a97ac3..0d49bc2c25f 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -13,11 +13,14 @@ import { InputGroupAddon, InputGroupInput, } from "#/components/InputGroup/InputGroup"; +import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; +import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { Fieldset } from "../Fieldset"; import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings"; @@ -34,6 +37,8 @@ type AppearanceSettingsPageViewProps = { export const AppearanceSettingsPageView: FC< AppearanceSettingsPageViewProps > = ({ appearance, isEntitled, isPremium, onSaveAppearance }) => { + const { permissions } = useAuthenticated(); + const applicationNameForm = useFormik<{ application_name: string; }>({ @@ -64,7 +69,20 @@ export const AppearanceSettingsPageView: FC< - {isEntitled && !isPremium ? : } + {isEntitled ? ( + isPremium ? ( + + ) : ( + + ) + ) : ( + + )}
Date: Tue, 4 Aug 2026 22:15:46 +0000 Subject: [PATCH 7/9] good feedback from coder-tasks --- docs/admin/monitoring/connection-logs.md | 2 +- docs/admin/users/groups-roles.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin/monitoring/connection-logs.md b/docs/admin/monitoring/connection-logs.md index d5ace1296f4..6f023cf747a 100644 --- a/docs/admin/monitoring/connection-logs.md +++ b/docs/admin/monitoring/connection-logs.md @@ -1,4 +1,4 @@ -# Connection Logs +# Connection Logs (Premium) > [!NOTE] > Connection logs require a diff --git a/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index ebb90d64e12..4c5c2966519 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -1,4 +1,4 @@ -# Groups and Roles +# Groups and Roles (Premium) Groups and roles can be manually assigned in Coder. For production deployments, these can also be [managed and synced by the identity provider](./idp-sync.md). From 3f5bab0056cbb100110ad7a4250bcc14221acff3 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Wed, 5 Aug 2026 20:56:36 +0000 Subject: [PATCH 8/9] permissions passed as PageView props --- .../GatewayKeysPage/GatewayKeysPage.tsx | 1 + .../GatewayKeysPageView.stories.tsx | 30 +++++++++++++- .../GatewayKeysPage/GatewayKeysPageView.tsx | 6 +-- site/src/pages/AuditPage/AuditPage.tsx | 3 ++ .../pages/AuditPage/AuditPageView.stories.tsx | 26 ++++++++++++ site/src/pages/AuditPage/AuditPageView.tsx | 6 +-- .../ConnectionLogPage/ConnectionLogPage.tsx | 3 ++ .../ConnectionLogPageView.stories.tsx | 26 ++++++++++++ .../ConnectionLogPageView.tsx | 6 +-- .../AppearanceSettingsPage.tsx | 1 + .../AppearanceSettingsPageView.stories.tsx | 41 +++++++++++++------ .../AppearanceSettingsPageView.tsx | 7 ++-- site/src/pages/GroupsPage/GroupsPage.tsx | 3 ++ .../GroupsPage/GroupsPageView.stories.tsx | 28 ++++++++++++- site/src/pages/GroupsPage/GroupsPageView.tsx | 6 +-- .../CreateOrganizationPage.tsx | 1 + .../CreateOrganizationPageView.stories.tsx | 22 +++++++++- .../CreateOrganizationPageView.tsx | 6 +-- .../CustomRolesPage/CustomRolesPage.tsx | 3 ++ .../CustomRolesPageView.stories.tsx | 25 +++++++++++ .../CustomRolesPage/CustomRolesPageView.tsx | 6 +-- .../OrganizationProvisionerKeysPage.tsx | 3 ++ ...izationProvisionerKeysPageView.stories.tsx | 27 ++++++++++++ .../OrganizationProvisionerKeysPageView.tsx | 7 ++-- .../OrganizationProvisionersPage.tsx | 3 ++ ...ganizationProvisionersPageView.stories.tsx | 27 ++++++++++++ .../OrganizationProvisionersPageView.tsx | 6 +-- .../WorkspaceProxyPage/WorkspaceProxyPage.tsx | 3 ++ .../WorkspaceProxyView.stories.tsx | 26 ++++++++++++ .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 6 +-- 30 files changed, 317 insertions(+), 47 deletions(-) diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx index f34f1f193c1..04920b74481 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx @@ -43,6 +43,7 @@ const GatewayKeysPage: FC = () => { isLoading={keysQuery.isLoading} error={keysQuery.error} showPaywall={showPaywall} + permissions={permissions} onCreateKey={() => setIsCreateOpen(true)} onDeleteKey={setKeyToDelete} /> diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx index 8b42ca2d2d2..27309b70e5f 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, within } from "storybook/test"; -import { MockAIGatewayKeys, mockApiError } from "#/testHelpers/entities"; +import { + MockAIGatewayKeys, + MockPermissions, + mockApiError, +} from "#/testHelpers/entities"; import { GatewayKeysPageView } from "./GatewayKeysPageView"; const meta: Meta = { @@ -13,6 +17,7 @@ const meta: Meta = { isLoading: false, error: null, showPaywall: false, + permissions: MockPermissions, onCreateKey: fn(), onDeleteKey: fn(), }, @@ -71,6 +76,29 @@ export const Paywall: Story = { showPaywall: true, keys: [], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const PaywallWithoutLicenseAccess: Story = { + args: { + ...Paywall.args, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const ClickCreate: Story = { diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index e729b685e8f..500d08ae9ec 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { relativeTime } from "#/utils/time"; @@ -29,6 +29,7 @@ interface GatewayKeysPageViewProps { isLoading: boolean; error: unknown; showPaywall: boolean; + permissions: Permissions; onCreateKey: () => void; onDeleteKey: (key: AIGatewayKey) => void; } @@ -38,11 +39,10 @@ export const GatewayKeysPageView: FC = ({ isLoading, error, showPaywall, + permissions, onCreateKey, onDeleteKey, }) => { - const { permissions } = useAuthenticated(); - return (
{ + const { permissions } = useAuthenticated(); const feats = useFeatureVisibility(); // The "else false" is required if audit_log is undefined. // It may happen if owner removes the license. @@ -84,6 +86,7 @@ const AuditPage: FC = () => { auditsQuery={auditsQuery} error={auditsQuery.error} showOrgDetails={showOrganizations} + permissions={permissions} filterProps={{ filter, error: auditsQuery.error, diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 7707bd5c6a3..14009fe342a 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; +import { expect, within } from "storybook/test"; import { getDefaultFilterProps, MockMenu, @@ -13,6 +14,7 @@ import { MockAuditLog, MockAuditLog2, MockAuditLog3, + MockPermissions, MockUserOwner, } from "#/testHelpers/entities"; import { pixelWithTablet } from "#/testHelpers/pixel"; @@ -43,6 +45,7 @@ const meta: Meta = { isAuditLogVisible: true, filterProps: defaultFilterProps, showOrgDetails: false, + permissions: MockPermissions, }, }; @@ -91,6 +94,29 @@ export const NotVisible: Story = { isAuditLogVisible: false, auditsQuery: mockInitialRenderResult, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotVisibleWithoutLicenseAccess: Story = { + args: { + ...NotVisible.args, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const MultiOrg: Story = { diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index 8b87023dd67..90a12cf11d4 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { AuditFilter } from "./AuditFilter"; import { AuditHelpPopover } from "./AuditHelpPopover"; @@ -34,6 +34,7 @@ interface AuditPageViewProps { filterProps: ComponentProps; auditsQuery: PaginationResult; showOrgDetails: boolean; + permissions: Permissions; } export const AuditPageView: FC = ({ @@ -44,9 +45,8 @@ export const AuditPageView: FC = ({ filterProps, auditsQuery: paginationResult, showOrgDetails, + permissions, }) => { - const { permissions } = useAuthenticated(); - const isLoading = (auditLogs === undefined || paginationResult.totalRecords === undefined) && !error; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx index 39216c1146e..d1c5692a3ed 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx @@ -4,6 +4,7 @@ import { paginatedConnectionLogs } from "#/api/queries/connectionlog"; import { useFilter } from "#/components/Filter/Filter"; import { useUserFilterMenu } from "#/components/Filter/UserFilter"; import { isNonInitialPage } from "#/components/PaginationWidget/utils"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; @@ -13,6 +14,7 @@ import { useStatusFilterMenu, useTypeFilterMenu } from "./ConnectionLogFilter"; import { ConnectionLogPageView } from "./ConnectionLogPageView"; const ConnectionLogPage: FC = () => { + const { permissions } = useAuthenticated(); const feats = useFeatureVisibility(); // The "else false" is required if connection_log is undefined, which may @@ -79,6 +81,7 @@ const ConnectionLogPage: FC = () => { isConnectionLogVisible={isConnectionLogVisible} connectionLogsQuery={connectionlogsQuery} error={connectionlogsQuery.error} + permissions={permissions} filterProps={{ filter, error: connectionlogsQuery.error, diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx index 966dde9bfcd..3a43a7e02d4 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; +import { expect, within } from "storybook/test"; import { getDefaultFilterProps, MockMenu, @@ -12,6 +13,7 @@ import type { UsePaginatedQueryResult } from "#/hooks/usePaginatedQuery"; import { MockConnectedSSHConnectionLog, MockDisconnectedSSHConnectionLog, + MockPermissions, MockUserOwner, } from "#/testHelpers/entities"; import { pixelWithTablet } from "#/testHelpers/pixel"; @@ -44,6 +46,7 @@ const meta: Meta = { ], isConnectionLogVisible: true, filterProps: defaultFilterProps, + permissions: MockPermissions, }, }; @@ -92,4 +95,27 @@ export const NotVisible: Story = { isConnectionLogVisible: false, connectionLogsQuery: mockInitialRenderResult, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotVisibleWithoutLicenseAccess: Story = { + args: { + ...NotVisible.args, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx index f2782c1bc9a..936a2a0e60e 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -20,7 +20,7 @@ import { } from "#/components/Table/Table"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ConnectionLogFilter } from "./ConnectionLogFilter"; import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover"; @@ -33,6 +33,7 @@ interface ConnectionLogPageViewProps { error?: unknown; filterProps: ComponentProps; connectionLogsQuery: PaginationResult; + permissions: Permissions; } export const ConnectionLogPageView: FC = ({ @@ -42,9 +43,8 @@ export const ConnectionLogPageView: FC = ({ error, filterProps, connectionLogsQuery: paginationResult, + permissions, }) => { - const { permissions } = useAuthenticated(); - const isLoading = (connectionLogs === undefined || paginationResult.totalRecords === undefined) && diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx index b7c6d318317..7e5b9cd31b8 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx @@ -62,6 +62,7 @@ const AppearanceSettingsPage: FC = () => { entitlements.features.appearance.entitlement !== "not_entitled" } isPremium={hasPremiumLicense} + permissions={permissions} /> diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index eb3e0fed5b6..2ed9e3cf2d9 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, userEvent } from "storybook/test"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { MockPermissions } from "#/testHelpers/entities"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; const meta: Meta = { @@ -23,31 +24,47 @@ const meta: Meta = { ], }, isEntitled: false, + permissions: MockPermissions, }, }; export default meta; type Story = StoryObj; -/** The badge is passive: hovering it must not surface a paywall. */ -const expectPassiveBadge = async (label: string) => { - await userEvent.hover(screen.getByText(label)); - await expect( - screen.queryByRole("link", { name: "Read the documentation" }), - ).not.toBeInTheDocument(); -}; - export const Entitled: Story = { args: { isEntitled: true, }, play: async () => { - await expectPassiveBadge("Enterprise"); + // The badge is passive: hovering it must not surface a paywall. + await userEvent.hover(screen.getByText("Enterprise")); + await expect( + screen.queryByRole("link", { name: "Read the documentation" }), + ).not.toBeInTheDocument(); }, }; export const NotEntitled: Story = { - play: async () => { - await expectPassiveBadge("Premium"); + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotEntitledWithoutLicenseAccess: Story = { + args: { + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 0d49bc2c25f..f4aefff4972 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -19,7 +19,7 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { Fieldset } from "../Fieldset"; @@ -29,6 +29,7 @@ type AppearanceSettingsPageViewProps = { appearance: UpdateAppearanceConfig; isEntitled: boolean; isPremium: boolean; + permissions: Permissions; onSaveAppearance: ( newConfig: Partial, ) => Promise; @@ -36,9 +37,7 @@ type AppearanceSettingsPageViewProps = { export const AppearanceSettingsPageView: FC< AppearanceSettingsPageViewProps -> = ({ appearance, isEntitled, isPremium, onSaveAppearance }) => { - const { permissions } = useAuthenticated(); - +> = ({ appearance, isEntitled, isPremium, permissions, onSaveAppearance }) => { const applicationNameForm = useFormik<{ application_name: string; }>({ diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index b1fd6d89f50..0f783091065 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -17,6 +17,7 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -24,6 +25,7 @@ import { useGroupsSettings } from "./GroupsPageProvider"; import { GroupsPageView, joinGroupsSpend } from "./GroupsPageView"; const GroupsPage: FC = () => { + const { permissions: authPermissions } = useAuthenticated(); const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility(); const { organization, showOrganizations } = useGroupsSettings(); const aibridgeVisible = Boolean(aibridge); @@ -128,6 +130,7 @@ const GroupsPage: FC = () => { canCreateGroup={permissions.createGroup} groupsEnabled={groupsEnabled} showAIBudget={aibridgeVisible} + permissions={authPermissions} />
); diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 454f184f620..adb97a9b13d 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -1,11 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; -import { MockGroup } from "#/testHelpers/entities"; +import { MockGroup, MockPermissions } from "#/testHelpers/entities"; import { GroupsPageView, type GroupWithSpend } from "./GroupsPageView"; const meta: Meta = { title: "pages/OrganizationGroupsPage", component: GroupsPageView, + args: { + permissions: MockPermissions, + }, }; export default meta; @@ -29,6 +32,29 @@ export const NotEnabled: Story = { canCreateGroup: true, groupsEnabled: false, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotEnabledWithoutLicenseAccess: Story = { + args: { + ...NotEnabled.args, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const WithGroups: Story = { diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 3a0ef1235ab..56c4a4424da 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -23,8 +23,8 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { SpendEstimateDocsLink } from "./AICostControl"; import { StatusIconTooltip } from "./StatusIconTooltip"; @@ -59,6 +59,7 @@ type GroupsPageViewProps = { canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; + permissions: Permissions; }; export const GroupsPageView: FC = ({ @@ -67,9 +68,8 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + permissions, }) => { - const { permissions } = useAuthenticated(); - if (!groupsEnabled) { return ( { { await createOrganizationMutation.mutateAsync(values); toast.success( diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx index 34ba1cbed90..3c4e55822aa 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, screen, userEvent, within } from "storybook/test"; -import { mockApiError } from "#/testHelpers/entities"; +import { MockPermissions, mockApiError } from "#/testHelpers/entities"; import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; const meta: Meta = { @@ -8,6 +8,7 @@ const meta: Meta = { component: CreateOrganizationPageView, args: { isEntitled: true, + permissions: MockPermissions, }, }; @@ -36,6 +37,25 @@ export const NotEntitled: Story = { await expect( canvas.getByRole("link", { name: "Read the documentation" }), ).toBeVisible(); + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotEntitledWithoutLicenseAccess: Story = { + args: { + isEntitled: false, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 16455558cdf..1bcb9451b89 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -12,7 +12,7 @@ import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Spinner } from "#/components/Spinner/Spinner"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { displayNameValidator, @@ -37,12 +37,12 @@ interface CreateOrganizationPageViewProps { error: unknown; onSubmit: (values: CreateOrganizationRequest) => Promise; isEntitled: boolean; + permissions: Permissions; } export const CreateOrganizationPageView: FC< CreateOrganizationPageViewProps -> = ({ error, onSubmit, isEntitled }) => { - const { permissions } = useAuthenticated(); +> = ({ error, onSubmit, isEntitled, permissions }) => { const form = useFormik({ initialValues: { name: "", diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 42a0d0d3b8d..c80cbd8d8d9 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -13,6 +13,7 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; @@ -21,6 +22,7 @@ import { pageTitle } from "#/utils/page"; import { CustomRolesPageView } from "./CustomRolesPageView"; const CustomRolesPage: FC = () => { + const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); const { custom_roles: isCustomRolesEnabled } = useFeatureVisibility(); const { organization: organizationName } = useParams() as { @@ -97,6 +99,7 @@ const CustomRolesPage: FC = () => { canDeleteOrgRole={organizationPermissions?.deleteOrgRoles ?? false} canEditDefaultRoles={organizationPermissions?.editSettings ?? false} isCustomRolesEnabled={isCustomRolesEnabled} + permissions={permissions} defaultRolesEntitled={defaultRolesEntitled} availableOrgRoles={organizationRolesQuery.data} isUpdatingDefaultRoles={updateOrganizationMutation.isPending} diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx index 5368db952e9..740cf071de1 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -5,6 +5,7 @@ import type { AssignableRoles } from "#/api/typesGenerated"; import { MockOrganization, MockOrganizationAuditorRole, + MockPermissions, MockRoleWithOrgPermissions, } from "#/testHelpers/entities"; import { CustomRolesPageView } from "./CustomRolesPageView"; @@ -55,6 +56,7 @@ const meta: Meta = { canCreateOrgRole: true, canEditDefaultRoles: true, isCustomRolesEnabled: true, + permissions: MockPermissions, }, }; @@ -67,6 +69,29 @@ export const NotEnabled: Story = { args: { isCustomRolesEnabled: false, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const NotEnabledWithoutLicenseAccess: Story = { + args: { + isCustomRolesEnabled: false, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const NotEnabledEmptyTable: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 27d5f37d8c2..11c0df2ed4d 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -25,7 +25,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { PermissionPillsList } from "./PermissionPillsList"; @@ -40,6 +40,7 @@ interface CustomRolesPageViewProps { canDeleteOrgRole: boolean; canEditDefaultRoles: boolean; isCustomRolesEnabled: boolean; + permissions: Permissions; defaultRolesEntitled?: boolean; availableOrgRoles?: AssignableRoles[]; onUpdateDefaultRoles?: (roles: string[]) => Promise; @@ -56,13 +57,12 @@ export const CustomRolesPageView: FC = ({ canDeleteOrgRole, canEditDefaultRoles, isCustomRolesEnabled, + permissions, defaultRolesEntitled, availableOrgRoles, onUpdateDefaultRoles, isUpdatingDefaultRoles, }) => { - const { permissions } = useAuthenticated(); - return (
{!isCustomRolesEnabled && ( diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx index 2b0bd122372..f56dea19402 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPage.tsx @@ -3,6 +3,7 @@ import { useQuery } from "react-query"; import { useParams } from "react-router"; import { provisionerDaemonGroups } from "#/api/queries/organizations"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; @@ -10,6 +11,7 @@ import { pageTitle } from "#/utils/page"; import { OrganizationProvisionerKeysPageView } from "./OrganizationProvisionerKeysPageView"; const OrganizationProvisionerKeysPage: FC = () => { + const { permissions } = useAuthenticated(); const { organization: organizationName } = useParams() as { organization: string; }; @@ -51,6 +53,7 @@ const OrganizationProvisionerKeysPage: FC = () => { showPaywall={!entitlements.features.multiple_organizations.enabled} provisionerKeyDaemons={provisionerKeyDaemonsQuery.data} error={provisionerKeyDaemonsQuery.error} + permissions={permissions} onRetry={provisionerKeyDaemonsQuery.refetch} /> diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx index 7ed15cb6707..60c5190a91f 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { type ProvisionerKeyDaemons, ProvisionerKeyIDBuiltIn, @@ -6,6 +7,7 @@ import { ProvisionerKeyIDUserAuth, } from "#/api/typesGenerated"; import { + MockPermissions, MockProvisioner, MockProvisionerKey, mockApiError, @@ -70,6 +72,7 @@ const meta: Meta = { args: { error: undefined, provisionerKeyDaemons: mockProvisionerKeyDaemons, + permissions: MockPermissions, onRetry: () => {}, }, }; @@ -91,6 +94,30 @@ export const Paywalled: Story = { args: { showPaywall: true, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const PaywalledWithoutLicenseAccess: Story = { + ...Default, + args: { + showPaywall: true, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const Empty: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx index 880d3e08e95..a7c022e42c5 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -23,7 +23,7 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ProvisionerKeyRow } from "./ProvisionerKeyRow"; @@ -39,14 +39,13 @@ interface OrganizationProvisionerKeysPageViewProps { showPaywall: boolean | undefined; provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined; error: unknown; + permissions: Permissions; onRetry: () => void; } export const OrganizationProvisionerKeysPageView: FC< OrganizationProvisionerKeysPageViewProps -> = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => { - const { permissions } = useAuthenticated(); - +> = ({ showPaywall, provisionerKeyDaemons, error, permissions, onRetry }) => { const filteredProvisionerKeyDaemons = provisionerKeyDaemons?.filter( (pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id), ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index c87dbfd6fdc..3d6823d61df 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx @@ -4,6 +4,7 @@ import { useParams, useSearchParams } from "react-router"; import { buildInfo } from "#/api/queries/buildInfo"; import { provisionerDaemons } from "#/api/queries/organizations"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; @@ -12,6 +13,7 @@ import { pageTitle } from "#/utils/page"; import { OrganizationProvisionersPageView } from "./OrganizationProvisionersPageView"; const OrganizationProvisionersPage: FC = () => { + const { permissions } = useAuthenticated(); const { organization: organizationName } = useParams() as { organization: string; }; @@ -63,6 +65,7 @@ const OrganizationProvisionersPage: FC = () => { error={provisionersQuery.error} provisioners={provisionersQuery.data} buildVersion={buildInfoQuery.data?.version} + permissions={permissions} onRetry={provisionersQuery.refetch} filter={queryParams} onFilterChange={({ ids, offline }) => { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx index 812cba959fd..9b5c6bf0570 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { MockBuildInfo, + MockPermissions, MockProvisioner, MockProvisionerWithTags, MockUserProvisioner, @@ -32,6 +34,7 @@ const meta: Meta = { ids: "", offline: true, }, + permissions: MockPermissions, }, }; @@ -67,6 +70,30 @@ export const Paywall: Story = { provisioners: undefined, showPaywall: true, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const PaywallWithoutLicenseAccess: Story = { + args: { + provisioners: undefined, + showPaywall: true, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const FilterByID: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index 3948ce5a852..c7a27245da4 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -26,7 +26,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { LastConnectionHead } from "./LastConnectionHead"; import { ProvisionerRow } from "./ProvisionerRow"; @@ -42,6 +42,7 @@ interface OrganizationProvisionersPageViewProps { buildVersion: string | undefined; error: unknown; filter: ProvisionersFilter; + permissions: Permissions; onRetry: () => void; onFilterChange: (filter: ProvisionersFilter) => void; } @@ -54,11 +55,10 @@ export const OrganizationProvisionersPageView: FC< provisioners, buildVersion, filter, + permissions, onFilterChange, onRetry, }) => { - const { permissions } = useAuthenticated(); - return (
diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx index 37a770b0989..d1e29af2b12 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx @@ -1,9 +1,11 @@ import type { FC } from "react"; import { useProxy } from "#/contexts/ProxyContext"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { WorkspaceProxyView } from "./WorkspaceProxyView"; const WorkspaceProxyPage: FC = () => { + const { permissions } = useAuthenticated(); const { proxyLatencies, proxies, @@ -23,6 +25,7 @@ const WorkspaceProxyPage: FC = () => { getWorkspaceProxiesError={proxiesError} preferredProxy={proxy.proxy} showPaywall={!workspaceProxyEnabled} + permissions={permissions} /> ); }; diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx index 4c7232ef3cb..0fe28464372 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { MockHealthyWildWorkspaceProxy, + MockPermissions, MockPrimaryWorkspaceProxy, MockProxyLatencies, MockWorkspaceProxies, @@ -13,6 +15,7 @@ const meta: Meta = { component: WorkspaceProxyView, args: { showPaywall: false, + permissions: MockPermissions, }, }; @@ -44,6 +47,29 @@ export const Paywall: Story = { ...Example.args, showPaywall: true, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, +}; + +export const PaywallWithoutLicenseAccess: Story = { + args: { + ...Paywall.args, + permissions: { ...MockPermissions, viewAllLicenses: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: "Learn about Premium" }), + ).not.toBeInTheDocument(); + }, }; export const Loading: Story = { diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx index 650b5169305..34ca5f3e1dd 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx @@ -18,7 +18,7 @@ import { import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import type { ProxyLatencyReport } from "#/contexts/useProxyLatency"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ProxyRow } from "./WorkspaceProxyRow"; @@ -31,6 +31,7 @@ interface WorkspaceProxyViewProps { preferredProxy?: Region; selectProxyError?: unknown; showPaywall: boolean; + permissions: Permissions; } export const WorkspaceProxyView: FC = ({ @@ -41,9 +42,8 @@ export const WorkspaceProxyView: FC = ({ hasLoaded, selectProxyError, showPaywall, + permissions, }) => { - const { permissions } = useAuthenticated(); - return (
Date: Wed, 5 Aug 2026 21:34:55 +0000 Subject: [PATCH 9/9] automated ci suggestions --- docs/admin/setup/appearance.md | 2 +- .../ExternalAuthSettingsPageView.stories.tsx | 4 ++-- .../CreateOrganizationPageView.tsx | 7 +------ .../OrganizationProvisionerKeysPageView.tsx | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/admin/setup/appearance.md b/docs/admin/setup/appearance.md index 61b4cf03cb8..62b2042dfd3 100644 --- a/docs/admin/setup/appearance.md +++ b/docs/admin/setup/appearance.md @@ -1,4 +1,4 @@ -# Appearance +# Appearance (Premium) > [!NOTE] > Customizing Coder's appearance is a Premium feature. diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 7f27623ebc5..94d0ecc0240 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -50,7 +50,7 @@ export const Page: Story = { name: "Read the External Authentication documentation", }), ).toBeVisible(); - } + }, }; export const Empty: Story = { @@ -59,4 +59,4 @@ export const Empty: Story = { external_auth: [], }, }, -} +}; diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 00928ead816..9c59e6d6741 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -13,13 +13,8 @@ import { IconField } from "#/components/IconField/IconField"; import { Label } from "#/components/Label/Label"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { Spinner } from "#/components/Spinner/Spinner"; -import type { Permissions } from "#/modules/permissions"; import { Textarea } from "#/components/Textarea/Textarea"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; +import type { Permissions } from "#/modules/permissions"; import { cn } from "#/utils/cn"; import { docs } from "#/utils/docs"; import { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx index c8ceaab9d24..ac137cf0e03 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -20,9 +20,9 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import type { Permissions } from "#/modules/permissions"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ProvisionerKeyRow } from "./ProvisionerKeyRow";