From 86c3983fc093b1548656dd90921dfb7daffe532c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 27 Mar 2026 12:51:51 +0700 Subject: [PATCH] feat: add AI Governance seat capacity banners (#23411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- codersdk/licenses.go | 2 + docs/ai-coder/ai-governance.md | 2 +- enterprise/coderd/license/license.go | 45 ++- enterprise/coderd/license/license_test.go | 287 +++++++++++++++--- site/src/api/typesGenerated.ts | 8 + site/src/components/Expander/Expander.tsx | 12 +- .../AIGovernanceSeatBannerView.stories.tsx | 85 ++++++ .../AIGovernanceSeatBannerView.tsx | 49 +++ .../dashboard/DashboardLayout.test.tsx | 75 ++++- .../dashboard/LicenseBanner/LicenseBanner.tsx | 164 +++++++++- .../LicenseBannerView.stories.tsx | 249 +++++++++++++-- .../LicenseBanner/LicenseBannerView.tsx | 232 ++++++++------ 12 files changed, 1036 insertions(+), 174 deletions(-) create mode 100644 site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.stories.tsx create mode 100644 site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.tsx diff --git a/codersdk/licenses.go b/codersdk/licenses.go index da90f92543..a5f2853b85 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -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 { diff --git a/docs/ai-coder/ai-governance.md b/docs/ai-coder/ai-governance.md index ce437a84a1..ace18eef55 100644 --- a/docs/ai-coder/ai-governance.md +++ b/docs/ai-coder/ai-governance.md @@ -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. diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index dfc2310919..8c7875fa93 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -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" diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index dc6af3e25a..3481e5b2b1 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -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") diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index eb6fbee1c9..f442aace81 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3849,6 +3849,14 @@ export interface License { readonly claims: Record; } +// 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"; diff --git a/site/src/components/Expander/Expander.tsx b/site/src/components/Expander/Expander.tsx index 2ba3e09c49..4d5df2894a 100644 --- a/site/src/components/Expander/Expander.tsx +++ b/site/src/components/Expander/Expander.tsx @@ -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 = ({ return ( -

- {children} -

+
{children}
- - - {expanded ? "Click here to hide" : "Click here to learn more"} - - + + {expanded ? "Show less" : "Show more"}
); diff --git a/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.stories.tsx b/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.stories.tsx new file mode 100644 index 0000000000..ae2a04ddcf --- /dev/null +++ b/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.stories.tsx @@ -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 = { + title: "modules/dashboard/AIGovernanceSeatBannerView", + parameters: { chromatic }, + component: AIGovernanceSeatBannerView, +}; + +export default meta; +type Story = StoryObj; + +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\)/, + ); + }, +}; diff --git a/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.tsx b/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.tsx new file mode 100644 index 0000000000..5cf739d218 --- /dev/null +++ b/site/src/modules/dashboard/AIGovernanceSeatBanner/AIGovernanceSeatBannerView.tsx @@ -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 = ( + props, +) => { + if (props.variant === "near-limit") { + return ( +
+
+
+ +
+
+ {LicenseAIGovernance90PercentWarningText} +
+
+
+ ); + } + + const { actual, limit } = props; + const overPercent = Math.max(1, Math.floor(((actual - limit) / limit) * 100)); + + return ( +
+
+
+ +
+
+ + Your organization is using {actual} / {limit} AI Governance user + seats ({overPercent}% over the limit). Contact{" "} + + + sales@coder.com + +
+
+
+ ); +}; diff --git a/site/src/modules/dashboard/DashboardLayout.test.tsx b/site/src/modules/dashboard/DashboardLayout.test.tsx index 655a250e89..5d2f6ecd70 100644 --- a/site/src/modules/dashboard/DashboardLayout.test.tsx +++ b/site/src/modules/dashboard/DashboardLayout.test.tsx @@ -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(, { + children: [{ element:

Test page

}], + }); + 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(, { children: [{ element:

Test page

}], diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx index 405566c554..ba5f739d93 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx @@ -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 ; + 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 ; }; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx index abca3476f5..53f8aa67f5 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx @@ -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 = { @@ -14,43 +29,243 @@ type Story = StoryObj; 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 ( + + + + ); +}; + +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\)/, + ); + }, +}; diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx index fc8c2e39b5..80674fcbb7 100644 --- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx +++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx @@ -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, "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["target"]; } -export const LicenseBannerView: React.FC = ({ - 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 ( -
- License Issue -
- {formatMessage(message)} -   - -
-
- ); +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 && ( + + {entry.link.label} + + )} + +); + +const LicenseMessageList: React.FC<{ + messages: readonly LicenseBannerMessage[]; +}> = ({ messages }) => ( +
    + {messages.map((entry, index) => ( +
  • + +
  • + ))} +
+); + +const ExpandableLicenseMessageList: React.FC<{ + visibleMessages: readonly LicenseBannerMessage[]; + hiddenMessages: readonly LicenseBannerMessage[]; +}> = ({ visibleMessages, hiddenMessages }) => { + const [showDetails, setShowDetails] = useState(false); + const showExpander = hiddenMessages.length > 0; + + return ( +
+ + {showExpander && ( + + + + )} +
+ ); +}; + +export const LicenseBannerView: React.FC = ({ + 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 (
- {`${messages.length} License Issues`} -
-
It looks like you've exceeded some limits of your license.
- -
    - {messages.map((message) => ( -
  • - {formatMessage(message)}  - -
  • - ))} -
-
+
+
+ +
+
+ {isSingleMessage ? ( +
+ +
+ ) : ( + <> +
+ {bannerTitle(bannerVariant)} +
+ + + )} +
);