feat: Add deployment settings page (#4590)

* Add base components for the Settings Page

* WIP OIDC page

* Imrove layout

* Add table

* Abstract option

* Refactor badges

* Load settings from the API

* Update deployment page

* feat: Add deployment settings page

This allows deployment admins to view options
set on their deployments.

* Format

* Remove replicas table since it's not used

* Remove references to HA table

* Fix tests

* Improve language

Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
Kyle Carberry
2022-10-17 12:22:59 -05:00
committed by GitHub
parent 9b5d627a55
commit 6b1b3a2037
19 changed files with 1420 additions and 15 deletions

View File

@ -38,7 +38,7 @@ type DeploymentFlags struct {
OAuth2GithubEnterpriseBaseURL *StringFlag `json:"oauth2_github_enterprise_base_url" typescript:",notnull"`
OIDCAllowSignups *BoolFlag `json:"oidc_allow_signups" typescript:",notnull"`
OIDCClientID *StringFlag `json:"oidc_client_id" typescript:",notnull"`
OIDCClientSecret *StringFlag `json:"oidc_cliet_secret" typescript:",notnull"`
OIDCClientSecret *StringFlag `json:"oidc_client_secret" typescript:",notnull"`
OIDCEmailDomain *StringFlag `json:"oidc_email_domain" typescript:",notnull"`
OIDCIssuerURL *StringFlag `json:"oidc_issuer_url" typescript:",notnull"`
OIDCScopes *StringArrayFlag `json:"oidc_scopes" typescript:",notnull"`
@ -49,7 +49,7 @@ type DeploymentFlags struct {
TLSCertFiles *StringArrayFlag `json:"tls_cert_files" typescript:",notnull"`
TLSClientCAFile *StringFlag `json:"tls_client_ca_file" typescript:",notnull"`
TLSClientAuth *StringFlag `json:"tls_client_auth" typescript:",notnull"`
TLSKeyFiles *StringArrayFlag `json:"tls_key_tiles" typescript:",notnull"`
TLSKeyFiles *StringArrayFlag `json:"tls_key_files" typescript:",notnull"`
TLSMinVersion *StringFlag `json:"tls_min_version" typescript:",notnull"`
TraceEnable *BoolFlag `json:"trace_enable" typescript:",notnull"`
SecureAuthCookie *BoolFlag `json:"secure_auth_cookie" typescript:",notnull"`

View File

@ -21,6 +21,7 @@ import { XServiceContext } from "xServices/StateContext"
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
@ -67,6 +68,18 @@ const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"))
const SettingsGroupPage = lazy(
() => import("./pages/GroupsPage/SettingsGroupPage"),
)
const GeneralSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/GeneralSettingsPage"),
)
const SecuritySettingsPage = lazy(
() => import("./pages/DeploySettingsPage/SecuritySettingsPage"),
)
const AuthSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/AuthSettingsPage"),
)
const NetworkSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/NetworkSettingsPage"),
)
export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
@ -237,6 +250,65 @@ export const AppRouter: FC = () => {
/>
</Route>
<Route path="/settings/deployment">
<Route
path="general"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentFlags)}
>
<DeploySettingsLayout>
<GeneralSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
}
/>
<Route
path="security"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentFlags)}
>
<DeploySettingsLayout>
<SecuritySettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
}
/>
<Route
path="network"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentFlags)}
>
<DeploySettingsLayout>
<NetworkSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
}
/>
<Route
path="auth"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentFlags)}
>
<DeploySettingsLayout>
<AuthSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
}
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />

View File

@ -641,3 +641,14 @@ export const getAgentListeningPorts = async (
)
return response.data
}
export const getDeploymentFlags =
async (): Promise<TypesGen.DeploymentFlags> => {
const response = await axios.get(`/api/v2/flags/deployment`)
return response.data
}
export const getReplicas = async (): Promise<TypesGen.Replica[]> => {
const response = await axios.get(`/api/v2/replicas`)
return response.data
}

View File

