mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: allow admins to create workspaces for other users in UI (#4247)
* added permission for creating a workspace on behalf of a user * committing stashed files * hooked up autocomplete for users * added label * added translations * wrote test * added inputMargin prop * fixed permissions * added inputSTyle prop * ran prettier * fix lint
This commit is contained in:
@ -10,11 +10,20 @@ import { ChangeEvent, useEffect, useState } from "react"
|
|||||||
import { searchUserMachine } from "xServices/users/searchUserXService"
|
import { searchUserMachine } from "xServices/users/searchUserXService"
|
||||||
|
|
||||||
export type UserAutocompleteProps = {
|
export type UserAutocompleteProps = {
|
||||||
value?: User
|
value: User | null
|
||||||
onChange: (user: User | null) => void
|
onChange: (user: User | null) => void
|
||||||
|
label?: string
|
||||||
|
inputMargin?: "none" | "dense" | "normal"
|
||||||
|
inputStyles?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onChange }) => {
|
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
inputMargin,
|
||||||
|
inputStyles,
|
||||||
|
}) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
|
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
|
||||||
const [searchState, sendSearch] = useMachine(searchUserMachine)
|
const [searchState, sendSearch] = useMachine(searchUserMachine)
|
||||||
@ -77,9 +86,11 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onCha
|
|||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
margin="none"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
margin={inputMargin ?? "normal"}
|
||||||
|
label={label ?? undefined}
|
||||||
placeholder="User email or username"
|
placeholder="User email or username"
|
||||||
|
className={inputStyles}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
...params.InputProps,
|
...params.InputProps,
|
||||||
onChange: handleFilterChange,
|
onChange: handleFilterChange,
|
||||||
@ -111,7 +122,7 @@ export const useStyles = makeStyles((theme) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"& input": {
|
"& input": {
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
|
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
5
site/src/i18n/en/createWorkspacePage.json
Normal file
5
site/src/i18n/en/createWorkspacePage.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"templateLabel": "Template",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"ownerLabel": "Workspace Owner"
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import auditLog from "./auditLog.json"
|
import auditLog from "./auditLog.json"
|
||||||
import common from "./common.json"
|
import common from "./common.json"
|
||||||
|
import createWorkspacePage from "./createWorkspacePage.json"
|
||||||
import templatePage from "./templatePage.json"
|
import templatePage from "./templatePage.json"
|
||||||
import templatesPage from "./templatesPage.json"
|
import templatesPage from "./templatesPage.json"
|
||||||
import workspacePage from "./workspacePage.json"
|
import workspacePage from "./workspacePage.json"
|
||||||
@ -10,4 +11,5 @@ export const en = {
|
|||||||
auditLog,
|
auditLog,
|
||||||
templatePage,
|
templatePage,
|
||||||
templatesPage,
|
templatesPage,
|
||||||
|
createWorkspacePage,
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||||
import { screen } from "@testing-library/react"
|
import { fireEvent, screen, waitFor } from "@testing-library/react"
|
||||||
import userEvent from "@testing-library/user-event"
|
import userEvent from "@testing-library/user-event"
|
||||||
import * as API from "api/api"
|
import * as API from "api/api"
|
||||||
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
|
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
|
||||||
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
|
import i18next from "i18next"
|
||||||
import { renderWithAuth } from "../../testHelpers/renderHelpers"
|
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
|
||||||
|
import { renderWithAuth } from "testHelpers/renderHelpers"
|
||||||
import CreateWorkspacePage from "./CreateWorkspacePage"
|
import CreateWorkspacePage from "./CreateWorkspacePage"
|
||||||
import { Language } from "./CreateWorkspacePageView"
|
|
||||||
|
const { t } = i18next
|
||||||
|
|
||||||
|
const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })
|
||||||
|
|
||||||
const renderCreateWorkspacePage = () => {
|
const renderCreateWorkspacePage = () => {
|
||||||
return renderWithAuth(<CreateWorkspacePage />, {
|
return renderWithAuth(<CreateWorkspacePage />, {
|
||||||
@ -22,14 +26,26 @@ describe("CreateWorkspacePage", () => {
|
|||||||
expect(element).toBeDefined()
|
expect(element).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("succeeds", async () => {
|
it("succeeds with default owner", async () => {
|
||||||
|
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
|
||||||
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
|
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
|
||||||
|
|
||||||
renderCreateWorkspacePage()
|
renderCreateWorkspacePage()
|
||||||
|
|
||||||
const nameField = await screen.findByLabelText(Language.nameLabel)
|
const nameField = await screen.findByLabelText(nameLabelText)
|
||||||
userEvent.type(nameField, "test")
|
|
||||||
|
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
|
||||||
|
fireEvent.change(nameField, {
|
||||||
|
target: { value: "test" },
|
||||||
|
})
|
||||||
|
|
||||||
const submitButton = screen.getByText(FooterLanguage.defaultSubmitLabel)
|
const submitButton = screen.getByText(FooterLanguage.defaultSubmitLabel)
|
||||||
userEvent.click(submitButton)
|
userEvent.click(submitButton)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(API.createWorkspace).toBeCalledWith(MockUser.organization_ids[0], MockUser.id, {
|
||||||
|
...MockWorkspaceRequest,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { useMachine } from "@xstate/react"
|
import { useActor, useMachine } from "@xstate/react"
|
||||||
import { FC } from "react"
|
import { User } from "api/typesGenerated"
|
||||||
|
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||||
|
import { FC, useContext, useState } from "react"
|
||||||
import { Helmet } from "react-helmet-async"
|
import { Helmet } from "react-helmet-async"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import { useOrganizationId } from "../../hooks/useOrganizationId"
|
import { pageTitle } from "util/page"
|
||||||
import { pageTitle } from "../../util/page"
|
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
|
||||||
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
|
import { XServiceContext } from "xServices/StateContext"
|
||||||
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
|
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
const CreateWorkspacePage: FC = () => {
|
const CreateWorkspacePage: FC = () => {
|
||||||
@ -28,8 +30,15 @@ const CreateWorkspacePage: FC = () => {
|
|||||||
getTemplateSchemaError,
|
getTemplateSchemaError,
|
||||||
getTemplatesError,
|
getTemplatesError,
|
||||||
createWorkspaceError,
|
createWorkspaceError,
|
||||||
|
permissions,
|
||||||
} = createWorkspaceState.context
|
} = createWorkspaceState.context
|
||||||
|
|
||||||
|
const xServices = useContext(XServiceContext)
|
||||||
|
const [authState] = useActor(xServices.authXService)
|
||||||
|
const { me } = authState.context
|
||||||
|
|
||||||
|
const [owner, setOwner] = useState<User | null>(me ?? null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@ -49,6 +58,9 @@ const CreateWorkspacePage: FC = () => {
|
|||||||
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
|
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
|
||||||
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
|
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
|
||||||
}}
|
}}
|
||||||
|
canCreateForUser={permissions?.createWorkspaceForUser}
|
||||||
|
defaultWorkspaceOwner={me ?? null}
|
||||||
|
setOwner={setOwner}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
navigate("/templates")
|
navigate("/templates")
|
||||||
}}
|
}}
|
||||||
@ -56,6 +68,7 @@ const CreateWorkspacePage: FC = () => {
|
|||||||
send({
|
send({
|
||||||
type: "CREATE_WORKSPACE",
|
type: "CREATE_WORKSPACE",
|
||||||
request,
|
request,
|
||||||
|
owner,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
import { makeStyles } from "@material-ui/core/styles"
|
|
||||||
import TextField from "@material-ui/core/TextField"
|
import TextField from "@material-ui/core/TextField"
|
||||||
|
import * as TypesGen from "api/typesGenerated"
|
||||||
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
|
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
|
||||||
|
import { FormFooter } from "components/FormFooter/FormFooter"
|
||||||
|
import { FullPageForm } from "components/FullPageForm/FullPageForm"
|
||||||
|
import { Loader } from "components/Loader/Loader"
|
||||||
|
import { ParameterInput } from "components/ParameterInput/ParameterInput"
|
||||||
|
import { Stack } from "components/Stack/Stack"
|
||||||
|
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
|
||||||
import { FormikContextType, FormikTouched, useFormik } from "formik"
|
import { FormikContextType, FormikTouched, useFormik } from "formik"
|
||||||
|
import { i18n } from "i18n"
|
||||||
import { FC, useState } from "react"
|
import { FC, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
|
||||||
import * as Yup from "yup"
|
import * as Yup from "yup"
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
|
||||||
import { FormFooter } from "../../components/FormFooter/FormFooter"
|
|
||||||
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
|
|
||||||
import { Loader } from "../../components/Loader/Loader"
|
|
||||||
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
|
|
||||||
import { Stack } from "../../components/Stack/Stack"
|
|
||||||
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils"
|
|
||||||
|
|
||||||
export const Language = {
|
|
||||||
templateLabel: "Template",
|
|
||||||
nameLabel: "Name",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CreateWorkspaceErrors {
|
export enum CreateWorkspaceErrors {
|
||||||
GET_TEMPLATES_ERROR = "getTemplatesError",
|
GET_TEMPLATES_ERROR = "getTemplatesError",
|
||||||
@ -33,21 +30,27 @@ export interface CreateWorkspacePageViewProps {
|
|||||||
selectedTemplate?: TypesGen.Template
|
selectedTemplate?: TypesGen.Template
|
||||||
templateSchema?: TypesGen.ParameterSchema[]
|
templateSchema?: TypesGen.ParameterSchema[]
|
||||||
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
|
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
|
||||||
|
canCreateForUser?: boolean
|
||||||
|
defaultWorkspaceOwner: TypesGen.User | null
|
||||||
|
setOwner: (arg0: TypesGen.User | null) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
|
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
|
||||||
// initialTouched is only used for testing the error state of the form.
|
// initialTouched is only used for testing the error state of the form.
|
||||||
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
|
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = i18n
|
||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
name: nameValidator(Language.nameLabel),
|
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspacePageViewProps>> = (
|
export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspacePageViewProps>> = (
|
||||||
props,
|
props,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation("createWorkspacePage")
|
||||||
|
|
||||||
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
|
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
|
||||||
useStyles()
|
|
||||||
|
|
||||||
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
|
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
|
||||||
useFormik<TypesGen.CreateWorkspaceRequest>({
|
useFormik<TypesGen.CreateWorkspaceRequest>({
|
||||||
@ -114,17 +117,15 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
|
|||||||
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
|
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
|
||||||
<form onSubmit={form.handleSubmit}>
|
<form onSubmit={form.handleSubmit}>
|
||||||
<Stack>
|
<Stack>
|
||||||
{props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] ? (
|
{Boolean(props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]) && (
|
||||||
<ErrorSummary
|
<ErrorSummary
|
||||||
error={props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]}
|
error={props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
disabled
|
||||||
fullWidth
|
fullWidth
|
||||||
label={Language.templateLabel}
|
label={t("templateLabel")}
|
||||||
value={props.selectedTemplate?.name || props.templateName}
|
value={props.selectedTemplate?.name || props.templateName}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
@ -138,10 +139,19 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
|
|||||||
onChange={onChangeTrimmed(form)}
|
onChange={onChangeTrimmed(form)}
|
||||||
autoFocus
|
autoFocus
|
||||||
fullWidth
|
fullWidth
|
||||||
label={Language.nameLabel}
|
label={t("nameLabel")}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{props.canCreateForUser && (
|
||||||
|
<UserAutocomplete
|
||||||
|
value={props.defaultWorkspaceOwner}
|
||||||
|
onChange={(user) => props.setOwner(user)}
|
||||||
|
label={t("ownerLabel")}
|
||||||
|
inputMargin="dense"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{props.templateSchema.length > 0 && (
|
{props.templateSchema.length > 0 && (
|
||||||
<Stack>
|
<Stack>
|
||||||
{props.templateSchema.map((schema) => (
|
{props.templateSchema.map((schema) => (
|
||||||
@ -168,33 +178,3 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
|
|||||||
</FullPageForm>
|
</FullPageForm>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
readMoreLink: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
|
|
||||||
"& svg": {
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
marginLeft: theme.spacing(0.5),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
padding: 0,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
textAlign: "left",
|
|
||||||
minHeight: "auto",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
},
|
|
||||||
emptyStateDescription: {
|
|
||||||
lineHeight: "160%",
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
background: theme.palette.background.paper,
|
|
||||||
width: "100%",
|
|
||||||
},
|
|
||||||
codeButton: {
|
|
||||||
background: theme.palette.background.paper,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
@ -324,6 +324,13 @@ export const MockQueuedWorkspace: TypesGen.Workspace = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requests the MockWorkspace
|
||||||
|
export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
|
||||||
|
name: "test",
|
||||||
|
parameter_values: [],
|
||||||
|
template_id: "test-template",
|
||||||
|
}
|
||||||
|
|
||||||
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
||||||
id: "test-app",
|
id: "test-app",
|
||||||
name: "test-app",
|
name: "test-app",
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
import { assign, createMachine } from "xstate"
|
import {
|
||||||
import { createWorkspace, getTemplates, getTemplateVersionSchema } from "../../api/api"
|
checkAuthorization,
|
||||||
|
createWorkspace,
|
||||||
|
getTemplates,
|
||||||
|
getTemplateVersionSchema,
|
||||||
|
} from "api/api"
|
||||||
import {
|
import {
|
||||||
CreateWorkspaceRequest,
|
CreateWorkspaceRequest,
|
||||||
ParameterSchema,
|
ParameterSchema,
|
||||||
Template,
|
Template,
|
||||||
|
User,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "../../api/typesGenerated"
|
} from "api/typesGenerated"
|
||||||
|
import { assign, createMachine } from "xstate"
|
||||||
|
|
||||||
type CreateWorkspaceContext = {
|
type CreateWorkspaceContext = {
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
owner: User | null
|
||||||
templateName: string
|
templateName: string
|
||||||
templates?: Template[]
|
templates?: Template[]
|
||||||
selectedTemplate?: Template
|
selectedTemplate?: Template
|
||||||
@ -18,11 +25,14 @@ type CreateWorkspaceContext = {
|
|||||||
createWorkspaceError?: Error | unknown
|
createWorkspaceError?: Error | unknown
|
||||||
getTemplatesError?: Error | unknown
|
getTemplatesError?: Error | unknown
|
||||||
getTemplateSchemaError?: Error | unknown
|
getTemplateSchemaError?: Error | unknown
|
||||||
|
permissions?: Record<string, boolean>
|
||||||
|
checkPermissionsError?: Error | unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateWorkspaceEvent = {
|
type CreateWorkspaceEvent = {
|
||||||
type: "CREATE_WORKSPACE"
|
type: "CREATE_WORKSPACE"
|
||||||
request: CreateWorkspaceRequest
|
request: CreateWorkspaceRequest
|
||||||
|
owner: User | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWorkspaceMachine = createMachine(
|
export const createWorkspaceMachine = createMachine(
|
||||||
@ -73,7 +83,7 @@ export const createWorkspaceMachine = createMachine(
|
|||||||
src: "getTemplateSchema",
|
src: "getTemplateSchema",
|
||||||
onDone: {
|
onDone: {
|
||||||
actions: ["assignTemplateSchema"],
|
actions: ["assignTemplateSchema"],
|
||||||
target: "fillingParams",
|
target: "checkingPermissions",
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
actions: ["assignGetTemplateSchemaError"],
|
actions: ["assignGetTemplateSchemaError"],
|
||||||
@ -81,10 +91,25 @@ export const createWorkspaceMachine = createMachine(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
checkingPermissions: {
|
||||||
|
entry: "clearCheckPermissionsError",
|
||||||
|
invoke: {
|
||||||
|
src: "checkPermissions",
|
||||||
|
id: "checkPermissions",
|
||||||
|
onDone: {
|
||||||
|
actions: ["assignPermissions"],
|
||||||
|
target: "fillingParams",
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
actions: ["assignCheckPermissionsError"],
|
||||||
|
target: "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
fillingParams: {
|
fillingParams: {
|
||||||
on: {
|
on: {
|
||||||
CREATE_WORKSPACE: {
|
CREATE_WORKSPACE: {
|
||||||
actions: ["assignCreateWorkspaceRequest"],
|
actions: ["assignCreateWorkspaceRequest", "assignOwner"],
|
||||||
target: "creatingWorkspace",
|
target: "creatingWorkspace",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -121,14 +146,37 @@ export const createWorkspaceMachine = createMachine(
|
|||||||
|
|
||||||
return getTemplateVersionSchema(selectedTemplate.active_version_id)
|
return getTemplateVersionSchema(selectedTemplate.active_version_id)
|
||||||
},
|
},
|
||||||
|
checkPermissions: async (context) => {
|
||||||
|
if (!context.organizationId) {
|
||||||
|
throw new Error("No organization ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: below, we pass in * for the owner_id, which is a hacky way of checking if the
|
||||||
|
// current user can create a workspace on behalf of anyone within the org (only org owners should be able to do this).
|
||||||
|
// This pattern should not be replicated outside of this narrow use case.
|
||||||
|
const permissionsToCheck = {
|
||||||
|
createWorkspaceForUser: {
|
||||||
|
object: {
|
||||||
|
resource_type: "workspace",
|
||||||
|
organization_id: `${context.organizationId}`,
|
||||||
|
owner_id: "*",
|
||||||
|
},
|
||||||
|
action: "create",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkAuthorization({
|
||||||
|
checks: permissionsToCheck,
|
||||||
|
})
|
||||||
|
},
|
||||||
createWorkspace: (context) => {
|
createWorkspace: (context) => {
|
||||||
const { createWorkspaceRequest, organizationId } = context
|
const { createWorkspaceRequest, organizationId, owner } = context
|
||||||
|
|
||||||
if (!createWorkspaceRequest) {
|
if (!createWorkspaceRequest) {
|
||||||
throw new Error("No create workspace request")
|
throw new Error("No create workspace request")
|
||||||
}
|
}
|
||||||
|
|
||||||
return createWorkspace(organizationId, "me", createWorkspaceRequest)
|
return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
@ -149,9 +197,21 @@ export const createWorkspaceMachine = createMachine(
|
|||||||
// CLI code: https://github.com/coder/coder/blob/main/cli/create.go#L152-L155
|
// CLI code: https://github.com/coder/coder/blob/main/cli/create.go#L152-L155
|
||||||
templateSchema: (_, event) => event.data.filter((param) => param.allow_override_source),
|
templateSchema: (_, event) => event.data.filter((param) => param.allow_override_source),
|
||||||
}),
|
}),
|
||||||
|
assignPermissions: assign({
|
||||||
|
permissions: (_, event) => event.data as Record<string, boolean>,
|
||||||
|
}),
|
||||||
|
assignCheckPermissionsError: assign({
|
||||||
|
checkPermissionsError: (_, event) => event.data,
|
||||||
|
}),
|
||||||
|
clearCheckPermissionsError: assign({
|
||||||
|
checkPermissionsError: (_) => undefined,
|
||||||
|
}),
|
||||||
assignCreateWorkspaceRequest: assign({
|
assignCreateWorkspaceRequest: assign({
|
||||||
createWorkspaceRequest: (_, event) => event.request,
|
createWorkspaceRequest: (_, event) => event.request,
|
||||||
}),
|
}),
|
||||||
|
assignOwner: assign({
|
||||||
|
owner: (_, event) => event.owner,
|
||||||
|
}),
|
||||||
assignCreateWorkspaceError: assign({
|
assignCreateWorkspaceError: assign({
|
||||||
createWorkspaceError: (_, event) => event.data,
|
createWorkspaceError: (_, event) => event.data,
|
||||||
}),
|
}),
|
||||||
|
@ -8,6 +8,7 @@ export type AutocompleteEvent = { type: "SEARCH"; query: string } | { type: "CLE
|
|||||||
export const searchUserMachine = createMachine(
|
export const searchUserMachine = createMachine(
|
||||||
{
|
{
|
||||||
id: "searchUserMachine",
|
id: "searchUserMachine",
|
||||||
|
predictableActionArguments: true,
|
||||||
schema: {
|
schema: {
|
||||||
context: {} as {
|
context: {} as {
|
||||||
searchResults?: User[]
|
searchResults?: User[]
|
||||||
|
Reference in New Issue
Block a user