mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,4 +1,4 @@
|
||||
# Connection Logs
|
||||
# Connection Logs (Premium)
|
||||
|
||||
> [!NOTE]
|
||||
> Connection logs require a
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
# Audit Logs
|
||||
# Audit Logs (Premium)
|
||||
|
||||
**Audit Logs** allows Auditors to monitor user operations in their deployment.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Appearance
|
||||
# Appearance (Premium)
|
||||
|
||||
> [!NOTE]
|
||||
> Customizing Coder's appearance is a Premium feature.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ const AppearanceSettingsPage: FC = () => {
|
||||
entitlements.features.appearance.entitlement !== "not_entitled"
|
||||
}
|
||||
isPremium={hasPremiumLicense}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</RequirePermission>
|
||||
</>
|
||||
|
||||
+34
-1
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+17
-29
@@ -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
|
||||
|
||||
+14
-1
@@ -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: {
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
+38
-1
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+17
-30
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+25
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
+27
@@ -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 = {
|
||||
|
||||
+4
-1
@@ -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">
|
||||
|
||||
+3
@@ -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 }) => {
|
||||
|
||||
+27
@@ -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 = {
|
||||
|
||||
+4
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
+3
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user