@ -293,7 +293,7 @@ export interface DeploymentFlags {
readonly oauth2_github_enterprise_base_url: StringFlag
readonly oidc_allow_signups: BoolFlag
readonly oidc_client_id: StringFlag
readonly oidc_cliet_secret: StringFlag
readonly oidc_client_secret: StringFlag
readonly oidc_email_domain: StringFlag
readonly oidc_issuer_url: StringFlag
readonly oidc_scopes: StringArrayFlag
@ -304,7 +304,7 @@ export interface DeploymentFlags {
readonly tls_cert_files: StringArrayFlag
readonly tls_client_ca_file: StringFlag
readonly tls_client_auth: StringFlag
readonly tls_key_tiles: StringArrayFlag
readonly tls_key_files: StringArrayFlag
readonly tls_min_version: StringFlag
readonly trace_enable: BoolFlag
readonly secure_auth_cookie: BoolFlag

View File

@ -0,0 +1,79 @@
import { makeStyles } from "@material-ui/core/styles"
import { Stack } from "components/Stack/Stack"
import React, { PropsWithChildren } from "react"
import { combineClasses } from "util/combineClasses"
export const EnabledBadge: React.FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
Enabled
</span>
)
}
export const DisabledBadge: React.FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.disabledBadge])}>
Disabled
</span>
)
}
export const EnterpriseBadge: React.FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.enterpriseBadge])}>
Enterprise
</span>
)
}
export const Badges: React.FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
return (
<Stack
className={styles.badges}
direction="row"
alignItems="center"
spacing={1}
>
{children}
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
badges: {
margin: theme.spacing(0, 0, 2),
},
badge: {
fontSize: 10,
height: 24,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.085em",
padding: theme.spacing(0, 1.5),
borderRadius: 9999,
display: "flex",
alignItems: "center",
width: "fit-content",
},
enterpriseBadge: {
backgroundColor: theme.palette.info.dark,
border: `1px solid ${theme.palette.info.light}`,
},
enabledBadge: {
border: `1px solid ${theme.palette.success.light}`,
backgroundColor: theme.palette.success.dark,
},
disabledBadge: {
border: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
},
}))

View File

@ -0,0 +1,73 @@
import { makeStyles } from "@material-ui/core/styles"
import { Margins } from "components/Margins/Margins"
import { Stack } from "components/Stack/Stack"
import { Sidebar } from "./Sidebar"
import React, {
createContext,
PropsWithChildren,
useContext,
useEffect,
} from "react"
import { useActor } from "@xstate/react"
import { XServiceContext } from "xServices/StateContext"
import { Loader } from "components/Loader/Loader"
import { DeploymentFlags } from "api/typesGenerated"
type DeploySettingsContextValue = { deploymentFlags: DeploymentFlags }
const DeploySettingsContext = createContext<
DeploySettingsContextValue | undefined
>(undefined)
export const useDeploySettings = (): DeploySettingsContextValue => {
const context = useContext(DeploySettingsContext)
if (!context) {
throw new Error(
"useDeploySettings should be used inside of DeploySettingsLayout",
)
}
return context
}
export const DeploySettingsLayout: React.FC<PropsWithChildren> = ({
children,
}) => {
const xServices = useContext(XServiceContext)
const [state, send] = useActor(xServices.deploymentFlagsXService)
const styles = useStyles()
const { deploymentFlags } = state.context
useEffect(() => {
if (state.matches("idle")) {
send("LOAD")
}
}, [send, state])
return (
<Margins>
<Stack className={styles.wrapper} direction="row" spacing={5}>
<Sidebar />
<main className={styles.content}>
{deploymentFlags ? (
<DeploySettingsContext.Provider value={{ deploymentFlags }}>
{children}
</DeploySettingsContext.Provider>
) : (
<Loader />
)}
</main>
</Stack>
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
padding: theme.spacing(6, 0),
},
content: {
maxWidth: 800,
width: "100%",
},
}))

View File

@ -0,0 +1,67 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
import { Stack } from "components/Stack/Stack"
import React from "react"
export const Header: React.FC<{
title: string | JSX.Element
description: string | JSX.Element
secondary?: boolean
docsHref?: string
}> = ({ title, description, docsHref, secondary }) => {
const styles = useStyles()
return (
<Stack alignItems="baseline" direction="row" justifyContent="space-between">
<div className={styles.headingGroup}>
<h1 className={`${styles.title} ${secondary ? "secondary" : ""}`}>
{title}
</h1>
<span className={styles.description}>{description}</span>
</div>
{docsHref && (
<Button
size="small"
startIcon={<LaunchOutlined />}
component="a"
href={docsHref}
target="_blank"
variant="outlined"
>
Read the docs
</Button>
)}
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
headingGroup: {
maxWidth: 420,
marginBottom: theme.spacing(3),
},
title: {
fontSize: 32,
fontWeight: 700,
display: "flex",
alignItems: "center",
lineHeight: "initial",
margin: 0,
marginBottom: theme.spacing(0.5),
gap: theme.spacing(1),
"&.secondary": {
fontSize: 24,
fontWeight: 500,
},
},
description: {
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: "160%",
},
}))

