feat: display the addon license UI (#22948)

<img width="1052" height="234" alt="Screenshot 2026-03-18 at 21 58 57"
src="https://github.com/user-attachments/assets/136ccb1f-e47a-44fd-804d-859301161435"
/>

---------

Co-authored-by: Steven Masley <stevenmasley@gmail.com>
This commit is contained in:
Jaayden Halko
2026-03-20 16:34:17 +00:00
committed by GitHub
co-authored by Steven Masley
parent 89eaf6ad74
commit 6f244cddde
12 changed files with 977 additions and 134 deletions
+32
View File
@@ -46,6 +46,12 @@ func Entitlements(
return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err)
}
// nolint:gocritic // Getting active AI seat count is a system function.
activeAISeatCount, err := db.GetActiveAISeatCount(dbauthz.AsSystemRestricted(ctx))
if err != nil {
return codersdk.Entitlements{}, xerrors.Errorf("query active AI seat count: %w", err)
}
// nolint:gocritic // Getting external templates is a system function.
externalTemplates, err := db.GetTemplatesWithFilter(dbauthz.AsSystemRestricted(ctx), database.GetTemplatesWithFilterParams{
HasExternalAgent: sql.NullBool{
@@ -59,6 +65,7 @@ func Entitlements(
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
ExternalAuthCount: externalAuthCount,
ExternalTemplateCount: int64(len(externalTemplates)),
@@ -88,6 +95,7 @@ func Entitlements(
type FeatureArguments struct {
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
ExternalAuthCount int
ExternalTemplateCount int64
@@ -326,6 +334,9 @@ func LicensesEntitlements(
if featureName == codersdk.FeatureUserLimit {
actual = &featureArguments.ActiveUserCount
}
if featureName == codersdk.FeatureAIGovernanceUserLimit {
actual = &featureArguments.ActiveAISeatCount
}
entitlements.AddFeature(featureName, codersdk.Feature{
Enabled: true,
@@ -478,6 +489,24 @@ func LicensesEntitlements(
"Your deployment has %d active users but the license with the limit %d is expired.",
featureArguments.ActiveUserCount, *userLimit.Limit))
}
if featureArguments.ActiveAISeatCount > 0 {
feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
switch {
case feature.Entitlement == codersdk.EntitlementNotEntitled:
// If the limit is not set
entitlements.Errors = append(entitlements.Errors,
fmt.Sprintf("Your deployment has %d active AI governance seats but the license is not entitled to this feature.", featureArguments.ActiveAISeatCount))
case feature.Entitlement == codersdk.EntitlementGracePeriod && feature.Limit != nil:
entitlements.Warnings = append(entitlements.Warnings,
fmt.Sprintf(
"Your deployment has %d active AI governance seats but the license with the limit %d is expired.",
featureArguments.ActiveAISeatCount, *feature.Limit))
case feature.Limit != nil && featureArguments.ActiveAISeatCount > *feature.Limit:
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
"Your deployment has %d active AI governance seats but is only licensed for %d.",
featureArguments.ActiveAISeatCount, *feature.Limit))
}
}
// Add a warning for every feature that is enabled but not entitled or
// is in a grace period.
@@ -486,6 +515,9 @@ func LicensesEntitlements(
if featureName == codersdk.FeatureUserLimit {
continue
}
if featureName == codersdk.FeatureAIGovernanceUserLimit {
continue
}
// High availability has it's own warnings based on replica count!
if featureName == codersdk.FeatureHighAvailability {
continue
+65
View File
@@ -851,6 +851,9 @@ func TestEntitlements(t *testing.T) {
mDB.EXPECT().
GetActiveUserCount(gomock.Any(), false).
Return(int64(1), nil)
mDB.EXPECT().
GetActiveAISeatCount(gomock.Any()).
Return(int64(27), nil)
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageDCManagedAgentsV1Params) bool {
// gomock doesn't seem to compare times very nicely, so check
@@ -885,10 +888,72 @@ func TestEntitlements(t *testing.T) {
require.NotNil(t, managedAgentLimit.Actual)
require.EqualValues(t, 175, *managedAgentLimit.Actual)
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.NotNil(t, aiGovernanceSeatLimit.Actual)
require.EqualValues(t, 27, *aiGovernanceSeatLimit.Actual)
require.NotNil(t, aiGovernanceSeatLimit.Limit)
require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit)
// Usage exceeds the limit, so an exceeded warning should be present.
require.Len(t, entitlements.Warnings, 1)
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := (&coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: 100,
},
}).
UserLimit(100)
lic := database.License{
ID: 1,
JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
Exp: licenseOpts.ExpiresAt,
}
mDB.EXPECT().
GetUnexpiredLicenses(gomock.Any()).
Return([]database.License{lic}, nil)
mDB.EXPECT().
GetActiveUserCount(gomock.Any(), false).
Return(int64(1), nil)
mDB.EXPECT().
GetActiveAISeatCount(gomock.Any()).
Return(int64(127), nil)
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.NotNil(t, aiGovernanceSeatLimit.Actual)
require.EqualValues(t, 127, *aiGovernanceSeatLimit.Actual)
require.NotNil(t, aiGovernanceSeatLimit.Limit)
require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit)
require.Len(t, entitlements.Warnings, 1)
require.Equal(t, "Your deployment has 127 active AI governance seats but is only licensed for 100.", entitlements.Warnings[0])
})
}
func TestLicenseEntitlements(t *testing.T) {
+1
View File
@@ -425,6 +425,7 @@ type Claims = {
all_features: boolean;
// feature_set is omitted on legacy licenses
feature_set?: string;
addons?: string[];
version: number;
features: Record<string, number>;
require_telemetry?: boolean;
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard";
const meta: Meta<typeof AIGovernanceAddOnCard> = {
title:
"pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard",
component: AIGovernanceAddOnCard,
args: {
title: "AI governance",
unit: "Seats",
actual: 750,
limit: 1000,
isExceeded: false,
},
};
export default meta;
type Story = StoryObj<typeof AIGovernanceAddOnCard>;
export const Default: Story = {};
export const Exceeded: Story = {
args: {
actual: 1200,
limit: 1000,
isExceeded: true,
},
};
@@ -0,0 +1,81 @@
import { Badge } from "components/Badge/Badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
type AIGovernanceAddOnCardProps = {
title: string;
unit: string;
actual?: number;
limit: number;
isExceeded: boolean;
};
export const AIGovernanceAddOnCard: FC<AIGovernanceAddOnCardProps> = ({
title,
unit,
actual,
limit,
isExceeded,
}) => {
const actualLabel = actual === undefined ? "—" : actual.toLocaleString();
return (
<div
className={`min-w-[320px] flex-1 rounded-sm border border-solid py-3 ${
isExceeded ? "border-border-destructive" : "border-border"
}`}
>
<div className="flex items-center gap-1 px-6 py-1.5">
<div className="flex flex-1 items-center gap-1">
<div className="flex items-center gap-6">
<div className="flex items-center gap-1">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-content-primary">
{title}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="AI governance seat information"
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
>
<InfoIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Seats consumed by users using AI Governance features.
</TooltipContent>
</Tooltip>
</div>
<Badge variant="magenta" size="sm" border="solid">
AI add-on
</Badge>
</div>
<div className="min-w-[100px] flex-1 pl-8 pr-3">
<div className="text-xs">
<div className="font-medium text-content-secondary">{unit}</div>
<div className="font-normal text-content-primary">
<span
className={
isExceeded
? "text-content-destructive"
: "text-content-primary"
}
>
{actualLabel}
</span>{" "}
/ {limit.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart";
const meta: Meta<typeof AIGovernanceUsersConsumption> = {
@@ -10,6 +11,7 @@ const meta: Meta<typeof AIGovernanceUsersConsumption> = {
enabled: true,
entitlement: "entitled",
limit: 1000,
actual: 750,
},
},
};
@@ -19,6 +21,25 @@ type Story = StoryObj<typeof AIGovernanceUsersConsumption>;
export const Default: Story = {};
export const Exceeded: Story = {
args: {
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
limit: 1000,
actual: 1200,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("1,200")).toBeInTheDocument();
await expect(
canvas.getByText(/of 1,000 Users Entitled/i),
).toBeInTheDocument();
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
},
};
export const Disabled: Story = {
args: {
aiGovernanceUserFeature: {
@@ -9,6 +9,7 @@ import {
import { Link } from "components/Link/Link";
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
interface AIGovernanceUsersConsumptionProps {
@@ -42,8 +43,15 @@ export const AIGovernanceUsersConsumption: FC<
return <ErrorAlert error="Invalid license usage limits" />;
}
const actual = aiGovernanceUserFeature.actual;
const isExceeded = actual !== undefined && actual > limit;
return (
<section className="border border-solid rounded">
<section
className={cn("border border-solid rounded", {
"border-border-destructive": isExceeded,
})}
>
<div className="p-4">
<Collapsible>
<header className="flex flex-col gap-2 items-start">
@@ -105,13 +113,44 @@ export const AIGovernanceUsersConsumption: FC<
<div className="px-6 py-12 border-0 border-t border-solid">
<div className="flex flex-col gap-4 text-center justify-center items-center">
<div className="flex flex-col gap-2 text-center justify-center items-center">
<div className="text-3xl font-bold">{limit.toLocaleString()}</div>
<div className="text-sm text-content-secondary">Users Entitled</div>
</div>
<div className="text-sm text-content-secondary">
Additional analytics and measurements coming soon
</div>
{actual !== undefined ? (
<>
<div className="flex flex-col gap-2 text-center justify-center items-center">
<div
className={cn("text-3xl font-bold", {
"text-content-destructive": isExceeded,
})}
>
{actual.toLocaleString()}
</div>
<div className="text-sm text-content-secondary">
of {limit.toLocaleString()} Users Entitled
</div>
</div>
<div
className={cn("text-sm", {
"text-content-destructive font-medium": isExceeded,
"text-content-secondary": !isExceeded,
})}
>
{isExceeded ? "Add-on exceeded" : "Active"}
</div>
</>
) : (
<>
<div className="flex flex-col gap-2 text-center justify-center items-center">
<div className="text-3xl font-bold">
{limit.toLocaleString()}
</div>
<div className="text-sm text-content-secondary">
Users Entitled
</div>
</div>
<div className="text-sm text-content-secondary">
Additional analytics and measurements coming soon
</div>
</>
)}
</div>
</div>
</section>
@@ -0,0 +1,366 @@
import { chromatic } from "testHelpers/chromatic";
import { MockLicenseResponse } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import dayjs from "dayjs";
import { expect, fn, within } from "storybook/test";
import { LicenseCard } from "./LicenseCard";
const meta: Meta<typeof LicenseCard> = {
title: "pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard",
component: LicenseCard,
parameters: { chromatic },
args: {
license: MockLicenseResponse[0],
userLimitActual: 4,
userLimitLimit: 10,
onRemove: fn(),
isRemoving: false,
},
};
export default meta;
type Story = StoryObj<typeof LicenseCard>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("#1")).toBeInTheDocument();
await expect(canvas.getByText("4 / 10")).toBeInTheDocument();
await expect(canvas.getByText("Enterprise")).toBeInTheDocument();
},
};
export const UnlimitedUsers: Story = {
args: {
userLimitLimit: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("4 / Unlimited")).toBeInTheDocument();
},
};
export const UsesLicenseUserLimit: Story = {
args: {
license: {
...MockLicenseResponse[0],
claims: {
...MockLicenseResponse[0].claims,
features: {
...MockLicenseResponse[0].claims.features,
user_limit: 3,
},
},
},
userLimitActual: 1,
userLimitLimit: 100,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("1 / 3")).toBeInTheDocument();
},
};
export const Premium: Story = {
args: {
license: MockLicenseResponse[1],
},
};
export const PremiumWithAIGovernance: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 750,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument();
await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument();
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("750 / 1,000");
},
};
export const PremiumWithoutAIGovernanceAddOn: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 100,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument();
await expect(canvas.queryByText("AI governance")).not.toBeInTheDocument();
},
};
export const Expired: Story = {
args: {
license: MockLicenseResponse[3],
},
};
export const ExceededUserLimit: Story = {
args: {
userLimitActual: 15,
userLimitLimit: 10,
},
};
export const ExceededAIGovernance: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 1200,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("1,200 / 1,000");
},
};
export const ExpiredAIGovernanceOverageShowsExpired: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
license_expires: dayjs().subtract(1, "day").unix(),
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 1200,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Expired")).toBeInTheDocument();
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("—");
await expect(seatsValue).toHaveTextContent("/ 1,000");
},
};
export const ExpiredAIGovernanceInGracePeriodShowsExceeded: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
license_expires: dayjs().subtract(1, "day").unix(),
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "grace_period",
actual: 1200,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("1,200 / 1,000");
},
};
export const NotYetValid: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
nbf: dayjs().add(7, "day").unix(),
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/Not started/)).toBeInTheDocument();
},
};
export const FutureAIGovernanceOverageShowsStartsOn: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
nbf: dayjs().add(7, "day").unix(),
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 1200,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/Not started/)).toBeInTheDocument();
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("—");
await expect(seatsValue).toHaveTextContent("/ 1,000");
},
};
export const FutureAIGovernanceUsageShowsNoCurrentSeats: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
nbf: dayjs().add(7, "day").unix(),
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("—");
await expect(seatsValue).toHaveTextContent("/ 1,000");
await expect(seatsValue).not.toHaveTextContent("0 / 1,000");
},
};
export const LowerLimitCardUsesMergedEntitlement: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 500,
},
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 750,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const seatsLabel = canvas.getByText("Seats");
const seatsValue = seatsLabel.nextElementSibling;
await expect(seatsValue).toHaveTextContent("—");
await expect(seatsValue).toHaveTextContent("/ 500");
await expect(seatsValue).not.toHaveTextContent("750 / 500");
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
},
};
export const EnterpriseDoesNotShowAIGovernanceAddOn: Story = {
args: {
license: {
...MockLicenseResponse[1],
claims: {
...MockLicenseResponse[1].claims,
features: {
...MockLicenseResponse[1].claims.features,
ai_governance_user_limit: 1000,
},
feature_set: "enterprise",
addons: ["ai_governance"],
},
},
aiGovernanceUserFeature: {
enabled: true,
entitlement: "entitled",
actual: 750,
limit: 1000,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument();
await expect(canvas.queryByText("AI add-on")).not.toBeInTheDocument();
},
};
@@ -1,46 +1,17 @@
import { MockLicenseResponse } from "testHelpers/entities";
import { render } from "testHelpers/renderHelpers";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import { LicenseCard } from "./LicenseCard";
const openRemoveDialog = async (user: UserEvent) => {
await user.click(
screen.getByRole("button", { name: /show license actions/i }),
);
await user.click(await screen.findByRole("menuitem", { name: /remove/i }));
};
describe("LicenseCard", () => {
it("renders (smoke test)", async () => {
// When
render(
<LicenseCard
license={MockLicenseResponse[0]}
userLimitActual={1}
userLimitLimit={10}
onRemove={() => null}
isRemoving={false}
/>,
);
// Then
await screen.findByText("#1");
await screen.findByText("1 / 10");
await screen.findByText("Enterprise");
});
it("renders userLimit as unlimited if there is not user limit", async () => {
// When
render(
<LicenseCard
license={MockLicenseResponse[0]}
userLimitActual={1}
userLimitLimit={undefined}
onRemove={() => null}
isRemoving={false}
/>,
);
// Then
await screen.findByText("#1");
await screen.findByText("1 / Unlimited");
await screen.findByText("Enterprise");
});
it("shows expired removal message for expired licenses", async () => {
const user = userEvent.setup();
render(
@@ -53,8 +24,7 @@ describe("LicenseCard", () => {
/>,
);
const removeButton = await screen.findByRole("button", { name: /remove/i });
await user.click(removeButton);
await openRemoveDialog(user);
const dialog = await screen.findByTestId("dialog");
expect(dialog).toHaveTextContent(/This license has already expired/);
@@ -72,42 +42,13 @@ describe("LicenseCard", () => {
/>,
);
const removeButton = await screen.findByRole("button", { name: /remove/i });
await user.click(removeButton);
await openRemoveDialog(user);
await screen.findByText(
/Removing this license will disable all Premium features/,
);
});
it("renders license's user_limit when it is available instead of using the default", async () => {
const licenseUserLimit = 3;
const license = {
...MockLicenseResponse[0],
claims: {
...MockLicenseResponse[0].claims,
features: {
...MockLicenseResponse[0].claims.features,
user_limit: licenseUserLimit,
},
},
};
// When
render(
<LicenseCard
license={license}
userLimitActual={1}
userLimitLimit={100} // This should not be used
onRemove={() => null}
isRemoving={false}
/>,
);
// Then
await screen.findByText("1 / 3");
});
it("requires typing the license ID before allowing removal", async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
@@ -123,7 +64,7 @@ describe("LicenseCard", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /remove/i }));
await openRemoveDialog(user);
const dialog = await screen.findByTestId("dialog");
const dialogScope = within(dialog);
@@ -1,12 +1,27 @@
import type { GetLicensesResponse } from "api/api";
import type { Feature } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "components/Collapsible/Collapsible";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { Pill } from "components/Pill/Pill";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import dayjs from "dayjs";
import { ChevronDownIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard";
type LicenseCardProps = {
license: GetLicensesResponse;
aiGovernanceUserFeature?: Feature;
userLimitActual?: number;
userLimitLimit?: number;
onRemove: (licenseId: number) => void;
@@ -15,6 +30,7 @@ type LicenseCardProps = {
export const LicenseCard: FC<LicenseCardProps> = ({
license,
aiGovernanceUserFeature,
userLimitActual,
userLimitLimit,
onRemove,
@@ -24,24 +40,117 @@ export const LicenseCard: FC<LicenseCardProps> = ({
number | undefined
>(undefined);
const currentUserLimit = license.claims.features.user_limit || userLimitLimit;
const currentUserLimit = license.claims.features.user_limit ?? userLimitLimit;
const confirmationName = licenseIDMarkedForRemoval?.toString() ?? "";
const isExpired = dayjs
.unix(license.claims.license_expires)
.isBefore(dayjs());
const isNotYetValid =
license.claims.nbf !== undefined &&
dayjs.unix(license.claims.nbf).isAfter(dayjs());
const isPremium = license.claims.feature_set?.toLowerCase() === "premium";
const aiGovernanceActual = aiGovernanceUserFeature?.actual;
const aiGovernanceMergedLimit = aiGovernanceUserFeature?.limit;
const aiGovernanceLimit =
license.claims.features?.ai_governance_user_limit ?? 0;
const licenseType = license.claims.trial
? "Trial"
: license.claims.feature_set?.toLowerCase() === "premium"
: isPremium
? "Premium"
: "Enterprise";
const hasExplicitAiGovernanceAddOn = (license.claims.addons ?? []).includes(
"ai_governance",
);
const isAiGovernanceEntitlementInGracePeriod =
aiGovernanceUserFeature?.entitlement === "grace_period";
// Overage/display checks only apply to licenses that are currently effective.
const isLicenseApplicableForAiGovernanceOverage =
!isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod);
// A license "wins" when its AI governance limit matches the merged limit.
const isWinningAiGovernanceLicense =
aiGovernanceMergedLimit !== undefined &&
aiGovernanceLimit > 0 &&
aiGovernanceLimit === aiGovernanceMergedLimit;
const canUseAiGovernanceUsageForThisLicense =
isLicenseApplicableForAiGovernanceOverage &&
hasExplicitAiGovernanceAddOn &&
isWinningAiGovernanceLicense;
// Show the add-on as exceeded only for the winning, active add-on license.
const isAiGovernanceAddOnExceeded =
canUseAiGovernanceUsageForThisLicense &&
aiGovernanceActual !== undefined &&
aiGovernanceActual > aiGovernanceLimit;
// Show actual usage only when this license is the one providing the limit.
const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense
? aiGovernanceActual
: undefined;
const statusClassName =
isAiGovernanceAddOnExceeded || isExpired
? "text-content-destructive"
: isNotYetValid
? "text-content-warning"
: "text-content-success";
const statusText = isAiGovernanceAddOnExceeded
? "Add-on exceeded"
: isExpired
? "Expired"
: isNotYetValid
? "Not started"
: "Active";
const hasCollapsibleContent = isPremium && hasExplicitAiGovernanceAddOn;
const headerContent = (
<>
<div className="flex items-center gap-1.5">
{hasCollapsibleContent && (
<ChevronDownIcon className="license-chevron size-4 text-content-secondary transition-colors transition-transform group-hover:text-content-primary" />
)}
<span className="text-base font-medium text-content-secondary">
#{license.id}
</span>
<span className="account-type text-base font-medium text-content-primary capitalize">
{licenseType}
</span>
</div>
<div className="ml-auto flex items-center gap-12 text-xs font-medium">
<div className="flex flex-col items-center">
<span className="text-content-secondary">Status</span>
<span className={statusClassName}>{statusText}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-content-secondary">Users</span>
<span className="text-content-primary user-limit">
{userLimitActual} {` / ${currentUserLimit || "Unlimited"}`}
</span>
</div>
{license.claims.nbf && (
<div className="flex flex-col items-center">
<span className="text-content-secondary">Valid From</span>
<span
className={cn("license-valid-from", {
"text-content-warning": statusText === "Not started",
"text-content-primary": statusText !== "Not started",
})}
>
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
</span>
</div>
)}
<div className="flex flex-col items-center">
<span className="text-content-secondary">Valid Until</span>
<span className="text-content-primary license-expires">
{dayjs.unix(license.claims.license_expires).format("MMMM D, YYYY")}
</span>
</div>
</div>
</>
);
return (
<div
key={license.id}
className="license-card rounded-lg border border-solid border-border bg-surface-secondary p-4 text-sm shadow-sm"
>
<Collapsible defaultOpen>
<DeleteDialog
key={licenseIDMarkedForRemoval}
isOpen={licenseIDMarkedForRemoval !== undefined}
@@ -64,52 +173,69 @@ export const LicenseCard: FC<LicenseCardProps> = ({
}
confirmLoading={isRemoving}
/>
<div className="flex flex-row gap-4 items-center">
<span className="text-content-secondary text-lg font-semibold">
#{license.id}
</span>
<span className="account-type font-semibold text-lg capitalize">
{licenseType}
</span>
<div className="flex flex-row justify-end gap-16 items-end flex-1">
<div className="flex flex-col items-center">
<span className="text-content-secondary">Users</span>
<span className="text-content-primary user-limit">
{userLimitActual} {` / ${currentUserLimit || "Unlimited"}`}
</span>
</div>
{license.claims.nbf && (
<div className="flex flex-col items-center">
<span className="text-content-secondary">Valid From</span>
<span className="text-content-secondary license-valid-from">
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
</span>
<div className="license-card group overflow-hidden rounded-md border border-solid border-border bg-surface-secondary text-sm shadow-sm">
<div className="flex items-center gap-6 p-3">
{hasCollapsibleContent ? (
<CollapsibleTrigger
asChild
className="[&[data-state=closed]_.license-chevron]:-rotate-90"
>
<button
type="button"
className="m-0 flex min-w-0 flex-1 appearance-none items-center gap-6 border-0 bg-transparent p-0 text-left"
>
{headerContent}
</button>
</CollapsibleTrigger>
) : (
<div className="m-0 flex min-w-0 flex-1 items-center gap-6">
{headerContent}
</div>
)}
<div className="flex flex-col items-center">
{isExpired ? (
<Pill className="mb-1" type="error">
Expired
</Pill>
) : (
<span className="text-content-secondary">Valid Until</span>
)}
<span className="text-content-secondary license-expires">
{dayjs
.unix(license.claims.license_expires)
.format("MMMM D, YYYY")}
</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setLicenseIDMarkedForRemoval(license.id)}
className="remove-button"
>
Remove&hellip;
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => event.stopPropagation()}
className="size-[30px]"
>
<EllipsisVerticalIcon />
<span className="sr-only">Show license actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => setLicenseIDMarkedForRemoval(license.id)}
>
<TrashIcon />
Remove&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CollapsibleContent>
{hasCollapsibleContent && (
<div className="border-0 border-t border-solid border-border bg-surface-primary px-4 py-4">
<div className="text-sm font-medium text-content-secondary">
Add-ons
</div>
<div className="mt-3 flex flex-wrap gap-3">
<AIGovernanceAddOnCard
title="AI governance"
unit="Seats"
actual={aiGovernanceDisplayActual}
limit={aiGovernanceLimit}
isExceeded={isAiGovernanceAddOnExceeded}
/>
</div>
</div>
)}
</CollapsibleContent>
</div>
</div>
</Collapsible>
);
};
@@ -1,5 +1,7 @@
import { MockLicenseResponse } from "testHelpers/entities";
import { MockEntitlements, MockLicenseResponse } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import dayjs from "dayjs";
import { expect, within } from "storybook/test";
import LicensesSettingsPage from "./LicensesSettingsPage";
const meta: Meta<typeof LicensesSettingsPage> = {
@@ -16,12 +18,152 @@ const meta: Meta<typeof LicensesSettingsPage> = {
export default meta;
type Story = StoryObj<typeof LicensesSettingsPage>;
const USER_STATUS_COUNTS_QUERY = {
key: ["insights", "userStatusCounts"],
data: { active: [] },
};
const withBaseQueries = ({
entitlements = MockEntitlements,
licenses = MockLicenseResponse,
}: {
entitlements?: typeof MockEntitlements;
licenses?: typeof MockLicenseResponse | unknown[];
}) => ({
queries: [
{ key: ["entitlements"], data: entitlements },
{ key: ["licenses"], data: licenses },
USER_STATUS_COUNTS_QUERY,
],
});
const createEntitlements = ({
userLimit,
aiGovernanceUserLimit,
}: {
userLimit: {
enabled: boolean;
entitlement: "entitled" | "not_entitled";
actual: number;
limit?: number;
};
aiGovernanceUserLimit?: {
enabled: boolean;
entitlement: "entitled" | "not_entitled" | "grace_period";
actual: number;
limit: number;
};
}) => ({
...MockEntitlements,
has_license: true,
features: {
...MockEntitlements.features,
user_limit: userLimit,
ai_governance_user_limit:
aiGovernanceUserLimit ??
MockEntitlements.features.ai_governance_user_limit,
},
});
const createLicense = ({
id,
uuid,
featureSet,
uploadedDaysAgo,
expiresInDays,
licenseExpiresInDays,
nbfOffsetDays,
aiGovernanceUserLimit,
userLimit,
addons,
}: {
id: number;
uuid: string;
featureSet: "PREMIUM" | "enterprise";
uploadedDaysAgo: number;
expiresInDays: number;
licenseExpiresInDays: number;
nbfOffsetDays: number;
aiGovernanceUserLimit: number;
userLimit: number;
addons?: string[];
}) => ({
id,
uploaded_at: String(dayjs().subtract(uploadedDaysAgo, "day").unix()),
expires_at: String(dayjs().add(expiresInDays, "day").unix()),
uuid,
claims: {
trial: false,
all_features: true,
feature_set: featureSet,
version: 1,
features: {
ai_governance_user_limit: aiGovernanceUserLimit,
user_limit: userLimit,
},
addons,
license_expires: dayjs().add(licenseExpiresInDays, "day").unix(),
nbf: dayjs().add(nbfOffsetDays, "day").unix(),
},
});
export const WithoutUserLimitFeature: Story = {
parameters: {
queries: [
{ key: ["entitlements"], data: { features: {} } },
{ key: ["licenses"], data: MockLicenseResponse },
{ key: ["insights", "userStatusCounts"], data: { active: [] } },
],
...withBaseQueries({
entitlements: {
...MockEntitlements,
features: {
...MockEntitlements.features,
user_limit: {
enabled: false,
entitlement: "not_entitled",
actual: 4,
},
},
},
}),
},
};
export const ShowsAddonUiForFutureLicenseBeforeNbf: Story = {
parameters: {
...withBaseQueries({
entitlements: createEntitlements({
userLimit: {
enabled: true,
entitlement: "entitled",
actual: 3,
limit: 10,
},
aiGovernanceUserLimit: {
enabled: false,
entitlement: "not_entitled",
actual: 0,
limit: 0,
},
}),
licenses: [
createLicense({
id: 44,
uuid: "future-premium-addon-license",
featureSet: "PREMIUM",
uploadedDaysAgo: 0,
expiresInDays: 365,
licenseExpiresInDays: 365,
nbfOffsetDays: 7,
aiGovernanceUserLimit: 100,
userLimit: 10,
addons: ["ai_governance"],
}),
],
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument();
const aiGovernanceTitles = canvas.getAllByText(/^ai governance$/i);
await expect(aiGovernanceTitles.length).toBeGreaterThan(0);
await expect(canvas.getByText(/not started/i)).toBeInTheDocument();
await expect(canvas.getByText(/valid from/i)).toBeInTheDocument();
},
};
@@ -127,6 +127,7 @@ const LicensesSettingsPageView: FC<Props> = ({
license={license}
userLimitActual={userLimitActual}
userLimitLimit={userLimitLimit}
aiGovernanceUserFeature={aiGovernanceUserFeature}
isRemoving={isRemovingLicense}
onRemove={removeLicense}
/>