diff --git a/docs/admin/external-auth/index.md b/docs/admin/external-auth/index.md index a9f3131bb84d8..bb271a82f154a 100644 --- a/docs/admin/external-auth/index.md +++ b/docs/admin/external-auth/index.md @@ -356,6 +356,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/monitoring/connection-logs.md b/docs/admin/monitoring/connection-logs.md index d5ace1296f4e7..6f023cf747ad1 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/networking/workspace-proxies.md b/docs/admin/networking/workspace-proxies.md index 69aa2073debe1..a85c6b9855522 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 b5cb32ba168ba..e6d2f30ea1432 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/setup/appearance.md b/docs/admin/setup/appearance.md index 61b4cf03cb84d..62b2042dfd355 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/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index 57d6721cb074b..4c5c296651991 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -1,10 +1,15 @@ -# 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). ## 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: diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx index 713a0fafa1c6f..5c91d115ebd63 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 { type LinkProps, Link as RouterLink } 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 b88104ed45704..24cb5a02f67f3 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 d86b12ba697bf..04cd594cc1cc4 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 60f965a6e7be0..0000000000000 --- 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 59929e8b78afa..0000000000000 --- 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 ( - - ); -}; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx index f34f1f193c12e..04920b744810b 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 8b42ca2d2d237..27309b70e5fdb 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 8d26234b1dffd..500d08ae9ecf7 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -20,6 +20,7 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { relativeTime } from "#/utils/time"; @@ -28,6 +29,7 @@ interface GatewayKeysPageViewProps { isLoading: boolean; error: unknown; showPaywall: boolean; + permissions: Permissions; onCreateKey: () => void; onDeleteKey: (key: AIGatewayKey) => void; } @@ -37,6 +39,7 @@ export const GatewayKeysPageView: FC = ({ isLoading, error, showPaywall, + permissions, onCreateKey, onDeleteKey, }) => { @@ -65,6 +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={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 077d5f7d96733..5e0cf37c7606a 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -4,6 +4,7 @@ import { paginatedAudits } from "#/api/queries/audits"; 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 { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"; import { AuditPageView } from "./AuditPageView"; const AuditPage: FC = () => { + 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 7707bd5c6a362..14009fe342a11 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 ed3a94dc7d027..8e76214acc500 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -15,6 +15,7 @@ import { Table, TableBody } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { AuditFilter } from "./AuditFilter"; import { AuditHelpPopover } from "./AuditHelpPopover"; @@ -28,6 +29,7 @@ interface AuditPageViewProps { filterProps: ComponentProps; auditsQuery: PaginationResult; showOrgDetails: boolean; + permissions: Permissions; } export const AuditPageView: FC = ({ @@ -38,6 +40,7 @@ export const AuditPageView: FC = ({ filterProps, auditsQuery: paginationResult, showOrgDetails, + permissions, }) => { const isLoading = (auditLogs === undefined || paginationResult.totalRecords === undefined) && @@ -84,6 +87,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={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx b/site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx index 39216c1146e19..d1c5692a3ed75 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 966dde9bfcdca..3a43a7e02d4b7 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 009e78a2e6954..c2c28c1100846 100644 --- a/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx +++ b/site/src/pages/ConnectionLogPage/ConnectionLogPageView.tsx @@ -15,6 +15,7 @@ import { Table, TableBody } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import { Timeline } from "#/components/Timeline/Timeline"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ConnectionLogFilter } from "./ConnectionLogFilter"; import { ConnectionLogHelpPopover } from "./ConnectionLogHelpPopover"; @@ -27,6 +28,7 @@ interface ConnectionLogPageViewProps { error?: unknown; filterProps: ComponentProps; connectionLogsQuery: PaginationResult; + permissions: Permissions; } export const ConnectionLogPageView: FC = ({ @@ -36,6 +38,7 @@ export const ConnectionLogPageView: FC = ({ error, filterProps, connectionLogsQuery: paginationResult, + permissions, }) => { const isLoading = (connectionLogs === undefined || @@ -84,6 +87,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={permissions.viewAllLicenses} /> )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx index b7c6d31831745..7e5b9cd31b8e7 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 72c5bd4f59d2f..2ed9e3cf2d9d4 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,4 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { MockPermissions } from "#/testHelpers/entities"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; const meta: Meta = { @@ -22,6 +24,7 @@ const meta: Meta = { ], }, isEntitled: false, + permissions: MockPermissions, }, }; @@ -32,6 +35,36 @@ export const Entitled: Story = { args: { isEntitled: true, }, + play: async () => { + // 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 ({ canvasElement }) => { + const canvas = within(canvasElement); + + const cta = canvas.getByRole("link", { name: "Learn about Premium" }); + await expect(cta).toHaveAttribute("href", "/deployment/premium"); + }, }; -export const NotEntitled: Story = {}; +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 74acf048df3f0..f4aefff4972f2 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -13,17 +13,13 @@ import { InputGroupAddon, InputGroupInput, } from "#/components/InputGroup/InputGroup"; -import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall"; +import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { Fieldset } from "../Fieldset"; @@ -33,6 +29,7 @@ type AppearanceSettingsPageViewProps = { appearance: UpdateAppearanceConfig; isEntitled: boolean; isPremium: boolean; + permissions: Permissions; onSaveAppearance: ( newConfig: Partial, ) => Promise; @@ -40,7 +37,7 @@ type AppearanceSettingsPageViewProps = { export const AppearanceSettingsPageView: FC< AppearanceSettingsPageViewProps -> = ({ appearance, isEntitled, isPremium, onSaveAppearance }) => { +> = ({ appearance, isEntitled, isPremium, permissions, onSaveAppearance }) => { const applicationNameForm = useFormik<{ application_name: string; }>({ @@ -71,29 +68,20 @@ export const AppearanceSettingsPageView: FC< - - {isEntitled && !isPremium ? ( - + {isEntitled ? ( + isPremium ? ( + ) : ( - - - - - - )} - - - - - + + ) + ) : ( + + )}
= { @@ -38,7 +39,19 @@ 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(); + }, +}; export const Empty: Story = { args: { diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index b6f6538d91efc..e41ebbbb474db 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -54,9 +54,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 3b0fa5f2e75be..185ced1813be6 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -10,6 +10,7 @@ import { import { Link } from "#/components/Link/Link"; import { Loader } from "#/components/Loader/Loader"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; 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 { permissions } = useAuthenticated(); 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={permissions.viewAllLicenses} /> ) : ( = { }, ], 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 6970caf059111..5a2a803fb5b98 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 c59ffeb0a7dcc..b3cfdbdb19a4f 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 a01a2c288403b..b48f101027b68 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/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index b1fd6d89f508e..0f78309106556 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 454f184f62006..adb97a9b13d96 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 b4b2eab52746c..6ae73a1ff096a 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -24,6 +24,7 @@ import { TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { SpendEstimateDocsLink } from "./AICostControl"; import { StatusIconTooltip } from "./StatusIconTooltip"; @@ -58,6 +59,7 @@ type GroupsPageViewProps = { canCreateGroup: boolean; groupsEnabled: boolean; showAIBudget: boolean; + permissions: Permissions; }; export const GroupsPageView: FC = ({ @@ -66,6 +68,7 @@ export const GroupsPageView: FC = ({ canCreateGroup, groupsEnabled, showAIBudget, + permissions, }) => { if (!groupsEnabled) { return ( @@ -73,6 +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={permissions.viewAllLicenses} /> ); } diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx index 51cdccf5ab5b9..b673dd93c806f 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx @@ -26,6 +26,7 @@ const CreateOrganizationPage: FC = () => { { 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 dfbff52a15ea6..3c4e55822aa5e 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { mockApiError } from "#/testHelpers/entities"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { MockPermissions, mockApiError } from "#/testHelpers/entities"; import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; const meta: Meta = { @@ -7,18 +8,55 @@ const meta: Meta = { component: CreateOrganizationPageView, args: { isEntitled: true, + permissions: MockPermissions, }, }; 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(); + 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(); + }, }; export const WithError: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx index 69980a654550c..9c59e6d67419c 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx @@ -12,14 +12,9 @@ import { FormField } from "#/components/FormField/FormField"; import { IconField } from "#/components/IconField/IconField"; import { Label } from "#/components/Label/Label"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; -import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall"; import { Spinner } from "#/components/Spinner/Spinner"; 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 { @@ -45,11 +40,12 @@ interface CreateOrganizationPageViewProps { error: unknown; onSubmit: (values: CreateOrganizationRequest) => Promise; isEntitled: boolean; + permissions: Permissions; } export const CreateOrganizationPageView: FC< CreateOrganizationPageViewProps -> = ({ error, onSubmit, isEntitled }) => { +> = ({ error, onSubmit, isEntitled, permissions }) => { const form = useFormik({ initialValues: { name: "", @@ -87,29 +83,11 @@ export const CreateOrganizationPageView: FC<
)} - - - {isEntitled && ( - - - - - - )} - - - - - - + {isEntitled && ( + + + + )}

New Organization

@@ -125,6 +103,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={permissions.viewAllLicenses} />
) : ( diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 42a0d0d3b8dd5..c80cbd8d8d9fa 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 5368db952e951..740cf071de142 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 331bf8510fdd5..7c064b226dc84 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -25,6 +25,7 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "#/components/TableLoader/TableLoader"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { PermissionPillsList } from "./PermissionPillsList"; @@ -39,6 +40,7 @@ interface CustomRolesPageViewProps { canDeleteOrgRole: boolean; canEditDefaultRoles: boolean; isCustomRolesEnabled: boolean; + permissions: Permissions; defaultRolesEntitled?: boolean; availableOrgRoles?: AssignableRoles[]; onUpdateDefaultRoles?: (roles: string[]) => Promise; @@ -55,6 +57,7 @@ export const CustomRolesPageView: FC = ({ canDeleteOrgRole, canEditDefaultRoles, isCustomRolesEnabled, + permissions, defaultRolesEntitled, availableOrgRoles, onUpdateDefaultRoles, @@ -67,6 +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={permissions.viewAllLicenses} /> )} {onUpdateDefaultRoles && ( diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 37ab7b1f3a33e..5b64d53cc6a77 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -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 { useAuthenticated } from "#/hooks/useAuthenticated"; 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 { permissions } = useAuthenticated(); 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={permissions.viewAllLicenses} /> ) : ( { + 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 2e671f51b23cf..6ed169bd122b4 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 Loading: Story = { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx index 96b4cc7d0cd42..ac137cf0e03dc 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerKeysPage/OrganizationProvisionerKeysPageView.tsx @@ -22,6 +22,7 @@ import { } from "#/components/Table/Table"; 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"; @@ -37,12 +38,13 @@ interface OrganizationProvisionerKeysPageViewProps { showPaywall: boolean | undefined; provisionerKeyDaemons: ProvisionerKeyDaemons[] | undefined; error: unknown; + permissions: Permissions; onRetry: () => void; } export const OrganizationProvisionerKeysPageView: FC< OrganizationProvisionerKeysPageViewProps -> = ({ showPaywall, provisionerKeyDaemons, error, onRetry }) => { +> = ({ showPaywall, provisionerKeyDaemons, error, permissions, onRetry }) => { const filteredProvisionerKeyDaemons = provisionerKeyDaemons?.filter( (pkd) => !HIDDEN_PROVISIONER_KEYS.includes(pkd.key.id), ); @@ -62,6 +64,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={permissions.viewAllLicenses} /> ) : ( diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index c87dbfd6fdcc7..3d6823d61df1d 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 812cba959fdfd..9b5c6bf057006 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 9aae04b80075e..8cd6f97ac5e82 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -25,6 +25,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { LastConnectionHead } from "./LastConnectionHead"; import { ProvisionerRow } from "./ProvisionerRow"; @@ -40,6 +41,7 @@ interface OrganizationProvisionersPageViewProps { buildVersion: string | undefined; error: unknown; filter: ProvisionersFilter; + permissions: Permissions; onRetry: () => void; onFilterChange: (filter: ProvisionersFilter) => void; } @@ -52,6 +54,7 @@ export const OrganizationProvisionersPageView: FC< provisioners, buildVersion, filter, + permissions, onFilterChange, onRetry, }) => { @@ -98,6 +101,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={permissions.viewAllLicenses} /> ) : ( <> diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index d51dc43d3d20a..5a0da8bf11009 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -8,6 +8,7 @@ import { templateACL, } from "#/api/queries/templates"; import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; 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 { permissions: authPermissions } = useAuthenticated(); 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={authPermissions.viewAllLicenses} /> ) : ( { + 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 4c7232ef3cbe3..0fe2846437276 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 946e8fb6d6a1e..34ca5f3e1dd38 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyView.tsx @@ -18,6 +18,7 @@ import { import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; import type { ProxyLatencyReport } from "#/contexts/useProxyLatency"; +import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { ProxyRow } from "./WorkspaceProxyRow"; @@ -30,6 +31,7 @@ interface WorkspaceProxyViewProps { preferredProxy?: Region; selectProxyError?: unknown; showPaywall: boolean; + permissions: Permissions; } export const WorkspaceProxyView: FC = ({ @@ -40,6 +42,7 @@ export const WorkspaceProxyView: FC = ({ hasLoaded, selectProxyError, showPaywall, + permissions, }) => { return (
@@ -62,6 +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={permissions.viewAllLicenses} /> ) : ( <>