feat: DEVEX-732 premium badging (#27847)

Premium badging and gating consistency

as a OSS user, I want to be upsold to premium, and tastefully

Summary

Standardize all base-Premium full-page gates and the two named inline
notices. Admins see an in-app “Learn about Premium” path; non-admins are
told to contact their deployment administrator.
* DEVEX-732
* updates for premium docs pages for consistency
* updates for premium badging and paywall components
* updates implemented uses of premium badge and premiumpaywall


| Before | After |
| --- | ----------- |
| <img width="1271" height="564" alt="Screenshot 2026-08-04 at 3 02
32 PM"
src="https://github.com/user-attachments/assets/027d4bca-3e34-40b2-ad69-28dbaa4a004b"
/> | <img width="1273" height="600" alt="Screenshot 2026-08-04 at 3 26
37 PM"
src="https://github.com/user-attachments/assets/321083c0-7a4c-4c7e-a19c-059807018d3b"
/> |

| Before | After |
| --- | ----------- |
| <img width="1084" height="672" alt="image"
src="https://github.com/user-attachments/assets/741e6bd9-93b0-4ae0-97df-027e8aba5716"
/> | <img width="1289" height="622" alt="Screenshot 2026-08-04 at 3 20
07 PM"
src="https://github.com/user-attachments/assets/b3a8c169-ca6e-439b-8752-9209131fc097"
/> |

| Before | After |
| --- | ----------- |
| <img width="1091" height="865" alt="image (1)"
src="https://github.com/user-attachments/assets/f7cd92dd-a975-4db0-bc2a-af092ba783ce"
/> | <img width="1268" height="680" alt="Screenshot 2026-08-04 at 3 37
06 PM"
src="https://github.com/user-attachments/assets/8af8081a-0ec9-4fd3-921c-470127f2328b"
/> |
This commit is contained in:
Samuel Volin
2026-08-06 12:13:12 -06:00
committed by GitHub
parent baed1455cf
commit 50640063a2
50 changed files with 608 additions and 168 deletions
+5
View File
@@ -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]
+1 -1
View File
@@ -1,4 +1,4 @@
# Connection Logs
# Connection Logs (Premium)
> [!NOTE]
> Connection logs require a
+6 -1
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
# Audit Logs
# Audit Logs (Premium)
**Audit Logs** allows Auditors to monitor user operations in their deployment.
+1 -1
View File
@@ -1,4 +1,4 @@
# Appearance
# Appearance (Premium)
> [!NOTE]
> Customizing Coder's appearance is a Premium feature.
+6 -1
View File
@@ -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:
+33
View File
@@ -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<React.ComponentProps<"a">> = ({
);
};
export const PaywallCTALink: FC<LinkProps> = ({
children,
className,
...props
}) => {
return (
<Button asChild>
<RouterLink className={cn("mx-7", className)} {...props}>
{children}
</RouterLink>
</Button>
);
};
export const PaywallGuidance: FC<React.ComponentProps<"p">> = ({
children,
className,
...props
}) => {
return (
<p
className={cn(
"font-inherit text-sm max-w-[280px] mx-7 my-0 text-content-secondary",
className,
)}
{...props}
>
{children}
</p>
);
};
const FeatureIcon: FC<React.ComponentProps<"svg">> = ({
className,
...props
@@ -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<typeof PaywallPremium> = {
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<typeof PaywallPremium>;
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 },
};
+23 -11
View File
@@ -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 (
<Paywall
className={cn(
@@ -76,9 +82,15 @@ const PaywallPremium = ({
</PaywallFeature>
))}
</PaywallFeatures>
<PaywallCTA href="https://coder.com/pricing#compare-plans">
Learn about Premium
</PaywallCTA>
{canViewLicenses ? (
<PaywallCTALink to={PREMIUM_PAGE_PATH}>
Learn about Premium
</PaywallCTALink>
) : (
<PaywallGuidance>
Contact your deployment administrator for Premium.
</PaywallGuidance>
)}
</PaywallStack>
</Paywall>
);
@@ -1,19 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PopoverPaywall } from "./PopoverPaywall";
const meta: Meta<typeof PopoverPaywall> = {
title: "components/Paywall/PopoverPaywall",
component: PopoverPaywall,
};
export default meta;
type Story = StoryObj<typeof PopoverPaywall>;
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",
},
};
@@ -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<PopoverPaywallProps> = ({
message,
description,
documentationLink,
}) => {
return (
<PaywallPremium
message={message}
description={description}
documentationLink={documentationLink}
compact
/>
);
};
@@ -43,6 +43,7 @@ const GatewayKeysPage: FC = () => {
isLoading={keysQuery.isLoading}
error={keysQuery.error}
showPaywall={showPaywall}
permissions={permissions}
onCreateKey={() => setIsCreateOpen(true)}
onDeleteKey={setKeyToDelete}
/>
@@ -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<typeof GatewayKeysPageView> = {
@@ -13,6 +17,7 @@ const meta: Meta<typeof GatewayKeysPageView> = {
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 = {
@@ -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<GatewayKeysPageViewProps> = ({
isLoading,
error,
showPaywall,
permissions,
onCreateKey,
onDeleteKey,
}) => {
@@ -65,6 +68,7 @@ export const GatewayKeysPageView: FC<GatewayKeysPageViewProps> = ({
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}
/>
)}
+3
View File
@@ -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,
@@ -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<typeof AuditPageView> = {
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 = {
@@ -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<typeof AuditFilter>;
auditsQuery: PaginationResult;
showOrgDetails: boolean;
permissions: Permissions;
}
export const AuditPageView: FC<AuditPageViewProps> = ({
@@ -38,6 +40,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
filterProps,
auditsQuery: paginationResult,
showOrgDetails,
permissions,
}) => {
const isLoading =
(auditLogs === undefined || paginationResult.totalRecords === undefined) &&
@@ -84,6 +87,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
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}
/>
)}
</Margins>
@@ -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,
@@ -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<typeof ConnectionLogPageView> = {
],
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();
},
};
@@ -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<typeof ConnectionLogFilter>;
connectionLogsQuery: PaginationResult;
permissions: Permissions;
}
export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
@@ -36,6 +38,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
error,
filterProps,
connectionLogsQuery: paginationResult,
permissions,
}) => {
const isLoading =
(connectionLogs === undefined ||
@@ -84,6 +87,7 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
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}
/>
)}
</Margins>
@@ -62,6 +62,7 @@ const AppearanceSettingsPage: FC = () => {
entitlements.features.appearance.entitlement !== "not_entitled"
}
isPremium={hasPremiumLicense}
permissions={permissions}
/>
</RequirePermission>
</>
@@ -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<typeof AppearanceSettingsPageView> = {
@@ -22,6 +24,7 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
],
},
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 = {};
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 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();
},
};
@@ -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<UpdateAppearanceConfig>,
) => Promise<void>;
@@ -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<
</SettingsHeader>
<Badges>
<Tooltip>
{isEntitled && !isPremium ? (
<EnterpriseBadge />
{isEntitled ? (
isPremium ? (
<PremiumBadge />
) : (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
>
<PopoverPaywall
message="Appearance"
description="With a Premium license, you can customize the appearance and branding of your deployment."
documentationLink={docs("/admin/setup/appearance")}
/>
</TooltipContent>
</Tooltip>
<EnterpriseBadge />
)
) : (
<PaywallPremium
message="Appearance"
description="With a Premium license, you can customize the appearance and branding of your deployment."
documentationLink={docs("/admin/setup/appearance")}
canViewPremium={permissions.viewAllLicenses}
/>
)}
</Badges>
<Fieldset
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
const meta: Meta<typeof ExternalAuthSettingsPageView> = {
@@ -38,7 +39,19 @@ const meta: Meta<typeof ExternalAuthSettingsPageView> = {
export default meta;
type Story = StoryObj<typeof ExternalAuthSettingsPageView>;
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: {
@@ -54,9 +54,20 @@ export const ExternalAuthSettingsPageView: FC<
/>
<div className="mt-6 mb-6">
<Alert severity="info" actions={<PremiumBadge key="enterprise" />}>
Integrating with multiple External authentication providers is an
Premium feature.
<Alert severity="info" actions={<PremiumBadge />}>
Integrating with multiple external authentication providers is a
Premium feature.{" "}
<a
href={docs(
"/admin/external-auth#multiple-external-providers-premium",
)}
target="_blank"
rel="noreferrer"
className="text-content-link font-medium"
>
Read the External Authentication documentation
</a>
.
</Alert>
</div>
@@ -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}
/>
) : (
<IdpOrgSyncPageView
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { SerpentGroup } from "#/api/typesGenerated";
import { ObservabilitySettingsPageView } from "./ObservabilitySettingsPageView";
@@ -45,6 +46,7 @@ const meta: Meta<typeof ObservabilitySettingsPageView> = {
},
],
featureAuditLogEnabled: true,
isPremium: false,
},
};
@@ -53,4 +55,39 @@ type Story = StoryObj<typeof ObservabilitySettingsPageView>;
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();
},
};
@@ -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<
</SettingsHeaderDescription>
</SettingsHeader>
<Badges>
<Tooltip>
{featureAuditLogEnabled && !isPremium ? (
<EnterpriseBadge />
) : (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
{featureAuditLogEnabled || isPremium ? (
<Badges>{isPremium ? <PremiumBadge /> : <EnterpriseBadge />}</Badges>
) : (
<Alert severity="info" actions={<PremiumBadge />}>
Audit logging lets auditors monitor user operations across your
deployment. It requires a Premium license.{" "}
<a
href={docs("/admin/security/audit-logs")}
target="_blank"
rel="noreferrer"
className="text-content-link font-medium"
>
<PopoverPaywall
message="Observability"
description="With a Premium license, you can monitor your application with logs and metrics."
documentationLink={docs("/admin/monitoring")}
/>
</TooltipContent>
</Tooltip>
</Badges>
Read the Audit Logs documentation
</a>
.
</Alert>
)}
</div>
<div>
@@ -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<typeof PremiumPageView> = {
@@ -10,14 +11,30 @@ export default meta;
type Story = StoryObj<typeof PremiumPageView>;
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);
},
};
@@ -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 = () => {
<div className="max-w-4xl">
<header className="flex flex-row justify-between align-baseline pb-5">
<div>
<h1 className="text-3xl m-0 font-semibold">Premium</h1>
<div className="flex flex-row items-center gap-3">
<h1 className="text-3xl m-0 font-semibold">Premium</h1>
<PremiumBadge />
</div>
<p className="text-sm max-w-xl mt-2 text-content-secondary font-medium">
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 = () => {
<div className="max-w-4xl">
<div className="flex flex-row justify-between align-baseline pb-10">
<div>
<h1 className="text-3xl m-0 text-content-primary font-semibold">
Premium
</h1>
<div className="flex flex-row items-center gap-3">
<h1 className="text-3xl m-0 text-content-primary font-semibold">
Premium
</h1>
<PremiumBadge />
</div>
<p className="text-sm max-w-xl mt-2 text-content-secondary">
Coder Premium is designed for enterprises that need to scale their
Coder deployment efficiently, securely, and with full control. By
+3
View File
@@ -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}
/>
</div>
);
@@ -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<typeof GroupsPageView> = {
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 = {
@@ -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<GroupsPageViewProps> = ({
@@ -66,6 +68,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
canCreateGroup,
groupsEnabled,
showAIBudget,
permissions,
}) => {
if (!groupsEnabled) {
return (
@@ -73,6 +76,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
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}
/>
);
}
@@ -26,6 +26,7 @@ const CreateOrganizationPage: FC = () => {
<CreateOrganizationPageView
error={error}
isEntitled={feats.multiple_organizations}
permissions={permissions}
onSubmit={async (values) => {
await createOrganizationMutation.mutateAsync(values);
toast.success(
@@ -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<typeof CreateOrganizationPageView> = {
@@ -7,18 +8,55 @@ const meta: Meta<typeof CreateOrganizationPageView> = {
component: CreateOrganizationPageView,
args: {
isEntitled: true,
permissions: MockPermissions,
},
};
export default meta;
type Story = StoryObj<typeof CreateOrganizationPageView>;
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 = {
@@ -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<void>;
isEntitled: boolean;
permissions: Permissions;
}
export const CreateOrganizationPageView: FC<
CreateOrganizationPageViewProps
> = ({ error, onSubmit, isEntitled }) => {
> = ({ error, onSubmit, isEntitled, permissions }) => {
const form = useFormik<CreateOrganizationRequest>({
initialValues: {
name: "",
@@ -87,29 +83,11 @@ export const CreateOrganizationPageView: FC<
</div>
)}
<Badges>
<Tooltip>
{isEntitled && (
<TooltipTrigger asChild>
<span>
<PremiumBadge />
</span>
</TooltipTrigger>
)}
<TooltipContent
sideOffset={-28}
collisionPadding={16}
className="p-0"
>
<PopoverPaywall
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")}
/>
</TooltipContent>
</Tooltip>
</Badges>
{isEntitled && (
<Badges>
<PremiumBadge />
</Badges>
)}
<header className="flex flex-col items-center">
<h1 className="text-3xl font-semibold m-0">New Organization</h1>
@@ -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}
/>
</div>
) : (
@@ -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}
@@ -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<typeof CustomRolesPageView> = {
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 = {
@@ -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<void>;
@@ -55,6 +57,7 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
canDeleteOrgRole,
canEditDefaultRoles,
isCustomRolesEnabled,
permissions,
defaultRolesEntitled,
availableOrgRoles,
onUpdateDefaultRoles,
@@ -67,6 +70,7 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
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 && (
@@ -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}
/>
) : (
<IdpSyncPageView
@@ -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}
/>
</>
@@ -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<typeof OrganizationProvisionerKeysPageView> = {
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 = {
@@ -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}
/>
) : (
<Table className="mt-6">
@@ -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 }) => {
@@ -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<typeof OrganizationProvisionersPageView> = {
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 = {
@@ -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}
/>
) : (
<>
@@ -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}
/>
) : (
<TemplatePermissionsPageView
@@ -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}
/>
);
};
@@ -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<typeof WorkspaceProxyView> = {
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 = {
@@ -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<WorkspaceProxyViewProps> = ({
@@ -40,6 +42,7 @@ export const WorkspaceProxyView: FC<WorkspaceProxyViewProps> = ({
hasLoaded,
selectProxyError,
showPaywall,
permissions,
}) => {
return (
<div className="flex flex-col gap-4">
@@ -62,6 +65,7 @@ export const WorkspaceProxyView: FC<WorkspaceProxyViewProps> = ({
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}
/>
) : (
<>