refactor: Refactor auth provider (#5782)

This commit is contained in:
Bruno Quaresma
2023-01-19 21:02:29 -03:00
committed by GitHub
parent a0a959c7a5
commit ff69c0e70f
39 changed files with 527 additions and 555 deletions
+98 -119
View File
@@ -1,6 +1,4 @@
import { useSelector } from "@xstate/react"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { RequirePermission } from "components/RequirePermission/RequirePermission"
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
import { UsersLayout } from "components/UsersLayout/UsersLayout"
import IndexPage from "pages"
@@ -12,12 +10,9 @@ import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSetting
import TemplatesPage from "pages/TemplatesPage/TemplatesPage"
import UsersPage from "pages/UsersPage/UsersPage"
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
import { FC, lazy, Suspense, useContext } from "react"
import { Route, Routes } from "react-router-dom"
import { selectPermissions } from "xServices/auth/authSelectors"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
import { DashboardLayout } from "./components/DashboardLayout/DashboardLayout"
import { FC, lazy, Suspense } from "react"
import { Route, Routes, BrowserRouter as Router } from "react-router-dom"
import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
@@ -123,135 +118,119 @@ const CreateTemplatePage = lazy(
)
export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
const permissions = useSelector(xServices.authXService, selectPermissions)
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
)
return (
<Suspense fallback={<FullScreenLoader />}>
<Routes>
<Route path="login" element={<LoginPage />} />
<Route path="setup" element={<SetupPage />} />
<Router>
<Routes>
<Route path="login" element={<LoginPage />} />
<Route path="setup" element={<SetupPage />} />
{/* Dashboard routes */}
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route index element={<IndexPage />} />
{/* Dashboard routes */}
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route index element={<IndexPage />} />
<Route path="gitauth" element={<GitAuthPage />} />
<Route path="gitauth" element={<GitAuthPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="starter-templates">
<Route index element={<StarterTemplatesPage />} />
<Route path=":exampleId" element={<StarterTemplatePage />} />
</Route>
<Route path="starter-templates">
<Route index element={<StarterTemplatesPage />} />
<Route path=":exampleId" element={<StarterTemplatePage />} />
</Route>
<Route path="templates">
<Route index element={<TemplatesPage />} />
<Route path="new" element={<CreateTemplatePage />} />
<Route path=":template">
<Route element={<TemplateLayout />}>
<Route index element={<TemplateSummaryPage />} />
<Route path="templates">
<Route index element={<TemplatesPage />} />
<Route path="new" element={<CreateTemplatePage />} />
<Route path=":template">
<Route element={<TemplateLayout />}>
<Route index element={<TemplateSummaryPage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
/>
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsPage />} />
<Route path="versions">
<Route path=":version" element={<TemplateVersionPage />} />
</Route>
</Route>
</Route>
<Route path="users">
<Route element={<UsersLayout />}>
<Route index element={<UsersPage />} />
</Route>
<Route path="create" element={<CreateUserPage />} />
</Route>
<Route path="/groups">
<Route element={<UsersLayout />}>
<Route index element={<GroupsPage />} />
</Route>
<Route path="create" element={<CreateGroupPage />} />
<Route path=":groupId" element={<GroupPage />} />
<Route
path=":groupId/settings"
element={<SettingsGroupPage />}
/>
</Route>
<Route path="/audit" element={<AuditPage />} />
<Route
path="/settings/deployment"
element={<DeploySettingsLayout />}
>
<Route path="general" element={<GeneralSettingsPage />} />
<Route path="security" element={<SecuritySettingsPage />} />
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route path="gitauth" element={<GitAuthSettingsPage />} />
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tokens" element={<TokensPage />} />
</Route>
<Route path="/@:username">
<Route path=":workspace">
<Route index element={<WorkspacePage />} />
<Route path="schedule" element={<WorkspaceSchedulePage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
path="builds/:buildNumber"
element={<WorkspaceBuildPage />}
/>
<Route
path="change-version"
element={<WorkspaceChangeVersionPage />}
/>
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsPage />} />
<Route path="versions">
<Route path=":version" element={<TemplateVersionPage />} />
</Route>
</Route>
</Route>
<Route path="users">
<Route element={<UsersLayout />}>
<Route index element={<UsersPage />} />
</Route>
<Route path="create" element={<CreateUserPage />} />
</Route>
<Route path="/groups">
<Route element={<UsersLayout />}>
<Route index element={<GroupsPage />} />
</Route>
<Route path="create" element={<CreateGroupPage />} />
<Route path=":groupId" element={<GroupPage />} />
<Route path=":groupId/settings" element={<SettingsGroupPage />} />
</Route>
<Route path="/audit">
<Route
index
element={
<RequirePermission
isFeatureVisible={
featureVisibility["audit_log"] &&
Boolean(permissions?.viewAuditLog)
}
>
<AuditPage />
</RequirePermission>
}
/>
</Route>
{/* Terminal and CLI auth pages don't have the dashboard layout */}
<Route
path="/settings/deployment"
element={<DeploySettingsLayout />}
>
<Route path="general" element={<GeneralSettingsPage />} />
<Route path="security" element={<SecuritySettingsPage />} />
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route path="gitauth" element={<GitAuthSettingsPage />} />
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tokens" element={<TokensPage />} />
</Route>
<Route path="/@:username">
<Route path=":workspace">
<Route index element={<WorkspacePage />} />
<Route path="schedule" element={<WorkspaceSchedulePage />} />
<Route
path="builds/:buildNumber"
element={<WorkspaceBuildPage />}
/>
<Route
path="change-version"
element={<WorkspaceChangeVersionPage />}
/>
</Route>
</Route>
path="/@:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="cli-auth" element={<CliAuthenticationPage />} />
</Route>
{/* Terminal and CLI auth pages don't have the dashboard layout */}
<Route
path="/@:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="cli-auth" element={<CliAuthenticationPage />} />
</Route>
{/* Using path="*"" means "match anything", so this route
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>
</Suspense>
)
}
+2 -21
View File
@@ -34,18 +34,6 @@ export const withDefaultFeatures = (
return fs as TypesGen.Entitlements["features"]
}
// defaultEntitlements has a default set of disabled functionality.
export const defaultEntitlements = (): TypesGen.Entitlements => {
return {
features: withDefaultFeatures({}),
has_license: false,
errors: [],
warnings: [],
experimental: false,
trial: false,
}
}
// Always attach CSRF token to all requests.
// In puppeteer the document is undefined. In those cases, just
// do nothing.
@@ -625,15 +613,8 @@ export const putWorkspaceExtension = async (
}
export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
try {
const response = await axios.get("/api/v2/entitlements")
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return defaultEntitlements()
}
throw error
}
const response = await axios.get("/api/v2/entitlements")
return response.data
}
export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
+12 -15
View File
@@ -1,29 +1,26 @@
import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { AuthProvider } from "components/AuthProvider/AuthProvider"
import { FC } from "react"
import { HelmetProvider } from "react-helmet-async"
import { BrowserRouter as Router } from "react-router-dom"
import { AppRouter } from "./AppRouter"
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary"
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"
import { dark } from "./theme"
import "./theme/globalFonts"
import { XServiceProvider } from "./xServices/StateContext"
export const App: FC = () => {
return (
<Router>
<HelmetProvider>
<ThemeProvider theme={dark}>
<CssBaseline />
<ErrorBoundary>
<XServiceProvider>
<AppRouter />
<GlobalSnackbar />
</XServiceProvider>
</ErrorBoundary>
</ThemeProvider>
</HelmetProvider>
</Router>
<HelmetProvider>
<ThemeProvider theme={dark}>
<CssBaseline />
<ErrorBoundary>
<AuthProvider>
<AppRouter />
<GlobalSnackbar />
</AuthProvider>
</ErrorBoundary>
</ThemeProvider>
</HelmetProvider>
)
}
@@ -0,0 +1,36 @@
import { useActor, useInterpret } from "@xstate/react"
import { createContext, FC, PropsWithChildren, useContext } from "react"
import { authMachine } from "xServices/auth/authXService"
import { ActorRefFrom } from "xstate"
interface AuthContextValue {
authService: ActorRefFrom<typeof authMachine>
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const authService = useInterpret(authMachine)
return (
<AuthContext.Provider value={{ authService }}>
{children}
</AuthContext.Provider>
)
}
type UseAuthReturnType = ReturnType<
typeof useActor<AuthContextValue["authService"]>
>
export const useAuth = (): UseAuthReturnType => {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth should be used inside of <AuthProvider />")
}
const auth = useActor(context.authService)
return auth
}
@@ -11,6 +11,7 @@ import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
import { usePermissions } from "hooks/usePermissions"
import { UpdateCheckResponse } from "api/typesGenerated"
import { DashboardProvider } from "./DashboardProvider"
export const DashboardLayout: FC = () => {
const styles = useStyles()
@@ -23,7 +24,7 @@ export const DashboardLayout: FC = () => {
const { error: updateCheckError, updateCheck } = updateCheckState.context
return (
<>
<DashboardProvider>
<ServiceBanner />
<LicenseBanner />
@@ -50,7 +51,7 @@ export const DashboardLayout: FC = () => {
</Suspense>
</div>
</div>
</>
</DashboardProvider>
)
}
@@ -0,0 +1,89 @@
import { useMachine } from "@xstate/react"
import {
AppearanceConfig,
BuildInfoResponse,
Entitlements,
Experiments,
} from "api/typesGenerated"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { createContext, FC, PropsWithChildren, useContext } from "react"
import { appearanceMachine } from "xServices/appearance/appearanceXService"
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService"
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"
import { experimentsMachine } from "xServices/experiments/experimentsMachine"
interface Appearance {
config: AppearanceConfig
preview: boolean
setPreview: (config: AppearanceConfig) => void
save: (config: AppearanceConfig) => void
}
interface DashboardProviderValue {
buildInfo: BuildInfoResponse
entitlements: Entitlements
appearance: Appearance
experiments: Experiments
}
export const DashboardProviderContext = createContext<
DashboardProviderValue | undefined
>(undefined)
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const [buildInfoState] = useMachine(buildInfoMachine)
const [entitlementsState] = useMachine(entitlementsMachine)
const [appearanceState, appearanceSend] = useMachine(appearanceMachine)
const [experimentsState] = useMachine(experimentsMachine)
const { buildInfo } = buildInfoState.context
const { entitlements } = entitlementsState.context
const { appearance, preview } = appearanceState.context
const { experiments } = experimentsState.context
const isLoading = !buildInfo || !entitlements || !appearance || !experiments
const setAppearancePreview = (config: AppearanceConfig) => {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: config,
})
}
const saveAppearance = (config: AppearanceConfig) => {
appearanceSend({
type: "SAVE_APPEARANCE",
appearance: config,
})
}
if (isLoading) {
return <FullScreenLoader />
}
return (
<DashboardProviderContext.Provider
value={{
buildInfo,
entitlements,
experiments,
appearance: {
preview,
config: appearance,
setPreview: setAppearancePreview,
save: saveAppearance,
},
}}
>
{children}
</DashboardProviderContext.Provider>
)
}
export const useDashboard = (): DashboardProviderValue => {
const context = useContext(DashboardProviderContext)
if (!context) {
throw new Error("useDashboard only can be used inside of DashboardProvider")
}
return context
}
@@ -1,27 +0,0 @@
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(<LicenseBanner />)
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(<LicenseBanner />)
const bannerPill = await screen.findByText(Language.licenseIssues(2))
expect(bannerPill).toBeDefined()
})
})
@@ -1,19 +1,9 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { XServiceContext } from "xServices/StateContext"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { LicenseBannerView } from "./LicenseBannerView"
export const LicenseBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [entitlementsState, entitlementsSend] = useActor(
xServices.entitlementsXService,
)
const { errors, warnings } = entitlementsState.context.entitlements
/** Gets license data on app mount because LicenseBanner is mounted in App */
useEffect(() => {
entitlementsSend("GET_ENTITLEMENTS")
}, [entitlementsSend])
const { entitlements } = useDashboard()
const { errors, warnings } = entitlements
if (errors.length > 0 || warnings.length > 0) {
return <LicenseBannerView errors={errors} warnings={warnings} />
+15 -18
View File
@@ -1,30 +1,27 @@
import { shallowEqual, useActor, useSelector } from "@xstate/react"
import { useContext, FC } from "react"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "../../xServices/StateContext"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
import { FC } from "react"
import { NavbarView } from "../NavbarView/NavbarView"
export const Navbar: FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState] = useActor(xServices.appearanceXService)
const [authState, authSend] = useActor(xServices.authXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const { me, permissions } = authState.context
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const { appearance, buildInfo } = useDashboard()
const [_, authSend] = useAuth()
const me = useMe()
const permissions = usePermissions()
const featureVisibility = useFeatureVisibility()
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions?.viewAuditLog)
const canViewDeployment = Boolean(permissions?.viewDeploymentConfig)
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
const canViewDeployment = Boolean(permissions.viewDeploymentConfig)
const onSignOut = () => authSend("SIGN_OUT")
return (
<NavbarView
user={me}
logo_url={appearanceState.context.appearance.logo_url}
buildInfo={buildInfoState.context.buildInfo}
logo_url={appearance.config.logo_url}
buildInfo={buildInfo}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
@@ -1,14 +1,12 @@
import { useActor } from "@xstate/react"
import { useContext, FC } from "react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { FC } from "react"
import { Navigate, useLocation } from "react-router"
import { Outlet } from "react-router-dom"
import { embedRedirect } from "../../util/redirect"
import { XServiceContext } from "../../xServices/StateContext"
import { FullScreenLoader } from "../Loader/FullScreenLoader"
export const RequireAuth: FC = () => {
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const [authState] = useAuth()
const location = useLocation()
const isHomePage = location.pathname === "/"
const navigateTo = isHomePage ? "/login" : embedRedirect(location.pathname)
@@ -1,22 +1,10 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { XServiceContext } from "xServices/StateContext"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { ServiceBannerView } from "./ServiceBannerView"
export const ServiceBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [authState] = useActor(xServices.authXService)
const { appearance } = useDashboard()
const { message, background_color, enabled } =
appearanceState.context.appearance.service_banner
useEffect(() => {
if (authState.matches("signedIn")) {
appearanceSend("GET_APPEARANCE")
}
}, [appearanceSend, authState])
appearance.config.service_banner
if (!enabled) {
return null
@@ -27,7 +15,7 @@ export const ServiceBanner: React.FC = () => {
<ServiceBannerView
message={message}
backgroundColor={background_color}
preview={appearanceState.context.preview}
preview={appearance.preview}
/>
)
} else {
@@ -5,7 +5,7 @@ import { AccountForm, AccountFormValues } from "./SettingsAccountForm"
// NOTE: it does not matter what the role props of MockUser are set to,
// only that editable is set to true or false. This is passed from
// the call to /authorization done by authXService
// the call to /authorization done by auth provider
describe("AccountForm", () => {
describe("when editable is set to true", () => {
it("allows updating username", async () => {
@@ -4,7 +4,7 @@ import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import { useMachine, useSelector } from "@xstate/react"
import { useMachine } from "@xstate/react"
import {
PageHeader,
PageHeaderSubtitle,
@@ -20,8 +20,6 @@ import {
} from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { firstLetter } from "util/firstLetter"
import { selectPermissions } from "xServices/auth/authSelectors"
import { XServiceContext } from "xServices/StateContext"
import {
TemplateContext,
templateMachine,
@@ -30,6 +28,7 @@ import { Margins } from "components/Margins/Margins"
import { Stack } from "components/Stack/Stack"
import { Permissions } from "xServices/auth/authXService"
import { Loader } from "components/Loader/Loader"
import { usePermissions } from "hooks/usePermissions"
const Language = {
settingsButton: "Settings",
@@ -108,8 +107,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
},
})
const { template, permissions: templatePermissions } = templateState.context
const xServices = useContext(XServiceContext)
const permissions = useSelector(xServices.authXService, selectPermissions)
const permissions = usePermissions()
const hasIcon = template && template.icon && template.icon !== ""
if (!template) {
@@ -1,5 +1,6 @@
import { Story } from "@storybook/react"
import * as Mocks from "../../testHelpers/renderHelpers"
import { MockWorkspace } from "testHelpers/renderHelpers"
import {
WorkspaceStats,
WorkspaceStatsProps,
@@ -18,3 +19,11 @@ export const Example = Template.bind({})
Example.args = {
workspace: Mocks.MockWorkspace,
}
export const Outdated = Template.bind({})
Outdated.args = {
workspace: {
...MockWorkspace,
outdated: true,
},
}
@@ -1,36 +0,0 @@
import { fireEvent, screen } from "@testing-library/react"
import { Language } from "components/Tooltips/OutdatedHelpTooltip"
import { WorkspaceStats } from "components/WorkspaceStats/WorkspaceStats"
import { MockOutdatedWorkspace } from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import * as CreateDayString from "util/createDayString"
describe("WorkspaceStats", () => {
it("shows an outdated tooltip", async () => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
const handleUpdateMock = jest.fn()
renderWithAuth(
<WorkspaceStats
handleUpdate={handleUpdateMock}
workspace={MockOutdatedWorkspace}
/>,
{
route: `/@${MockOutdatedWorkspace.owner_name}/${MockOutdatedWorkspace.name}`,
path: "/@:username/:workspace",
},
)
const tooltipButton = await screen.findByRole("button")
fireEvent.click(tooltipButton)
expect(
await screen.findByText(Language.versionTooltipText),
).toBeInTheDocument()
const updateButton = screen.getByRole("button", {
name: "update version",
})
fireEvent.click(updateButton)
expect(handleUpdateMock).toBeCalledTimes(1)
})
})
+3 -5
View File
@@ -1,10 +1,8 @@
import { useSelector } from "@xstate/react"
import { FeatureName } from "api/typesGenerated"
import { useContext } from "react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
export const useFeatureVisibility = (): Record<FeatureName, boolean> => {
const xServices = useContext(XServiceContext)
return useSelector(xServices.entitlementsXService, selectFeatureVisibility)
const { entitlements } = useDashboard()
return selectFeatureVisibility(entitlements)
}
+3 -5
View File
@@ -1,12 +1,10 @@
import { useSelector } from "@xstate/react"
import { User } from "api/typesGenerated"
import { useContext } from "react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { selectUser } from "xServices/auth/authSelectors"
import { XServiceContext } from "xServices/StateContext"
export const useMe = (): User => {
const xServices = useContext(XServiceContext)
const me = useSelector(xServices.authXService, selectUser)
const [authState] = useAuth()
const me = selectUser(authState)
if (!me) {
throw new Error("User not found.")
+3 -5
View File
@@ -1,11 +1,9 @@
import { useSelector } from "@xstate/react"
import { useContext } from "react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { selectOrgId } from "../xServices/auth/authSelectors"
import { XServiceContext } from "../xServices/StateContext"
export const useOrganizationId = (): string => {
const xServices = useContext(XServiceContext)
const organizationId = useSelector(xServices.authXService, selectOrgId)
const [authState] = useAuth()
const organizationId = selectOrgId(authState)
if (!organizationId) {
throw new Error("No organization ID found")
+4 -5
View File
@@ -1,14 +1,13 @@
import { useActor } from "@xstate/react"
import { useContext } from "react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { AuthContext } from "xServices/auth/authXService"
import { XServiceContext } from "xServices/StateContext"
export const usePermissions = (): NonNullable<AuthContext["permissions"]> => {
const xServices = useContext(XServiceContext)
const [authState, _] = useActor(xServices.authXService)
const [authState] = useAuth()
const { permissions } = authState.context
if (!permissions) {
throw new Error("Permissions are not loaded yet.")
}
return permissions
}
+8
View File
@@ -15,5 +15,13 @@
"notAvailable": "Not available",
"onBehalfOf": " on behalf of {{owner}}"
}
},
"paywall": {
"title": "Audit logs",
"description": "Audit Logs allows Auditors to monitor user operations in their deployment. To use this feature, you have to upgrade your account.",
"actions": {
"upgrade": "See how to upgrade",
"readDocs": "Read the docs"
}
}
}
+44 -12
View File
@@ -1,26 +1,64 @@
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { rest } from "msw"
import {
history,
renderWithAuth,
MockAuditLog,
MockAuditLog2,
render,
waitForLoaderToBeRemoved,
MockEntitlementsWithAuditLog,
} from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
import * as CreateDayString from "util/createDayString"
import AuditPage from "./AuditPage"
interface RenderPageOptions {
filter?: string
page?: number
}
const renderPage = async ({ filter, page }: RenderPageOptions = {}) => {
let route = "/audit"
const params = new URLSearchParams()
if (filter) {
params.set("filter", filter)
}
if (page) {
params.set("page", page.toString())
}
if (Array.from(params).length > 0) {
route += `?${params.toString()}`
}
renderWithAuth(<AuditPage />, {
route,
path: "/audit",
})
await waitForLoaderToBeRemoved()
}
describe("AuditPage", () => {
beforeEach(() => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
// Mock the entitlements
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithAuditLog))
}),
)
})
it("shows the audit logs", async () => {
// When
render(<AuditPage />)
await renderPage()
// Then
await screen.findByTestId(`audit-log-row-${MockAuditLog.id}`)
@@ -29,8 +67,7 @@ describe("AuditPage", () => {
describe("Filtering", () => {
it("filters by typing", async () => {
render(<AuditPage />)
await waitForLoaderToBeRemoved()
await renderPage()
await screen.findByText("updated", { exact: false })
const filterField = screen.getByLabelText("Filter")
@@ -47,19 +84,14 @@ describe("AuditPage", () => {
.mockResolvedValue({ audit_logs: [MockAuditLog], count: 1 })
const query = "resource_type:workspace action:create"
history.push(`/audit?filter=${encodeURIComponent(query)}`)
render(<AuditPage />)
await waitForLoaderToBeRemoved()
await renderPage({ filter: query })
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
})
it("resets page to 1 when filter is changed", async () => {
history.push(`/audit?page=2`)
render(<AuditPage />)
await renderPage({ page: 2 })
await waitForLoaderToBeRemoved()
const getAuditLogsSpy = jest.spyOn(API, "getAuditLogs")
const filterField = screen.getByLabelText("Filter")
+3
View File
@@ -3,6 +3,7 @@ import {
getPaginationContext,
nonInitialPage,
} from "components/PaginationWidget/utils"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useSearchParams } from "react-router-dom"
@@ -27,6 +28,7 @@ const AuditPage: FC = () => {
const { auditLogs, count } = auditState.context
const paginationRef = auditState.context.paginationRef as PaginationMachineRef
const { audit_log: isAuditLogVisible } = useFeatureVisibility()
return (
<>
@@ -42,6 +44,7 @@ const AuditPage: FC = () => {
}}
paginationRef={paginationRef}
isNonInitialPage={nonInitialPage(searchParams)}
isAuditLogVisible={isAuditLogVisible}
/>
</>
)
@@ -16,6 +16,9 @@ export default {
paginationRef: {
defaultValue: createPaginationRef({ page: 1, limit: 25 }),
},
isAuditLogVisible: {
defaultValue: true,
},
},
} as ComponentMeta<typeof AuditPageView>
@@ -45,6 +48,11 @@ NoLogs.args = {
isNonInitialPage: false,
}
export const NotVisible = Template.bind({})
NotVisible.args = {
isAuditLogVisible: false,
}
export const AuditPageSmallViewport = Template.bind({})
AuditPageSmallViewport.parameters = {
chromatic: { viewports: [600] },
+53 -40
View File
@@ -22,6 +22,7 @@ import { AuditHelpTooltip } from "components/Tooltips"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { AuditPaywall } from "./AuditPaywall"
export const Language = {
title: "Audit",
@@ -46,6 +47,7 @@ export interface AuditPageViewProps {
onFilter: (filter: string) => void
paginationRef: PaginationMachineRef
isNonInitialPage: boolean
isAuditLogVisible: boolean
}
export const AuditPageView: FC<AuditPageViewProps> = ({
@@ -55,6 +57,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
onFilter,
paginationRef,
isNonInitialPage,
isAuditLogVisible,
}) => {
const { t } = useTranslation("auditLog")
const isLoading = auditLogs === undefined || count === undefined
@@ -72,53 +75,63 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<PageHeaderSubtitle>{Language.subtitle}</PageHeaderSubtitle>
</PageHeader>
<SearchBarWithFilter
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
/>
<ChooseOne>
<Cond condition={isAuditLogVisible}>
<SearchBarWithFilter
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
/>
<TableContainer>
<Table>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableContainer>
<Table>
<TableBody>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={t("table.emptyPage")} />
</TableCell>
</TableRow>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={t("table.emptyPage")} />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={t("table.noLogs")} />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={t("table.noLogs")} />
</TableCell>
</TableRow>
{auditLogs && (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow key={log.id} auditLog={log} />
)}
/>
)}
</Cond>
</ChooseOne>
</Cond>
<Cond>
{auditLogs && (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => <AuditLogRow key={log.id} auditLog={log} />}
/>
)}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</TableBody>
</Table>
</TableContainer>
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
</Cond>
<Cond>
<AuditPaywall />
</Cond>
</ChooseOne>
</Margins>
)
}
+40
View File
@@ -0,0 +1,40 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined"
import { Paywall } from "components/Paywall/Paywall"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import { useTranslation } from "react-i18next"
export const AuditPaywall: FC = () => {
const { t } = useTranslation("auditLog")
return (
<Paywall
message={t("paywall.title")}
description={t("paywall.description")}
cta={
<Stack direction="row" alignItems="center">
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/upgrade"
target="_blank"
rel="noreferrer"
>
<Button size="small" startIcon={<ArrowRightAltOutlined />}>
{t("paywall.actions.upgrade")}
</Button>
</Link>
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/audit-logs"
target="_blank"
rel="noreferrer"
>
{t("paywall.actions.readDocs")}
</Link>
</Stack>
}
/>
)
}
@@ -1,9 +1,8 @@
import { useActor } from "@xstate/react"
import { AppearanceConfig } from "api/typesGenerated"
import { useContext, FC } from "react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"
// ServiceBanner is unlike the other Deployment Settings pages because it
@@ -11,36 +10,23 @@ import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const AppearanceSettingsPage: FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceXService, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const appearance = appearanceXService.context.appearance
const { appearance, entitlements } = useDashboard()
const isEntitled =
entitlementsState.context.entitlements.features["appearance"]
.entitlement !== "not_entitled"
entitlements.features["appearance"].entitlement !== "not_entitled"
const updateAppearance = (
newConfig: Partial<AppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
...appearance,
...appearance.config,
...newConfig,
}
if (preview) {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: newAppearance,
})
appearance.setPreview(newAppearance)
return
}
appearanceSend({
type: "SET_APPEARANCE",
appearance: newAppearance,
})
appearance.save(newAppearance)
}
return (
@@ -50,7 +36,7 @@ const AppearanceSettingsPage: FC = () => {
</Helmet>
<AppearanceSettingsPageView
appearance={appearance}
appearance={appearance.config}
isEntitled={isEntitled}
updateAppearance={updateAppearance}
/>
@@ -1,15 +1,13 @@
import { useActor } from "@xstate/react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { useContext, FC } from "react"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
import { SecuritySettingsPageView } from "./SecuritySettingsPageView"
const SecuritySettingsPage: FC = () => {
const { deploymentConfig: deploymentConfig } = useDeploySettings()
const xServices = useContext(XServiceContext)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const { entitlements } = useDashboard()
return (
<>
@@ -19,12 +17,9 @@ const SecuritySettingsPage: FC = () => {
<SecuritySettingsPageView
deploymentConfig={deploymentConfig}
featureAuditLogEnabled={
entitlementsState.context.entitlements.features["audit_log"].enabled
}
featureAuditLogEnabled={entitlements.features["audit_log"].enabled}
featureBrowserOnlyEnabled={
entitlementsState.context.entitlements.features["browser_only"]
.enabled
entitlements.features["browser_only"].enabled
}
/>
</>
+3 -5
View File
@@ -1,16 +1,14 @@
import { useActor } from "@xstate/react"
import { FC, useContext } from "react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { Navigate, useLocation } from "react-router-dom"
import { retrieveRedirect } from "../../util/redirect"
import { XServiceContext } from "../../xServices/StateContext"
import { LoginPageView } from "./LoginPageView"
export const LoginPage: FC = () => {
const location = useLocation()
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const [authState, authSend] = useAuth()
const redirectTo = retrieveRedirect(location.search)
const commonTranslation = useTranslation("common")
const loginPageTranslation = useTranslation("loginPage")
+4 -5
View File
@@ -1,14 +1,13 @@
import { useActor, useMachine } from "@xstate/react"
import { FC, useContext, useEffect } from "react"
import { useMachine } from "@xstate/react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { FC, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { setupMachine } from "xServices/setup/setupXService"
import { XServiceContext } from "xServices/StateContext"
import { SetupPageView } from "./SetupPageView"
export const SetupPage: FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const [authState, authSend] = useAuth()
const [setupState, setupSend] = useMachine(setupMachine, {
actions: {
onCreateFirstUser: ({ firstUser }) => {
@@ -1,16 +1,14 @@
import { useActor } from "@xstate/react"
import { FC, useContext } from "react"
import { FC } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { XServiceContext } from "../../../xServices/StateContext"
import { useAuth } from "components/AuthProvider/AuthProvider"
export const Language = {
title: "Account",
}
export const AccountPage: FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const [authState, authSend] = useAuth()
const { me, permissions, updateProfileError } = authState.context
const canEditUsers = permissions && permissions.updateUsers
@@ -113,7 +113,8 @@ describe("WorkspacePage", () => {
const confirmButton = await screen.findByRole("button", { name: "Delete" })
await user.click(confirmButton)
expect(deleteWorkspaceMock).toBeCalled()
})
// This test takes long to finish
}, 20_000)
it("requests a start job when the user presses Start", async () => {
server.use(
@@ -1,6 +1,8 @@
import { useActor, useSelector } from "@xstate/react"
import { useActor } from "@xstate/react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import dayjs from "dayjs"
import { useContext, useEffect } from "react"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
@@ -10,7 +12,6 @@ import {
getMaxDeadlineChange,
getMinDeadline,
} from "util/schedule"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { quotaMachine } from "xServices/quotas/quotasXService"
import { StateFrom } from "xstate"
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
@@ -20,7 +21,6 @@ import {
} from "../../components/Workspace/Workspace"
import { pageTitle } from "../../util/page"
import { getFaviconByStatus } from "../../util/workspace"
import { XServiceContext } from "../../xServices/StateContext"
import {
WorkspaceEvent,
workspaceMachine,
@@ -40,16 +40,8 @@ export const WorkspaceReadyPage = ({
const [_, bannerSend] = useActor(
workspaceState.children["scheduleBannerMachine"],
)
const xServices = useContext(XServiceContext)
const experiments = useSelector(
xServices.experimentsXService,
(state) => state.context.experiments || [],
)
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const { buildInfo, experiments } = useDashboard()
const featureVisibility = useFeatureVisibility()
const {
workspace,
template,
@@ -133,7 +125,7 @@ export const WorkspaceReadyPage = ({
[WorkspaceErrors.BUILD_ERROR]: buildError,
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
}}
buildInfo={buildInfoState.context.buildInfo}
buildInfo={buildInfo}
applicationsHost={applicationsHost}
template={template}
quota_budget={quotaState.context.quota?.budget}
+18 -15
View File
@@ -5,6 +5,8 @@ import {
screen,
waitForElementToBeRemoved,
} from "@testing-library/react"
import { AuthProvider } from "components/AuthProvider/AuthProvider"
import { DashboardLayout } from "components/Dashboard/DashboardLayout"
import { createMemoryHistory } from "history"
import { i18n } from "i18n"
import { FC, ReactElement } from "react"
@@ -18,7 +20,6 @@ import {
} from "react-router-dom"
import { RequireAuth } from "../components/RequireAuth/RequireAuth"
import { dark } from "../theme"
import { XServiceProvider } from "../xServices/StateContext"
import { MockUser } from "./entities"
export const history = createMemoryHistory()
@@ -28,11 +29,11 @@ export const WrapperComponent: FC<React.PropsWithChildren<unknown>> = ({
}) => {
return (
<HelmetProvider>
<HistoryRouter history={history}>
<XServiceProvider>
<ThemeProvider theme={dark}>{children}</ThemeProvider>
</XServiceProvider>
</HistoryRouter>
<ThemeProvider theme={dark}>
<HistoryRouter history={history}>
<AuthProvider>{children}</AuthProvider>
</HistoryRouter>
</ThemeProvider>
</HelmetProvider>
)
}
@@ -59,20 +60,22 @@ export function renderWithAuth(
): RenderWithAuthResult {
const renderResult = wrappedRender(
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<XServiceProvider>
<I18nextProvider i18n={i18n}>
<ThemeProvider theme={dark}>
<I18nextProvider i18n={i18n}>
<ThemeProvider theme={dark}>
<AuthProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route element={<RequireAuth />}>
<Route path={path ?? route} element={ui} />
<Route element={<DashboardLayout />}>
<Route path={path ?? route} element={ui} />
</Route>
</Route>
{routes}
</Routes>
</ThemeProvider>
</I18nextProvider>
</XServiceProvider>
</MemoryRouter>
</MemoryRouter>
</AuthProvider>
</ThemeProvider>
</I18nextProvider>
</HelmetProvider>,
)
-42
View File
@@ -1,42 +0,0 @@
import { useInterpret } from "@xstate/react"
import { createContext, FC, ReactNode } from "react"
import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { experimentsMachine } from "./experiments/experimentsMachine"
import { appearanceMachine } from "./appearance/appearanceXService"
interface XServiceContextType {
authXService: ActorRefFrom<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
experimentsXService: ActorRefFrom<typeof experimentsMachine>
appearanceXService: ActorRefFrom<typeof appearanceMachine>
}
/**
* Consuming this Context will not automatically cause rerenders because
* the xServices in it are static references.
*
* To use one of the xServices, `useActor` will access all its state
* (causing re-renders for any changes to that one xService) and
* `useSelector` will access just one piece of state.
*/
export const XServiceContext = createContext({} as XServiceContextType)
export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
return (
<XServiceContext.Provider
value={{
authXService: useInterpret(authMachine),
buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine),
experimentsXService: useInterpret(experimentsMachine),
appearanceXService: useInterpret(appearanceMachine),
}}
>
{children}
</XServiceContext.Provider>
)
}
@@ -4,25 +4,15 @@ import * as API from "../../api/api"
import { AppearanceConfig } from "../../api/typesGenerated"
export type AppearanceContext = {
appearance: AppearanceConfig
appearance?: AppearanceConfig
getAppearanceError?: Error | unknown
setAppearanceError?: Error | unknown
preview: boolean
}
export type AppearanceEvent =
| {
type: "GET_APPEARANCE"
}
| { type: "SET_PREVIEW_APPEARANCE"; appearance: AppearanceConfig }
| { type: "SET_APPEARANCE"; appearance: AppearanceConfig }
const emptyAppearance: AppearanceConfig = {
logo_url: "",
service_banner: {
enabled: false,
},
}
| { type: "SAVE_APPEARANCE"; appearance: AppearanceConfig }
export const appearanceMachine = createMachine(
{
@@ -42,16 +32,20 @@ export const appearanceMachine = createMachine(
},
},
context: {
appearance: emptyAppearance,
preview: false,
},
initial: "idle",
initial: "gettingAppearance",
states: {
idle: {
on: {
GET_APPEARANCE: "gettingAppearance",
SET_PREVIEW_APPEARANCE: "settingPreviewAppearance",
SET_APPEARANCE: "settingAppearance",
SET_PREVIEW_APPEARANCE: {
actions: [
"clearGetAppearanceError",
"clearSetAppearanceError",
"assignPreviewAppearance",
],
},
SAVE_APPEARANCE: "savingAppearance",
},
},
gettingAppearance: {
@@ -69,17 +63,7 @@ export const appearanceMachine = createMachine(
},
},
},
settingPreviewAppearance: {
entry: [
"clearGetAppearanceError",
"clearSetAppearanceError",
"assignPreviewAppearance",
],
always: {
target: "idle",
},
},
settingAppearance: {
savingAppearance: {
entry: "clearSetAppearanceError",
invoke: {
id: "setAppearance",
@@ -100,16 +84,14 @@ export const appearanceMachine = createMachine(
actions: {
assignPreviewAppearance: assign({
appearance: (_, event) => event.appearance,
// The xState docs suggest that we can use a static value, but I failed
// to find a way to do that that doesn't generate type errors.
preview: (_, __) => true,
preview: (_) => true,
}),
notifyUpdateAppearanceSuccess: () => {
displaySuccess("Successfully updated appearance settings!")
},
assignAppearance: assign({
appearance: (_, event) => event.data as AppearanceConfig,
preview: (_, __) => false,
preview: (_) => false,
}),
assignGetAppearanceError: assign({
getAppearanceError: (_, event) => event.data,
+3 -3
View File
@@ -1,7 +1,7 @@
import { State } from "xstate"
import { AuthContext, AuthEvent } from "./authXService"
import { StateFrom } from "xstate"
import { AuthContext, authMachine } from "./authXService"
type AuthState = State<AuthContext, AuthEvent>
type AuthState = StateFrom<typeof authMachine>
export const selectOrgId = (state: AuthState): string | undefined => {
return state.context.me?.organization_ids[0]
@@ -1,8 +1,4 @@
import { Feature, FeatureName } from "api/typesGenerated"
import { State } from "xstate"
import { EntitlementsContext, EntitlementsEvent } from "./entitlementsXService"
type EntitlementState = State<EntitlementsContext, EntitlementsEvent>
import { Entitlements, Feature, FeatureName } from "api/typesGenerated"
/**
* @param hasLicense true if Enterprise edition
@@ -27,10 +23,7 @@ export const getFeatureVisibility = (
}
export const selectFeatureVisibility = (
state: EntitlementState,
entitlements: Entitlements,
): Record<FeatureName, boolean> => {
return getFeatureVisibility(
state.context.entitlements.has_license,
state.context.entitlements.features,
)
return getFeatureVisibility(entitlements.has_license, entitlements.features)
}
@@ -1,31 +1,12 @@
import { withDefaultFeatures } from "./../../api/api"
import { MockEntitlementsWithWarnings } from "testHelpers/entities"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { Entitlements } from "../../api/typesGenerated"
export type EntitlementsContext = {
entitlements: Entitlements
entitlements?: Entitlements
getEntitlementsError?: Error | unknown
}
export type EntitlementsEvent =
| {
type: "GET_ENTITLEMENTS"
}
| { type: "SHOW_MOCK_BANNER" }
| { type: "HIDE_MOCK_BANNER" }
const emptyEntitlements = {
errors: [],
warnings: [],
features: withDefaultFeatures({}),
has_license: false,
experimental: false,
experimental_features: [],
trial: false,
}
export const entitlementsMachine = createMachine(
{
id: "entitlementsMachine",
@@ -33,40 +14,35 @@ export const entitlementsMachine = createMachine(
tsTypes: {} as import("./entitlementsXService.typegen").Typegen0,
schema: {
context: {} as EntitlementsContext,
events: {} as EntitlementsEvent,
services: {
getEntitlements: {
data: {} as Entitlements,
},
},
},
context: {
entitlements: emptyEntitlements,
},
initial: "idle",
initial: "gettingEntitlements",
states: {
idle: {
on: {
GET_ENTITLEMENTS: "gettingEntitlements",
SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" },
HIDE_MOCK_BANNER: "gettingEntitlements",
},
},
gettingEntitlements: {
entry: "clearGetEntitlementsError",
invoke: {
id: "getEntitlements",
src: "getEntitlements",
onDone: {
target: "idle",
target: "success",
actions: ["assignEntitlements"],
},
onError: {
target: "idle",
target: "error",
actions: ["assignGetEntitlementsError"],
},
},
},
success: {
type: "final",
},
error: {
type: "final",
},
},
},
{
@@ -80,9 +56,6 @@ export const entitlementsMachine = createMachine(
clearGetEntitlementsError: assign({
getEntitlementsError: (_) => undefined,
}),
assignMockEntitlements: assign({
entitlements: (_) => MockEntitlementsWithWarnings,
}),
},
services: {
getEntitlements: API.getEntitlements,
@@ -20,9 +20,6 @@ export const experimentsMachine = createMachine(
}
},
},
context: {
experiments: undefined,
},
initial: "gettingExperiments",
states: {
gettingExperiments: {