View File

@ -0,0 +1,40 @@
import { makeStyles } from "@material-ui/core/styles"
import React, { PropsWithChildren } from "react"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
export const OptionName: React.FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
return <span className={styles.optionName}>{children}</span>
}
export const OptionDescription: React.FC<PropsWithChildren> = ({
children,
}) => {
const styles = useStyles()
return <span className={styles.optionDescription}>{children}</span>
}
export const OptionValue: React.FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
return <span className={styles.optionValue}>{children}</span>
}
const useStyles = makeStyles((theme) => ({
optionName: {
display: "block",
},
optionDescription: {
display: "block",
color: theme.palette.text.secondary,
fontSize: 14,
marginTop: theme.spacing(0.5),
},
optionValue: {
fontSize: 14,
fontFamily: MONOSPACE_FONT_FAMILY,
"& ul": {
padding: theme.spacing(2),
},
},
}))

View File

@ -0,0 +1,114 @@
import { makeStyles } from "@material-ui/core/styles"
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
import LockRounded from "@material-ui/icons/LockRounded"
import Globe from "@material-ui/icons/Public"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import { Stack } from "components/Stack/Stack"
import React, { ElementType, PropsWithChildren, ReactNode } from "react"
import { NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
const SidebarNavItem: React.FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
> = ({ children, href, icon }) => {
const styles = useStyles()
return (
<NavLink
to={href}
className={({ isActive }) =>
combineClasses([
styles.sidebarNavItem,
isActive ? styles.sidebarNavItemActive : undefined,
])
}
>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
</NavLink>
)
}
const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
icon: Icon,
}) => {
const styles = useStyles()
return <Icon className={styles.sidebarNavItemIcon} />
}
export const Sidebar: React.FC = () => {
const styles = useStyles()
return (
<nav className={styles.sidebar}>
<SidebarNavItem
href="../general"
icon={<SidebarNavItemIcon icon={LaunchOutlined} />}
>
General
</SidebarNavItem>
<SidebarNavItem
href="../auth"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
Authentication
</SidebarNavItem>
<SidebarNavItem
href="../network"
icon={<SidebarNavItemIcon icon={Globe} />}
>
Network
</SidebarNavItem>
<SidebarNavItem
href="../security"
icon={<SidebarNavItemIcon icon={LockRounded} />}
>
Security
</SidebarNavItem>
</nav>
)
}
const useStyles = makeStyles((theme) => ({
sidebar: {
width: 245,
},
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 16,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 3),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
position: "relative",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
sidebarNavItemActive: {
backgroundColor: theme.palette.action.hover,
"&:before": {
content: '""',
display: "block",
width: 3,
height: "100%",
position: "absolute",
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderRadius: theme.shape.borderRadius,
},
},
sidebarNavItemIcon: {
width: theme.spacing(2),
height: theme.spacing(2),
},
}))

View File

