= ({ children }) => {
+ const styles = useStyles()
+ const organizationId = useOrganizationId()
+ const templateName = useTemplateName()
+ const [templateState, templateSend] = useMachine(templateMachine, {
+ context: {
+ templateName,
+ organizationId,
+ },
+ })
+ const {
+ template,
+ activeTemplateVersion,
+ templateResources,
+ templateDAUs,
+ permissions: templatePermissions,
+ } = templateState.context
+ const xServices = useContext(XServiceContext)
+ const permissions = useSelector(xServices.authXService, selectPermissions)
+ const isLoading =
+ !template ||
+ !activeTemplateVersion ||
+ !templateResources ||
+ !permissions ||
+ !templateDAUs ||
+ !templatePermissions
+
+ if (isLoading) {
+ return
+ }
+
+ if (templateState.matches("deleted")) {
+ return
+ }
+
+ const hasIcon = template.icon && template.icon !== ""
+
+ const createWorkspaceButton = (className?: string) => (
+
+ }>
+ {Language.createButton}
+
+
+ )
+
+ const handleDeleteTemplate = () => {
+ templateSend("DELETE")
+ }
+
+ return (
+ <>
+
+
+
+
+ }>
+ {Language.settingsButton}
+
+
+
+
+ ),
+ },
+ ]}
+ canCancel={false}
+ />
+
+
+ {createWorkspaceButton()}
+
+ }
+ >
+
+
+ {hasIcon ? (
+
+

+
+ ) : (
+
+ {firstLetter(template.name)}
+
+ )}
+
+
+
{template.name}
+
+ {template.description === ""
+ ? Language.noDescription
+ : template.description}
+
+
+
+
+
+
+
+
+
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Summary
+
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Permissions
+
+
+
+
+
+
+
+ {children}
+
+
+
+ {
+ templateSend("CONFIRM_DELETE")
+ }}
+ onCancel={() => {
+ templateSend("CANCEL_DELETE")
+ }}
+ entity="template"
+ name={template.name}
+ />
+ >
+ )
+}
+
+export const useStyles = makeStyles((theme) => {
+ return {
+ actionButton: {
+ border: "none",
+ borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
+ },
+ pageTitle: {
+ alignItems: "center",
+ },
+ avatar: {
+ width: theme.spacing(6),
+ height: theme.spacing(6),
+ fontSize: theme.spacing(3),
+ },
+ iconWrapper: {
+ width: theme.spacing(6),
+ height: theme.spacing(6),
+ "& img": {
+ width: "100%",
+ },
+ },
+
+ tabs: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ marginBottom: theme.spacing(5),
+ },
+
+ tabItem: {
+ textDecoration: "none",
+ color: theme.palette.text.secondary,
+ fontSize: 14,
+ display: "block",
+ padding: theme.spacing(0, 2, 2),
+
+ "&:hover": {
+ color: theme.palette.text.primary,
+ },
+ },
+
+ tabItemActive: {
+ color: theme.palette.text.primary,
+ position: "relative",
+
+ "&:before": {
+ content: `""`,
+ left: 0,
+ bottom: 0,
+ height: 2,
+ width: "100%",
+ background: theme.palette.secondary.dark,
+ position: "absolute",
+ },
+ },
+ }
+})
diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx
index ec41f50acbf99..bd9c05d7816ea 100644
--- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx
+++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx
@@ -7,6 +7,7 @@ import { User } from "api/typesGenerated"
import { AvatarData } from "components/AvatarData/AvatarData"
import debounce from "just-debounce-it"
import { ChangeEvent, FC, useEffect, useState } from "react"
+import { combineClasses } from "util/combineClasses"
import { searchUserMachine } from "xServices/users/searchUserXService"
import { AutocompleteAvatar } from "./AutocompleteAvatar"
@@ -16,12 +17,14 @@ export type UserAutocompleteProps = {
label?: string
inputMargin?: "none" | "dense" | "normal"
inputStyles?: string
+ className?: string
showAvatar?: boolean
}
export const UserAutocomplete: FC = ({
value,
onChange,
+ className,
label,
inputMargin,
inputStyles,
@@ -31,7 +34,6 @@ export const UserAutocomplete: FC = ({
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
const { searchResults } = searchState.context
- const [selectedValue, setSelectedValue] = useState(value || null)
// seed list of options on the first page load if a user pases in a value
// since some organizations have long lists of users, we do not load all options on page load.
@@ -51,7 +53,7 @@ export const UserAutocomplete: FC = ({
return (
{
@@ -65,7 +67,6 @@ export const UserAutocomplete: FC = ({
sendSearch("CLEAR_RESULTS")
}
- setSelectedValue(newValue)
onChange(newValue)
}}
getOptionSelected={(option: User, value: User) =>
@@ -90,7 +91,7 @@ export const UserAutocomplete: FC = ({
)}
options={searchResults}
loading={searchState.matches("searching")}
- className={styles.autocomplete}
+ className={combineClasses([styles.autocomplete, className])}
renderInput={(params) => (
= ({
...params.InputProps,
onChange: handleFilterChange,
startAdornment: (
- <>
- {showAvatar && selectedValue && (
-
- )}
- >
+ <>{showAvatar && value && }>
),
endAdornment: (
<>
@@ -156,3 +153,28 @@ export const useStyles = makeStyles((theme) => {
},
}
})
+
+export const UserAutocompleteInline: React.FC = (
+ props,
+) => {
+ const style = useInlineStyle()
+
+ return
+}
+
+export const useInlineStyle = makeStyles(() => {
+ return {
+ inline: {
+ width: "300px",
+
+ "& .MuiFormControl-root": {
+ margin: 0,
+ },
+
+ "& .MuiInputBase-root": {
+ // Match button small height
+ height: 36,
+ },
+ },
+ }
+})
diff --git a/site/src/components/UserAvatar/UserAvatar.tsx b/site/src/components/UserAvatar/UserAvatar.tsx
index f4416251218f3..a238db47c4f72 100644
--- a/site/src/components/UserAvatar/UserAvatar.tsx
+++ b/site/src/components/UserAvatar/UserAvatar.tsx
@@ -14,7 +14,7 @@ export const UserAvatar: FC = ({
avatarURL,
}) => {
return (
-
+
{avatarURL ? (
) : (
diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx
new file mode 100644
index 0000000000000..9e3f75672274c
--- /dev/null
+++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx
@@ -0,0 +1,148 @@
+import CircularProgress from "@material-ui/core/CircularProgress"
+import { makeStyles } from "@material-ui/core/styles"
+import TextField from "@material-ui/core/TextField"
+import Autocomplete from "@material-ui/lab/Autocomplete"
+import { useMachine } from "@xstate/react"
+import { Group, User } from "api/typesGenerated"
+import { AvatarData } from "components/AvatarData/AvatarData"
+import debounce from "just-debounce-it"
+import { ChangeEvent, useState } from "react"
+import { searchUsersAndGroupsMachine } from "xServices/template/searchUsersAndGroupsXService"
+
+export type UserOrGroupAutocompleteValue = User | Group | null
+
+const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
+ return value !== null && "members" in value
+}
+
+export type UserOrGroupAutocompleteProps = {
+ value: UserOrGroupAutocompleteValue
+ onChange: (value: UserOrGroupAutocompleteValue) => void
+ organizationId: string
+ exclude: UserOrGroupAutocompleteValue[]
+}
+
+export const UserOrGroupAutocomplete: React.FC<
+ UserOrGroupAutocompleteProps
+> = ({ value, onChange, organizationId, exclude }) => {
+ const styles = useStyles()
+ const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
+ const [searchState, sendSearch] = useMachine(searchUsersAndGroupsMachine, {
+ context: {
+ userResults: [],
+ groupResults: [],
+ organizationId,
+ },
+ })
+ const { userResults, groupResults } = searchState.context
+ const options = [...groupResults, ...userResults].filter((result) => {
+ const excludeIds = exclude.map((optionToExclude) => optionToExclude?.id)
+ return !excludeIds.includes(result.id)
+ })
+
+ const handleFilterChange = debounce(
+ (event: ChangeEvent) => {
+ sendSearch("SEARCH", { query: event.target.value })
+ },
+ 500,
+ )
+
+ return (
+ {
+ setIsAutocompleteOpen(true)
+ }}
+ onClose={() => {
+ setIsAutocompleteOpen(false)
+ }}
+ onChange={(_, newValue) => {
+ if (newValue === null) {
+ sendSearch("CLEAR_RESULTS")
+ }
+
+ onChange(newValue)
+ }}
+ getOptionSelected={(option, value) => option.id === value.id}
+ getOptionLabel={(option) =>
+ isGroup(option) ? option.name : option.email
+ }
+ renderOption={(option) => {
+ const isOptionGroup = isGroup(option)
+
+ return (
+
+ ) : null
+ }
+ />
+ )
+ }}
+ options={options}
+ loading={searchState.matches("searching")}
+ className={styles.autocomplete}
+ renderInput={(params) => (
+
+ {searchState.matches("searching") ? (
+
+ ) : null}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ }}
+ />
+ )}
+ />
+ )
+}
+
+export const useStyles = makeStyles((theme) => {
+ return {
+ autocomplete: {
+ width: "300px",
+
+ "& .MuiFormControl-root": {
+ width: "100%",
+ },
+
+ "& .MuiInputBase-root": {
+ width: "100%",
+ // Match button small height
+ height: 36,
+ },
+
+ "& input": {
+ fontSize: 14,
+ padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
+ },
+ },
+
+ avatar: {
+ width: theme.spacing(4.5),
+ height: theme.spacing(4.5),
+ borderRadius: "100%",
+ },
+ }
+})
diff --git a/site/src/components/UsersLayout/UsersLayout.tsx b/site/src/components/UsersLayout/UsersLayout.tsx
new file mode 100644
index 0000000000000..c2d778c96c2fe
--- /dev/null
+++ b/site/src/components/UsersLayout/UsersLayout.tsx
@@ -0,0 +1,123 @@
+import Button from "@material-ui/core/Button"
+import Link from "@material-ui/core/Link"
+import { makeStyles } from "@material-ui/core/styles"
+import GroupAdd from "@material-ui/icons/GroupAddOutlined"
+import PersonAdd from "@material-ui/icons/PersonAddOutlined"
+import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
+import { useFeatureVisibility } from "hooks/useFeatureVisibility"
+import { usePermissions } from "hooks/usePermissions"
+import { FC, PropsWithChildren } from "react"
+import { Link as RouterLink, NavLink, useNavigate } from "react-router-dom"
+import { combineClasses } from "util/combineClasses"
+import { Margins } from "../../components/Margins/Margins"
+import { Stack } from "../../components/Stack/Stack"
+
+export const UsersLayout: FC = ({ children }) => {
+ const styles = useStyles()
+ const { createUser: canCreateUser, createGroup: canCreateGroup } =
+ usePermissions()
+ const navigate = useNavigate()
+ const { rbac: isRBACEnabled } = useFeatureVisibility()
+
+ return (
+ <>
+
+
+ {canCreateUser && (
+
+ )}
+ {canCreateGroup && isRBACEnabled && (
+
+ }>Create group
+
+ )}
+ >
+ }
+ >
+ Users
+
+
+
+
+
+
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Users
+
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Groups
+
+
+
+
+
+ {children}
+ >
+ )
+}
+
+export const useStyles = makeStyles((theme) => {
+ return {
+ tabs: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ marginBottom: theme.spacing(5),
+ },
+
+ tabItem: {
+ textDecoration: "none",
+ color: theme.palette.text.secondary,
+ fontSize: 14,
+ display: "block",
+ padding: theme.spacing(0, 2, 2),
+
+ "&:hover": {
+ color: theme.palette.text.primary,
+ },
+ },
+
+ tabItemActive: {
+ color: theme.palette.text.primary,
+ position: "relative",
+
+ "&:before": {
+ content: `""`,
+ left: 0,
+ bottom: 0,
+ height: 2,
+ width: "100%",
+ background: theme.palette.secondary.dark,
+ position: "absolute",
+ },
+ },
+ }
+})
diff --git a/site/src/hooks/useFeatureVisibility.ts b/site/src/hooks/useFeatureVisibility.ts
new file mode 100644
index 0000000000000..75533272a1728
--- /dev/null
+++ b/site/src/hooks/useFeatureVisibility.ts
@@ -0,0 +1,9 @@
+import { useSelector } from "@xstate/react"
+import { useContext } from "react"
+import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
+import { XServiceContext } from "xServices/StateContext"
+
+export const useFeatureVisibility = (): Record => {
+ const xServices = useContext(XServiceContext)
+ return useSelector(xServices.entitlementsXService, selectFeatureVisibility)
+}
diff --git a/site/src/hooks/useMe.ts b/site/src/hooks/useMe.ts
new file mode 100644
index 0000000000000..c40fa474b9663
--- /dev/null
+++ b/site/src/hooks/useMe.ts
@@ -0,0 +1,16 @@
+import { useSelector } from "@xstate/react"
+import { User } from "api/typesGenerated"
+import { useContext } from "react"
+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)
+
+ if (!me) {
+ throw new Error("User not found.")
+ }
+
+ return me
+}
diff --git a/site/src/hooks/usePermissions.ts b/site/src/hooks/usePermissions.ts
new file mode 100644
index 0000000000000..cd0ec0546046b
--- /dev/null
+++ b/site/src/hooks/usePermissions.ts
@@ -0,0 +1,14 @@
+import { useActor } from "@xstate/react"
+import { useContext } from "react"
+import { AuthContext } from "xServices/auth/authXService"
+import { XServiceContext } from "xServices/StateContext"
+
+export const usePermissions = (): NonNullable => {
+ const xServices = useContext(XServiceContext)
+ const [authState, _] = useActor(xServices.authXService)
+ const { permissions } = authState.context
+ if (!permissions) {
+ throw new Error("Permissions are not loaded yet.")
+ }
+ return permissions
+}
diff --git a/site/src/pages/GroupsPage/CreateGroupPage.tsx b/site/src/pages/GroupsPage/CreateGroupPage.tsx
new file mode 100644
index 0000000000000..e5b67c8d4ff29
--- /dev/null
+++ b/site/src/pages/GroupsPage/CreateGroupPage.tsx
@@ -0,0 +1,43 @@
+import { useMachine } from "@xstate/react"
+import { useOrganizationId } from "hooks/useOrganizationId"
+import React from "react"
+import { Helmet } from "react-helmet-async"
+import { useNavigate } from "react-router-dom"
+import { pageTitle } from "util/page"
+import { createGroupMachine } from "xServices/groups/createGroupXService"
+import CreateGroupPageView from "./CreateGroupPageView"
+
+export const CreateGroupPage: React.FC = () => {
+ const navigate = useNavigate()
+ const organizationId = useOrganizationId()
+ const [createState, sendCreateEvent] = useMachine(createGroupMachine, {
+ context: {
+ organizationId,
+ },
+ actions: {
+ onCreate: (_, { data }) => {
+ navigate(`/groups/${data.id}`)
+ },
+ },
+ })
+ const { createGroupFormErrors } = createState.context
+
+ return (
+ <>
+
+ {pageTitle("Create Group")}
+
+ {
+ sendCreateEvent({
+ type: "CREATE",
+ data,
+ })
+ }}
+ formErrors={createGroupFormErrors}
+ isLoading={createState.matches("creatingGroup")}
+ />
+ >
+ )
+}
+export default CreateGroupPage
diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx
new file mode 100644
index 0000000000000..b728a2c54540f
--- /dev/null
+++ b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx
@@ -0,0 +1,17 @@
+import { Story } from "@storybook/react"
+import {
+ CreateGroupPageView,
+ CreateGroupPageViewProps,
+} from "./CreateGroupPageView"
+
+export default {
+ title: "pages/CreateGroupPageView",
+ component: CreateGroupPageView,
+}
+
+const Template: Story = (
+ args: CreateGroupPageViewProps,
+) =>
+
+export const Example = Template.bind({})
+Example.args = {}
diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.tsx
new file mode 100644
index 0000000000000..8fd863815c59f
--- /dev/null
+++ b/site/src/pages/GroupsPage/CreateGroupPageView.tsx
@@ -0,0 +1,57 @@
+import TextField from "@material-ui/core/TextField"
+import { CreateGroupRequest } from "api/typesGenerated"
+import { FormFooter } from "components/FormFooter/FormFooter"
+import { FullPageForm } from "components/FullPageForm/FullPageForm"
+import { Margins } from "components/Margins/Margins"
+import { useFormik } from "formik"
+import React from "react"
+import { useNavigate } from "react-router-dom"
+import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
+import * as Yup from "yup"
+
+const validationSchema = Yup.object({
+ name: nameValidator("Name"),
+})
+
+export type CreateGroupPageViewProps = {
+ onSubmit: (data: CreateGroupRequest) => void
+ formErrors: unknown | undefined
+ isLoading: boolean
+}
+
+export const CreateGroupPageView: React.FC = ({
+ onSubmit,
+ formErrors,
+ isLoading,
+}) => {
+ const navigate = useNavigate()
+ const form = useFormik({
+ initialValues: {
+ name: "",
+ },
+ validationSchema,
+ onSubmit,
+ })
+ const getFieldHelpers = getFormHelpers(form, formErrors)
+ const onCancel = () => navigate("/groups")
+
+ return (
+
+
+
+
+
+ )
+}
+export default CreateGroupPageView
diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx
new file mode 100644
index 0000000000000..0411e35747b0d
--- /dev/null
+++ b/site/src/pages/GroupsPage/GroupPage.tsx
@@ -0,0 +1,227 @@
+import Button from "@material-ui/core/Button"
+import Link from "@material-ui/core/Link"
+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 DeleteOutline from "@material-ui/icons/DeleteOutline"
+import PersonAdd from "@material-ui/icons/PersonAdd"
+import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
+import { useMachine } from "@xstate/react"
+import { User } from "api/typesGenerated"
+import { AvatarData } from "components/AvatarData/AvatarData"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
+import { EmptyState } from "components/EmptyState/EmptyState"
+import { Loader } from "components/Loader/Loader"
+import { LoadingButton } from "components/LoadingButton/LoadingButton"
+import { Margins } from "components/Margins/Margins"
+import {
+ PageHeader,
+ PageHeaderSubtitle,
+ PageHeaderTitle,
+} from "components/PageHeader/PageHeader"
+import { Stack } from "components/Stack/Stack"
+import { TableRowMenu } from "components/TableRowMenu/TableRowMenu"
+import { UserAutocompleteInline } from "components/UserAutocomplete/UserAutocomplete"
+import { useState } from "react"
+import { Helmet } from "react-helmet-async"
+import { Link as RouterLink, useNavigate, useParams } from "react-router-dom"
+import { pageTitle } from "util/page"
+import { groupMachine } from "xServices/groups/groupXService"
+import { Maybe } from "components/Conditionals/Maybe"
+
+const AddGroupMember: React.FC<{
+ isLoading: boolean
+ onSubmit: (user: User, reset: () => void) => void
+}> = ({ isLoading, onSubmit }) => {
+ const [selectedUser, setSelectedUser] = useState(null)
+
+ const resetValues = () => {
+ setSelectedUser(null)
+ }
+
+ return (
+
+ )
+}
+
+export const GroupPage: React.FC = () => {
+ const { groupId } = useParams()
+ if (!groupId) {
+ throw new Error("groupId is not defined.")
+ }
+
+ const navigate = useNavigate()
+ const [state, send] = useMachine(groupMachine, {
+ context: {
+ groupId,
+ },
+ actions: {
+ redirectToGroups: () => {
+ navigate("/groups")
+ },
+ },
+ })
+ const { group, permissions } = state.context
+ const isLoading = group === undefined || permissions === undefined
+ const canUpdateGroup = permissions ? permissions.canUpdateGroup : false
+
+ return (
+ <>
+
+ {pageTitle(group?.name ?? "Loading...")}
+
+
+
+
+
+
+
+
+
+
+ }>Settings
+
+
+
+ }
+ >
+ {group?.name}
+
+ {group?.members.length} members
+
+
+
+
+
+ {
+ send({
+ type: "ADD_MEMBER",
+ userId: user.id,
+ callback: reset,
+ })
+ }}
+ />
+
+
+
+
+
+ User
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {group?.members.map((member) => (
+
+
+
+
+
+
+ {
+ send({
+ type: "REMOVE_MEMBER",
+ userId: member.id,
+ })
+ },
+ },
+ ]}
+ />
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {group && (
+ {
+ send("CONFIRM_DELETE")
+ }}
+ onCancel={() => {
+ send("CANCEL_DELETE")
+ }}
+ />
+ )}
+ >
+ )
+}
+
+export default GroupPage
diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx
new file mode 100644
index 0000000000000..a66b0c122575e
--- /dev/null
+++ b/site/src/pages/GroupsPage/GroupsPage.tsx
@@ -0,0 +1,37 @@
+import { useMachine } from "@xstate/react"
+import { useFeatureVisibility } from "hooks/useFeatureVisibility"
+import { useOrganizationId } from "hooks/useOrganizationId"
+import { usePermissions } from "hooks/usePermissions"
+import React from "react"
+import { Helmet } from "react-helmet-async"
+import { pageTitle } from "util/page"
+import { groupsMachine } from "xServices/groups/groupsXService"
+import GroupsPageView from "./GroupsPageView"
+
+export const GroupsPage: React.FC = () => {
+ const organizationId = useOrganizationId()
+ const [state] = useMachine(groupsMachine, {
+ context: {
+ organizationId,
+ },
+ })
+ const { groups } = state.context
+ const { createGroup: canCreateGroup } = usePermissions()
+ const { rbac: isRBACEnabled } = useFeatureVisibility()
+
+ return (
+ <>
+
+ {pageTitle("Groups")}
+
+
+
+ >
+ )
+}
+
+export default GroupsPage
diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx
new file mode 100644
index 0000000000000..1207cdae02978
--- /dev/null
+++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx
@@ -0,0 +1,40 @@
+import { Story } from "@storybook/react"
+import { MockGroup } from "testHelpers/entities"
+import { GroupsPageView, GroupsPageViewProps } from "./GroupsPageView"
+
+export default {
+ title: "pages/GroupsPageView",
+ component: GroupsPageView,
+}
+
+const Template: Story = (args: GroupsPageViewProps) => (
+
+)
+
+export const NotEnabled = Template.bind({})
+NotEnabled.args = {
+ groups: [MockGroup],
+ canCreateGroup: true,
+ isRBACEnabled: false,
+}
+
+export const WithGroups = Template.bind({})
+WithGroups.args = {
+ groups: [MockGroup],
+ canCreateGroup: true,
+ isRBACEnabled: true,
+}
+
+export const EmptyGroup = Template.bind({})
+EmptyGroup.args = {
+ groups: [],
+ canCreateGroup: false,
+ isRBACEnabled: true,
+}
+
+export const EmptyGroupWithPermission = Template.bind({})
+EmptyGroupWithPermission.args = {
+ groups: [],
+ canCreateGroup: true,
+ isRBACEnabled: true,
+}
diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx
new file mode 100644
index 0000000000000..fb25c93940c94
--- /dev/null
+++ b/site/src/pages/GroupsPage/GroupsPageView.tsx
@@ -0,0 +1,204 @@
+import Button from "@material-ui/core/Button"
+import Link from "@material-ui/core/Link"
+import { makeStyles } from "@material-ui/core/styles"
+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 ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined"
+import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
+import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
+import AvatarGroup from "@material-ui/lab/AvatarGroup"
+import { AvatarData } from "components/AvatarData/AvatarData"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { EmptyState } from "components/EmptyState/EmptyState"
+import { Stack } from "components/Stack/Stack"
+import { TableLoader } from "components/TableLoader/TableLoader"
+import { UserAvatar } from "components/UserAvatar/UserAvatar"
+import React from "react"
+import { Link as RouterLink, useNavigate } from "react-router-dom"
+import { Paywall } from "components/Paywall/Paywall"
+import { Group } from "api/typesGenerated"
+
+export type GroupsPageViewProps = {
+ groups: Group[] | undefined
+ canCreateGroup: boolean
+ isRBACEnabled: boolean
+}
+
+export const GroupsPageView: React.FC = ({
+ groups,
+ canCreateGroup,
+ isRBACEnabled,
+}) => {
+ const isLoading = Boolean(groups === undefined)
+ const isEmpty = Boolean(groups && groups.length === 0)
+ const navigate = useNavigate()
+ const styles = useStyles()
+
+ return (
+ <>
+
+
+
+
+ }>
+ See how to upgrade
+
+
+
+ Read the docs
+
+
+ }
+ />
+
+
+
+
+
+
+ Name
+ Users
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ Create group
+
+
+ )
+ }
+ />
+
+
+
+
+
+ {groups?.map((group) => {
+ const groupPageLink = `/groups/${group.id}`
+
+ return (
+ {
+ navigate(groupPageLink)
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ navigate(groupPageLink)
+ }
+ }}
+ className={styles.clickableTableRow}
+ >
+
+
+
+
+
+ {group.members.length === 0 && "-"}
+
+ {group.members.map((member) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ clickableTableRow: {
+ cursor: "pointer",
+
+ "&:hover td": {
+ backgroundColor: theme.palette.action.hover,
+ },
+
+ "&:focus": {
+ outline: `1px solid ${theme.palette.secondary.dark}`,
+ },
+
+ "& .MuiTableCell-root:last-child": {
+ paddingRight: theme.spacing(2),
+ },
+ },
+ arrowRight: {
+ color: theme.palette.text.secondary,
+ width: 20,
+ height: 20,
+ },
+ arrowCell: {
+ display: "flex",
+ },
+}))
+
+export default GroupsPageView
diff --git a/site/src/pages/GroupsPage/SettingsGroupPage.tsx b/site/src/pages/GroupsPage/SettingsGroupPage.tsx
new file mode 100644
index 0000000000000..4460292d0d840
--- /dev/null
+++ b/site/src/pages/GroupsPage/SettingsGroupPage.tsx
@@ -0,0 +1,50 @@
+import { useMachine } from "@xstate/react"
+import React from "react"
+import { Helmet } from "react-helmet-async"
+import { useNavigate, useParams } from "react-router-dom"
+import { pageTitle } from "util/page"
+import { editGroupMachine } from "xServices/groups/editGroupXService"
+import SettingsGroupPageView from "./SettingsGroupPageView"
+
+export const SettingsGroupPage: React.FC = () => {
+ const { groupId } = useParams()
+ if (!groupId) {
+ throw new Error("Group ID not defined.")
+ }
+
+ const navigate = useNavigate()
+
+ const navigateToGroup = () => {
+ navigate(`/groups/${groupId}`)
+ }
+
+ const [editState, sendEditEvent] = useMachine(editGroupMachine, {
+ context: {
+ groupId,
+ },
+ actions: {
+ onUpdate: navigateToGroup,
+ },
+ })
+ const { updateGroupFormErrors, group } = editState.context
+
+ return (
+ <>
+
+ {pageTitle("Settings Group")}
+
+
+ {
+ sendEditEvent({ type: "UPDATE", data })
+ }}
+ group={group}
+ formErrors={updateGroupFormErrors}
+ isLoading={editState.matches("loading")}
+ isUpdating={editState.matches("updating")}
+ />
+ >
+ )
+}
+export default SettingsGroupPage
diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx
new file mode 100644
index 0000000000000..1cde5c8ff80ad
--- /dev/null
+++ b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx
@@ -0,0 +1,21 @@
+import { Story } from "@storybook/react"
+import { MockGroup } from "testHelpers/entities"
+import {
+ SettingsGroupPageView,
+ SettingsGroupPageViewProps,
+} from "./SettingsGroupPageView"
+
+export default {
+ title: "pages/SettingsGroupPageView",
+ component: SettingsGroupPageView,
+}
+
+const Template: Story = (
+ args: SettingsGroupPageViewProps,
+) =>
+
+export const Example = Template.bind({})
+Example.args = {
+ group: MockGroup,
+ isLoading: false,
+}
diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx
new file mode 100644
index 0000000000000..c1b63915345ad
--- /dev/null
+++ b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx
@@ -0,0 +1,93 @@
+import TextField from "@material-ui/core/TextField"
+import { Group } from "api/typesGenerated"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { FormFooter } from "components/FormFooter/FormFooter"
+import { FullPageForm } from "components/FullPageForm/FullPageForm"
+import { FullScreenLoader } from "components/Loader/FullScreenLoader"
+import { Margins } from "components/Margins/Margins"
+import { useFormik } from "formik"
+import React from "react"
+import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
+import * as Yup from "yup"
+
+type FormData = {
+ name: string
+}
+
+const validationSchema = Yup.object({
+ name: nameValidator("Name"),
+})
+
+const UpdateGroupForm: React.FC<{
+ group: Group
+ errors: unknown
+ onSubmit: (data: FormData) => void
+ onCancel: () => void
+ isLoading: boolean
+}> = ({ group, errors, onSubmit, onCancel, isLoading }) => {
+ const form = useFormik({
+ initialValues: {
+ name: group.name,
+ },
+ validationSchema,
+ onSubmit,
+ })
+ const getFieldHelpers = getFormHelpers(form, errors)
+
+ return (
+
+
+
+ )
+}
+
+export type SettingsGroupPageViewProps = {
+ onCancel: () => void
+ onSubmit: (data: FormData) => void
+ group: Group | undefined
+ formErrors: unknown
+ isLoading: boolean
+ isUpdating: boolean
+}
+
+export const SettingsGroupPageView: React.FC = ({
+ onCancel,
+ onSubmit,
+ group,
+ formErrors,
+ isLoading,
+ isUpdating,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SettingsGroupPageView
diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx
deleted file mode 100644
index 22660826d06e9..0000000000000
--- a/site/src/pages/TemplatePage/TemplatePage.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { makeStyles } from "@material-ui/core/styles"
-import { useMachine, useSelector } from "@xstate/react"
-import { AlertBanner } from "components/AlertBanner/AlertBanner"
-import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
-import { Margins } from "components/Margins/Margins"
-import { FC, useContext } from "react"
-import { Helmet } from "react-helmet-async"
-import { Navigate, useParams } from "react-router-dom"
-import { selectPermissions } from "xServices/auth/authSelectors"
-import { XServiceContext } from "xServices/StateContext"
-import { Loader } from "../../components/Loader/Loader"
-import { useOrganizationId } from "../../hooks/useOrganizationId"
-import { pageTitle } from "../../util/page"
-import { templateMachine } from "../../xServices/template/templateXService"
-import { TemplatePageView } from "./TemplatePageView"
-
-const useTemplateName = () => {
- const { template } = useParams()
-
- if (!template) {
- throw new Error("No template found in the URL")
- }
-
- return template
-}
-
-export const TemplatePage: FC> = () => {
- const styles = useStyles()
- const organizationId = useOrganizationId()
- const templateName = useTemplateName()
- const [templateState, templateSend] = useMachine(templateMachine, {
- context: {
- templateName,
- organizationId,
- },
- })
-
- const {
- template,
- activeTemplateVersion,
- templateResources,
- templateVersions,
- deleteTemplateError,
- templateDAUs,
- getTemplateError,
- } = templateState.context
- const xServices = useContext(XServiceContext)
- const permissions = useSelector(xServices.authXService, selectPermissions)
- const isLoading =
- !template ||
- !activeTemplateVersion ||
- !templateResources ||
- !permissions ||
- !templateDAUs
-
- const handleDeleteTemplate = () => {
- templateSend("DELETE")
- }
-
- if (templateState.matches("error") && Boolean(getTemplateError)) {
- return (
-
-
-
- )
- }
-
- if (isLoading) {
- return
- }
-
- if (templateState.matches("deleted")) {
- return
- }
-
- return (
- <>
-
- {pageTitle(`${template.name} ยท Template`)}
-
-
-
- {
- templateSend("CONFIRM_DELETE")
- }}
- onCancel={() => {
- templateSend("CANCEL_DELETE")
- }}
- />
- >
- )
-}
-
-const useStyles = makeStyles((theme) => ({
- errorBox: {
- padding: theme.spacing(3),
- },
-}))
-
-export default TemplatePage
diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx
deleted file mode 100644
index 57da6565b2fb3..0000000000000
--- a/site/src/pages/TemplatePage/TemplatePageView.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import Avatar from "@material-ui/core/Avatar"
-import Button from "@material-ui/core/Button"
-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 { DeleteButton } from "components/DropdownButton/ActionCtas"
-import { DropdownButton } from "components/DropdownButton/DropdownButton"
-import { AlertBanner } from "components/AlertBanner/AlertBanner"
-import { Markdown } from "components/Markdown/Markdown"
-import frontMatter from "front-matter"
-import { FC } from "react"
-import { Link as RouterLink } from "react-router-dom"
-import { firstLetter } from "util/firstLetter"
-import {
- Template,
- TemplateDAUsResponse,
- TemplateVersion,
- WorkspaceResource,
-} from "../../api/typesGenerated"
-import { Margins } from "../../components/Margins/Margins"
-import {
- PageHeader,
- PageHeaderSubtitle,
- PageHeaderTitle,
-} from "../../components/PageHeader/PageHeader"
-import { Stack } from "../../components/Stack/Stack"
-import { TemplateResourcesTable } from "../../components/TemplateResourcesTable/TemplateResourcesTable"
-import { TemplateStats } from "../../components/TemplateStats/TemplateStats"
-import { VersionsTable } from "../../components/VersionsTable/VersionsTable"
-import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection"
-import { DAUChart } from "./DAUChart"
-
-const Language = {
- settingsButton: "Settings",
- createButton: "Create workspace",
- noDescription: "",
- readmeTitle: "README",
- resourcesTitle: "Resources",
- versionsTitle: "Version history",
-}
-
-export interface TemplatePageViewProps {
- template: Template
- activeTemplateVersion: TemplateVersion
- templateResources: WorkspaceResource[]
- templateVersions?: TemplateVersion[]
- templateDAUs?: TemplateDAUsResponse
- handleDeleteTemplate: (templateId: string) => void
- deleteTemplateError: Error | unknown
- canDeleteTemplate: boolean
-}
-
-export const TemplatePageView: FC<
- React.PropsWithChildren
-> = ({
- template,
- activeTemplateVersion,
- templateResources,
- templateVersions,
- templateDAUs,
- handleDeleteTemplate,
- deleteTemplateError,
- canDeleteTemplate,
-}) => {
- const styles = useStyles()
- const readme = frontMatter(activeTemplateVersion.readme)
- const hasIcon = template.icon && template.icon !== ""
-
- const deleteError = Boolean(deleteTemplateError) && (
-
- )
-
- const getStartedResources = (resources: WorkspaceResource[]) => {
- return resources.filter(
- (resource) => resource.workspace_transition === "start",
- )
- }
-
- const createWorkspaceButton = (className?: string) => (
-
- }>
- {Language.createButton}
-
-
- )
-
- return (
-
- <>
-
-
- }>
- {Language.settingsButton}
-
-
-
- {canDeleteTemplate ? (
- handleDeleteTemplate(template.id)}
- />
- ),
- },
- ]}
- canCancel={false}
- />
- ) : (
- createWorkspaceButton()
- )}
- >
- }
- >
-
-
- {hasIcon ? (
-
-

-
- ) : (
-
- {firstLetter(template.name)}
-
- )}
-
-
-
{template.name}
-
- {template.description === ""
- ? Language.noDescription
- : template.description}
-
-
-
-
-
-
- {deleteError}
- {templateDAUs && }
-
-
-
-
- {readme.body}
-
-
-
-
-
-
- >
-
- )
-}
-
-export const useStyles = makeStyles((theme) => {
- return {
- actionButton: {
- border: "none",
- borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
- },
- readmeContents: {
- margin: 0,
- },
- markdownWrapper: {
- background: theme.palette.background.paper,
- padding: theme.spacing(3, 4),
- },
- versionsTableContents: {
- margin: 0,
- },
- pageTitle: {
- alignItems: "center",
- },
- avatar: {
- width: theme.spacing(6),
- height: theme.spacing(6),
- fontSize: theme.spacing(3),
- },
- iconWrapper: {
- width: theme.spacing(6),
- height: theme.spacing(6),
- "& img": {
- width: "100%",
- },
- },
- }
-})
diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx
new file mode 100644
index 0000000000000..9851f1c13e9cc
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx
@@ -0,0 +1,102 @@
+import Button from "@material-ui/core/Button"
+import Link from "@material-ui/core/Link"
+import ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined"
+import { useMachine } from "@xstate/react"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { Paywall } from "components/Paywall/Paywall"
+import { Stack } from "components/Stack/Stack"
+import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
+import { useFeatureVisibility } from "hooks/useFeatureVisibility"
+import { useOrganizationId } from "hooks/useOrganizationId"
+import { FC } from "react"
+import { Helmet } from "react-helmet-async"
+import { pageTitle } from "util/page"
+import { templateACLMachine } from "xServices/template/templateACLXService"
+import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"
+
+export const TemplatePermissionsPage: FC<
+ React.PropsWithChildren
+> = () => {
+ const organizationId = useOrganizationId()
+ const { context } = useTemplateLayoutContext()
+ const { template, permissions } = context
+ if (!template || !permissions) {
+ throw new Error(
+ "This page should not be displayed until template or permissions being loaded.",
+ )
+ }
+ const { rbac: isRBACEnabled } = useFeatureVisibility()
+ const [state, send] = useMachine(templateACLMachine, {
+ context: { templateId: template.id },
+ })
+ const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context
+
+ return (
+ <>
+
+ {pageTitle(`${template.name} ยท Permissions`)}
+
+
+
+
+
+ }>
+ See how to upgrade
+
+
+
+ Read the docs
+
+
+ }
+ />
+
+
+ {
+ send("ADD_USER", { user, role, onDone: reset })
+ }}
+ isAddingUser={state.matches("addingUser")}
+ onUpdateUser={(user, role) => {
+ send("UPDATE_USER_ROLE", { user, role })
+ }}
+ updatingUser={userToBeUpdated}
+ onRemoveUser={(user) => {
+ send("REMOVE_USER", { user })
+ }}
+ onAddGroup={(group, role, reset) => {
+ send("ADD_GROUP", { group, role, onDone: reset })
+ }}
+ isAddingGroup={state.matches("addingGroup")}
+ onUpdateGroup={(group, role) => {
+ send("UPDATE_GROUP_ROLE", { group, role })
+ }}
+ updatingGroup={groupToBeUpdated}
+ onRemoveGroup={(group) => {
+ send("REMOVE_GROUP", { group })
+ }}
+ />
+
+
+ >
+ )
+}
+
+export default TemplatePermissionsPage
diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx
new file mode 100644
index 0000000000000..9e8d8c19cda85
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx
@@ -0,0 +1,38 @@
+import { Story } from "@storybook/react"
+import {
+ MockOrganization,
+ MockTemplateACL,
+ MockTemplateACLEmpty,
+} from "testHelpers/entities"
+import {
+ TemplatePermissionsPageView,
+ TemplatePermissionsPageViewProps,
+} from "./TemplatePermissionsPageView"
+
+export default {
+ title: "pages/TemplatePermissionsPageView",
+ component: TemplatePermissionsPageView,
+}
+
+const Template: Story = (
+ args: TemplatePermissionsPageViewProps,
+) =>
+
+export const Empty = Template.bind({})
+Empty.args = {
+ templateACL: MockTemplateACLEmpty,
+ canUpdatePermissions: false,
+}
+
+export const WithTemplateACL = Template.bind({})
+WithTemplateACL.args = {
+ templateACL: MockTemplateACL,
+ canUpdatePermissions: false,
+}
+
+export const WithUpdatePermissions = Template.bind({})
+WithUpdatePermissions.args = {
+ templateACL: MockTemplateACL,
+ canUpdatePermissions: true,
+ organizationId: MockOrganization.id,
+}
diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx
new file mode 100644
index 0000000000000..a423d503ea2e2
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx
@@ -0,0 +1,367 @@
+import MenuItem from "@material-ui/core/MenuItem"
+import Select from "@material-ui/core/Select"
+import { makeStyles } from "@material-ui/core/styles"
+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 PersonAdd from "@material-ui/icons/PersonAdd"
+import {
+ Group,
+ TemplateACL,
+ TemplateGroup,
+ TemplateRole,
+ TemplateUser,
+} from "api/typesGenerated"
+import { AvatarData } from "components/AvatarData/AvatarData"
+import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
+import { EmptyState } from "components/EmptyState/EmptyState"
+import { LoadingButton } from "components/LoadingButton/LoadingButton"
+import { Stack } from "components/Stack/Stack"
+import { TableLoader } from "components/TableLoader/TableLoader"
+import { TableRowMenu } from "components/TableRowMenu/TableRowMenu"
+import {
+ UserOrGroupAutocomplete,
+ UserOrGroupAutocompleteValue,
+} from "components/UserOrGroupAutocomplete/UserOrGroupAutocomplete"
+import { FC, useState } from "react"
+import { Maybe } from "components/Conditionals/Maybe"
+
+type AddTemplateUserOrGroupProps = {
+ organizationId: string
+ isLoading: boolean
+ templateACL: TemplateACL | undefined
+ onSubmit: (
+ userOrGroup: TemplateUser | TemplateGroup,
+ role: TemplateRole,
+ reset: () => void,
+ ) => void
+}
+
+const AddTemplateUserOrGroup: React.FC = ({
+ isLoading,
+ onSubmit,
+ organizationId,
+ templateACL,
+}) => {
+ const styles = useStyles()
+ const [selectedOption, setSelectedOption] =
+ useState(null)
+ const [selectedRole, setSelectedRole] = useState("view")
+ const excludeFromAutocomplete = templateACL
+ ? [...templateACL.group, ...templateACL.users]
+ : []
+
+ const resetValues = () => {
+ setSelectedOption(null)
+ setSelectedRole("view")
+ }
+
+ return (
+
+ )
+}
+
+export interface TemplatePermissionsPageViewProps {
+ templateACL: TemplateACL | undefined
+ organizationId: string
+ canUpdatePermissions: boolean
+ // User
+ onAddUser: (user: TemplateUser, role: TemplateRole, reset: () => void) => void
+ isAddingUser: boolean
+ onUpdateUser: (user: TemplateUser, role: TemplateRole) => void
+ updatingUser: TemplateUser | undefined
+ onRemoveUser: (user: TemplateUser) => void
+ // Group
+ onAddGroup: (
+ group: TemplateGroup,
+ role: TemplateRole,
+ reset: () => void,
+ ) => void
+ isAddingGroup: boolean
+ onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void
+ updatingGroup: TemplateGroup | undefined
+ onRemoveGroup: (group: Group) => void
+}
+
+export const TemplatePermissionsPageView: FC<
+ React.PropsWithChildren
+> = ({
+ templateACL,
+ canUpdatePermissions,
+ organizationId,
+ // User
+ onAddUser,
+ isAddingUser,
+ updatingUser,
+ onUpdateUser,
+ onRemoveUser,
+ // Group
+ onAddGroup,
+ isAddingGroup,
+ updatingGroup,
+ onUpdateGroup,
+ onRemoveGroup,
+}) => {
+ const styles = useStyles()
+ const isEmpty = Boolean(
+ templateACL &&
+ templateACL.users.length === 0 &&
+ templateACL.group.length === 0,
+ )
+
+ return (
+
+
+
+ "members" in value
+ ? onAddGroup(value, role, resetAutocomplete)
+ : onAddUser(value, role, resetAutocomplete)
+ }
+ />
+
+
+
+
+
+ Member
+ Role
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {templateACL?.group.map((group) => (
+
+
+
+
+
+
+
+
+
+
+ {group.role}
+
+
+
+
+
+
+ onRemoveGroup(group),
+ },
+ ]}
+ />
+
+
+
+ ))}
+
+ {templateACL?.users.map((user) => (
+
+
+
+ ) : null
+ }
+ />
+
+
+
+
+
+
+
+ {user.role}
+
+
+
+
+
+
+ onRemoveUser(user),
+ },
+ ]}
+ />
+
+
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export const useStyles = makeStyles((theme) => {
+ return {
+ select: {
+ // Match button small height
+ height: 36,
+ fontSize: 14,
+ width: 100,
+ },
+
+ avatar: {
+ width: theme.spacing(4.5),
+ height: theme.spacing(4.5),
+ borderRadius: "100%",
+ },
+
+ updateSelect: {
+ margin: 0,
+ // Set a fixed width for the select. It avoids selects having different sizes
+ // depending on how many roles they have selected.
+ width: theme.spacing(25),
+ "& .MuiSelect-root": {
+ // Adjusting padding because it does not have label
+ paddingTop: theme.spacing(1.5),
+ paddingBottom: theme.spacing(1.5),
+ },
+ },
+
+ role: {
+ textTransform: "capitalize",
+ },
+ }
+})
diff --git a/site/src/pages/TemplatePage/DAUChart.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx
similarity index 100%
rename from site/src/pages/TemplatePage/DAUChart.test.tsx
rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx
diff --git a/site/src/pages/TemplatePage/DAUChart.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx
similarity index 98%
rename from site/src/pages/TemplatePage/DAUChart.tsx
rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx
index 352db1fdf8a9b..d747a7be8f4ef 100644
--- a/site/src/pages/TemplatePage/DAUChart.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx
@@ -1,6 +1,6 @@
-import useTheme from "@material-ui/styles/useTheme"
-
import { Theme } from "@material-ui/core/styles"
+import useTheme from "@material-ui/styles/useTheme"
+import * as TypesGen from "api/typesGenerated"
import {
CategoryScale,
Chart as ChartJS,
@@ -25,7 +25,6 @@ import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection"
import dayjs from "dayjs"
import { FC } from "react"
import { Line } from "react-chartjs-2"
-import * as TypesGen from "../../api/typesGenerated"
ChartJS.register(
CategoryScale,
diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx
similarity index 79%
rename from site/src/pages/TemplatePage/TemplatePage.test.tsx
rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx
index 99cb2ea71930a..fd2945506365a 100644
--- a/site/src/pages/TemplatePage/TemplatePage.test.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx
@@ -1,16 +1,17 @@
import { fireEvent, screen } from "@testing-library/react"
+import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
import { rest } from "msw"
import { ResizeObserver } from "resize-observer"
-import { server } from "testHelpers/server"
-import * as CreateDayString from "util/createDayString"
import {
MockMemberPermissions,
MockTemplate,
MockTemplateVersion,
MockWorkspaceResource,
renderWithAuth,
-} from "../../testHelpers/renderHelpers"
-import { TemplatePage } from "./TemplatePage"
+} from "testHelpers/renderHelpers"
+import { server } from "testHelpers/server"
+import * as CreateDayString from "util/createDayString"
+import { TemplateSummaryPage } from "./TemplateSummaryPage"
jest.mock("remark-gfm", () => jest.fn())
@@ -18,26 +19,31 @@ Object.defineProperty(window, "ResizeObserver", {
value: ResizeObserver,
})
-describe("TemplatePage", () => {
+const renderPage = () =>
+ renderWithAuth(
+
+
+ ,
+ {
+ route: `/templates/${MockTemplate.id}`,
+ path: "/templates/:template",
+ },
+ )
+
+describe("TemplateSummaryPage", () => {
it("shows the template name, readme and resources", async () => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
- renderWithAuth(, {
- route: `/templates/${MockTemplate.id}`,
- path: "/templates/:template",
- })
+ renderPage()
await screen.findByText(MockTemplate.name)
screen.getByTestId("markdown")
screen.getByText(MockWorkspaceResource.name)
screen.queryAllByText(`${MockTemplateVersion.name}`).length
})
it("allows an admin to delete a template", async () => {
- renderWithAuth(, {
- route: `/templates/${MockTemplate.id}`,
- path: "/templates/:template",
- })
+ renderPage()
const dropdownButton = await screen.findByLabelText("open-dropdown")
fireEvent.click(dropdownButton)
const deleteButton = await screen.findByText("Delete")
@@ -50,10 +56,7 @@ describe("TemplatePage", () => {
return res(ctx.status(200), ctx.json(MockMemberPermissions))
}),
)
- renderWithAuth(, {
- route: `/templates/${MockTemplate.id}`,
- path: "/templates/:template",
- })
+ renderPage()
const dropdownButton = screen.queryByLabelText("open-dropdown")
expect(dropdownButton).toBe(null)
})
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx
new file mode 100644
index 0000000000000..6a5718e613742
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx
@@ -0,0 +1,41 @@
+import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
+import { FC } from "react"
+import { Helmet } from "react-helmet-async"
+import { pageTitle } from "util/page"
+import { TemplateSummaryPageView } from "./TemplateSummaryPageView"
+
+export const TemplateSummaryPage: FC = () => {
+ const { context } = useTemplateLayoutContext()
+ const {
+ template,
+ activeTemplateVersion,
+ templateResources,
+ templateVersions,
+ deleteTemplateError,
+ templateDAUs,
+ } = context
+
+ if (!template || !activeTemplateVersion || !templateResources) {
+ throw new Error(
+ "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.",
+ )
+ }
+
+ return (
+ <>
+
+ {pageTitle(`${template.name} ยท Template`)}
+
+
+ >
+ )
+}
+
+export default TemplateSummaryPage
diff --git a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
similarity index 78%
rename from site/src/pages/TemplatePage/TemplatePageView.stories.tsx
rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
index 378a5ce1d5e81..f3f348695ddea 100644
--- a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
@@ -1,14 +1,17 @@
import { Story } from "@storybook/react"
-import * as Mocks from "../../testHelpers/renderHelpers"
-import { TemplatePageView, TemplatePageViewProps } from "./TemplatePageView"
+import * as Mocks from "testHelpers/renderHelpers"
+import {
+ TemplateSummaryPageView,
+ TemplateSummaryPageViewProps,
+} from "./TemplateSummaryPageView"
export default {
- title: "pages/TemplatePageView",
- component: TemplatePageView,
+ title: "pages/TemplateSummaryPageView",
+ component: TemplateSummaryPageView,
}
-const Template: Story = (args) => (
-
+const Template: Story = (args) => (
+
)
export const Example = Template.bind({})
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx
new file mode 100644
index 0000000000000..b80593f3f523a
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx
@@ -0,0 +1,90 @@
+import { makeStyles } from "@material-ui/core/styles"
+import {
+ Template,
+ TemplateDAUsResponse,
+ TemplateVersion,
+ WorkspaceResource,
+} from "api/typesGenerated"
+import { AlertBanner } from "components/AlertBanner/AlertBanner"
+import { Markdown } from "components/Markdown/Markdown"
+import { Stack } from "components/Stack/Stack"
+import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
+import { TemplateStats } from "components/TemplateStats/TemplateStats"
+import { VersionsTable } from "components/VersionsTable/VersionsTable"
+import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection"
+import frontMatter from "front-matter"
+import { FC } from "react"
+import { DAUChart } from "./DAUChart"
+
+const Language = {
+ readmeTitle: "README",
+ resourcesTitle: "Resources",
+}
+
+export interface TemplateSummaryPageViewProps {
+ template: Template
+ activeTemplateVersion: TemplateVersion
+ templateResources: WorkspaceResource[]
+ templateVersions?: TemplateVersion[]
+ templateDAUs?: TemplateDAUsResponse
+ deleteTemplateError: Error | unknown
+}
+
+export const TemplateSummaryPageView: FC<
+ React.PropsWithChildren
+> = ({
+ template,
+ activeTemplateVersion,
+ templateResources,
+ templateVersions,
+ templateDAUs,
+ deleteTemplateError,
+}) => {
+ const styles = useStyles()
+ const readme = frontMatter(activeTemplateVersion.readme)
+
+ const deleteError = deleteTemplateError ? (
+
+ ) : null
+
+ const getStartedResources = (resources: WorkspaceResource[]) => {
+ return resources.filter(
+ (resource) => resource.workspace_transition === "start",
+ )
+ }
+
+ return (
+
+ {deleteError}
+ {templateDAUs && }
+
+
+
+
+ {readme.body}
+
+
+
+
+ )
+}
+
+export const useStyles = makeStyles((theme) => {
+ return {
+ readmeContents: {
+ margin: 0,
+ },
+ markdownWrapper: {
+ background: theme.palette.background.paper,
+ padding: theme.spacing(3, 4),
+ },
+ }
+})
diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx
index 18b1705b0fe00..4052c1bd65c5b 100644
--- a/site/src/pages/UsersPage/UsersPage.test.tsx
+++ b/site/src/pages/UsersPage/UsersPage.test.tsx
@@ -14,16 +14,23 @@ import {
MockAuditorRole,
MockUser,
MockUser2,
- render,
+ renderWithAuth,
SuspendedMockUser,
} from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
-import { permissionsToCheck } from "../../xServices/auth/authXService"
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
-import { Language as UsersViewLanguage } from "./UsersPageView"
const { t } = i18n
+const renderPage = () => {
+ return renderWithAuth(
+ <>
+
+
+ >,
+ )
+}
+
const suspendUser = async (setupActionSpies: () => void) => {
const user = userEvent.setup()
// Get the first user in the table
@@ -186,52 +193,15 @@ const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
describe("UsersPage", () => {
it("shows users", async () => {
- render()
+ renderPage()
const users = await screen.findAllByText(/.*@coder.com/)
expect(users.length).toEqual(3)
})
- it("shows 'Create user' button to an authorized user", async () => {
- render()
- const createUserButton = await screen.findByText(
- UsersViewLanguage.createButton,
- )
- // wait for users page to finish loading
- await screen.findAllByLabelText("more")
- expect(createUserButton).toBeDefined()
- })
-
- it("does not show 'Create user' button to unauthorized user", async () => {
- server.use(
- rest.post("/api/v2/authcheck", async (req, res, ctx) => {
- const permissions = Object.keys(permissionsToCheck)
- const response = permissions.reduce((obj, permission) => {
- return {
- ...obj,
- [permission]: true,
- createUser: false,
- }
- }, {})
-
- return res(ctx.status(200), ctx.json(response))
- }),
- )
- render()
- const createUserButton = screen.queryByText(UsersViewLanguage.createButton)
- // wait for users page to finish loading
- await screen.findAllByLabelText("more")
- expect(createUserButton).toBeNull()
- })
-
describe("suspend user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser)
@@ -253,12 +223,7 @@ describe("UsersPage", () => {
})
describe("when it fails", () => {
it("shows an error message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
@@ -277,12 +242,7 @@ describe("UsersPage", () => {
describe("delete user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined)
@@ -307,12 +267,7 @@ describe("UsersPage", () => {
})
describe("when it fails", () => {
it("shows an error message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockRejectedValueOnce({})
@@ -331,12 +286,7 @@ describe("UsersPage", () => {
describe("activate user", () => {
describe("when user is successfully activated", () => {
it("shows a success message and refreshes the page", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await activateUser(() => {
jest
@@ -359,12 +309,7 @@ describe("UsersPage", () => {
})
describe("when activation fails", () => {
it("shows an error message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await activateUser(() => {
jest.spyOn(API, "activateUser").mockRejectedValueOnce({})
@@ -383,12 +328,7 @@ describe("UsersPage", () => {
describe("reset user password", () => {
describe("when it is success", () => {
it("shows a success message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await resetUserPassword(() => {
jest.spyOn(API, "updateUserPassword").mockResolvedValueOnce(undefined)
@@ -407,12 +347,7 @@ describe("UsersPage", () => {
})
describe("when it fails", () => {
it("shows an error message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await resetUserPassword(() => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({})
@@ -434,12 +369,7 @@ describe("UsersPage", () => {
describe("Update user role", () => {
describe("when it is success", () => {
it("updates the roles", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
const { rolesMenuTrigger } = await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
@@ -465,12 +395,7 @@ describe("UsersPage", () => {
describe("when it fails", () => {
it("shows an error message", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({})
@@ -492,12 +417,7 @@ describe("UsersPage", () => {
)
})
it("shows an error from the backend", async () => {
- render(
- <>
-
-
- >,
- )
+ renderPage()
server.use(
rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => {
diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx
index 59d24af33f7c9..ce28b51032295 100644
--- a/site/src/pages/UsersPage/UsersPage.tsx
+++ b/site/src/pages/UsersPage/UsersPage.tsx
@@ -1,5 +1,6 @@
import { useActor, useMachine } from "@xstate/react"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
+import { usePermissions } from "hooks/usePermissions"
import { FC, ReactNode, useContext, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate } from "react-router"
@@ -44,21 +45,14 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const userToBeDeleted = users?.find((u) => u.id === userIdToDelete)
const userToBeActivated = users?.find((u) => u.id === userIdToActivate)
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
-
- const [authState, _] = useActor(xServices.authXService)
- const { permissions } = authState.context
- const canEditUsers = permissions && permissions.updateUsers
- const canCreateUser = permissions && permissions.createUser
-
+ const { updateUsers: canEditUsers } = usePermissions()
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
const { roles } = rolesState.context
// Is loading if
- // - permissions are loading or
// - users are loading or
// - the user can edit the users but the roles are loading
const isLoading =
- authState.matches("gettingPermissions") ||
usersState.matches("gettingUsers") ||
(canEditUsers && rolesState.matches("gettingRoles"))
@@ -88,9 +82,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
{
- navigate("/users/create")
- }}
onListWorkspaces={(user) => {
navigate(
"/workspaces?filter=" +
@@ -120,7 +111,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
isLoading={isLoading}
canEditUsers={canEditUsers}
- canCreateUser={canCreateUser}
filter={usersState.context.filter}
onFilter={(query) => {
searchParams.set("filter", query)
diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx
index 65915b727918c..94e84d9b4cf79 100644
--- a/site/src/pages/UsersPage/UsersPageView.tsx
+++ b/site/src/pages/UsersPage/UsersPageView.tsx
@@ -1,19 +1,10 @@
-import Button from "@material-ui/core/Button"
-import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
-import { Margins } from "../../components/Margins/Margins"
-import {
- PageHeader,
- PageHeaderTitle,
-} from "../../components/PageHeader/PageHeader"
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
import { UsersTable } from "../../components/UsersTable/UsersTable"
import { userFilterQuery } from "../../util/filters"
export const Language = {
- pageTitle: "Users",
- createButton: "New user",
activeUsersFilterName: "Active users",
allUsersFilterName: "All users",
}
@@ -24,9 +15,7 @@ export interface UsersPageViewProps {
error?: unknown
isUpdatingUserRoles?: boolean
canEditUsers?: boolean
- canCreateUser?: boolean
isLoading?: boolean
- openUserCreationDialog: () => void
onSuspendUser: (user: TypesGen.User) => void
onDeleteUser: (user: TypesGen.User) => void
onListWorkspaces: (user: TypesGen.User) => void
@@ -42,7 +31,6 @@ export interface UsersPageViewProps {
export const UsersPageView: FC> = ({
users,
roles,
- openUserCreationDialog,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
@@ -52,7 +40,6 @@ export const UsersPageView: FC> = ({
error,
isUpdatingUserRoles,
canEditUsers,
- canCreateUser,
isLoading,
filter,
onFilter,
@@ -63,22 +50,7 @@ export const UsersPageView: FC> = ({
]
return (
-
- }
- >
- {Language.createButton}
-
- ) : undefined
- }
- >
- {Language.pageTitle}
-
-
+ <>
> = ({
canEditUsers={canEditUsers}
isLoading={isLoading}
/>
-
+ >
)
}
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 1433f0fca8002..c633b3e8fb88d 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -909,3 +909,20 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
user_workspace_count: 0,
user_workspace_limit: 100,
}
+
+export const MockGroup: TypesGen.Group = {
+ id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
+ name: "Front-End",
+ organization_id: MockOrganization.id,
+ members: [MockUser, MockUser2],
+}
+
+export const MockTemplateACL: TypesGen.TemplateACL = {
+ group: [{ ...MockGroup, role: "admin" }],
+ users: [{ ...MockUser, role: "view" }],
+}
+
+export const MockTemplateACLEmpty: TypesGen.TemplateACL = {
+ group: [],
+ users: [],
+}
diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts
index 54de671d74f05..fd6903cad88a7 100644
--- a/site/src/testHelpers/handlers.ts
+++ b/site/src/testHelpers/handlers.ts
@@ -3,6 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types"
import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities"
+import { MockGroup } from "./entities"
export const handlers = [
rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => {
@@ -104,7 +105,10 @@ export const handlers = [
return res(ctx.status(200), ctx.json(M.MockSiteRoles))
}),
rest.post("/api/v2/authcheck", async (req, res, ctx) => {
- const permissions = Object.keys(permissionsToCheck)
+ const permissions = [
+ ...Object.keys(permissionsToCheck),
+ "canUpdateTemplate",
+ ]
const response = permissions.reduce((obj, permission) => {
return {
...obj,
@@ -215,4 +219,28 @@ export const handlers = [
rest.get("/api/v2/applications/host", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ host: "dev.coder.com" }))
}),
+
+ // Groups
+ rest.get("/api/v2/organizations/:organizationId/groups", (req, res, ctx) => {
+ return res(ctx.status(200), ctx.json([MockGroup]))
+ }),
+
+ rest.post(
+ "/api/v2/organizations/:organizationId/groups",
+ async (req, res, ctx) => {
+ return res(ctx.status(201), ctx.json(M.MockGroup))
+ },
+ ),
+
+ rest.get("/api/v2/groups/:groupId", (req, res, ctx) => {
+ return res(ctx.status(200), ctx.json(MockGroup))
+ }),
+
+ rest.patch("/api/v2/groups/:groupId", (req, res, ctx) => {
+ return res(ctx.status(200), ctx.json(MockGroup))
+ }),
+
+ rest.delete("/api/v2/groups/:groupId", (req, res, ctx) => {
+ return res(ctx.status(204))
+ }),
]
diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts
index 4739c43bc4b0a..d2cba576370b3 100644
--- a/site/src/xServices/auth/authXService.ts
+++ b/site/src/xServices/auth/authXService.ts
@@ -16,6 +16,7 @@ export const checks = {
createTemplates: "createTemplates",
deleteTemplates: "deleteTemplates",
viewAuditLog: "viewAuditLog",
+ createGroup: "createGroup",
} as const
export const permissionsToCheck = {
@@ -55,9 +56,15 @@ export const permissionsToCheck = {
},
action: "read",
},
+ [checks.createGroup]: {
+ object: {
+ resource_type: "group",
+ },
+ action: "create",
+ },
} as const
-type Permissions = Record
+export type Permissions = Record
export interface AuthContext {
getUserError?: Error | unknown
diff --git a/site/src/xServices/groups/createGroupXService.ts b/site/src/xServices/groups/createGroupXService.ts
new file mode 100644
index 0000000000000..7faf4ee6fb9ad
--- /dev/null
+++ b/site/src/xServices/groups/createGroupXService.ts
@@ -0,0 +1,81 @@
+import { createGroup } from "api/api"
+import {
+ ApiError,
+ getErrorMessage,
+ hasApiFieldErrors,
+ isApiError,
+ mapApiErrorToFieldErrors,
+} from "api/errors"
+import { CreateGroupRequest, Group } from "api/typesGenerated"
+import { displayError } from "components/GlobalSnackbar/utils"
+import { createMachine } from "xstate"
+
+export const createGroupMachine = createMachine(
+ {
+ id: "createGroupMachine",
+ schema: {
+ context: {} as {
+ organizationId: string
+ createGroupFormErrors?: unknown
+ },
+ services: {} as {
+ createGroup: {
+ data: Group
+ }
+ },
+ events: {} as {
+ type: "CREATE"
+ data: CreateGroupRequest
+ },
+ },
+ tsTypes: {} as import("./createGroupXService.typegen").Typegen0,
+ initial: "idle",
+ states: {
+ idle: {
+ on: {
+ CREATE: {
+ target: "creatingGroup",
+ },
+ },
+ },
+ creatingGroup: {
+ invoke: {
+ src: "createGroup",
+ onDone: {
+ target: "idle",
+ actions: ["onCreate"],
+ },
+ onError: [
+ {
+ target: "idle",
+ cond: "hasFieldErrors",
+ actions: ["assignCreateGroupFormErrors"],
+ },
+ {
+ target: "idle",
+ actions: ["displayCreateGroupError"],
+ },
+ ],
+ },
+ },
+ },
+ },
+ {
+ guards: {
+ hasFieldErrors: (_, event) =>
+ isApiError(event.data) && hasApiFieldErrors(event.data),
+ },
+ services: {
+ createGroup: ({ organizationId }, { data }) =>
+ createGroup(organizationId, data),
+ },
+ actions: {
+ displayCreateGroupError: (_, { data }) => {
+ const message = getErrorMessage(data, "Error on creating the group.")
+ displayError(message)
+ },
+ assignCreateGroupFormErrors: (_, event) =>
+ mapApiErrorToFieldErrors((event.data as ApiError).response.data),
+ },
+ },
+)
diff --git a/site/src/xServices/groups/editGroupXService.ts b/site/src/xServices/groups/editGroupXService.ts
new file mode 100644
index 0000000000000..78d4b1a14370b
--- /dev/null
+++ b/site/src/xServices/groups/editGroupXService.ts
@@ -0,0 +1,115 @@
+import { getGroup, patchGroup } from "api/api"
+import {
+ ApiError,
+ getErrorMessage,
+ hasApiFieldErrors,
+ isApiError,
+ mapApiErrorToFieldErrors,
+} from "api/errors"
+import { Group } from "api/typesGenerated"
+import { displayError } from "components/GlobalSnackbar/utils"
+import { assign, createMachine } from "xstate"
+
+export const editGroupMachine = createMachine(
+ {
+ id: "editGroup",
+ schema: {
+ context: {} as {
+ groupId: string
+ group?: Group
+ updateGroupFormErrors?: unknown
+ },
+ services: {} as {
+ loadGroup: {
+ data: Group
+ }
+ updateGroup: {
+ data: Group
+ }
+ },
+ events: {} as {
+ type: "UPDATE"
+ data: { name: string }
+ },
+ },
+ tsTypes: {} as import("./editGroupXService.typegen").Typegen0,
+ initial: "loading",
+ states: {
+ loading: {
+ invoke: {
+ src: "loadGroup",
+ onDone: {
+ actions: ["assignGroup"],
+ target: "idle",
+ },
+ onError: {
+ actions: ["displayLoadGroupError"],
+ target: "idle",
+ },
+ },
+ },
+ idle: {
+ on: {
+ UPDATE: {
+ target: "updating",
+ },
+ },
+ },
+ updating: {
+ invoke: {
+ src: "updateGroup",
+ onDone: {
+ actions: ["onUpdate"],
+ },
+ onError: [
+ {
+ target: "idle",
+ cond: "hasFieldErrors",
+ actions: ["assignUpdateGroupFormErrors"],
+ },
+ {
+ target: "idle",
+ actions: ["displayUpdateGroupError"],
+ },
+ ],
+ },
+ },
+ },
+ },
+ {
+ guards: {
+ hasFieldErrors: (_, event) =>
+ isApiError(event.data) && hasApiFieldErrors(event.data),
+ },
+ services: {
+ loadGroup: ({ groupId }) => getGroup(groupId),
+
+ updateGroup: ({ group }, { data }) => {
+ if (!group) {
+ throw new Error("Group not defined.")
+ }
+
+ return patchGroup(group.id, {
+ ...data,
+ add_users: [],
+ remove_users: [],
+ })
+ },
+ },
+ actions: {
+ assignGroup: assign({
+ group: (_, { data }) => data,
+ }),
+ displayLoadGroupError: (_, { data }) => {
+ const message = getErrorMessage(data, "Failed to the group.")
+ displayError(message)
+ },
+ displayUpdateGroupError: (_, { data }) => {
+ const message = getErrorMessage(data, "Failed to update the group.")
+ displayError(message)
+ },
+ assignUpdateGroupFormErrors: (_, event) =>
+ mapApiErrorToFieldErrors((event.data as ApiError).response.data),
+ },
+ },
+)
diff --git a/site/src/xServices/groups/groupXService.ts b/site/src/xServices/groups/groupXService.ts
new file mode 100644
index 0000000000000..66d9b482d1022
--- /dev/null
+++ b/site/src/xServices/groups/groupXService.ts
@@ -0,0 +1,270 @@
+import { deleteGroup, getGroup, patchGroup, checkAuthorization } from "api/api"
+import { getErrorMessage } from "api/errors"
+import { AuthorizationResponse, Group } from "api/typesGenerated"
+import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"
+import { assign, createMachine } from "xstate"
+
+export const groupMachine = createMachine(
+ {
+ id: "group",
+ schema: {
+ context: {} as {
+ groupId: string
+ group?: Group
+ permissions?: AuthorizationResponse
+ addMemberCallback?: () => void
+ removingMember?: string
+ },
+ services: {} as {
+ loadGroup: {
+ data: Group
+ }
+ loadPermissions: {
+ data: AuthorizationResponse
+ }
+ addMember: {
+ data: Group
+ }
+ removeMember: {
+ data: Group
+ }
+ deleteGroup: {
+ data: unknown
+ }
+ },
+ events: {} as
+ | {
+ type: "ADD_MEMBER"
+ userId: string
+ callback: () => void
+ }
+ | {
+ type: "REMOVE_MEMBER"
+ userId: string
+ }
+ | {
+ type: "DELETE"
+ }
+ | {
+ type: "CONFIRM_DELETE"
+ }
+ | {
+ type: "CANCEL_DELETE"
+ },
+ },
+ tsTypes: {} as import("./groupXService.typegen").Typegen0,
+ initial: "loading",
+ states: {
+ loading: {
+ type: "parallel",
+ states: {
+ data: {
+ initial: "loading",
+ states: {
+ loading: {
+ invoke: {
+ src: "loadGroup",
+ onDone: {
+ actions: ["assignGroup"],
+ target: "success",
+ },
+ onError: {
+ actions: ["displayLoadGroupError"],
+ },
+ },
+ },
+ success: {
+ type: "final",
+ },
+ },
+ },
+ permissions: {
+ initial: "loading",
+ states: {
+ loading: {
+ invoke: {
+ src: "loadPermissions",
+ onDone: {
+ actions: ["assignPermissions"],
+ target: "success",
+ },
+ onError: {
+ actions: ["displayLoadPermissionsError"],
+ },
+ },
+ },
+ success: {
+ type: "final",
+ },
+ },
+ },
+ },
+ onDone: "idle",
+ },
+ idle: {
+ on: {
+ ADD_MEMBER: {
+ target: "addingMember",
+ actions: ["assignAddMemberCallback"],
+ },
+ REMOVE_MEMBER: {
+ target: "removingMember",
+ actions: ["removeUserFromMembers"],
+ },
+ DELETE: {
+ target: "confirmingDelete",
+ },
+ },
+ },
+ addingMember: {
+ invoke: {
+ src: "addMember",
+ onDone: {
+ actions: ["assignGroup", "callAddMemberCallback"],
+ target: "idle",
+ },
+ onError: {
+ target: "idle",
+ actions: ["displayAddMemberError"],
+ },
+ },
+ },
+ removingMember: {
+ invoke: {
+ src: "removeMember",
+ onDone: {
+ actions: ["assignGroup", "displayRemoveMemberSuccess"],
+ target: "idle",
+ },
+ onError: {
+ target: "idle",
+ actions: ["displayRemoveMemberError"],
+ },
+ },
+ },
+ confirmingDelete: {
+ on: {
+ CONFIRM_DELETE: "deleting",
+ CANCEL_DELETE: "idle",
+ },
+ },
+ deleting: {
+ invoke: {
+ src: "deleteGroup",
+ onDone: {
+ actions: ["redirectToGroups"],
+ },
+ onError: {
+ actions: ["displayDeleteGroupError"],
+ },
+ },
+ },
+ },
+ },
+ {
+ services: {
+ loadGroup: ({ groupId }) => getGroup(groupId),
+ loadPermissions: ({ groupId }) =>
+ checkAuthorization({
+ checks: {
+ canUpdateGroup: {
+ object: {
+ resource_type: "group",
+ resource_id: groupId,
+ },
+ action: "update",
+ },
+ },
+ }),
+ addMember: ({ group }, { userId }) => {
+ if (!group) {
+ throw new Error("Group not defined.")
+ }
+
+ return patchGroup(group.id, {
+ name: "",
+ add_users: [userId],
+ remove_users: [],
+ })
+ },
+ removeMember: ({ group }, { userId }) => {
+ if (!group) {
+ throw new Error("Group not defined.")
+ }
+
+ return patchGroup(group.id, {
+ name: "",
+ add_users: [],
+ remove_users: [userId],
+ })
+ },
+ deleteGroup: ({ group }) => {
+ if (!group) {
+ throw new Error("Group not defined.")
+ }
+
+ return deleteGroup(group.id)
+ },
+ },
+ actions: {
+ assignGroup: assign({
+ group: (_, { data }) => data,
+ }),
+ assignAddMemberCallback: assign({
+ addMemberCallback: (_, { callback }) => callback,
+ }),
+ displayLoadGroupError: (_, { data }) => {
+ const message = getErrorMessage(data, "Failed to load the group.")
+ displayError(message)
+ },
+ displayAddMemberError: (_, { data }) => {
+ const message = getErrorMessage(
+ data,
+ "Failed to add member to the group.",
+ )
+ displayError(message)
+ },
+ callAddMemberCallback: ({ addMemberCallback }) => {
+ if (addMemberCallback) {
+ addMemberCallback()
+ }
+ },
+ // Optimistically remove the user from members
+ removeUserFromMembers: assign({
+ group: ({ group }, { userId }) => {
+ if (!group) {
+ throw new Error("Group is not defined.")
+ }
+
+ return {
+ ...group,
+ members: group.members.filter(
+ (currentMember) => currentMember.id !== userId,
+ ),
+ }
+ },
+ }),
+ displayRemoveMemberError: (_, { data }) => {
+ const message = getErrorMessage(
+ data,
+ "Failed to remove member from the group.",
+ )
+ displayError(message)
+ },
+ displayRemoveMemberSuccess: () => {
+ displaySuccess("Member removed successfully.")
+ },
+ displayDeleteGroupError: (_, { data }) => {
+ const message = getErrorMessage(data, "Failed to delete group.")
+ displayError(message)
+ },
+ assignPermissions: assign({
+ permissions: (_, { data }) => data,
+ }),
+ displayLoadPermissionsError: (_, { data }) => {
+ const message = getErrorMessage(data, "Failed to load the permissions.")
+ displayError(message)
+ },
+ },
+ },
+)
diff --git a/site/src/xServices/groups/groupsXService.ts b/site/src/xServices/groups/groupsXService.ts
new file mode 100644
index 0000000000000..451bd85fc534f
--- /dev/null
+++ b/site/src/xServices/groups/groupsXService.ts
@@ -0,0 +1,55 @@
+import { getGroups } from "api/api"
+import { getErrorMessage } from "api/errors"
+import { Group } from "api/typesGenerated"
+import { displayError } from "components/GlobalSnackbar/utils"
+import { assign, createMachine } from "xstate"
+
+export const groupsMachine = createMachine(
+ {
+ id: "groupsMachine",
+ predictableActionArguments: true,
+ schema: {
+ context: {} as {
+ organizationId: string
+ groups?: Group[]
+ },
+ services: {} as {
+ loadGroups: {
+ data: Group[]
+ }
+ },
+ },
+ tsTypes: {} as import("./groupsXService.typegen").Typegen0,
+ initial: "loading",
+ states: {
+ loading: {
+ invoke: {
+ src: "loadGroups",
+ onDone: {
+ actions: ["assignGroups"],
+ target: "idle",
+ },
+ onError: {
+ target: "idle",
+ actions: ["displayLoadingGroupsError"],
+ },
+ },
+ },
+ idle: {},
+ },
+ },
+ {
+ services: {
+ loadGroups: ({ organizationId }) => getGroups(organizationId),
+ },
+ actions: {
+ assignGroups: assign({
+ groups: (_, { data }) => data,
+ }),
+ displayLoadingGroupsError: (_, { data }) => {
+ const message = getErrorMessage(data, "Error on loading groups.")
+ displayError(message)
+ },
+ },
+ },
+)
diff --git a/site/src/xServices/template/searchUsersAndGroupsXService.ts b/site/src/xServices/template/searchUsersAndGroupsXService.ts
new file mode 100644
index 0000000000000..eccfdb9099eea
--- /dev/null
+++ b/site/src/xServices/template/searchUsersAndGroupsXService.ts
@@ -0,0 +1,81 @@
+import { getGroups, getUsers } from "api/api"
+import { Group, User } from "api/typesGenerated"
+import { queryToFilter } from "util/filters"
+import { assign, createMachine } from "xstate"
+
+export type SearchUsersAndGroupsEvent =
+ | { type: "SEARCH"; query: string }
+ | { type: "CLEAR_RESULTS" }
+
+export const searchUsersAndGroupsMachine = createMachine(
+ {
+ id: "searchUsersAndGroups",
+ predictableActionArguments: true,
+ schema: {
+ context: {} as {
+ organizationId: string
+ userResults: User[]
+ groupResults: Group[]
+ },
+ events: {} as SearchUsersAndGroupsEvent,
+ services: {} as {
+ search: {
+ data: {
+ users: User[]
+ groups: Group[]
+ }
+ }
+ },
+ },
+ tsTypes: {} as import("./searchUsersAndGroupsXService.typegen").Typegen0,
+ initial: "idle",
+ states: {
+ idle: {
+ on: {
+ SEARCH: {
+ target: "searching",
+ cond: "queryHasMinLength",
+ },
+ CLEAR_RESULTS: {
+ actions: ["clearResults"],
+ target: "idle",
+ },
+ },
+ },
+ searching: {
+ invoke: {
+ src: "search",
+ onDone: {
+ target: "idle",
+ actions: ["assignSearchResults"],
+ },
+ },
+ },
+ },
+ },
+ {
+ services: {
+ search: async ({ organizationId }, { query }) => {
+ const [users, groups] = await Promise.all([
+ getUsers(queryToFilter(query)),
+ getGroups(organizationId),
+ ])
+
+ return { users, groups }
+ },
+ },
+ actions: {
+ assignSearchResults: assign({
+ userResults: (_, { data }) => data.users,
+ groupResults: (_, { data }) => data.groups,
+ }),
+ clearResults: assign({
+ userResults: (_) => [],
+ groupResults: (_) => [],
+ }),
+ },
+ guards: {
+ queryHasMinLength: (_, { query }) => query.length >= 3,
+ },
+ },
+)
diff --git a/site/src/xServices/template/templateACLXService.ts b/site/src/xServices/template/templateACLXService.ts
new file mode 100644
index 0000000000000..4ff85299c692d
--- /dev/null
+++ b/site/src/xServices/template/templateACLXService.ts
@@ -0,0 +1,366 @@
+import { getTemplateACL, updateTemplateACL } from "api/api"
+import {
+ TemplateACL,
+ TemplateGroup,
+ TemplateRole,
+ TemplateUser,
+} from "api/typesGenerated"
+import { displaySuccess } from "components/GlobalSnackbar/utils"
+import { assign, createMachine } from "xstate"
+
+export const templateACLMachine = createMachine(
+ {
+ schema: {
+ context: {} as {
+ templateId: string
+ templateACL?: TemplateACL
+ // User
+ userToBeAdded?: TemplateUser
+ userToBeUpdated?: TemplateUser
+ addUserCallback?: () => void
+ // Group
+ groupToBeAdded?: TemplateGroup
+ groupToBeUpdated?: TemplateGroup
+ addGroupCallback?: () => void
+ },
+ services: {} as {
+ loadTemplateACL: {
+ data: TemplateACL
+ }
+ // User
+ addUser: {
+ data: unknown
+ }
+ updateUser: {
+ data: unknown
+ }
+ // Group
+ addGroup: {
+ data: unknown
+ }
+ updateGroup: {
+ data: unknown
+ }
+ },
+ events: {} as // User
+ | {
+ type: "ADD_USER"
+ user: TemplateUser
+ role: TemplateRole
+ onDone: () => void
+ }
+ | {
+ type: "UPDATE_USER_ROLE"
+ user: TemplateUser
+ role: TemplateRole
+ }
+ | {
+ type: "REMOVE_USER"
+ user: TemplateUser
+ }
+ // Group
+ | {
+ type: "ADD_GROUP"
+ group: TemplateGroup
+ role: TemplateRole
+ onDone: () => void
+ }
+ | {
+ type: "UPDATE_GROUP_ROLE"
+ group: TemplateGroup
+ role: TemplateRole
+ }
+ | {
+ type: "REMOVE_GROUP"
+ group: TemplateGroup
+ },
+ },
+ tsTypes: {} as import("./templateACLXService.typegen").Typegen0,
+ id: "templateACL",
+ initial: "loading",
+ states: {
+ loading: {
+ invoke: {
+ src: "loadTemplateACL",
+ onDone: {
+ actions: ["assignTemplateACL"],
+ target: "idle",
+ },
+ },
+ },
+ idle: {
+ on: {
+ // User
+ ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] },
+ UPDATE_USER_ROLE: {
+ target: "updatingUser",
+ actions: ["assignUserToBeUpdated"],
+ },
+ REMOVE_USER: {
+ target: "removingUser",
+ actions: ["removeUserFromTemplateACL"],
+ },
+ // Group
+ ADD_GROUP: {
+ target: "addingGroup",
+ actions: ["assignGroupToBeAdded"],
+ },
+ UPDATE_GROUP_ROLE: {
+ target: "updatingGroup",
+ actions: ["assignGroupToBeUpdated"],
+ },
+ REMOVE_GROUP: {
+ target: "removingGroup",
+ actions: ["removeGroupFromTemplateACL"],
+ },
+ },
+ },
+ // User
+ addingUser: {
+ invoke: {
+ src: "addUser",
+ onDone: {
+ target: "idle",
+ actions: ["addUserToTemplateACL", "runAddUserCallback"],
+ },
+ },
+ },
+ updatingUser: {
+ invoke: {
+ src: "updateUser",
+ onDone: {
+ target: "idle",
+ actions: [
+ "updateUserOnTemplateACL",
+ "clearUserToBeUpdated",
+ "displayUpdateUserSuccessMessage",
+ ],
+ },
+ },
+ },
+ removingUser: {
+ invoke: {
+ src: "removeUser",
+ onDone: {
+ target: "idle",
+ actions: ["displayRemoveUserSuccessMessage"],
+ },
+ },
+ },
+ // Group
+ addingGroup: {
+ invoke: {
+ src: "addGroup",
+ onDone: {
+ target: "idle",
+ actions: ["addGroupToTemplateACL", "runAddGroupCallback"],
+ },
+ },
+ },
+ updatingGroup: {
+ invoke: {
+ src: "updateGroup",
+ onDone: {
+ target: "idle",
+ actions: [
+ "updateGroupOnTemplateACL",
+ "clearGroupToBeUpdated",
+ "displayUpdateGroupSuccessMessage",
+ ],
+ },
+ },
+ },
+ removingGroup: {
+ invoke: {
+ src: "removeGroup",
+ onDone: {
+ target: "idle",
+ actions: ["displayRemoveGroupSuccessMessage"],
+ },
+ },
+ },
+ },
+ },
+ {
+ services: {
+ loadTemplateACL: ({ templateId }) => getTemplateACL(templateId),
+ // User
+ addUser: ({ templateId }, { user, role }) =>
+ updateTemplateACL(templateId, {
+ user_perms: {
+ [user.id]: role,
+ },
+ }),
+ updateUser: ({ templateId }, { user, role }) =>
+ updateTemplateACL(templateId, {
+ user_perms: {
+ [user.id]: role,
+ },
+ }),
+ removeUser: ({ templateId }, { user }) =>
+ updateTemplateACL(templateId, {
+ user_perms: {
+ [user.id]: "",
+ },
+ }),
+ // Group
+ addGroup: ({ templateId }, { group, role }) =>
+ updateTemplateACL(templateId, {
+ group_perms: {
+ [group.id]: role,
+ },
+ }),
+ updateGroup: ({ templateId }, { group, role }) =>
+ updateTemplateACL(templateId, {
+ group_perms: {
+ [group.id]: role,
+ },
+ }),
+ removeGroup: ({ templateId }, { group }) =>
+ updateTemplateACL(templateId, {
+ group_perms: {
+ [group.id]: "",
+ },
+ }),
+ },
+ actions: {
+ assignTemplateACL: assign({
+ templateACL: (_, { data }) => data,
+ }),
+ // User
+ assignUserToBeAdded: assign({
+ userToBeAdded: (_, { user, role }) => ({ ...user, role }),
+ addUserCallback: (_, { onDone }) => onDone,
+ }),
+ addUserToTemplateACL: assign({
+ templateACL: ({ templateACL, userToBeAdded }) => {
+ if (!userToBeAdded) {
+ throw new Error("No user to be added")
+ }
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ users: [...templateACL.users, userToBeAdded],
+ }
+ },
+ }),
+ runAddUserCallback: ({ addUserCallback }) => {
+ if (addUserCallback) {
+ addUserCallback()
+ }
+ },
+ assignUserToBeUpdated: assign({
+ userToBeUpdated: (_, { user, role }) => ({ ...user, role }),
+ }),
+ updateUserOnTemplateACL: assign({
+ templateACL: ({ templateACL, userToBeUpdated }) => {
+ if (!userToBeUpdated) {
+ throw new Error("No user to be added")
+ }
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ users: templateACL.users.map((oldTemplateUser) => {
+ return oldTemplateUser.id === userToBeUpdated.id
+ ? userToBeUpdated
+ : oldTemplateUser
+ }),
+ }
+ },
+ }),
+ clearUserToBeUpdated: assign({
+ userToBeUpdated: (_) => undefined,
+ }),
+ displayUpdateUserSuccessMessage: () => {
+ displaySuccess("User role update successfully!")
+ },
+ removeUserFromTemplateACL: assign({
+ templateACL: ({ templateACL }, { user }) => {
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ users: templateACL.users.filter((oldTemplateUser) => {
+ return oldTemplateUser.id !== user.id
+ }),
+ }
+ },
+ }),
+ displayRemoveUserSuccessMessage: () => {
+ displaySuccess("User removed successfully!")
+ },
+ // Group
+ assignGroupToBeAdded: assign({
+ groupToBeAdded: (_, { group, role }) => ({ ...group, role }),
+ addGroupCallback: (_, { onDone }) => onDone,
+ }),
+ addGroupToTemplateACL: assign({
+ templateACL: ({ templateACL, groupToBeAdded }) => {
+ if (!groupToBeAdded) {
+ throw new Error("No group to be added")
+ }
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ group: [...templateACL.group, groupToBeAdded],
+ }
+ },
+ }),
+ runAddGroupCallback: ({ addGroupCallback }) => {
+ if (addGroupCallback) {
+ addGroupCallback()
+ }
+ },
+ assignGroupToBeUpdated: assign({
+ groupToBeUpdated: (_, { group, role }) => ({ ...group, role }),
+ }),
+ updateGroupOnTemplateACL: assign({
+ templateACL: ({ templateACL, groupToBeUpdated }) => {
+ if (!groupToBeUpdated) {
+ throw new Error("No group to be added")
+ }
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ group: templateACL.group.map((oldTemplateGroup) => {
+ return oldTemplateGroup.id === groupToBeUpdated.id
+ ? groupToBeUpdated
+ : oldTemplateGroup
+ }),
+ }
+ },
+ }),
+ clearGroupToBeUpdated: assign({
+ groupToBeUpdated: (_) => undefined,
+ }),
+ displayUpdateGroupSuccessMessage: () => {
+ displaySuccess("Group role update successfully!")
+ },
+ removeGroupFromTemplateACL: assign({
+ templateACL: ({ templateACL }, { group }) => {
+ if (!templateACL) {
+ throw new Error("Template ACL is not loaded yet")
+ }
+ return {
+ ...templateACL,
+ group: templateACL.group.filter((oldTemplateGroup) => {
+ return oldTemplateGroup.id !== group.id
+ }),
+ }
+ },
+ }),
+ displayRemoveGroupSuccessMessage: () => {
+ displaySuccess("Group removed successfully!")
+ },
+ },
+ },
+)
diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts
index 531bd20bae189..0b8aea7f35179 100644
--- a/site/src/xServices/template/templateXService.ts
+++ b/site/src/xServices/template/templateXService.ts
@@ -2,6 +2,7 @@ import { displaySuccess } from "components/GlobalSnackbar/utils"
import { t } from "i18next"
import { assign, createMachine } from "xstate"
import {
+ checkAuthorization,
deleteTemplate,
getTemplateByName,
getTemplateDAUs,
@@ -10,20 +11,22 @@ import {
getTemplateVersions,
} from "../../api/api"
import {
+ AuthorizationResponse,
Template,
TemplateDAUsResponse,
TemplateVersion,
WorkspaceResource,
} from "../../api/typesGenerated"
-interface TemplateContext {
+export interface TemplateContext {
organizationId: string
templateName: string
template?: Template
activeTemplateVersion?: TemplateVersion
templateResources?: WorkspaceResource[]
templateVersions?: TemplateVersion[]
- templateDAUs: TemplateDAUsResponse
+ templateDAUs?: TemplateDAUsResponse
+ permissions?: AuthorizationResponse
deleteTemplateError?: Error | unknown
getTemplateError?: Error | unknown
}
@@ -33,6 +36,16 @@ type TemplateEvent =
| { type: "CONFIRM_DELETE" }
| { type: "CANCEL_DELETE" }
+const getPermissionsToCheck = (templateId: string) => ({
+ canUpdateTemplate: {
+ object: {
+ resource_type: "template",
+ resource_id: templateId,
+ },
+ action: "update",
+ },
+})
+
export const templateMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhgBdyCoAVMVABwBt1ywBiCAe0JIIDcuAazAk0WPIVIUq+WvWaswCAV0ytcPANoAGALqJQDLrFxUehkAA9EAJgDsOkgGZbO2wE5bANg8BGABZnHR0AgBoQAE9EPx0-Eg9EpIAOe28-D28ggF9siPEcAmI+fDNcdCYASXwAMy4SLCp+MDpGFjYANTAAJ1MeMjBKagBBTCaWhXawLt7NfE4eUVURMQxCqRKyiuq6hrHcZtbFTp6+-AGhuVHxo6mZs5V8QXVzfF0DJBBjU1fLGwQAgF4t4AKzeWzOTIhEEhZIRaIIZI6EEkEIhZyg2xuEF+XL5NaSYoELZVWr1NhtJQAJTgXAArt1MHALrJ5JS2DTYPTGXAFrxlqICoTSMSqNsySQKccwJzuUzYCzqLdqbSGfLHs8NNp9JZvmULJ9-kDkiRks4QR50SDkh5nH5nPCYjpXKi0SDnObbeaAniQEKiiLSmLSbspXdTnMFTIlZMlPdI3ylk9hIKCQHNsGduTYydZjwo4NWcrc2dYBq1Fq3jrPnrfobEAFQqbQt5kiCcf4go6ECD7Ca0XFMskAmksr7-RtReUQ1xEyRYOQlKsJOmp+K6rqTPr8H9ELaTclkg5wQEzRidB5u-Y0q6QgFbAEsuCMuO0xsmFx0BBIOwACIAUQAGX-Gh-03H45ksBFrycGE7zcW1bD8e0In+Pw3G8EggT8M0-Fbc8PGSV8Vw2TAeBqXBulQahfzAJhBg4ABhAB5AA5AAxSoqQAWQAfQA4DQPA7ddwQZCMVRUF7Bw3svXsEFuz8MFMNtC8bRtHQzWHYj1mKMjako6i5Fo+i2HYRjhlYxigP4oCQLAmstzrUA0OcaSSHsZwbSUjwAl83zwiiGJGw8LCwTtU8vGQnI8j9N9im-UzqDnAUSEShjizAYTnOsRB7BHEglLwoqskCWxFJ8ewPJ0bx8tsC1IQhZwdOFNK6MGZKem6LhuhIY46iotrTImdkssciCDRcvcbVRJrwr8fLXHsRTYRIOCau8WqYRxIjfXwLhv3gT4J2KaM5Ey7LIPrAFyqChBLVvEJPGk2w21PFrVyDacsz2G4c2mCN+jOqBrgOEbpXjSavicq6prEhar1tR7IXSaSfVik7AxJH7GjBzLIfOWA6UweUjqMGGof+TzbEKsEMiRdwYUhbtkhw5HnHvXx0g8D7Jy+9d6lxw5-oJy7Kb3B07vsJDkekjwcSyWxeaJfmZ0lf7ZTVZlgcyzWeTJ6GJp3a7kOWu7YjcR6wQCEFAQfbxlaxzMJTDFUuS1hUiZJuADdrWHcoQVtMJxdtWfvaL0hWm2rfcRXHxBR2M2+l2NdVfWxeNuHbW7Xz+xCWJkm8ZE3LbRO1zV12S0jRVzpFwH8F9inM4D03u2Ux6sWvQj2wTjH4qd5PQzrvMG-nYnSYz0TQRNG3gnUyE+zNNv-EevDrUya9mr7kiVexlPRoJxujdE7O7r8gIO+dXtUkyMvVazSfrt8bt7xpgcFttC1nXsROPy-SBH5w1iO6MKwRghAkbH2BSUtHBrTRPeC8rhxKJ30hRKiNF2psEAS3ZCNMkR4R8MOWqfloEIntCvDmnoRzyUIvLRO6VWTYP+F4TCNt0g22REhV6pCYgEJIPVDENsPBpA9GkehmCAHjREtdVmmFPCKy2u6RwcJzaQicMOb0wQ3ALVxNvXSRAmExBUWQvOA5baqXlrbXIuQgA */
createMachine(
@@ -62,6 +75,9 @@ export const templateMachine =
getTemplateDAUs: {
data: TemplateDAUsResponse
}
+ getTemplatePermissions: {
+ data: AuthorizationResponse
+ }
},
},
initial: "gettingTemplate",
@@ -162,6 +178,23 @@ export const templateMachine =
},
},
},
+ templatePermissions: {
+ initial: "gettingTemplatePermissions",
+ states: {
+ gettingTemplatePermissions: {
+ invoke: {
+ src: "getTemplatePermissions",
+ onDone: {
+ target: "success",
+ actions: "assignPermissions",
+ },
+ },
+ },
+ success: {
+ type: "final",
+ },
+ },
+ },
},
onDone: {
target: "loaded",
@@ -263,6 +296,14 @@ export const templateMachine =
}
return getTemplateDAUs(ctx.template.id)
},
+ getTemplatePermissions: (ctx) => {
+ if (!ctx.template) {
+ throw new Error("Template not loaded")
+ }
+ return checkAuthorization({
+ checks: getPermissionsToCheck(ctx.template.id),
+ })
+ },
},
actions: {
assignTemplate: assign({
@@ -283,6 +324,9 @@ export const templateMachine =
assignTemplateDAUs: assign({
templateDAUs: (_, event) => event.data,
}),
+ assignPermissions: assign({
+ permissions: (_, event) => event.data,
+ }),
assignDeleteTemplateError: assign({
deleteTemplateError: (_, event) => event.data,
}),
diff --git a/t b/t
new file mode 100644
index 0000000000000..a5221f80459be
--- /dev/null
+++ b/t
@@ -0,0 +1,38 @@
+usage: git tag [-a | -s | -u ] [-f] [-m | -F ]
+ []
+ or: git tag -d ...
+ or: git tag -l [-n[]] [--contains ] [--no-contains ] [--points-at