diff --git a/site/package.json b/site/package.json index 0d2422fb574e7..5ef59569e7790 100644 --- a/site/package.json +++ b/site/package.json @@ -33,7 +33,7 @@ "@material-ui/lab": "4.0.0-alpha.42", "@testing-library/react-hooks": "8.0.1", "@xstate/inspect": "0.6.5", - "@xstate/react": "3.0.0", + "@xstate/react": "3.0.1", "axios": "0.26.1", "can-ndjson-stream": "1.0.2", "cron-parser": "4.5.0", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 27b624e5b9d6c..5f2955eb3a6fa 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -1,9 +1,12 @@ import { useSelector } from "@xstate/react" +import { FeatureNames } from "api/types" +import { RequirePermission } from "components/RequirePermission/RequirePermission" import { SetupPage } from "pages/SetupPage/SetupPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import { FC, lazy, Suspense, useContext } from "react" import { Navigate, Route, Routes } from "react-router-dom" import { selectPermissions } from "xServices/auth/authSelectors" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" import { XServiceContext } from "xServices/StateContext" import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" @@ -35,6 +38,8 @@ const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage")) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) const permissions = useSelector(xServices.authXService, selectPermissions) + const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility) + return ( }> @@ -134,11 +139,17 @@ export const AppRouter: FC = () => { ) : ( - + + + ) } diff --git a/site/src/api/types.ts b/site/src/api/types.ts index daf4e451ac5e8..1ac58f28cfccc 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -14,3 +14,9 @@ export interface ReconnectingPTYRequest { export type WorkspaceBuildTransition = "start" | "stop" | "delete" export type Message = { message: string } + +// Keep up to date with coder/codersdk/features.go +export enum FeatureNames { + AuditLog = "audit_log", + UserLimit = "user_limit", +} diff --git a/site/src/components/Navbar/Navbar.test.tsx b/site/src/components/Navbar/Navbar.test.tsx new file mode 100644 index 0000000000000..7b2c65d12f4ca --- /dev/null +++ b/site/src/components/Navbar/Navbar.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { App } from "app" +import { Language } from "components/NavbarView/NavbarView" +import { rest } from "msw" +import { + MockEntitlementsWithAuditLog, + MockMemberPermissions, + MockUser, +} from "testHelpers/renderHelpers" +import { server } from "testHelpers/server" + +/** + * The LicenseBanner, mounted above the AppRouter, fetches entitlements. Thus, to test their + * effects, we must test at the App level and `waitFor` the fetch to be done. + */ +describe("Navbar", () => { + it("shows Audit Log link when permitted and entitled", async () => { + // set entitlements to allow audit log + server.use( + rest.get("/api/v2/entitlements", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog)) + }), + ) + render() + await waitFor( + () => { + const link = screen.getByText(Language.audit) + expect(link).toBeDefined() + }, + { timeout: 2000 }, + ) + }) + + it("does not show Audit Log link when not entitled", async () => { + // by default, user is an Admin with permission to see the audit log, + // but is unlicensed so not entitled to see the audit log + render() + await waitFor( + () => { + const link = screen.queryByText(Language.audit) + expect(link).toBe(null) + }, + { timeout: 2000 }, + ) + }) + + it("does not show Audit Log link when not permitted via role", async () => { + // set permissions to Member (can't audit) + server.use( + rest.post(`/api/v2/users/${MockUser.id}/authorization`, async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockMemberPermissions)) + }), + ) + // set entitlements to allow audit log + server.use( + rest.get("/api/v2/entitlements", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog)) + }), + ) + render() + await waitFor( + () => { + const link = screen.queryByText(Language.audit) + expect(link).toBe(null) + }, + { timeout: 2000 }, + ) + }) +}) diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index cbfdfd949dd19..608e8697e4f91 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -1,5 +1,7 @@ -import { useActor } from "@xstate/react" +import { shallowEqual, useActor, useSelector } from "@xstate/react" +import { FeatureNames } from "api/types" import React, { useContext } from "react" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" import { XServiceContext } from "../../xServices/StateContext" import { NavbarView } from "../NavbarView/NavbarView" @@ -7,13 +9,13 @@ export const Navbar: React.FC = () => { const xServices = useContext(XServiceContext) const [authState, authSend] = useActor(xServices.authXService) const { me, permissions } = authState.context + const featureVisibility = useSelector( + xServices.entitlementsXService, + selectFeatureVisibility, + shallowEqual, + ) + const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && !!permissions?.viewAuditLog const onSignOut = () => authSend("SIGN_OUT") - return ( - - ) + return } diff --git a/site/src/components/RequirePermission/RequirePermission.tsx b/site/src/components/RequirePermission/RequirePermission.tsx new file mode 100644 index 0000000000000..b17b56ad6f201 --- /dev/null +++ b/site/src/components/RequirePermission/RequirePermission.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" +import { Navigate } from "react-router" + +export interface RequirePermissionProps { + children: JSX.Element + isFeatureVisible: boolean +} + +/** + * Wraps routes that are available based on RBAC or licensing. + */ +export const RequirePermission: FC = ({ children, isFeatureVisible }) => { + if (!isFeatureVisible) { + return + } else { + return children + } +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5c28f574704f5..e93975e4d15da 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -51,6 +51,10 @@ export function assignableRole(role: TypesGen.Role, assignable: boolean): TypesG } } +export const MockMemberPermissions = { + viewAuditLog: false, +} + export const MockUser: TypesGen.User = { id: "test-user", username: "TestUser", @@ -647,11 +651,26 @@ export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { warnings: ["You are over your active user limit.", "And another thing."], has_license: true, features: { - activeUsers: { + user_limit: { enabled: true, - entitlement: "entitled", + entitlement: "grace_period", limit: 100, actual: 102, }, + audit_log: { + enabled: true, + entitlement: "entitled", + }, + }, +} + +export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { + warnings: [], + has_license: true, + features: { + audit_log: { + enabled: true, + entitlement: "entitled", + }, }, } diff --git a/site/src/xServices/entitlements/entitlementsSelectors.test.ts b/site/src/xServices/entitlements/entitlementsSelectors.test.ts new file mode 100644 index 0000000000000..9d179457a6e2e --- /dev/null +++ b/site/src/xServices/entitlements/entitlementsSelectors.test.ts @@ -0,0 +1,34 @@ +import { getFeatureVisibility } from "./entitlementsSelectors" + +describe("getFeatureVisibility", () => { + it("returns empty object if there is no license", () => { + const result = getFeatureVisibility(false, { + audit_log: { entitlement: "entitled", enabled: true }, + }) + expect(result).toEqual(expect.objectContaining({})) + }) + it("returns false for a feature that is not enabled", () => { + const result = getFeatureVisibility(true, { + audit_log: { entitlement: "entitled", enabled: false }, + }) + expect(result).toEqual(expect.objectContaining({ audit_log: false })) + }) + it("returns false for a feature that is not entitled", () => { + const result = getFeatureVisibility(true, { + audit_log: { entitlement: "not_entitled", enabled: true }, + }) + expect(result).toEqual(expect.objectContaining({ audit_log: false })) + }) + it("returns true for a feature that is in grace period", () => { + const result = getFeatureVisibility(true, { + audit_log: { entitlement: "grace_period", enabled: true }, + }) + expect(result).toEqual(expect.objectContaining({ audit_log: true })) + }) + it("returns true for a feature that is in entitled", () => { + const result = getFeatureVisibility(true, { + audit_log: { entitlement: "entitled", enabled: true }, + }) + expect(result).toEqual(expect.objectContaining({ audit_log: true })) + }) +}) diff --git a/site/src/xServices/entitlements/entitlementsSelectors.ts b/site/src/xServices/entitlements/entitlementsSelectors.ts new file mode 100644 index 0000000000000..62d7aae4b1e0b --- /dev/null +++ b/site/src/xServices/entitlements/entitlementsSelectors.ts @@ -0,0 +1,34 @@ +import { Feature } from "api/typesGenerated" +import { State } from "xstate" +import { EntitlementsContext, EntitlementsEvent } from "./entitlementsXService" + +type EntitlementState = State + +/** + * @param hasLicense true if Enterprise edition + * @param features record from feature name to feature object + * @returns record from feature name whether to show the feature + */ +export const getFeatureVisibility = ( + hasLicense: boolean, + features: Record, +): Record => { + if (hasLicense) { + const permissionPairs = Object.keys(features).map((feature) => { + const { entitlement, limit, actual, enabled } = features[feature] + const entitled = ["entitled", "grace_period"].includes(entitlement) + const limitCompliant = limit && actual ? limit >= actual : true + return [feature, entitled && limitCompliant && enabled] + }) + return Object.fromEntries(permissionPairs) + } else { + return {} + } +} + +export const selectFeatureVisibility = (state: EntitlementState): Record => { + return getFeatureVisibility( + state.context.entitlements.has_license, + state.context.entitlements.features, + ) +} diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 7458067172fa5..0da90acd79237 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -47,7 +47,7 @@ export const entitlementsMachine = createMachine( on: { GET_ENTITLEMENTS: "gettingEntitlements", SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" }, - HIDE_MOCK_BANNER: { actions: "clearMockEntitlements" }, + HIDE_MOCK_BANNER: "gettingEntitlements", }, }, gettingEntitlements: { @@ -81,9 +81,6 @@ export const entitlementsMachine = createMachine( assignMockEntitlements: assign({ entitlements: (_) => MockEntitlementsWithWarnings, }), - clearMockEntitlements: assign({ - entitlements: (_) => emptyEntitlements, - }), }, services: { getEntitlements: () => API.getEntitlements(), diff --git a/site/yarn.lock b/site/yarn.lock index 511aa73a00fa8..b9460c91fe599 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3896,10 +3896,10 @@ resolved "https://registry.yarnpkg.com/@xstate/machine-extractor/-/machine-extractor-0.7.0.tgz#3f46a3686462f309ee208a97f6285e7d6a55cdb8" integrity sha512-dXHI/sWWWouN/yG687ZuRCP7Cm6XggFWSK1qWj3NohBTyhaYWSR7ojwP6OUK6e1cbiJqxmM9EDnE2Auf+Xlp+A== -"@xstate/react@3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba" - integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw== +"@xstate/react@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.1.tgz#937eeb5d5d61734ab756ca40146f84a6fe977095" + integrity sha512-/tq/gg92P9ke8J+yDNDBv5/PAxBvXJf2cYyGDByzgtl5wKaxKxzDT82Gj3eWlCJXkrBg4J5/V47//gRJuVH2fA== dependencies: use-isomorphic-layout-effect "^1.0.0" use-sync-external-store "^1.0.0"