diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6d8d84cd00801..20a5156e9d3f9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -378,3 +378,8 @@ export const putWorkspaceExtension = async ( ): Promise => { await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline }) } + +export const getEntitlements = async (): Promise => { + const response = await axios.get("/api/v2/entitlements") + return response.data +} diff --git a/site/src/app.tsx b/site/src/app.tsx index 9c7ece503b878..f4441b4c98c28 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -1,5 +1,6 @@ import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" +import { LicenseBanner } from "components/LicenseBanner/LicenseBanner" import { FC } from "react" import { HelmetProvider } from "react-helmet-async" import { BrowserRouter as Router } from "react-router-dom" @@ -18,6 +19,7 @@ export const App: FC = () => { + diff --git a/site/src/components/DropdownArrows/DropdownArrows.tsx b/site/src/components/DropdownArrows/DropdownArrows.tsx index 6f64ad36d1451..fa438157e87d0 100644 --- a/site/src/components/DropdownArrows/DropdownArrows.tsx +++ b/site/src/components/DropdownArrows/DropdownArrows.tsx @@ -3,10 +3,10 @@ import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown" import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp" import { FC } from "react" -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles((theme: Theme) => ({ arrowIcon: { color: fade(theme.palette.primary.contrastText, 0.7), - marginLeft: theme.spacing(1), + marginLeft: ({ margin }) => (margin ? theme.spacing(1) : 0), width: 16, height: 16, }, @@ -15,12 +15,16 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })) -export const OpenDropdown: FC = () => { - const styles = useStyles() +interface ArrowProps { + margin?: boolean +} + +export const OpenDropdown: FC = ({ margin = true }) => { + const styles = useStyles({ margin }) return } -export const CloseDropdown: FC = () => { - const styles = useStyles() +export const CloseDropdown: FC = ({ margin = true }) => { + const styles = useStyles({ margin }) return } diff --git a/site/src/components/ErrorSummary/ErrorSummary.tsx b/site/src/components/ErrorSummary/ErrorSummary.tsx index 9b7ff8416237d..6e8352a8e9544 100644 --- a/site/src/components/ErrorSummary/ErrorSummary.tsx +++ b/site/src/components/ErrorSummary/ErrorSummary.tsx @@ -1,11 +1,11 @@ import Button from "@material-ui/core/Button" import Collapse from "@material-ui/core/Collapse" import IconButton from "@material-ui/core/IconButton" -import Link from "@material-ui/core/Link" import { darken, lighten, makeStyles, Theme } from "@material-ui/core/styles" import CloseIcon from "@material-ui/icons/Close" import RefreshIcon from "@material-ui/icons/Refresh" import { ApiError, getErrorDetail, getErrorMessage } from "api/errors" +import { Expander } from "components/Expander/Expander" import { Stack } from "components/Stack/Stack" import { FC, useState } from "react" @@ -36,10 +36,6 @@ export const ErrorSummary: FC> = ({ const styles = useStyles({ showDetails }) - const toggleShowDetails = () => { - setShowDetails(!showDetails) - } - const closeError = () => { setOpen(false) } @@ -51,19 +47,10 @@ export const ErrorSummary: FC> = ({ return ( -
+ {message} - {!!detail && ( - - {showDetails ? Language.lessDetails : Language.moreDetails} - - )} -
+ {!!detail && } +
{dismissible && ( @@ -101,6 +88,9 @@ const useStyles = makeStyles((theme) => ({ borderRadius: theme.shape.borderRadius, gap: 0, }, + flex: { + display: "flex", + }, messageBox: { justifyContent: "space-between", }, diff --git a/site/src/components/Expander/Expander.stories.tsx b/site/src/components/Expander/Expander.stories.tsx new file mode 100644 index 0000000000000..c3f41869f2157 --- /dev/null +++ b/site/src/components/Expander/Expander.stories.tsx @@ -0,0 +1,22 @@ +import { Story } from "@storybook/react" +import { Expander, ExpanderProps } from "./Expander" + +export default { + title: "components/Expander", + component: Expander, + argTypes: { + setExpanded: { action: "setExpanded" }, + }, +} + +const Template: Story = (args) => + +export const Expanded = Template.bind({}) +Expanded.args = { + expanded: true, +} + +export const Collapsed = Template.bind({}) +Collapsed.args = { + expanded: false, +} diff --git a/site/src/components/Expander/Expander.tsx b/site/src/components/Expander/Expander.tsx new file mode 100644 index 0000000000000..c11a180088382 --- /dev/null +++ b/site/src/components/Expander/Expander.tsx @@ -0,0 +1,45 @@ +import Link from "@material-ui/core/Link" +import makeStyles from "@material-ui/core/styles/makeStyles" +import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" + +const Language = { + expand: "More", + collapse: "Less", +} + +export interface ExpanderProps { + expanded: boolean + setExpanded: (val: boolean) => void +} + +export const Expander: React.FC = ({ expanded, setExpanded }) => { + const toggleExpanded = () => setExpanded(!expanded) + const styles = useStyles() + return ( + + {expanded ? ( + + {Language.collapse} + {" "} + + ) : ( + + {Language.expand} + + + )} + + ) +} + +const useStyles = makeStyles((theme) => ({ + expandLink: { + cursor: "pointer", + color: theme.palette.text.primary, + display: "flex", + }, + text: { + display: "flex", + alignItems: "center", + }, +})) diff --git a/site/src/components/LicenseBanner/LicenseBanner.test.tsx b/site/src/components/LicenseBanner/LicenseBanner.test.tsx new file mode 100644 index 0000000000000..f45ac75d0ffa6 --- /dev/null +++ b/site/src/components/LicenseBanner/LicenseBanner.test.tsx @@ -0,0 +1,27 @@ +import { screen } from "@testing-library/react" +import { rest } from "msw" +import { MockEntitlementsWithWarnings } from "testHelpers/entities" +import { render } from "testHelpers/renderHelpers" +import { server } from "testHelpers/server" +import { LicenseBanner } from "./LicenseBanner" +import { Language } from "./LicenseBannerView" + +describe("LicenseBanner", () => { + it("does not show when there are no warnings", async () => { + render() + const bannerPillSingular = await screen.queryByText(Language.licenseIssue) + const bannerPillPlural = await screen.queryByText(Language.licenseIssues(2)) + expect(bannerPillSingular).toBe(null) + expect(bannerPillPlural).toBe(null) + }) + it("shows when there are warnings", async () => { + server.use( + rest.get("/api/v2/entitlements", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockEntitlementsWithWarnings)) + }), + ) + render() + const bannerPill = await screen.findByText(Language.licenseIssues(2)) + expect(bannerPill).toBeDefined() + }) +}) diff --git a/site/src/components/LicenseBanner/LicenseBanner.tsx b/site/src/components/LicenseBanner/LicenseBanner.tsx new file mode 100644 index 0000000000000..f2a78f0f12d62 --- /dev/null +++ b/site/src/components/LicenseBanner/LicenseBanner.tsx @@ -0,0 +1,21 @@ +import { useActor } from "@xstate/react" +import { useContext, useEffect } from "react" +import { XServiceContext } from "xServices/StateContext" +import { LicenseBannerView } from "./LicenseBannerView" + +export const LicenseBanner: React.FC = () => { + const xServices = useContext(XServiceContext) + const [entitlementsState, entitlementsSend] = useActor(xServices.entitlementsXService) + const { warnings } = entitlementsState.context.entitlements + + /** Gets license data on app mount because LicenseBanner is mounted in App */ + useEffect(() => { + entitlementsSend("GET_ENTITLEMENTS") + }, [entitlementsSend]) + + if (warnings.length) { + return + } else { + return null + } +} diff --git a/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx new file mode 100644 index 0000000000000..7328c24f3230f --- /dev/null +++ b/site/src/components/LicenseBanner/LicenseBannerView.stories.tsx @@ -0,0 +1,22 @@ +import { Story } from "@storybook/react" +import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView" + +export default { + title: "components/LicenseBannerView", + component: LicenseBannerView, +} + +const Template: Story = (args) => + +export const OneWarning = Template.bind({}) +OneWarning.args = { + warnings: ["You have exceeded the number of seats in your license."], +} + +export const TwoWarnings = Template.bind({}) +TwoWarnings.args = { + warnings: [ + "You have exceeded the number of seats in your license.", + "You are flying too close to the sun.", + ], +} diff --git a/site/src/components/LicenseBanner/LicenseBannerView.tsx b/site/src/components/LicenseBanner/LicenseBannerView.tsx new file mode 100644 index 0000000000000..5d63d23bedb67 --- /dev/null +++ b/site/src/components/LicenseBanner/LicenseBannerView.tsx @@ -0,0 +1,87 @@ +import Collapse from "@material-ui/core/Collapse" +import { makeStyles } from "@material-ui/core/styles" +import { Expander } from "components/Expander/Expander" +import { Pill } from "components/Pill/Pill" +import { useState } from "react" + +export const Language = { + licenseIssue: "License Issue", + licenseIssues: (num: number): string => `${num} License Issues`, + upgrade: "Contact us to upgrade your license.", + exceeded: "It looks like you've exceeded some limits of your license.", + lessDetails: "Less", + moreDetails: "More", +} + +export interface LicenseBannerViewProps { + warnings: string[] +} + +export const LicenseBannerView: React.FC = ({ warnings }) => { + const styles = useStyles() + const [showDetails, setShowDetails] = useState(false) + if (warnings.length === 1) { + return ( +
+ + {warnings[0]} +   + + {Language.upgrade} + +
+ ) + } else { + return ( +
+
+
+ + {Language.exceeded} +   + + {Language.upgrade} + +
+ +
+ +
    + {warnings.map((warning) => ( +
  • + {warning} +
  • + ))} +
+
+
+ ) + } +} + +const useStyles = makeStyles((theme) => ({ + container: { + padding: theme.spacing(1.5), + backgroundColor: theme.palette.warning.main, + }, + flex: { + display: "flex", + }, + leftContent: { + marginRight: theme.spacing(1), + }, + text: { + marginLeft: theme.spacing(1), + }, + link: { + color: "inherit", + textDecoration: "none", + fontWeight: "bold", + }, + list: { + margin: theme.spacing(1.5), + }, + listItem: { + margin: theme.spacing(1), + }, +})) diff --git a/site/src/components/Pill/Pill.stories.tsx b/site/src/components/Pill/Pill.stories.tsx new file mode 100644 index 0000000000000..5358cb7aa0834 --- /dev/null +++ b/site/src/components/Pill/Pill.stories.tsx @@ -0,0 +1,57 @@ +import { Story } from "@storybook/react" +import { Pill, PillProps } from "./Pill" + +export default { + title: "components/Pill", + component: Pill, +} + +const Template: Story = (args) => + +export const Primary = Template.bind({}) +Primary.args = { + text: "Primary", + type: "primary", +} + +export const Secondary = Template.bind({}) +Secondary.args = { + text: "Secondary", + type: "secondary", +} + +export const Success = Template.bind({}) +Success.args = { + text: "Success", + type: "success", +} + +export const Info = Template.bind({}) +Info.args = { + text: "Information", + type: "info", +} + +export const Warning = Template.bind({}) +Warning.args = { + text: "Warning", + type: "warning", +} + +export const Error = Template.bind({}) +Error.args = { + text: "Error", + type: "error", +} + +export const Default = Template.bind({}) +Default.args = { + text: "Default", +} + +export const WarningLight = Template.bind({}) +WarningLight.args = { + text: "Warning", + type: "warning", + lightBorder: true, +} diff --git a/site/src/components/Pill/Pill.tsx b/site/src/components/Pill/Pill.tsx new file mode 100644 index 0000000000000..2c70a6d674d75 --- /dev/null +++ b/site/src/components/Pill/Pill.tsx @@ -0,0 +1,68 @@ +import { makeStyles } from "@material-ui/core/styles" +import { FC } from "react" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { PaletteIndex } from "theme/palettes" +import { combineClasses } from "util/combineClasses" + +export interface PillProps { + className?: string + icon?: React.ReactNode + text: string + type?: PaletteIndex + lightBorder?: boolean +} + +export const Pill: FC = ({ className, icon, text, type, lightBorder = false }) => { + const styles = useStyles({ icon, type, lightBorder }) + return ( +
+ {icon &&
{icon}
} + {text} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + fontFamily: MONOSPACE_FONT_FAMILY, + display: "inline-flex", + alignItems: "center", + borderWidth: 1, + borderStyle: "solid", + borderRadius: 99999, + fontSize: 14, + fontWeight: 500, + color: "#FFF", + height: theme.spacing(3), + paddingLeft: ({ icon }: { icon?: React.ReactNode }) => + icon ? theme.spacing(0.75) : theme.spacing(1.5), + paddingRight: theme.spacing(1.5), + whiteSpace: "nowrap", + }, + + pillColor: { + backgroundColor: ({ type }: { type?: PaletteIndex }) => + type ? theme.palette[type].dark : theme.palette.text.secondary, + borderColor: ({ type, lightBorder }: { type?: PaletteIndex; lightBorder?: boolean }) => + type + ? lightBorder + ? theme.palette[type].light + : theme.palette[type].main + : theme.palette.text.secondary, + }, + + iconWrapper: { + marginRight: theme.spacing(0.5), + width: theme.spacing(2), + height: theme.spacing(2), + lineHeight: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + + "& > svg": { + width: theme.spacing(2), + height: theme.spacing(2), + }, + }, +})) diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 3a60412f07119..469b644afdbeb 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -1,12 +1,11 @@ import CircularProgress from "@material-ui/core/CircularProgress" -import { makeStyles, Theme, useTheme } from "@material-ui/core/styles" import ErrorIcon from "@material-ui/icons/ErrorOutline" import StopIcon from "@material-ui/icons/PauseOutlined" import PlayIcon from "@material-ui/icons/PlayArrowOutlined" import { WorkspaceBuild } from "api/typesGenerated" +import { Pill } from "components/Pill/Pill" import React from "react" -import { MONOSPACE_FONT_FAMILY } from "theme/constants" -import { combineClasses } from "util/combineClasses" +import { PaletteIndex } from "theme/palettes" import { getWorkspaceStatus } from "util/workspace" const StatusLanguage = { @@ -28,11 +27,9 @@ const LoadingIcon: React.FC = () => { } export const getStatus = ( - theme: Theme, build: WorkspaceBuild, ): { - borderColor: string - backgroundColor: string + type?: PaletteIndex text: string icon: React.ReactNode } => { @@ -40,78 +37,66 @@ export const getStatus = ( switch (status) { case undefined: return { - borderColor: theme.palette.text.secondary, - backgroundColor: theme.palette.text.secondary, text: StatusLanguage.loading, icon: , } case "started": return { - borderColor: theme.palette.success.main, - backgroundColor: theme.palette.success.dark, + type: "success", text: StatusLanguage.started, icon: , } case "starting": return { - borderColor: theme.palette.success.main, - backgroundColor: theme.palette.success.dark, + type: "success", text: StatusLanguage.starting, icon: , } case "stopping": return { - borderColor: theme.palette.warning.main, - backgroundColor: theme.palette.warning.dark, + type: "warning", text: StatusLanguage.stopping, icon: , } case "stopped": return { - borderColor: theme.palette.warning.main, - backgroundColor: theme.palette.warning.dark, + type: "warning", text: StatusLanguage.stopped, icon: , } case "deleting": return { - borderColor: theme.palette.warning.main, - backgroundColor: theme.palette.warning.dark, + type: "warning", text: StatusLanguage.deleting, icon: , } case "deleted": return { - borderColor: theme.palette.error.main, - backgroundColor: theme.palette.error.dark, + type: "error", text: StatusLanguage.deleted, icon: , } case "canceling": return { - borderColor: theme.palette.warning.main, - backgroundColor: theme.palette.warning.dark, + type: "warning", text: StatusLanguage.canceling, icon: , } case "canceled": return { - borderColor: theme.palette.warning.main, - backgroundColor: theme.palette.warning.dark, + type: "warning", text: StatusLanguage.canceled, icon: , } case "error": return { - borderColor: theme.palette.error.main, - backgroundColor: theme.palette.error.dark, + type: "error", text: StatusLanguage.failed, icon: , } case "queued": return { - borderColor: theme.palette.info.main, - backgroundColor: theme.palette.info.dark, + type: "info", text: StatusLanguage.queued, icon: , } @@ -128,50 +113,6 @@ export const WorkspaceStatusBadge: React.FC { - const styles = useStyles() - const theme = useTheme() - const { text, icon, ...colorStyles } = getStatus(theme, build) - return ( -
-
{icon}
- {text} -
- ) + const { text, icon, type } = getStatus(build) + return } - -const useStyles = makeStyles((theme) => ({ - wrapper: { - fontFamily: MONOSPACE_FONT_FAMILY, - display: "inline-flex", - alignItems: "center", - borderWidth: 1, - borderStyle: "solid", - borderRadius: 99999, - fontSize: 14, - fontWeight: 500, - color: "#FFF", - height: theme.spacing(3), - paddingLeft: theme.spacing(0.75), - paddingRight: theme.spacing(1.5), - whiteSpace: "nowrap", - }, - - iconWrapper: { - marginRight: theme.spacing(0.5), - width: theme.spacing(2), - height: theme.spacing(2), - lineHeight: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - - "& > svg": { - width: theme.spacing(2), - height: theme.spacing(2), - }, - }, -})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8b600828e2dac..5c28f574704f5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -636,3 +636,22 @@ export const makeMockApiError = ({ }, isAxiosError: true, }) + +export const MockEntitlements: TypesGen.Entitlements = { + warnings: [], + has_license: false, + features: {}, +} + +export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { + warnings: ["You are over your active user limit.", "And another thing."], + has_license: true, + features: { + activeUsers: { + enabled: true, + entitlement: "entitled", + limit: 100, + actual: 102, + }, + }, +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index ae4f23d3b08db..a12b46179a5be 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -144,4 +144,7 @@ export const handlers = [ rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs)) }), + rest.get("/api/v2/entitlements", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockEntitlements)) + }), ] diff --git a/site/src/theme/palettes.ts b/site/src/theme/palettes.ts index e2e23cdf4dbeb..eb2fd91294893 100644 --- a/site/src/theme/palettes.ts +++ b/site/src/theme/palettes.ts @@ -1,6 +1,9 @@ import { PaletteOptions } from "@material-ui/core/styles/createPalette" import { colors } from "./colors" +// Couldn't find a type for this so I made one. We can extend the palette if needed with module augmentation. +export type PaletteIndex = "primary" | "secondary" | "info" | "success" | "error" | "warning" + export const darkPalette: PaletteOptions = { type: "dark", primary: { @@ -24,6 +27,7 @@ export const darkPalette: PaletteOptions = { }, divider: colors.gray[13], warning: { + light: colors.orange[7], main: colors.orange[11], dark: colors.orange[15], }, diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index cf120ec939bd4..93c9291d373d1 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -4,12 +4,14 @@ import { useNavigate } from "react-router" import { ActorRefFrom } from "xstate" import { authMachine } from "./auth/authXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService" +import { entitlementsMachine } from "./entitlements/entitlementsXService" import { siteRolesMachine } from "./roles/siteRolesXService" import { usersMachine } from "./users/usersXService" interface XServiceContextType { authXService: ActorRefFrom buildInfoXService: ActorRefFrom + entitlementsXService: ActorRefFrom usersXService: ActorRefFrom siteRolesXService: ActorRefFrom } @@ -40,6 +42,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => { authMachine.withConfig({ actions: { redirectToSetupPage } }), ), buildInfoXService: useInterpret(buildInfoMachine), + entitlementsXService: useInterpret(entitlementsMachine), usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } }), ), diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts new file mode 100644 index 0000000000000..7458067172fa5 --- /dev/null +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -0,0 +1,92 @@ +import { MockEntitlementsWithWarnings } from "testHelpers/entities" +import { assign, createMachine } from "xstate" +import * as API from "../../api/api" +import { Entitlements } from "../../api/typesGenerated" + +export const Language = { + getEntitlementsError: "Error getting license entitlements.", +} + +export type EntitlementsContext = { + entitlements: Entitlements + getEntitlementsError?: Error | unknown +} + +export type EntitlementsEvent = + | { + type: "GET_ENTITLEMENTS" + } + | { type: "SHOW_MOCK_BANNER" } + | { type: "HIDE_MOCK_BANNER" } + +const emptyEntitlements = { + warnings: [], + features: {}, + has_license: false, +} + +export const entitlementsMachine = createMachine( + { + id: "entitlementsMachine", + initial: "idle", + schema: { + context: {} as EntitlementsContext, + events: {} as EntitlementsEvent, + services: { + getEntitlements: { + data: {} as Entitlements, + }, + }, + }, + tsTypes: {} as import("./entitlementsXService.typegen").Typegen0, + context: { + entitlements: emptyEntitlements, + }, + states: { + idle: { + on: { + GET_ENTITLEMENTS: "gettingEntitlements", + SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" }, + HIDE_MOCK_BANNER: { actions: "clearMockEntitlements" }, + }, + }, + gettingEntitlements: { + entry: "clearGetEntitlementsError", + invoke: { + id: "getEntitlements", + src: "getEntitlements", + onDone: { + target: "idle", + actions: ["assignEntitlements"], + }, + onError: { + target: "idle", + actions: ["assignGetEntitlementsError"], + }, + }, + }, + }, + }, + { + actions: { + assignEntitlements: assign({ + entitlements: (_, event) => event.data, + }), + assignGetEntitlementsError: assign({ + getEntitlementsError: (_, event) => event.data, + }), + clearGetEntitlementsError: assign({ + getEntitlementsError: (_) => undefined, + }), + assignMockEntitlements: assign({ + entitlements: (_) => MockEntitlementsWithWarnings, + }), + clearMockEntitlements: assign({ + entitlements: (_) => emptyEntitlements, + }), + }, + services: { + getEntitlements: () => API.getEntitlements(), + }, + }, +)