From 31ae8eb434d6e9155bae7a774386a58fb121a3bd Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:00:04 +0000 Subject: [PATCH 1/7] Add stubbed api call --- site/src/api/api.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8517dab9e8d26..b2a4ee8901f5a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -68,6 +68,29 @@ export const checkUserPermissions = async ( return response.data } +export const getLicenseData = async (): Promise => { + const fakeLicenseData = { + features: { + audit: { + entitled: false, + enabled: true + }, + createUser: { + entitled: true, + enabled: true, + limit: 1, + actual: 2 + }, + createOrg: { + entitled: true, + enabled: false + } + }, + warnings: ["This is a test license compliance banner", "Here is a second one"] + } + return Promise.resolve(fakeLicenseData) +} + export const getApiKey = async (): Promise => { const response = await axios.post("/api/v2/users/me/keys") return response.data From ead0ab44619bfff1d968916b67d44fc6ede9fbe5 Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:01:06 +0000 Subject: [PATCH 2/7] Add licenseXService --- site/src/api/types.ts | 14 +++ site/src/xServices/StateContext.tsx | 3 + .../src/xServices/license/licenseSelectors.ts | 24 +++++ site/src/xServices/license/licenseXService.ts | 94 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 site/src/xServices/license/licenseSelectors.ts create mode 100644 site/src/xServices/license/licenseXService.ts diff --git a/site/src/api/types.ts b/site/src/api/types.ts index daf4e451ac5e8..340d1c078c092 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -14,3 +14,17 @@ export interface ReconnectingPTYRequest { export type WorkspaceBuildTransition = "start" | "stop" | "delete" export type Message = { message: string } + +export type LicensePermission = "audit" | "createUser" | "createOrg" + +export type LicenseFeatures = Record + +export type LicenseData = { + features: LicenseFeatures + warnings: string[] +} diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index c9628cfe2608e..bc5ce8083bc8f 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router" import { ActorRefFrom } from "xstate" import { authMachine } from "./auth/authXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService" +import { licenseMachine } from "./license/licenseXService" import { siteRolesMachine } from "./roles/siteRolesXService" import { usersMachine } from "./users/usersXService" @@ -12,6 +13,7 @@ interface XServiceContextType { buildInfoXService: ActorRefFrom usersXService: ActorRefFrom siteRolesXService: ActorRefFrom + licenseXService: ActorRefFrom } /** @@ -39,6 +41,7 @@ export const XServiceProvider: React.FC = ({ children }) => { usersMachine.withConfig({ actions: { redirectToUsersPage } }), ), siteRolesXService: useInterpret(siteRolesMachine), + licenseXService: useInterpret(licenseMachine) }} > {children} diff --git a/site/src/xServices/license/licenseSelectors.ts b/site/src/xServices/license/licenseSelectors.ts new file mode 100644 index 0000000000000..378e506622d11 --- /dev/null +++ b/site/src/xServices/license/licenseSelectors.ts @@ -0,0 +1,24 @@ +import { State } from "xstate" +import { LicensePermission } from "../../api/types" +import { LicenseContext, LicenseEvent } from "./licenseXService" +type LicenseState = State + +export const selectLicenseVisibility = (state: LicenseState): Record => { + const features = state.context.licenseData.features + const featureNames = Object.keys(features) as LicensePermission[] + const visibilityPairs = featureNames.map((feature: LicensePermission) => { + return [feature, features[feature].enabled] + }) + return Object.fromEntries(visibilityPairs) +} + +export const selectLicenseEntitlement = (state: LicenseState): Record => { + const features = state.context.licenseData.features + const featureNames = Object.keys(features) as LicensePermission[] + const permissionPairs = featureNames.map((feature: LicensePermission) => { + const { entitled, limit, actual } = features[feature] + const limitCompliant = limit && actual && limit >= actual + return [feature, entitled && limitCompliant] + }) + return Object.fromEntries(permissionPairs) +} diff --git a/site/src/xServices/license/licenseXService.ts b/site/src/xServices/license/licenseXService.ts new file mode 100644 index 0000000000000..0940cf7683b1b --- /dev/null +++ b/site/src/xServices/license/licenseXService.ts @@ -0,0 +1,94 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api/api" +import { LicenseData } from "../../api/types" + +export const Language = { + getLicenseError: "Error getting license information.", +} + +/* deserves more thought but this is one way to handle unlicensed cases */ +const defaultLicenseData = { + warnings: [], + features: { + audit: { + enabled: false, + entitled: false + }, + createUser: { + enabled: true, + entitled: true, + limit: 0 + }, + createOrg: { + enabled: false, + entitled: false + } + } +} + +export type LicenseContext = { + licenseData: LicenseData + getLicenseError?: Error | unknown +} + +export type LicenseEvent = { + type: "GET_LICENSE_DATA" +} + +export const licenseMachine = createMachine( + { + id: "licenseMachine", + initial: "idle", + schema: { + context: {} as LicenseContext, + events: {} as LicenseEvent, + services: { + getLicenseData: { + data: {} as LicenseData, + }, + }, + }, + tsTypes: {} as import("./licenseXService.typegen").Typegen0, + context: { + licenseData: defaultLicenseData + }, + states: { + idle: { + on: { + GET_LICENSE_DATA: "gettingLicenseData", + }, + }, + gettingLicenseData: { + entry: "clearGetLicenseError", + invoke: { + id: "getLicenseData", + src: "getLicenseData", + onDone: { + target: "idle", + actions: ["assignLicenseData"], + }, + onError: { + target: "idle", + actions: ["assignGetLicenseError"], + }, + }, + }, + }, + }, + { + actions: { + assignLicenseData: assign({ + licenseData: (_, event) => event.data, + }), + assignGetLicenseError: assign({ + getLicenseError: (_, event) => event.data, + }), + clearGetLicenseError: assign({ + getLicenseError: (_) => undefined, + }), + }, + services: { + getLicenseData: () => API.getLicenseData(), + }, + }, +) From 9aa9f04c81efb7f76a22630a51cbe3a7834b7491 Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:01:35 +0000 Subject: [PATCH 3/7] add LicenseBanner that calls xservice --- .../LicenseBanner/LicenseBanner.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 site/src/components/LicenseBanner/LicenseBanner.tsx diff --git a/site/src/components/LicenseBanner/LicenseBanner.tsx b/site/src/components/LicenseBanner/LicenseBanner.tsx new file mode 100644 index 0000000000000..7445fea4c497b --- /dev/null +++ b/site/src/components/LicenseBanner/LicenseBanner.tsx @@ -0,0 +1,20 @@ +import { useActor } from "@xstate/react" +import { useContext, useEffect } from "react" +import { XServiceContext } from "../../xServices/StateContext" + +export const LicenseBanner: React.FC = () => { + const xServices = useContext(XServiceContext) + const [licenseState, licenseSend] = useActor(xServices.licenseXService) + const warnings = licenseState.context.licenseData.warnings + + /** Gets license data on app mount because LicenseBanner is mounted in App */ + useEffect(() => { + licenseSend("GET_LICENSE_DATA") + }, [licenseSend]) + + if (warnings) { + return
{warnings.map((warning, i) =>

{warning}

)}
+ } else { + return null + } +} From cd600e21ac0be7cb5e9abc74fa8bd3f453acc141 Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:02:08 +0000 Subject: [PATCH 4/7] Mount license banner --- site/src/app.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/app.tsx b/site/src/app.tsx index 8782f663884d6..b6ba3b324f150 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -6,6 +6,7 @@ import { SWRConfig } from "swr" import { AppRouter } from "./AppRouter" import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary" import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar" +import { LicenseBanner } from "./components/LicenseBanner/LicenseBanner" import { dark } from "./theme" import "./theme/globalFonts" import { XServiceProvider } from "./xServices/StateContext" @@ -35,6 +36,7 @@ export const App: FC = () => { + From 371661297e60532a9c36da2959c602fcc0565445 Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:02:54 +0000 Subject: [PATCH 5/7] Show stub audit log page if feature enabled --- site/src/AppRouter.tsx | 3 +++ .../RequireLicense/RequireLicense.tsx | 24 +++++++++++++++++++ site/src/pages/AuditLogPage/AuditLogPage.tsx | 5 ++++ 3 files changed, 32 insertions(+) create mode 100644 site/src/components/RequireLicense/RequireLicense.tsx create mode 100644 site/src/pages/AuditLogPage/AuditLogPage.tsx diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index d76dbc687da77..196522900655a 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -2,9 +2,11 @@ import { FC, lazy, Suspense } from "react" import { Route, Routes } from "react-router-dom" import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" +import { RequireLicense } from "./components/RequireLicense/RequireLicense" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { IndexPage } from "./pages" import { NotFoundPage } from "./pages/404Page/404Page" +import { AuditLogPage } from "./pages/AuditLogPage/AuditLogPage" import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage" import { HealthzPage } from "./pages/HealthzPage/HealthzPage" import { LoginPage } from "./pages/LoginPage/LoginPage" @@ -113,6 +115,7 @@ export const AppRouter: FC = () => ( } /> } /> } /> + } /> diff --git a/site/src/components/RequireLicense/RequireLicense.tsx b/site/src/components/RequireLicense/RequireLicense.tsx new file mode 100644 index 0000000000000..a65844c9597a4 --- /dev/null +++ b/site/src/components/RequireLicense/RequireLicense.tsx @@ -0,0 +1,24 @@ +import { useSelector } from "@xstate/react" +import React, { useContext } from "react" +import { Navigate } from "react-router" +import { FullScreenLoader } from "../Loader/FullScreenLoader" +import { XServiceContext } from "../../xServices/StateContext" +import { selectLicenseVisibility } from "../../xServices/license/licenseSelectors" +import { LicensePermission } from "../../api/types" + +export interface RequireLicenseProps { + children: JSX.Element + permissionRequired: LicensePermission +} + +export const RequireLicense: React.FC = ({ children, permissionRequired }) => { + const xServices = useContext(XServiceContext) + const visibility = useSelector(xServices.licenseXService, selectLicenseVisibility) + if (!visibility) { + return + } else if (!visibility[permissionRequired]) { + return + } else { + return children + } +} diff --git a/site/src/pages/AuditLogPage/AuditLogPage.tsx b/site/src/pages/AuditLogPage/AuditLogPage.tsx new file mode 100644 index 0000000000000..9e054ae1735f7 --- /dev/null +++ b/site/src/pages/AuditLogPage/AuditLogPage.tsx @@ -0,0 +1,5 @@ +export const AuditLogPage = () => { + return
+ This is a stub for the audit log page. +
+} From d372600549dcc8856dc11e75c2b0c858fb46deab Mon Sep 17 00:00:00 2001 From: presleyp Date: Fri, 15 Jul 2022 18:40:58 +0000 Subject: [PATCH 6/7] Move audit page and add nav link --- site/src/AppRouter.tsx | 4 +++- site/src/components/Navbar/Navbar.tsx | 7 +++++-- site/src/components/NavbarView/NavbarView.tsx | 11 ++++++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 196522900655a..787f4d9e964a8 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -111,11 +111,13 @@ export const AppRouter: FC = () => ( />
+ + } /> + }> } /> } /> } /> - } /> diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 0ac64ef7d1269..9a74375b09804 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -1,5 +1,6 @@ -import { useActor } from "@xstate/react" +import { useActor, useSelector } from "@xstate/react" import React, { useContext } from "react" +import { selectLicenseVisibility } from "../../xServices/license/licenseSelectors" import { XServiceContext } from "../../xServices/StateContext" import { NavbarView } from "../NavbarView/NavbarView" @@ -9,5 +10,7 @@ export const Navbar: React.FC = () => { const { me } = authState.context const onSignOut = () => authSend("SIGN_OUT") - return + const showAuditLog = useSelector(xServices.licenseXService, selectLicenseVisibility)["audit"] + + return } diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 26d9fa2d0fb2a..bba6925437393 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -11,15 +11,17 @@ import { UserDropdown } from "../UserDropdown/UsersDropdown" export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void + showAuditLog: boolean } export const Language = { workspaces: "Workspaces", templates: "Templates", users: "Users", + audit: "Audit" } -export const NavbarView: React.FC = ({ user, onSignOut }) => { +export const NavbarView: React.FC = ({ user, onSignOut, showAuditLog }) => { const styles = useStyles() const location = useLocation() return ( @@ -51,6 +53,13 @@ export const NavbarView: React.FC = ({ user, onSignOut }) => { {Language.users} + {showAuditLog && + + + {Language.audit} + + + }
From 0ae85e3dd5bffd3db2bb35611d9c40dbf2e2b91e Mon Sep 17 00:00:00 2001 From: presleyp Date: Mon, 18 Jul 2022 20:52:12 +0000 Subject: [PATCH 7/7] Example smaller feature --- site/src/api/api.ts | 4 ++++ site/src/api/types.ts | 2 +- site/src/components/Workspace/Workspace.tsx | 2 ++ .../components/WorkspaceSchedule/WorkspaceSchedule.tsx | 9 +++++++-- .../WorkspaceScheduleButton/CELAdminScheduleLabel.tsx | 9 +++++++++ .../WorkspaceScheduleButton/CELChangeScheduleLink.tsx | 10 ++++++++++ .../WorkspaceScheduleButton/OSSChangeScheduleLink.tsx | 9 +++++++++ .../WorkspaceScheduleButton.tsx | 6 +++++- site/src/pages/WorkspacePage/WorkspacePage.tsx | 3 +++ site/src/xServices/license/licenseXService.ts | 4 ++++ 10 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 site/src/components/WorkspaceScheduleButton/CELAdminScheduleLabel.tsx create mode 100644 site/src/components/WorkspaceScheduleButton/CELChangeScheduleLink.tsx create mode 100644 site/src/components/WorkspaceScheduleButton/OSSChangeScheduleLink.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b2a4ee8901f5a..66814e0b5726e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -84,6 +84,10 @@ export const getLicenseData = async (): Promise => { createOrg: { entitled: true, enabled: false + }, + adminScheduling: { + enabled: true, + entitled: true } }, warnings: ["This is a test license compliance banner", "Here is a second one"] diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 340d1c078c092..6f70b1a40b43b 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -15,7 +15,7 @@ export type WorkspaceBuildTransition = "start" | "stop" | "delete" export type Message = { message: string } -export type LicensePermission = "audit" | "createUser" | "createOrg" +export type LicensePermission = "audit" | "createUser" | "createOrg" | "adminScheduling" export type LicenseFeatures = Record void } scheduleProps: { + adminScheduling: boolean onDeadlinePlus: () => void onDeadlineMinus: () => void } @@ -61,6 +62,7 @@ export const Workspace: FC = ({ actions={ = ({ workspace, canUpdateWorkspace, + adminScheduling }) => { const styles = useStyles() const timezone = workspace.autostart_schedule @@ -71,7 +76,7 @@ export const WorkspaceSchedule: FC = ({ component={RouterLink} to={`/@${workspace.owner_name}/${workspace.name}/schedule`} > - {Language.editScheduleLink} + {adminScheduling ? : }
)} diff --git a/site/src/components/WorkspaceScheduleButton/CELAdminScheduleLabel.tsx b/site/src/components/WorkspaceScheduleButton/CELAdminScheduleLabel.tsx new file mode 100644 index 0000000000000..a40234e830bb0 --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/CELAdminScheduleLabel.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const Language = { + orgDefault: "Org default:" +} + +export const CELAdminScheduleLabel: React.FC = () => { + return {Language.orgDefault}  +} diff --git a/site/src/components/WorkspaceScheduleButton/CELChangeScheduleLink.tsx b/site/src/components/WorkspaceScheduleButton/CELChangeScheduleLink.tsx new file mode 100644 index 0000000000000..202aeefd5c8f9 --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/CELChangeScheduleLink.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +const Language = { + overrideScheduleLink: "Override Schedule" +} + +export const CELChangeScheduleLink: React.FC = () => { + return {Language.overrideScheduleLink} +} + diff --git a/site/src/components/WorkspaceScheduleButton/OSSChangeScheduleLink.tsx b/site/src/components/WorkspaceScheduleButton/OSSChangeScheduleLink.tsx new file mode 100644 index 0000000000000..9d88f8bc43ada --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/OSSChangeScheduleLink.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const Language = { + editScheduleLink: "Edit Schedule" +} + +export const OSSChangeScheduleLink: React.FC = () => { + return {Language.editScheduleLink} +} diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index d03c0ad357136..13bdfcae4a727 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -17,6 +17,7 @@ import { Workspace } from "../../api/typesGenerated" import { isWorkspaceOn } from "../../util/workspace" import { Stack } from "../Stack/Stack" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" +import { CELAdminScheduleLabel } from "./CELAdminScheduleLabel" import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -55,6 +56,7 @@ export interface WorkspaceScheduleButtonProps { onDeadlinePlus: () => void onDeadlineMinus: () => void canUpdateWorkspace: boolean + adminScheduling: boolean } export const WorkspaceScheduleButton: React.FC = ({ @@ -62,6 +64,7 @@ export const WorkspaceScheduleButton: React.FC = ( onDeadlinePlus, onDeadlineMinus, canUpdateWorkspace, + adminScheduling }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -75,6 +78,7 @@ export const WorkspaceScheduleButton: React.FC = ( return (
+ {adminScheduling && } {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && ( @@ -126,7 +130,7 @@ export const WorkspaceScheduleButton: React.FC = ( horizontal: "right", }} > - +
diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 7dfbac41e803c..787ce67f91d6e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -11,6 +11,7 @@ import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { pageTitle } from "../../util/page" import { selectUser } from "../../xServices/auth/authSelectors" +import { selectLicenseVisibility } from "../../xServices/license/licenseSelectors" import { XServiceContext } from "../../xServices/StateContext" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService" @@ -24,6 +25,7 @@ export const WorkspacePage: React.FC = () => { const xServices = useContext(XServiceContext) const me = useSelector(xServices.authXService, selectUser) + const adminScheduling = useSelector(xServices.licenseXService, selectLicenseVisibility)["adminScheduling"] const [workspaceState, workspaceSend] = useMachine(workspaceMachine, { context: { @@ -68,6 +70,7 @@ export const WorkspacePage: React.FC = () => { }, }} scheduleProps={{ + adminScheduling, onDeadlineMinus: () => { bannerSend({ type: "UPDATE_DEADLINE", diff --git a/site/src/xServices/license/licenseXService.ts b/site/src/xServices/license/licenseXService.ts index 0940cf7683b1b..6b1b9513e8da7 100644 --- a/site/src/xServices/license/licenseXService.ts +++ b/site/src/xServices/license/licenseXService.ts @@ -22,6 +22,10 @@ const defaultLicenseData = { createOrg: { enabled: false, entitled: false + }, + adminScheduling: { + enabled: true, + entitled: true } } }