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(), + }, + }, +) 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