@ -17,6 +17,7 @@ export const Navbar: React.FC = () => {
const canViewAuditLog =
featureVisibility[FeatureNames.AuditLog] &&
Boolean(permissions?.viewAuditLog)
const canViewDeployment = Boolean(permissions?.viewDeploymentFlags)
const onSignOut = () => authSend("SIGN_OUT")
return (
@ -24,6 +25,7 @@ export const Navbar: React.FC = () => {
user={me}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
/>
)
}

View File

@ -22,26 +22,54 @@ describe("NavbarView", () => {
it("renders content", async () => {
// When
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
// Then
await screen.findAllByText("Coder", { exact: false })
})
it("workspaces nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
const workspacesLink = await screen.findByText(navLanguage.workspaces)
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
})
it("templates nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
const templatesLink = await screen.findByText(navLanguage.templates)
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
})
it("users nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
const userLink = await screen.findByText(navLanguage.users)
expect((userLink as HTMLAnchorElement).href).toContain("/users")
})
@ -55,7 +83,14 @@ describe("NavbarView", () => {
}
// When
render(<NavbarView user={mockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={mockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
// Then
// There should be a 'B' avatar!
@ -64,16 +99,56 @@ describe("NavbarView", () => {
})
it("audit nav link has the correct href", async () => {
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
const auditLink = await screen.findByText(navLanguage.audit)
expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
})
it("audit nav link is hidden for members", async () => {
render(
<NavbarView user={MockUser2} onSignOut={noop} canViewAuditLog={false} />,
<NavbarView
user={MockUser2}
onSignOut={noop}
canViewAuditLog={false}
canViewDeployment
/>,
)
const auditLink = screen.queryByText(navLanguage.audit)
expect(auditLink).not.toBeInTheDocument()
})
it("deployment nav link has the correct href", async () => {
render(
<NavbarView
user={MockUser}
onSignOut={noop}
canViewAuditLog
canViewDeployment
/>,
)
const auditLink = await screen.findByText(navLanguage.deployment)
expect((auditLink as HTMLAnchorElement).href).toContain(
"/settings/deployment/general",
)
})
it("deployment nav link is hidden for members", async () => {
render(
<NavbarView
user={MockUser2}
onSignOut={noop}
canViewAuditLog={false}
canViewDeployment={false}
/>,
)
const auditLink = screen.queryByText(navLanguage.deployment)
expect(auditLink).not.toBeInTheDocument()
})
})

View File

@ -17,6 +17,7 @@ export interface NavbarViewProps {
user?: TypesGen.User
onSignOut: () => void
canViewAuditLog: boolean
canViewDeployment: boolean
}
export const Language = {
@ -24,11 +25,16 @@ export const Language = {
templates: "Templates",
users: "Users",
audit: "Audit",
deployment: "Deployment",
}
const NavItems: React.FC<
React.PropsWithChildren<{ className?: string; canViewAuditLog: boolean }>
> = ({ className, canViewAuditLog }) => {
React.PropsWithChildren<{
className?: string
canViewAuditLog: boolean
canViewDeployment: boolean
}>
> = ({ className, canViewAuditLog, canViewDeployment }) => {
const styles = useStyles()
const location = useLocation()
@ -65,6 +71,13 @@ const NavItems: React.FC<
</NavLink>
</ListItem>
)}
{canViewDeployment && (
<ListItem button className={styles.item}>
<NavLink className={styles.link} to="/settings/deployment/general">
{Language.deployment}
</NavLink>
</ListItem>
)}
</List>
)
}
@ -72,6 +85,7 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
user,
onSignOut,
canViewAuditLog,
canViewDeployment,
}) => {
const styles = useStyles()
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@ -98,7 +112,10 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<div className={styles.drawerHeader}>
<Logo fill="white" opacity={1} width={125} />
</div>
<NavItems canViewAuditLog={canViewAuditLog} />
<NavItems
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
/>
</div>
</Drawer>
@ -109,6 +126,7 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<NavItems
className={styles.desktopNavItems}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
/>
<div className={styles.profileButton}>
@ -192,7 +210,7 @@ const useStyles = makeStyles((theme) => ({
fontSize: 16,
padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`,
textDecoration: "none",
transition: "background-color 0.3s ease",
transition: "background-color 0.15s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.action.hover,

View File

@ -0,0 +1,314 @@
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import {
Badges,
DisabledBadge,
EnabledBadge,
} from "components/DeploySettingsLayout/Badges"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { Header } from "components/DeploySettingsLayout/Header"
import {
OptionDescription,
OptionName,
OptionValue,
} from "components/DeploySettingsLayout/Option"
import { Stack } from "components/Stack/Stack"
import React from "react"
const AuthSettingsPage: React.FC = () => {
const { deploymentFlags } = useDeploySettings()
return (
<>
<Stack direction="column" spacing={6}>
<div>
<Header
title="Login with OpenID Connect"
secondary
description="Set up authentication to login with OpenID Connect."
docsHref="https://coder.com/docs/coder-oss/latest/admin/auth#openid-connect-with-google"
/>
<Badges>
{deploymentFlags.oidc_client_id.value ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
</Badges>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oidc_client_id.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oidc_client_id.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oidc_client_id.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oidc_client_secret.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oidc_client_secret.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oidc_client_secret.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oidc_allow_signups.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oidc_allow_signups.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oidc_allow_signups.value.toString()}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oidc_email_domain.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oidc_email_domain.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oidc_email_domain.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oidc_issuer_url.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oidc_issuer_url.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oidc_issuer_url.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.oidc_scopes.name}</OptionName>
<OptionDescription>
{deploymentFlags.oidc_scopes.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
<ul>
{deploymentFlags.oidc_scopes.value.map((scope) => (
<li key={scope}>{scope}</li>
))}
</ul>
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
<div>
<Header
title="Login with GitHub"
secondary
description="Set up authentication to login with GitHub."
docsHref="https://coder.com/docs/coder-oss/latest/admin/auth#github"
/>
<Badges>
{deploymentFlags.oauth2_github_client_id.value ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
</Badges>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_client_id.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oauth2_github_client_id.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oauth2_github_client_id.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_client_secret.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oauth2_github_client_secret.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oauth2_github_client_secret.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_allow_signups.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oauth2_github_allow_signups.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oauth2_github_allow_signups.value.toString()}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_allowed_organizations.name}
</OptionName>
<OptionDescription>
{
deploymentFlags.oauth2_github_allowed_organizations
.description
}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
<ul>
{deploymentFlags.oauth2_github_allowed_organizations.value.map(
(org) => (
<li key={org}>{org}</li>
),
)}
</ul>
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_allowed_teams.name}
</OptionName>
<OptionDescription>
{deploymentFlags.oauth2_github_allowed_teams.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
<ul>
{deploymentFlags.oauth2_github_allowed_teams.value.map(
(team) => (
<li key={team}>{team}</li>
),
)}
</ul>
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.oauth2_github_enterprise_base_url.name}
</OptionName>
<OptionDescription>
{
deploymentFlags.oauth2_github_enterprise_base_url
.description
}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.oauth2_github_enterprise_base_url.value}
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
</Stack>
</>
)
}
export default AuthSettingsPage

View File

@ -0,0 +1,85 @@
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { Header } from "components/DeploySettingsLayout/Header"
import {
OptionDescription,
OptionName,
OptionValue,
} from "components/DeploySettingsLayout/Option"
import React from "react"
const GeneralSettingsPage: React.FC = () => {
const { deploymentFlags } = useDeploySettings()
return (
<>
<Header
title="General"
description="Settings for accessing your Coder deployment."
docsHref="https://coder.com/docs/coder-oss/latest/admin/auth#openid-connect-with-google"
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.access_url.name}</OptionName>
<OptionDescription>
{deploymentFlags.access_url.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>{deploymentFlags.access_url.value}</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.address.name}</OptionName>
<OptionDescription>
{deploymentFlags.address.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>{deploymentFlags.address.value}</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.wildcard_access_url.name}
</OptionName>
<OptionDescription>
{deploymentFlags.wildcard_access_url.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.wildcard_access_url.value}
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</>
)
}
export default GeneralSettingsPage

View File

@ -0,0 +1,121 @@
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import {
DisabledBadge,
EnabledBadge,
} from "components/DeploySettingsLayout/Badges"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { Header } from "components/DeploySettingsLayout/Header"
import {
OptionDescription,
OptionName,
OptionValue,
} from "components/DeploySettingsLayout/Option"
import { Stack } from "components/Stack/Stack"
import React from "react"
const NetworkSettingsPage: React.FC = () => {
const { deploymentFlags } = useDeploySettings()
return (
<Stack direction="column" spacing={6}>
<div>
<Header
title="Network"
description="Configure your deployment connectivity."
docsHref="https://coder.com/docs/coder-oss/latest/admin/auth#openid-connect-with-google"
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.derp_server_enabled.name}
</OptionName>
<OptionDescription>
{deploymentFlags.derp_server_enabled.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.derp_server_enabled.value ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.derp_server_region_name.name}
</OptionName>
<OptionDescription>
{deploymentFlags.derp_server_region_name.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.derp_server_region_name.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.derp_server_stun_address.name}
</OptionName>
<OptionDescription>
{deploymentFlags.derp_server_stun_address.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.derp_server_stun_address.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.derp_config_url.name}
</OptionName>
<OptionDescription>
{deploymentFlags.derp_config_url.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.derp_config_url.value}
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
</Stack>
)
}
export default NetworkSettingsPage

View File

@ -0,0 +1,231 @@
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { useActor } from "@xstate/react"
import { FeatureNames } from "api/types"
import {
Badges,
DisabledBadge,
EnabledBadge,
EnterpriseBadge,
} from "components/DeploySettingsLayout/Badges"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { Header } from "components/DeploySettingsLayout/Header"
import {
OptionDescription,
OptionName,
OptionValue,
} from "components/DeploySettingsLayout/Option"
import { Stack } from "components/Stack/Stack"
import React, { useContext } from "react"
import { XServiceContext } from "xServices/StateContext"
const SecuritySettingsPage: React.FC = () => {
const { deploymentFlags } = useDeploySettings()
const xServices = useContext(XServiceContext)
const [entitlementsState] = useActor(xServices.entitlementsXService)
return (
<Stack direction="column" spacing={6}>
<div>
<Header
title="Security"
description="Ensure your Coder deployment is secure."
docsHref="https://coder.com/docs/coder-oss/latest/admin/auth#openid-connect-with-google"
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.ssh_keygen_algorithm.name}
</OptionName>
<OptionDescription>
{deploymentFlags.ssh_keygen_algorithm.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.ssh_keygen_algorithm.value}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.secure_auth_cookie.name}
</OptionName>
<OptionDescription>
{deploymentFlags.secure_auth_cookie.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.secure_auth_cookie.value ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
<div>
<Header
title="Audit Logging"
secondary
description="Allow auditors to monitor user operations in your deployment."
docsHref="https://coder.com/docs/coder-oss/latest/admin/audit-logs"
/>
<Badges>
{entitlementsState.context.entitlements.features[
FeatureNames.AuditLog
].enabled ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
<EnterpriseBadge />
</Badges>
</div>
<div>
<Header
title="Browser Only Connections"
secondary
description="Block all workspace access via SSH, port forward, and other non-browser connections."
docsHref="https://coder.com/docs/coder-oss/latest/networking#browser-only-connections-enterprise"
/>
<Badges>
{entitlementsState.context.entitlements.features[
FeatureNames.BrowserOnly
].enabled ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
<EnterpriseBadge />
</Badges>
</div>
<div>
<Header
title="TLS"
secondary
description="Ensure TLS is properly configured for your Coder deployment."
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Option</TableCell>
<TableCell width="50%">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.tls_enable.name}</OptionName>
<OptionDescription>
{deploymentFlags.tls_enable.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.tls_enable.value ? (
<EnabledBadge />
) : (
<DisabledBadge />
)}
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.tls_cert_files.name}</OptionName>
<OptionDescription>
{deploymentFlags.tls_cert_files.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
<ul>
{deploymentFlags.tls_cert_files.value.map(
(file, index) => (
<li key={index}>{file}</li>
),
)}
</ul>
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>{deploymentFlags.tls_key_files.name}</OptionName>
<OptionDescription>
{deploymentFlags.tls_key_files.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
<ul>
{deploymentFlags.tls_key_files.value.map(
(file, index) => (
<li key={index}>{file}</li>
),
)}
</ul>
</OptionValue>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<OptionName>
{deploymentFlags.tls_min_version.name}
</OptionName>
<OptionDescription>
{deploymentFlags.tls_min_version.description}
</OptionDescription>
</TableCell>
<TableCell>
<OptionValue>
{deploymentFlags.tls_min_version.value}
</OptionValue>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
</Stack>
)
}
export default SecuritySettingsPage

View File

@ -3,6 +3,7 @@ import { createContext, FC, ReactNode } from "react"
import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { deploymentFlagsMachine } from "./deploymentFlags/deploymentFlagsMachine"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { siteRolesMachine } from "./roles/siteRolesXService"
@ -11,6 +12,8 @@ interface XServiceContextType {
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
deploymentFlagsXService: ActorRefFrom<typeof deploymentFlagsMachine>
}
/**
@ -31,6 +34,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine),
siteRolesXService: useInterpret(siteRolesMachine),
deploymentFlagsXService: useInterpret(deploymentFlagsMachine),
}}
>
{children}

View File

@ -16,6 +16,7 @@ export const checks = {
createTemplates: "createTemplates",
deleteTemplates: "deleteTemplates",
viewAuditLog: "viewAuditLog",
viewDeploymentFlags: "viewDeploymentFlags",
createGroup: "createGroup",
} as const
@ -56,6 +57,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.viewDeploymentFlags]: {
object: {
resource_type: "deployment_flags",
},
action: "read",
},
[checks.createGroup]: {
object: {
resource_type: "group",
@ -93,6 +100,7 @@ export type AuthEvent =
| { type: "REGENERATE_SSH_KEY" }
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
| { type: "CANCEL_REGENERATE_SSH_KEY" }
| { type: "GET_AUTH_METHODS" }
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogBsATimEAzDIAcAFgUB2VTJ26ANCACeiAIwaADKsLKFtu-dsBfRwbRZc+IqQolyUBuS0ECJEvgBufADWXmTkAWL8gsKiSBKICubKhKpSyhoArAbGCGaqGoRSlVXVlfnOrhg4eATEsb7+gWAATl18XYQ8ADb4AGZ9ALatFPGpiSRCImKSCLLySmqa2ro6RaYFAEzWDscK9SBuTZ6EMOhCfgCqsN1BIYThUUQ3ALJgCQLzySW6Uy2VyBV2JXyUhMFRqcLqLnOjQ8LRudygj2e3V6-SGowm1zA6B+fySi1Sy32MnyhHyyksYK2ug0EJM1IURxO9jOFxRnyJ6IACt1xiRYKQRLAXpQ3uQItFCABjTBgRWRYVdUXi5LwWb-BYpUDLZRScygvKFIyIGQaQ4FHnI5r827tDVaiXkKXYvoDYboMaapUqtVusUe3W8fWAimIE1mnIW1l0rImOE1BENdxOwkuvw-LB8CBS4Iy94K75EzCFiMgOYGoEIZRUwgyVT5BQmfaW4omGxZGxcuwOrNXNHtfNVou0b24v0ByYVgtF0kA8lG2PN1vtzvd0zszmD06I3nZ7yUCABAa9EYkQahCB32j3QUAEQAggAVACibEFACUqAAMQYAAZL8V3rGMSjbGQKihLsISkOlaWHS4WjPSBLx4a9byIVAeAgfBXRwx8S1COUPkIE8rgwi9yCvPgbzvQh8MIoUSLABB3kVIiRAAbXMABdCDo3XaD8lgpCpAQq0EA0eTUL5KZzywjiWIIoi-EFDjpx6H08X9AlqPQ2JMPo7DGNw9S2OIyy7y4iieINAThL1MlDTScTJPg3dGxkfZFNPUy6OIWBMDeB8wFoJgvw-NhsGwAAJNgAGkvwATREtdPM7VQrE0XyTF7coB0PQKaOCy9xXCsc-ASxKUrAQxpXI+UiGMmIKDM0KaoFdp6sawwHIiJzkhcrKPOWIqkOscFZNTfYOXtY9HQqrqQuqnN0QGprdJxX18UDDrlO6zbaqgHahu43jyHGtzV0m0x9jyxQ5p7ExzBhUrB3Kkz1qqsLCEGPhkAgSAIsfP8vxilgvz-T8f3q1KMomht9g+8ocnMGQCtZDRZAPLkMyREc-pU+jNuB0HwcVEQb01S6-zAGBKC6TxaAAYTfFgOa-EC2ChmG4YR+KkuRzL7sgsT0bMQhzCUcxpMK20vsPBRieO2iAfCqmwYgJU6ZIBmksGpmWe6dmOaoFhgL-L4Behr9Yfh79ReStKJcjdy0Yx0Fsdx+b23MX7OvJnqgZBvXCC6ZmwFZzSLpN3ayNlNqqNWsnTsB3XwZj822e2pOrscm67q9h6GzMBQrDy7cZJ7DR1ZQlbSdDrOdcj3PY-jwuGt2mcDsMo6M7bjbs87-W87ji3e8G4a+FG-ihNRqCq5rtsO3r0wpG0ZvMzQ0eqtVVAunmQwIai5931d7Avw5+4-wYD9PdrKNsqmsoOQyBM3sQZ6pBDidDax9T7oHPqxBO2AQFnxaqnSimtKoU2gWA6ykDkHFxGqXZektRI5U-ooBkiZZIKFyHvEmB8gFH0VCfM+qDtroL2vpOcRkR6UKQdQ0B4CNL0I4Wfeei9brYPLlLPBjcCE-18m2KwGtWFa0CIwVgbAqD3A-CvaWWgYSEN-iUDIZpUxpiqBoQBZ52g0HQLAssoczFqM8k2WCW5N6+X2OYeShMTgyNbspUxdAB4GXnMpaxOD35-w0XLCRrJ0ZWH0QYqQRiW4UOVKqSI7RAJG1gOgTEXQLEUQVMdRJaoUlpIyU8Lo-CsGuWEbg5YmhCD7B3nU-YA5lA6HVhCZxYjoRshyDvJQ+NVCAIAO7IABH4QCfQPwqlSV0dJmT6DMHYJwGx1Sm7Yx0I3SwziTBOLqWaLQdIpC2HyKoLZ5gESInIIWOAYgEHrUCZU4JJRsY0iIT2akZoPEUJMX4GY9zHoIDbIcdGLzt4qFhDEpCgDzqZKWYgVQ+xWSyCyOC2okK+paRFGGHUML-mmnNNorZbIwUxI+Upc6E5qzYq2LUuQwKSitnymrI8+8lJyIYkxe8zELlfj0l0bFVcaRlCklvRspzjGILZVZEgkVCAzj5Y3AV+MfIQjyEy8hLLxUWXZRfOVRVsiKqVhCRuhxtgaBVY3A5MgxX-XMmpCB7E7K-CCX8oq+RnnaIKJa+J6rrUSrvHykwHZZq+X2bScwYbzUSTUBCr1QUfWbSlX6p1lctlusKp2Q4JLY1hzOmixOfdii-MrlIiotKPpyCtdm8e1N9YJsdYWqCmyshyH9vigoVhkWxIre3CO1aDbkHpuMRm3cZ51tft7BtqhzAZoVga+aGhexuOOJmtalaO69qnj3fqRc+UKHpIQIq87S25GXZnMea69Y7qhPuswxVCptnKNsOQ7Z2xUhPYfCmYV-WBtLVOjkJqzUkP6TGldp10EX0IFynlcroRywsI4iEu6ArAdPVQmhKDa0yqg0m1e+NNFwZ3BCNswdkPvuIGB2tcqakuPlgR4h2MWzMgAxartwDeEoLtf1dB-rXVBoQyQljqHOFfq+q2v9jHG7mqUKqm55N-UuN4-NT6DGdD5C0Gp-Ypq31eL8HcsdFcG0yA+rB5QtHijOKOYoNWgD8nJNGUU6F2GxK9LlvSTIcLGn5C2ZUNpLj5CxIUFSD6XmuyDOGeiMZXQJlgCmTMkp2LGm1OUFCHQORGkyEVqoZQbTNly2-poaERy6kh0pYl5LrZpLNIy1l2Sm5dAkPRs4wz+NlDOGcEAA */
@ -340,6 +348,36 @@ export const authMachine =
},
},
},
methods: {
initial: "idle",
states: {
idle: {
on: {
GET_AUTH_METHODS: {
target: "gettingMethods",
},
},
},
gettingMethods: {
entry: "clearGetMethodsError",
invoke: {
src: "getMethods",
onDone: [
{
actions: ["assignMethods", "clearGetMethodsError"],
target: "idle",
},
],
onError: [
{
actions: "assignGetMethodsError",
target: "idle",
},
],
},
},
},
},
security: {
initial: "idle",
states: {

View File

@ -0,0 +1,61 @@
import { getDeploymentFlags } from "api/api"
import { DeploymentFlags } from "api/typesGenerated"
import { createMachine, assign } from "xstate"
export const deploymentFlagsMachine = createMachine(
{
id: "deploymentFlagsMachine",
initial: "idle",
schema: {
context: {} as {
deploymentFlags?: DeploymentFlags
getDeploymentFlagsError?: unknown
},
events: {} as { type: "LOAD" },
services: {} as {
getDeploymentFlags: {
data: DeploymentFlags
}
},
},
tsTypes: {} as import("./deploymentFlagsMachine.typegen").Typegen0,
states: {
idle: {
on: {
LOAD: {
target: "loading",
},
},
},
loading: {
invoke: {
src: "getDeploymentFlags",
onDone: {
target: "loaded",
actions: ["assignDeploymentFlags"],
},
onError: {
target: "idle",
actions: ["assignGetDeploymentFlagsError"],
},
},
},
loaded: {
type: "final",
},
},
},
{
services: {
getDeploymentFlags,
},
actions: {
assignDeploymentFlags: assign({
deploymentFlags: (_, { data }) => data,
}),
assignGetDeploymentFlagsError: assign({
getDeploymentFlagsError: (_, { data }) => data,
}),
},
},
)