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:
Kira Pilot
2022-09-29 14:32:38 -04:00
committed by GitHub
parent 70d7dd9b2f
commit 776f287685
9 changed files with 169 additions and 74 deletions

View File

@ -10,11 +10,20 @@ import { ChangeEvent, useEffect, useState } from "react"
import { searchUserMachine } from "xServices/users/searchUserXService"
export type UserAutocompleteProps = {
value?: User
value: User | null
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 [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
@ -77,9 +86,11 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onCha
renderInput={(params) => (
<TextField
{...params}
margin="none"
variant="outlined"
margin={inputMargin ?? "normal"}
label={label ?? undefined}
placeholder="User email or username"
className={inputStyles}
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
@ -111,7 +122,7 @@ export const useStyles = makeStyles((theme) => {
},
"& input": {
fontSize: 14,
fontSize: 16,
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
},
},

View File

@ -0,0 +1,5 @@
{
"templateLabel": "Template",
"nameLabel": "Name",
"ownerLabel": "Workspace Owner"
}

View File

@ -1,5 +1,6 @@
import auditLog from "./auditLog.json"
import common from "./common.json"
import createWorkspacePage from "./createWorkspacePage.json"
import templatePage from "./templatePage.json"
import templatesPage from "./templatesPage.json"
import workspacePage from "./workspacePage.json"
@ -10,4 +11,5 @@ export const en = {
auditLog,
templatePage,
templatesPage,
createWorkspacePage,
}

View File

@ -1,12 +1,16 @@
/* 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 * as API from "api/api"
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
import i18next from "i18next"
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import CreateWorkspacePage from "./CreateWorkspacePage"
import { Language } from "./CreateWorkspacePageView"
const { t } = i18next
const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })
const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
@ -22,14 +26,26 @@ describe("CreateWorkspacePage", () => {
expect(element).toBeDefined()
})
it("succeeds", async () => {
it("succeeds with default owner", async () => {
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
renderCreateWorkspacePage()
const nameField = await screen.findByLabelText(Language.nameLabel)
userEvent.type(nameField, "test")
const nameField = await screen.findByLabelText(nameLabelText)
// 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)
userEvent.click(submitButton)
await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(MockUser.organization_ids[0], MockUser.id, {
...MockWorkspaceRequest,
}),
)
})
})

View File

@ -1,10 +1,12 @@
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { useActor, useMachine } from "@xstate/react"
import { User } from "api/typesGenerated"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC, useContext, useState } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { useOrganizationId } from "../../hooks/useOrganizationId"
import { pageTitle } from "../../util/page"
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
import { pageTitle } from "util/page"
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import { XServiceContext } from "xServices/StateContext"
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
const CreateWorkspacePage: FC = () => {
@ -28,8 +30,15 @@ const CreateWorkspacePage: FC = () => {
getTemplateSchemaError,
getTemplatesError,
createWorkspaceError,
permissions,
} = createWorkspaceState.context
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [owner, setOwner] = useState<User | null>(me ?? null)
return (
<>
<Helmet>
@ -49,6 +58,9 @@ const CreateWorkspacePage: FC = () => {
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
defaultWorkspaceOwner={me ?? null}
setOwner={setOwner}
onCancel={() => {
navigate("/templates")
}}
@ -56,6 +68,7 @@ const CreateWorkspacePage: FC = () => {
send({
type: "CREATE_WORKSPACE",
request,
owner,
})
}}
/>

View File

@ -1,21 +1,18 @@
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import * as TypesGen from "api/typesGenerated"
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 { i18n } from "i18n"
import { FC, useState } from "react"
import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
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 {
GET_TEMPLATES_ERROR = "getTemplatesError",
@ -33,21 +30,27 @@ export interface CreateWorkspacePageViewProps {
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
defaultWorkspaceOwner: TypesGen.User | null
setOwner: (arg0: TypesGen.User | null) => void
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
// initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
}
const { t } = i18n
export const validationSchema = Yup.object({
name: nameValidator(Language.nameLabel),
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
})
export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspacePageViewProps>> = (
props,
) => {
const { t } = useTranslation("createWorkspacePage")
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
useStyles()
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
@ -114,17 +117,15 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
<Stack>
{props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] ? (
{Boolean(props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]) && (
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]}
/>
) : (
<></>
)}
<TextField
disabled
fullWidth
label={Language.templateLabel}
label={t("templateLabel")}
value={props.selectedTemplate?.name || props.templateName}
variant="outlined"
/>
@ -138,10 +139,19 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={Language.nameLabel}
label={t("nameLabel")}
variant="outlined"
/>
{props.canCreateForUser && (
<UserAutocomplete
value={props.defaultWorkspaceOwner}
onChange={(user) => props.setOwner(user)}
label={t("ownerLabel")}
inputMargin="dense"
/>
)}
{props.templateSchema.length > 0 && (
<Stack>
{props.templateSchema.map((schema) => (
@ -168,33 +178,3 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
</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,
},
}))

View File

@ -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 = {
id: "test-app",
name: "test-app",

View File

@ -1,14 +1,21 @@
import { assign, createMachine } from "xstate"
import { createWorkspace, getTemplates, getTemplateVersionSchema } from "../../api/api"
import {
checkAuthorization,
createWorkspace,
getTemplates,
getTemplateVersionSchema,
} from "api/api"
import {
CreateWorkspaceRequest,
ParameterSchema,
Template,
User,
Workspace,
} from "../../api/typesGenerated"
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
type CreateWorkspaceContext = {
organizationId: string
owner: User | null
templateName: string
templates?: Template[]
selectedTemplate?: Template
@ -18,11 +25,14 @@ type CreateWorkspaceContext = {
createWorkspaceError?: Error | unknown
getTemplatesError?: Error | unknown
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
}
type CreateWorkspaceEvent = {
type: "CREATE_WORKSPACE"
request: CreateWorkspaceRequest
owner: User | null
}
export const createWorkspaceMachine = createMachine(
@ -73,7 +83,7 @@ export const createWorkspaceMachine = createMachine(
src: "getTemplateSchema",
onDone: {
actions: ["assignTemplateSchema"],
target: "fillingParams",
target: "checkingPermissions",
},
onError: {
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: {
on: {
CREATE_WORKSPACE: {
actions: ["assignCreateWorkspaceRequest"],
actions: ["assignCreateWorkspaceRequest", "assignOwner"],
target: "creatingWorkspace",
},
},
@ -121,14 +146,37 @@ export const createWorkspaceMachine = createMachine(
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) => {
const { createWorkspaceRequest, organizationId } = context
const { createWorkspaceRequest, organizationId, owner } = context
if (!createWorkspaceRequest) {
throw new Error("No create workspace request")
}
return createWorkspace(organizationId, "me", createWorkspaceRequest)
return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest)
},
},
guards: {
@ -149,9 +197,21 @@ export const createWorkspaceMachine = createMachine(
// CLI code: https://github.com/coder/coder/blob/main/cli/create.go#L152-L155
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({
createWorkspaceRequest: (_, event) => event.request,
}),
assignOwner: assign({
owner: (_, event) => event.owner,
}),
assignCreateWorkspaceError: assign({
createWorkspaceError: (_, event) => event.data,
}),

View File

@ -8,6 +8,7 @@ export type AutocompleteEvent = { type: "SEARCH"; query: string } | { type: "CLE
export const searchUserMachine = createMachine(
{
id: "searchUserMachine",
predictableActionArguments: true,
schema: {
context: {} as {
searchResults?: User[]