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" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy