mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor: Refactor auth provider (#5782)
This commit is contained in:
+98
-119
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+3
-2
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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] },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+8
-22
@@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user