feat: add AI Governance seat capacity banners (#23411)

## Summary

Add site-wide banners for AI Governance seat usage thresholds:

1. **90% capacity warning (admin-only):** When actual AI Governance
seats are ≥90% and <100% of the license limit, admins see:
   > "You have used 90% of your AI governance add-on seats."

2. **Over-limit banner (admin-only):** When actual seats exceed the
license limit, admins see a prominent warning:
> "Your organization is using {actual} / {limit} AI Governance user
seats ({X}% over the limit). Contact sales@coder.com"
   - Uses floor whole percentage (Go int division / `Math.floor`)
   - Includes a clickable `mailto:sales@coder.com` link
This commit is contained in:
Jaayden Halko
2026-03-27 12:51:51 +07:00
committed by GitHub
parent 2312e5c428
commit 86c3983fc0
12 changed files with 1036 additions and 174 deletions
+2
View File
@@ -15,6 +15,8 @@ const (
LicenseExpiryClaim = "license_expires"
LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
)
type AddLicenseRequest struct {
+1 -1
View File
@@ -81,7 +81,7 @@ rates, and usage patterns to inform decisions about AI strategy.
Starting with Coder v2.30 (February 2026), AI Bridge and Agent Boundaries are
generally available as part of the AI Governance Add-On.
The AI Governance Add-On is required to use AI Bridge and Agent Boundaries.
The AI Governance add-on is required to use AI Bridge and Agent Boundaries.
If your deployment does not have the add-on, you'll see a notification banner
reminding you to enable it.
+38 -7
View File
@@ -490,21 +490,31 @@ func LicensesEntitlements(
featureArguments.ActiveUserCount, *userLimit.Limit))
}
if featureArguments.ActiveAISeatCount > 0 {
actual := featureArguments.ActiveAISeatCount
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))
fmt.Sprintf("Your deployment has %d active AI Governance seats but the license is not entitled to this feature.", actual))
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))
actual, *feature.Limit))
// Also emit seat-capacity warnings during grace period so admins
// see both expiry and usage details.
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
entitlements.Warnings,
actual,
*feature.Limit,
)
case feature.Limit != nil:
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
entitlements.Warnings,
actual,
*feature.Limit,
)
}
}
@@ -555,7 +565,7 @@ func LicensesEntitlements(
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
if aiBridgeFeature.Enabled && aiBridgeFeature.Entitlement.Entitled() && !hasExplicitAIBridgeEntitlement {
entitlements.Warnings = append(entitlements.Warnings,
"The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
"The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
}
}
@@ -572,6 +582,27 @@ func LicensesEntitlements(
return entitlements, nil
}
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
}
if actual > limit {
overLimitSeats := actual - limit
return append(warnings, fmt.Sprintf(
codersdk.LicenseAIGovernanceOverLimitWarningText,
actual,
limit,
overLimitSeats,
))
} else if actual*10 >= limit*9 {
usedPercent := (actual * 100) / limit
return append(warnings, fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, usedPercent))
}
return warnings
}
const (
CurrentVersion = 3
HeaderKeyID = "kid"
+240 -47
View File
@@ -900,59 +900,252 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) {
t.Run("AIGovernanceSeatWarnings", 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,
testCases := []struct {
name string
limit int64
activeSeatCount int64
expectedWarning string
}{
{
name: "At90Percent",
limit: 100,
activeSeatCount: 90,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 90),
},
{
name: "Below90Percent",
limit: 100,
activeSeatCount: 89,
},
{
name: "OverLimit",
limit: 100,
activeSeatCount: 110,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 110, 100, 10),
},
{
name: "AtLimit",
limit: 100,
activeSeatCount: 100,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 100),
},
{
name: "OverLimitRoundingDown",
limit: 101,
activeSeatCount: 106,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 106, 101, 5),
},
{
name: "TinyOverage",
limit: 1000,
activeSeatCount: 1001,
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 1001, 1000, 1),
},
{
name: "ZeroLimitGuard",
limit: 0,
activeSeatCount: 5,
},
}).
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)
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
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)
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: tc.limit,
},
}).
UserLimit(100)
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])
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(tc.activeSeatCount, 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)
if tc.limit > 0 {
require.NotNil(t, aiGovernanceSeatLimit.Actual)
require.EqualValues(t, tc.activeSeatCount, *aiGovernanceSeatLimit.Actual)
require.NotNil(t, aiGovernanceSeatLimit.Limit)
require.EqualValues(t, tc.limit, *aiGovernanceSeatLimit.Limit)
} else {
require.Nil(t, aiGovernanceSeatLimit.Actual)
require.Nil(t, aiGovernanceSeatLimit.Limit)
}
if tc.expectedWarning == "" {
require.Len(t, entitlements.Warnings, 0)
} else {
require.Len(t, entitlements.Warnings, 1)
require.Equal(t, tc.expectedWarning, entitlements.Warnings[0])
}
})
}
t.Run("GracePeriodOverLimit", func(t *testing.T) {
t.Parallel()
const (
limit int64 = 100
activeSeatCount int64 = 127
)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := &coderdenttest.LicenseOptions{
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: limit,
},
}
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(activeSeatCount, nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
enablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIGovernanceUserLimit: true,
}
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
activeSeatCount, limit,
),
)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, activeSeatCount, limit, 27),
)
})
t.Run("GracePeriod90Percent", func(t *testing.T) {
t.Parallel()
const (
limit int64 = 100
activeSeatCount int64 = 95
)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
licenseOpts := &coderdenttest.LicenseOptions{
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
Features: license.Features{
codersdk.FeatureAIGovernanceUserLimit: limit,
},
}
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(activeSeatCount, nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
enablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIGovernanceUserLimit: true,
}
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
require.True(t, ok)
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
expiryWarning := fmt.Sprintf(
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
activeSeatCount,
limit,
)
require.Contains(t, entitlements.Warnings, expiryWarning)
require.Contains(t, entitlements.Warnings,
fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 95))
for _, warning := range entitlements.Warnings {
require.NotContains(t, warning, "over the limit")
}
})
})
}
@@ -1344,7 +1537,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
codersdk.FeatureAIBridge: false,
}
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
@@ -1924,7 +2117,7 @@ func TestAIGovernanceAddon(t *testing.T) {
// AI Bridge should be enabled without warning when addon is present.
aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
require.True(t, aibridgeFeature.Enabled, "AI Bridge should be enabled when addon is present and enablements are set")
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage, "AI Bridge warning should not appear when AI Governance addon is present")
// require.Equal(t, codersdk.EntitlementEntitled, aibridgeFeature.Entitlement, "AI Bridge should be entitled when addon is present")
+8
View File
@@ -3849,6 +3849,14 @@ export interface License {
readonly claims: Record<string, unknown>;
}
// From codersdk/licenses.go
export const LicenseAIGovernance90PercentWarningText =
"You have used %d%% of your AI Governance add-on seats.";
// From codersdk/licenses.go
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
+3 -9
View File
@@ -1,5 +1,4 @@
import type { FC, ReactNode } from "react";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import {
Collapsible,
CollapsibleContent,
@@ -20,15 +19,10 @@ export const Expander: FC<ExpanderProps> = ({
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleContent>
<p className="flex items-center text-content-secondary text-xs">
{children}
</p>
<div className="text-content-primary text-xs">{children}</div>
</CollapsibleContent>
<CollapsibleTrigger className="cursor-pointer text-content-secondary hover:underline">
<span className="flex items-center text-xs">
{expanded ? "Click here to hide" : "Click here to learn more"}
<ChevronDownIcon open={expanded} className="size-4" />
</span>
<CollapsibleTrigger className="appearance-none bg-transparent border-0 cursor-pointer p-0 text-content-link text-xs hover:underline">
{expanded ? "Show less" : "Show more"}
</CollapsibleTrigger>
</Collapsible>
);
@@ -0,0 +1,85 @@
import { chromatic } from "testHelpers/chromatic";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LicenseAIGovernance90PercentWarningText } from "api/typesGenerated";
import { expect, within } from "storybook/test";
import { AIGovernanceSeatBannerView } from "./AIGovernanceSeatBannerView";
const meta: Meta<typeof AIGovernanceSeatBannerView> = {
title: "modules/dashboard/AIGovernanceSeatBannerView",
parameters: { chromatic },
component: AIGovernanceSeatBannerView,
};
export default meta;
type Story = StoryObj<typeof AIGovernanceSeatBannerView>;
export const OverLimit: Story = {
args: {
variant: "over-limit",
actual: 110,
limit: 100,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
/110 \/ 100 AI Governance user seats \(10% over the limit\)/,
);
await expect(
canvas.getByRole("link", { name: "sales@coder.com" }),
).toHaveAttribute("href", "mailto:sales@coder.com");
},
};
export const NearLimit: Story = {
args: {
variant: "near-limit",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
LicenseAIGovernance90PercentWarningText,
);
},
};
export const FloorPercentage: Story = {
args: {
variant: "over-limit",
actual: 106,
limit: 101,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
/106 \/ 101 AI Governance user seats \(4% over the limit\)/,
);
},
};
export const TinyOverage: Story = {
args: {
variant: "over-limit",
actual: 1001,
limit: 1000,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
/1001 \/ 1000 AI Governance user seats \(1% over the limit\)/,
);
},
};
export const LargeNumbers: Story = {
args: {
variant: "over-limit",
actual: 1200,
limit: 1000,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
/1200 \/ 1000 AI Governance user seats \(20% over the limit\)/,
);
},
};
@@ -0,0 +1,49 @@
import { LicenseAIGovernance90PercentWarningText } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
type AIGovernanceSeatBannerViewProps =
| { variant: "over-limit"; actual: number; limit: number }
| { variant: "near-limit" };
export const AIGovernanceSeatBannerView: FC<AIGovernanceSeatBannerViewProps> = (
props,
) => {
if (props.variant === "near-limit") {
return (
<div role="alert" className="flex items-center bg-surface-secondary p-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div className="flex h-[30px] items-center">
<TriangleAlertIcon className="size-4 text-content-warning" />
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1 py-1.5 text-xs font-medium leading-4 text-content-primary">
<span>{LicenseAIGovernance90PercentWarningText}</span>
</div>
</div>
</div>
);
}
const { actual, limit } = props;
const overPercent = Math.max(1, Math.floor(((actual - limit) / limit) * 100));
return (
<div role="alert" className="flex items-center bg-surface-orange p-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div className="flex h-[30px] items-center">
<TriangleAlertIcon className="size-4 text-content-warning" />
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1 py-1.5 text-xs font-medium leading-4 text-content-primary">
<span>
Your organization is using {actual} / {limit} AI Governance user
seats ({overPercent}% over the limit). Contact{" "}
</span>
<Link href="mailto:sales@coder.com" showExternalIcon={false}>
sales@coder.com
</Link>
</div>
</div>
</div>
);
};
@@ -1,13 +1,60 @@
import { server } from "testHelpers/server";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import {
MockEntitlements,
MockNoPermissions,
MockPermissions,
} from "#/testHelpers/entities";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "#/testHelpers/renderHelpers";
import { server } from "#/testHelpers/server";
import { DashboardLayout } from "./DashboardLayout";
const renderDashboardLayout = async ({
actual,
entitlement = "entitled",
limit,
permissions = MockPermissions,
warnings,
}: {
actual?: number;
entitlement?: "entitled" | "grace_period" | "not_entitled";
limit?: number;
permissions?: typeof MockPermissions;
warnings?: string[];
}) => {
server.use(
http.get("/api/v2/entitlements", () => {
return HttpResponse.json({
...MockEntitlements,
warnings: warnings ?? MockEntitlements.warnings,
has_license: true,
refreshed_at: new Date().toISOString(),
features: {
...MockEntitlements.features,
ai_governance_user_limit: {
entitlement,
enabled: true,
...(actual !== undefined ? { actual } : {}),
...(limit !== undefined ? { limit } : {}),
},
},
});
}),
http.post("/api/v2/authcheck", () => {
return HttpResponse.json(permissions);
}),
);
renderWithAuth(<DashboardLayout />, {
children: [{ element: <h1>Test page</h1> }],
});
await waitForLoaderToBeRemoved();
};
test("Show the new Coder version notification", async () => {
server.use(
http.get("/api/v2/updatecheck", () => {
@@ -24,6 +71,32 @@ test("Show the new Coder version notification", async () => {
await screen.findByTestId("update-check-snackbar");
});
test("hides AI Governance seat warnings for non-admin users", async () => {
await renderDashboardLayout({
actual: 110,
limit: 100,
permissions: MockNoPermissions,
});
expect(
screen.queryByText(/AI Governance add-on seats/),
).not.toBeInTheDocument();
});
test("shows AI Governance over-limit warning in LicenseBanner for admin users", async () => {
await renderDashboardLayout({
actual: 110,
limit: 100,
permissions: MockPermissions,
});
expect(
screen.getByText(
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
),
).toBeInTheDocument();
});
test("renders a skip link before navigation content", async () => {
renderWithAuth(<DashboardLayout />, {
children: [{ element: <h1>Test page</h1> }],
@@ -1,14 +1,166 @@
import type { FC } from "react";
import {
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { LicenseBannerView } from "./LicenseBannerView";
import { docs } from "#/utils/docs";
import {
type LicenseBannerLink,
type LicenseBannerMessage,
LicenseBannerView,
} from "./LicenseBannerView";
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors, warnings } = entitlements;
const aiGovernanceOverLimitWarningPrefix =
LicenseAIGovernanceOverLimitWarningText.split("%d")[0];
const aiGovernanceNearLimitWarningPrefix =
LicenseAIGovernance90PercentWarningText.split("%d%%")[0];
const AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE =
"You are approaching your AI Governance add-on seat limit.";
if (errors.length === 0 && warnings.length === 0) {
const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
const isAIGovernanceNearLimitWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
typeof useDashboard
>["entitlements"]["features"]["ai_governance_user_limit"],
): string | null => {
if (!feature) {
return null;
}
return <LicenseBannerView errors={errors} warnings={warnings} />;
const { actual, entitlement, limit } = feature;
if (
(entitlement !== "entitled" && entitlement !== "grace_period") ||
actual === undefined ||
limit === undefined ||
limit <= 0 ||
actual <= limit
) {
return null;
}
const overLimitSeats = actual - limit;
return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
.replace("%d", `${limit}`)
.replace("%d", `${overLimitSeats}`);
};
const aiGovernanceNearLimitMessage = (
feature: ReturnType<
typeof useDashboard
>["entitlements"]["features"]["ai_governance_user_limit"],
): string | null => {
if (!feature) {
return null;
}
const { actual, entitlement, limit } = feature;
if (
(entitlement !== "entitled" && entitlement !== "grace_period") ||
actual === undefined ||
limit === undefined ||
limit <= 0
) {
return null;
}
const usedPercent = Math.floor((actual * 100) / limit);
if (usedPercent < 90) {
return null;
}
return LicenseAIGovernance90PercentWarningText.replace(
"%d%%",
`${usedPercent}%`,
);
};
const normalizeAIGovernanceWarning = (
message: string,
feature: ReturnType<
typeof useDashboard
>["entitlements"]["features"]["ai_governance_user_limit"],
): string => {
if (message !== LicenseAIGovernance90PercentWarningText) {
return message;
}
return (
aiGovernanceNearLimitMessage(feature) ??
AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE
);
};
const messageLink = (message: string): LicenseBannerLink => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
label: "View AI Governance",
showExternalIcon: true,
target: "_blank",
};
}
if (message === LicenseTelemetryRequiredErrorText) {
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com if you need an exception.",
showExternalIcon: false,
};
}
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
showExternalIcon: false,
};
};
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors } = entitlements;
const warnings = [...entitlements.warnings];
const aiGovernanceUserLimitFeature =
entitlements.features.ai_governance_user_limit;
const overLimitWarning = aiGovernanceOverLimitMessage(
aiGovernanceUserLimitFeature,
);
if (
overLimitWarning &&
!warnings.some((warning) => isAIGovernanceWarning(warning))
) {
warnings.push(overLimitWarning);
}
const normalizedWarnings = warnings.map((warning) =>
normalizeAIGovernanceWarning(warning, aiGovernanceUserLimitFeature),
);
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
variant: "error" as const,
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
variant: isAIGovernanceNearLimitWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
})),
];
if (messages.length === 0) {
return null;
}
return <LicenseBannerView messages={messages} />;
};
@@ -1,6 +1,21 @@
import { chromatic } from "testHelpers/chromatic";
import {
MockAppearanceConfig,
MockBuildInfo,
MockDefaultOrganization,
MockEntitlements,
MockExperiments,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LicenseManagedAgentLimitExceededWarningText } from "#/api/typesGenerated";
import { chromatic } from "#/testHelpers/chromatic";
import { expect, within } from "storybook/test";
import {
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { docs } from "#/utils/docs";
import { DashboardContext, type DashboardValue } from "../DashboardProvider";
import { LicenseBanner } from "./LicenseBanner";
import { LicenseBannerView } from "./LicenseBannerView";
const meta: Meta<typeof LicenseBannerView> = {
@@ -14,43 +29,243 @@ type Story = StoryObj<typeof LicenseBannerView>;
export const OneWarning: Story = {
args: {
errors: [],
warnings: ["You have exceeded the number of seats in your license."],
messages: [
{
message: "You have exceeded the number of seats in your license.",
variant: "warningProminent",
link: {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
showExternalIcon: false,
},
},
],
},
};
export const TwoWarnings: Story = {
args: {
errors: [],
warnings: [
"You have exceeded the number of seats in your license.",
"You are flying too close to the sun.",
messages: [
{
message: "You have exceeded the number of seats in your license.",
variant: "warningProminent",
},
{
message: "You are flying too close to the sun.",
variant: "warningProminent",
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
},
};
export const ThreeWarnings: Story = {
args: {
messages: [
{
message: "You have exceeded the number of seats in your license.",
variant: "warningProminent",
},
{
message: "You are flying too close to the sun.",
variant: "warningProminent",
},
{
message: "Another warning that should be hidden until expanded.",
variant: "warningProminent",
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: "Show more" }),
).toBeInTheDocument();
},
};
export const OneError: Story = {
args: {
errors: [
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
messages: [
{
message:
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
variant: "error",
},
],
warnings: [],
},
};
export const TwoErrors: Story = {
args: {
messages: [
{
message:
"You have multiple replicas but high availability is an Enterprise feature.",
variant: "error",
},
{
message: "Telemetry is required for this deployment.",
variant: "error",
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("License errors require attention"),
).toBeInTheDocument();
},
};
export const TelemetryRequiredError: Story = {
args: {
messages: [
{
message: LicenseTelemetryRequiredErrorText,
variant: "error",
link: {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com if you need an exception.",
showExternalIcon: false,
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
LicenseTelemetryRequiredErrorText,
);
await expect(
canvas.getByRole("link", {
name: /Contact sales@coder\.com if you need an exception\./i,
}),
).toHaveAttribute("href", "mailto:sales@coder.com");
},
};
export const ManagedAgentLimitExceeded: Story = {
args: {
errors: [],
warnings: [LicenseManagedAgentLimitExceededWarningText],
messages: [
{
message: LicenseManagedAgentLimitExceededWarningText,
variant: "warningProminent",
link: {
href: docs("/ai-coder/ai-governance"),
label: "View AI Governance",
showExternalIcon: true,
target: "_blank",
},
},
],
},
};
export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
args: {
errors: [],
warnings: [
LicenseManagedAgentLimitExceededWarningText,
"You have exceeded the number of seats in your license.",
messages: [
{
message: LicenseManagedAgentLimitExceededWarningText,
variant: "warningProminent",
},
{
message: "You have exceeded the number of seats in your license.",
variant: "warningProminent",
},
],
},
};
const renderLicenseBannerWithAIGovernance = ({
actual,
entitlement = "entitled",
limit,
warnings = [],
}: {
actual: number;
entitlement?: "entitled" | "grace_period" | "not_entitled";
limit?: number;
warnings?: string[];
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
warnings,
features: {
...MockEntitlements.features,
ai_governance_user_limit: {
enabled: true,
entitlement,
actual,
...(limit !== undefined ? { limit } : {}),
},
},
},
experiments: MockExperiments,
appearance: MockAppearanceConfig,
buildInfo: MockBuildInfo,
organizations: [MockDefaultOrganization],
showOrganizations: false,
canViewOrganizationSettings: false,
};
return (
<DashboardContext.Provider value={mockDashboardValue}>
<LicenseBanner />
</DashboardContext.Provider>
);
};
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
actual: 95,
limit: 100,
warnings: [LicenseAIGovernance90PercentWarningText],
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
},
};
export const AIGovernanceOverLimitFromFeature: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
actual: 110,
limit: 100,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toHaveTextContent(
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
);
},
};
export const AIGovernanceOverLimitGracePeriod: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
actual: 110,
entitlement: "grace_period",
limit: 100,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toHaveTextContent(
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
);
},
};
@@ -1,13 +1,9 @@
import { cva } from "class-variance-authority";
import { TriangleAlertIcon } from "lucide-react";
import { useState } from "react";
import {
LicenseManagedAgentLimitExceededWarningText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { Expander } from "#/components/Expander/Expander";
import { Link } from "#/components/Link/Link";
import { Pill } from "#/components/Pill/Pill";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
const formatMessage = (message: string) => {
// If the message ends with an alphanumeric character, add a period.
@@ -17,96 +13,160 @@ const formatMessage = (message: string) => {
return message;
};
const messageLinkProps = (
message: string,
): Pick<React.ComponentProps<typeof Link>, "href" | "children" | "target"> => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
children: "View AI Governance",
target: "_blank",
};
}
if (message === LicenseTelemetryRequiredErrorText) {
return {
href: "mailto:sales@coder.com",
children: "Contact sales@coder.com if you need an exception.",
};
}
return {
href: "mailto:sales@coder.com",
children: "Contact sales@coder.com.",
};
};
type LicenseBannerVariant = "warning" | "warningProminent" | "error";
interface LicenseBannerViewProps {
errors: readonly string[];
warnings: readonly string[];
export interface LicenseBannerLink {
href: string;
label: string;
showExternalIcon?: boolean;
target?: React.ComponentProps<typeof Link>["target"];
}
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
errors,
warnings,
}) => {
const [showDetails, setShowDetails] = useState(false);
const isError = errors.length > 0;
const messages = [...errors, ...warnings];
const type = isError ? "error" : "warning";
export interface LicenseBannerMessage {
message: string;
variant: LicenseBannerVariant;
link?: LicenseBannerLink;
}
if (messages.length === 1) {
const [message] = messages;
const bannerVariants = cva("flex items-center p-3", {
variants: {
variant: {
warning: "bg-surface-secondary",
warningProminent: "bg-surface-orange",
error: "bg-surface-red",
},
},
});
return (
<div
className={cn(
"flex items-center p-3 text-sm",
isError ? "bg-surface-red" : "bg-surface-orange",
)}
>
<Pill type={type}>License Issue</Pill>
<div className="mx-2">
<span>{formatMessage(message)}</span>
&nbsp;
<Link
className={cn(
"font-medium",
isError ? "!text-content-destructive" : "!text-content-warning",
)}
{...messageLinkProps(message)}
/>
</div>
</div>
);
const iconVariants = cva("size-4", {
variants: {
variant: {
warning: "text-content-warning",
warningProminent: "text-content-warning",
error: "text-content-destructive",
},
},
});
interface LicenseBannerViewProps {
messages: readonly LicenseBannerMessage[];
}
const messageLinkClass = "text-xs font-medium !text-content-link";
const listClass =
"m-0 list-disc space-y-1 pl-4 text-xs leading-[18px] text-content-primary";
const getBannerVariant = (
messages: readonly LicenseBannerMessage[],
): LicenseBannerVariant => {
const hasError = messages.some((entry) => entry.variant === "error");
if (hasError) {
return "error";
}
const hasProminentWarning = messages.some(
(entry) => entry.variant === "warningProminent",
);
return hasProminentWarning ? "warningProminent" : "warning";
};
const bannerTitle = (variant: LicenseBannerVariant): string =>
variant === "error"
? "License errors require attention"
: "Your license limits have been exceeded";
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
const LicenseMessageText: React.FC<{
entry: LicenseBannerMessage;
}> = ({ entry }) => (
<>
{formatMessage(entry.message)}{" "}
{entry.link && (
<Link
className={messageLinkClass}
href={entry.link.href}
showExternalIcon={entry.link.showExternalIcon}
target={entry.link.target}
>
{entry.link.label}
</Link>
)}
</>
);
const LicenseMessageList: React.FC<{
messages: readonly LicenseBannerMessage[];
}> = ({ messages }) => (
<ul className={listClass}>
{messages.map((entry, index) => (
<li key={`${entry.message}-${index}`}>
<LicenseMessageText entry={entry} />
</li>
))}
</ul>
);
const ExpandableLicenseMessageList: React.FC<{
visibleMessages: readonly LicenseBannerMessage[];
hiddenMessages: readonly LicenseBannerMessage[];
}> = ({ visibleMessages, hiddenMessages }) => {
const [showDetails, setShowDetails] = useState(false);
const showExpander = hiddenMessages.length > 0;
return (
<div className="flex flex-col gap-1">
<LicenseMessageList messages={visibleMessages} />
{showExpander && (
<Expander expanded={showDetails} setExpanded={setShowDetails}>
<LicenseMessageList messages={hiddenMessages} />
</Expander>
)}
</div>
);
};
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
messages,
}) => {
if (messages.length === 0) {
return null;
}
const isSingleMessage = messages.length === 1;
const bannerVariant = getBannerVariant(messages);
const visibleMessages = messages.slice(0, 2);
const hiddenMessages = messages.slice(2);
return (
<div
className={cn(
"flex items-center p-3 text-sm",
isError ? "bg-surface-red" : "bg-surface-orange",
)}
role={bannerRole(bannerVariant)}
className={cn(bannerVariants({ variant: bannerVariant }))}
>
<Pill type={type}>{`${messages.length} License Issues`}</Pill>
<div className="mx-2">
<div>It looks like you've exceeded some limits of your license.</div>
<Expander expanded={showDetails} setExpanded={setShowDetails}>
<ul className="p-2 m-0">
{messages.map((message) => (
<li className="m-1" key={message}>
{formatMessage(message)}&nbsp;
<Link
className={cn(
"font-medium text-xs px-0",
isError
? "!text-content-destructive"
: "!text-content-warning",
)}
{...messageLinkProps(message)}
/>
</li>
))}
</ul>
</Expander>
<div className="flex min-w-0 flex-1 items-start gap-2">
<div className="flex h-6 items-center">
<TriangleAlertIcon
className={cn(iconVariants({ variant: bannerVariant }))}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{isSingleMessage ? (
<div className="flex min-h-6 items-center text-xs leading-4 text-content-primary">
<LicenseMessageText entry={messages[0]} />
</div>
) : (
<>
<div className="text-sm font-semibold leading-6 text-content-primary">
{bannerTitle(bannerVariant)}
</div>
<ExpandableLicenseMessageList
hiddenMessages={hiddenMessages}
visibleMessages={visibleMessages}
/>
</>
)}
</div>
</div>
</div>
);