mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Add update user roles action (#1361)
This commit is contained in:
@ -158,3 +158,16 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
|
|||||||
|
|
||||||
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
|
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
|
||||||
axios.put(`/api/v2/users/${userId}/password`, { password })
|
axios.put(`/api/v2/users/${userId}/password`, { password })
|
||||||
|
|
||||||
|
export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
|
||||||
|
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUserRoles = async (
|
||||||
|
roles: TypesGen.Role["name"][],
|
||||||
|
userId: TypesGen.User["id"],
|
||||||
|
): Promise<TypesGen.User> => {
|
||||||
|
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
24
site/src/components/RoleSelect/RoleSelect.stories.tsx
Normal file
24
site/src/components/RoleSelect/RoleSelect.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { MockAdminRole, MockMemberRole, MockSiteRoles } from "../../testHelpers"
|
||||||
|
import { RoleSelect, RoleSelectProps } from "./RoleSelect"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/RoleSelect",
|
||||||
|
component: RoleSelect,
|
||||||
|
} as ComponentMeta<typeof RoleSelect>
|
||||||
|
|
||||||
|
const Template: Story<RoleSelectProps> = (args) => <RoleSelect {...args} />
|
||||||
|
|
||||||
|
export const Close = Template.bind({})
|
||||||
|
Close.args = {
|
||||||
|
roles: MockSiteRoles,
|
||||||
|
selectedRoles: [MockAdminRole, MockMemberRole],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Open = Template.bind({})
|
||||||
|
Open.args = {
|
||||||
|
open: true,
|
||||||
|
roles: MockSiteRoles,
|
||||||
|
selectedRoles: [MockAdminRole, MockMemberRole],
|
||||||
|
}
|
59
site/src/components/RoleSelect/RoleSelect.tsx
Normal file
59
site/src/components/RoleSelect/RoleSelect.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Checkbox from "@material-ui/core/Checkbox"
|
||||||
|
import MenuItem from "@material-ui/core/MenuItem"
|
||||||
|
import Select from "@material-ui/core/Select"
|
||||||
|
import { makeStyles, Theme } from "@material-ui/core/styles"
|
||||||
|
import React from "react"
|
||||||
|
import { Role } from "../../api/typesGenerated"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
label: "Roles",
|
||||||
|
}
|
||||||
|
export interface RoleSelectProps {
|
||||||
|
roles: Role[]
|
||||||
|
selectedRoles: Role[]
|
||||||
|
onChange: (roles: Role["name"][]) => void
|
||||||
|
loading?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoleSelect: React.FC<RoleSelectProps> = ({ roles, selectedRoles, loading, onChange, open }) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
const value = selectedRoles.map((r) => r.name)
|
||||||
|
const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ")
|
||||||
|
const sortedRoles = roles.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={Language.label}
|
||||||
|
open={open}
|
||||||
|
multiple
|
||||||
|
value={value}
|
||||||
|
renderValue={renderValue}
|
||||||
|
variant="outlined"
|
||||||
|
className={styles.select}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { value } = e.target
|
||||||
|
onChange(value as string[])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedRoles.map((r) => {
|
||||||
|
const isChecked = selectedRoles.some((selectedRole) => selectedRole.name === r.name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem key={r.name} value={r.name} disabled={loading}>
|
||||||
|
<Checkbox color="primary" checked={isChecked} /> {r.display_name}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
select: {
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}))
|
@ -8,10 +8,14 @@ export interface TableHeadersProps {
|
|||||||
hasMenu?: boolean
|
hasMenu?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
|
export const TableHeaderRow: React.FC = ({ children }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
return <TableRow className={styles.root}>{children}</TableRow>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
|
||||||
return (
|
return (
|
||||||
<TableRow className={styles.root}>
|
<TableHeaderRow>
|
||||||
{columns.map((c, idx) => (
|
{columns.map((c, idx) => (
|
||||||
<TableCell key={idx} size="small">
|
<TableCell key={idx} size="small">
|
||||||
{c}
|
{c}
|
||||||
@ -19,7 +23,7 @@ export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu })
|
|||||||
))}
|
))}
|
||||||
{/* 1% is a trick to make the table cell width fit the content */}
|
{/* 1% is a trick to make the table cell width fit the content */}
|
||||||
{hasMenu && <TableCell width="1%" />}
|
{hasMenu && <TableCell width="1%" />}
|
||||||
</TableRow>
|
</TableHeaderRow>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ComponentMeta, Story } from "@storybook/react"
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { MockUser, MockUser2 } from "../../testHelpers"
|
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
|
||||||
import { UsersTable, UsersTableProps } from "./UsersTable"
|
import { UsersTable, UsersTableProps } from "./UsersTable"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -13,9 +13,11 @@ const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
|
|||||||
export const Example = Template.bind({})
|
export const Example = Template.bind({})
|
||||||
Example.args = {
|
Example.args = {
|
||||||
users: [MockUser, MockUser2],
|
users: [MockUser, MockUser2],
|
||||||
|
roles: MockSiteRoles,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Empty = Template.bind({})
|
export const Empty = Template.bind({})
|
||||||
Empty.args = {
|
Empty.args = {
|
||||||
users: [],
|
users: [],
|
||||||
|
roles: MockSiteRoles,
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
|
import Box from "@material-ui/core/Box"
|
||||||
|
import Table from "@material-ui/core/Table"
|
||||||
|
import TableBody from "@material-ui/core/TableBody"
|
||||||
|
import TableCell from "@material-ui/core/TableCell"
|
||||||
|
import TableHead from "@material-ui/core/TableHead"
|
||||||
|
import TableRow from "@material-ui/core/TableRow"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { UserResponse } from "../../api/types"
|
import { UserResponse } from "../../api/types"
|
||||||
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
import { EmptyState } from "../EmptyState/EmptyState"
|
import { EmptyState } from "../EmptyState/EmptyState"
|
||||||
import { Column, Table } from "../Table/Table"
|
import { RoleSelect } from "../RoleSelect/RoleSelect"
|
||||||
|
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
|
||||||
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
||||||
|
import { TableTitle } from "../TableTitle/TableTitle"
|
||||||
import { UserCell } from "../UserCell/UserCell"
|
import { UserCell } from "../UserCell/UserCell"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
@ -12,48 +21,79 @@ export const Language = {
|
|||||||
usernameLabel: "User",
|
usernameLabel: "User",
|
||||||
suspendMenuItem: "Suspend",
|
suspendMenuItem: "Suspend",
|
||||||
resetPasswordMenuItem: "Reset password",
|
resetPasswordMenuItem: "Reset password",
|
||||||
|
rolesLabel: "Roles",
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyState = <EmptyState message={Language.emptyMessage} />
|
|
||||||
|
|
||||||
const columns: Column<UserResponse>[] = [
|
|
||||||
{
|
|
||||||
key: "username",
|
|
||||||
name: Language.usernameLabel,
|
|
||||||
renderer: (field, data) => {
|
|
||||||
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface UsersTableProps {
|
export interface UsersTableProps {
|
||||||
users: UserResponse[]
|
users: UserResponse[]
|
||||||
onSuspendUser: (user: UserResponse) => void
|
onSuspendUser: (user: UserResponse) => void
|
||||||
onResetUserPassword: (user: UserResponse) => void
|
onResetUserPassword: (user: UserResponse) => void
|
||||||
|
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
|
||||||
|
roles: TypesGen.Role[]
|
||||||
|
isUpdatingUserRoles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
|
export const UsersTable: React.FC<UsersTableProps> = ({
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
onSuspendUser,
|
||||||
|
onResetUserPassword,
|
||||||
|
onUpdateUserRoles,
|
||||||
|
isUpdatingUserRoles,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table>
|
||||||
columns={columns}
|
<TableHead>
|
||||||
data={users}
|
<TableTitle title={Language.usersTitle} />
|
||||||
title={Language.usersTitle}
|
<TableHeaderRow>
|
||||||
emptyState={emptyState}
|
<TableCell size="small">{Language.usernameLabel}</TableCell>
|
||||||
rowMenu={(user) => (
|
<TableCell size="small">{Language.rolesLabel}</TableCell>
|
||||||
<TableRowMenu
|
{/* 1% is a trick to make the table cell width fit the content */}
|
||||||
data={user}
|
<TableCell size="small" width="1%" />
|
||||||
menuItems={[
|
</TableHeaderRow>
|
||||||
{
|
</TableHead>
|
||||||
label: Language.suspendMenuItem,
|
<TableBody>
|
||||||
onClick: onSuspendUser,
|
{users.map((u) => (
|
||||||
},
|
<TableRow key={u.id}>
|
||||||
{
|
<TableCell>
|
||||||
label: Language.resetPasswordMenuItem,
|
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
|
||||||
onClick: onResetUserPassword,
|
</TableCell>
|
||||||
},
|
<TableCell>
|
||||||
]}
|
<RoleSelect
|
||||||
/>
|
roles={roles}
|
||||||
)}
|
selectedRoles={u.roles}
|
||||||
/>
|
loading={isUpdatingUserRoles}
|
||||||
|
onChange={(roles) => onUpdateUserRoles(u, roles)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableRowMenu
|
||||||
|
data={u}
|
||||||
|
menuItems={[
|
||||||
|
{
|
||||||
|
label: Language.suspendMenuItem,
|
||||||
|
onClick: onSuspendUser,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: Language.resetPasswordMenuItem,
|
||||||
|
onClick: onResetUserPassword,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{users.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={999}>
|
||||||
|
<Box p={4}>
|
||||||
|
<EmptyState message={Language.emptyMessage} />
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import * as API from "../../api"
|
import * as API from "../../api"
|
||||||
|
import { Role } from "../../api/typesGenerated"
|
||||||
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
|
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
|
||||||
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
|
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
|
||||||
|
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
|
||||||
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
|
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
|
||||||
import { MockUser, MockUser2, render } from "../../testHelpers"
|
import { MockAuditorRole, MockUser, MockUser2, render } from "../../testHelpers"
|
||||||
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
|
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
|
||||||
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
|
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
|
||||||
|
|
||||||
@ -62,6 +64,34 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
|
|||||||
fireEvent.click(confirmButton)
|
fireEvent.click(confirmButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
|
||||||
|
// Get the first user in the table
|
||||||
|
const users = await screen.findAllByText(/.*@coder.com/)
|
||||||
|
const firstUserRow = users[0].closest("tr")
|
||||||
|
if (!firstUserRow) {
|
||||||
|
throw new Error("Error on get the first user row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the "roles" menu to display the role options
|
||||||
|
const rolesLabel = within(firstUserRow).getByLabelText(RoleSelectLanguage.label)
|
||||||
|
const rolesMenuTrigger = within(rolesLabel).getByRole("button")
|
||||||
|
// For MUI v4, the Select was changed to open on mouseDown instead of click
|
||||||
|
// https://github.com/mui-org/material-ui/pull/17978
|
||||||
|
fireEvent.mouseDown(rolesMenuTrigger)
|
||||||
|
|
||||||
|
// Setup spies to check the actions after
|
||||||
|
setupActionSpies()
|
||||||
|
|
||||||
|
// Click on the role option
|
||||||
|
const listBox = screen.getByRole("listbox")
|
||||||
|
const auditorOption = within(listBox).getByRole("option", { name: role.display_name })
|
||||||
|
fireEvent.click(auditorOption)
|
||||||
|
|
||||||
|
return {
|
||||||
|
rolesMenuTrigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("Users Page", () => {
|
describe("Users Page", () => {
|
||||||
it("shows users", async () => {
|
it("shows users", async () => {
|
||||||
render(<UsersPage />)
|
render(<UsersPage />)
|
||||||
@ -164,4 +194,55 @@ describe("Users Page", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Update user role", () => {
|
||||||
|
describe("when it is success", () => {
|
||||||
|
it("updates the roles", async () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<UsersPage />
|
||||||
|
<GlobalSnackbar />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { rolesMenuTrigger } = await updateUserRole(() => {
|
||||||
|
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
|
||||||
|
...MockUser,
|
||||||
|
roles: [...MockUser.roles, MockAuditorRole],
|
||||||
|
})
|
||||||
|
}, MockAuditorRole)
|
||||||
|
|
||||||
|
// Check if the select text was updated with the Auditor role
|
||||||
|
await waitFor(() => expect(rolesMenuTrigger).toHaveTextContent("Admin, Member, Auditor"))
|
||||||
|
|
||||||
|
// Check if the API was called correctly
|
||||||
|
const currentRoles = MockUser.roles.map((r) => r.name)
|
||||||
|
expect(API.updateUserRoles).toBeCalledTimes(1)
|
||||||
|
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when it fails", () => {
|
||||||
|
it("shows an error message", async () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<UsersPage />
|
||||||
|
<GlobalSnackbar />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await updateUserRole(() => {
|
||||||
|
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({})
|
||||||
|
}, MockAuditorRole)
|
||||||
|
|
||||||
|
// Check if the error message is displayed
|
||||||
|
await screen.findByText(usersXServiceLanguage.updateUserRolesError)
|
||||||
|
|
||||||
|
// Check if the API was called correctly
|
||||||
|
const currentRoles = MockUser.roles.map((r) => r.name)
|
||||||
|
expect(API.updateUserRoles).toBeCalledTimes(1)
|
||||||
|
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -13,6 +13,23 @@ export const Language = {
|
|||||||
suspendDialogMessagePrefix: "Do you want to suspend the user",
|
suspendDialogMessagePrefix: "Do you want to suspend the user",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useRoles = () => {
|
||||||
|
const xServices = useContext(XServiceContext)
|
||||||
|
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
|
||||||
|
const { roles } = rolesState.context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch roles on component mount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
rolesSend({
|
||||||
|
type: "GET_ROLES",
|
||||||
|
})
|
||||||
|
}, [rolesSend])
|
||||||
|
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
export const UsersPage: React.FC = () => {
|
export const UsersPage: React.FC = () => {
|
||||||
const xServices = useContext(XServiceContext)
|
const xServices = useContext(XServiceContext)
|
||||||
const [usersState, usersSend] = useActor(xServices.usersXService)
|
const [usersState, usersSend] = useActor(xServices.usersXService)
|
||||||
@ -20,6 +37,7 @@ export const UsersPage: React.FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
|
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
|
||||||
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
|
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
|
||||||
|
const roles = useRoles()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch users on component mount
|
* Fetch users on component mount
|
||||||
@ -28,12 +46,13 @@ export const UsersPage: React.FC = () => {
|
|||||||
usersSend("GET_USERS")
|
usersSend("GET_USERS")
|
||||||
}, [usersSend])
|
}, [usersSend])
|
||||||
|
|
||||||
if (!users) {
|
if (!users || !roles) {
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UsersPageView
|
<UsersPageView
|
||||||
|
roles={roles}
|
||||||
users={users}
|
users={users}
|
||||||
openUserCreationDialog={() => {
|
openUserCreationDialog={() => {
|
||||||
navigate("/users/create")
|
navigate("/users/create")
|
||||||
@ -44,7 +63,15 @@ export const UsersPage: React.FC = () => {
|
|||||||
onResetUserPassword={(user) => {
|
onResetUserPassword={(user) => {
|
||||||
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
|
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
|
||||||
}}
|
}}
|
||||||
|
onUpdateUserRoles={(user, roles) => {
|
||||||
|
usersSend({
|
||||||
|
type: "UPDATE_USER_ROLES",
|
||||||
|
userId: user.id,
|
||||||
|
roles,
|
||||||
|
})
|
||||||
|
}}
|
||||||
error={getUsersError}
|
error={getUsersError}
|
||||||
|
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ComponentMeta, Story } from "@storybook/react"
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { MockUser, MockUser2 } from "../../testHelpers"
|
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
|
||||||
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
|
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -13,8 +13,10 @@ const Template: Story<UsersPageViewProps> = (args) => <UsersPageView {...args} /
|
|||||||
export const Ready = Template.bind({})
|
export const Ready = Template.bind({})
|
||||||
Ready.args = {
|
Ready.args = {
|
||||||
users: [MockUser, MockUser2],
|
users: [MockUser, MockUser2],
|
||||||
|
roles: MockSiteRoles,
|
||||||
}
|
}
|
||||||
export const Empty = Template.bind({})
|
export const Empty = Template.bind({})
|
||||||
Empty.args = {
|
Empty.args = {
|
||||||
users: [],
|
users: [],
|
||||||
|
roles: MockSiteRoles,
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { UserResponse } from "../../api/types"
|
import { UserResponse } from "../../api/types"
|
||||||
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||||
import { Header } from "../../components/Header/Header"
|
import { Header } from "../../components/Header/Header"
|
||||||
import { Margins } from "../../components/Margins/Margins"
|
import { Margins } from "../../components/Margins/Margins"
|
||||||
@ -16,15 +17,21 @@ export interface UsersPageViewProps {
|
|||||||
openUserCreationDialog: () => void
|
openUserCreationDialog: () => void
|
||||||
onSuspendUser: (user: UserResponse) => void
|
onSuspendUser: (user: UserResponse) => void
|
||||||
onResetUserPassword: (user: UserResponse) => void
|
onResetUserPassword: (user: UserResponse) => void
|
||||||
|
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
|
||||||
|
roles: TypesGen.Role[]
|
||||||
error?: unknown
|
error?: unknown
|
||||||
|
isUpdatingUserRoles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
||||||
users,
|
users,
|
||||||
|
roles,
|
||||||
openUserCreationDialog,
|
openUserCreationDialog,
|
||||||
onSuspendUser,
|
onSuspendUser,
|
||||||
onResetUserPassword,
|
onResetUserPassword,
|
||||||
|
onUpdateUserRoles,
|
||||||
error,
|
error,
|
||||||
|
isUpdatingUserRoles,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -33,7 +40,14 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
|||||||
{error ? (
|
{error ? (
|
||||||
<ErrorSummary error={error} />
|
<ErrorSummary error={error} />
|
||||||
) : (
|
) : (
|
||||||
<UsersTable users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} />
|
<UsersTable
|
||||||
|
users={users}
|
||||||
|
onSuspendUser={onSuspendUser}
|
||||||
|
onResetUserPassword={onResetUserPassword}
|
||||||
|
onUpdateUserRoles={onUpdateUserRoles}
|
||||||
|
roles={roles}
|
||||||
|
isUpdatingUserRoles={isUpdatingUserRoles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Margins>
|
</Margins>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
WorkspaceAutostartRequest,
|
WorkspaceAutostartRequest,
|
||||||
WorkspaceResource,
|
WorkspaceResource,
|
||||||
} from "../api/types"
|
} from "../api/types"
|
||||||
import { AuthMethods } from "../api/typesGenerated"
|
import { AuthMethods, Role } from "../api/typesGenerated"
|
||||||
|
|
||||||
export const MockSessionToken = { session_token: "my-session-token" }
|
export const MockSessionToken = { session_token: "my-session-token" }
|
||||||
|
|
||||||
@ -21,6 +21,23 @@ export const MockBuildInfo: BuildInfoResponse = {
|
|||||||
version: "v99.999.9999+c9cdf14",
|
version: "v99.999.9999+c9cdf14",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MockAdminRole: Role = {
|
||||||
|
name: "admin",
|
||||||
|
display_name: "Admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockMemberRole: Role = {
|
||||||
|
name: "member",
|
||||||
|
display_name: "Member",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockAuditorRole: Role = {
|
||||||
|
name: "auditor",
|
||||||
|
display_name: "Auditor",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockSiteRoles = [MockAdminRole, MockAuditorRole, MockMemberRole]
|
||||||
|
|
||||||
export const MockUser: UserResponse = {
|
export const MockUser: UserResponse = {
|
||||||
id: "test-user",
|
id: "test-user",
|
||||||
username: "TestUser",
|
username: "TestUser",
|
||||||
@ -28,7 +45,7 @@ export const MockUser: UserResponse = {
|
|||||||
created_at: "",
|
created_at: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||||
roles: [],
|
roles: [MockAdminRole, MockMemberRole],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockUser2: UserResponse = {
|
export const MockUser2: UserResponse = {
|
||||||
@ -38,7 +55,7 @@ export const MockUser2: UserResponse = {
|
|||||||
created_at: "",
|
created_at: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||||
roles: [],
|
roles: [MockMemberRole],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockOrganization: Organization = {
|
export const MockOrganization: Organization = {
|
||||||
|
@ -51,6 +51,9 @@ export const handlers = [
|
|||||||
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
|
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockAuthMethods))
|
return res(ctx.status(200), ctx.json(M.MockAuthMethods))
|
||||||
}),
|
}),
|
||||||
|
rest.get("/api/v2/users/roles", async (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(M.MockSiteRoles))
|
||||||
|
}),
|
||||||
|
|
||||||
// workspaces
|
// workspaces
|
||||||
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
|
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
|
||||||
|
@ -4,6 +4,7 @@ import { useNavigate } from "react-router"
|
|||||||
import { ActorRefFrom } from "xstate"
|
import { ActorRefFrom } from "xstate"
|
||||||
import { authMachine } from "./auth/authXService"
|
import { authMachine } from "./auth/authXService"
|
||||||
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
||||||
|
import { siteRolesMachine } from "./roles/siteRolesXService"
|
||||||
import { usersMachine } from "./users/usersXService"
|
import { usersMachine } from "./users/usersXService"
|
||||||
import { workspaceMachine } from "./workspace/workspaceXService"
|
import { workspaceMachine } from "./workspace/workspaceXService"
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ interface XServiceContextType {
|
|||||||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||||
usersXService: ActorRefFrom<typeof usersMachine>
|
usersXService: ActorRefFrom<typeof usersMachine>
|
||||||
workspaceXService: ActorRefFrom<typeof workspaceMachine>
|
workspaceXService: ActorRefFrom<typeof workspaceMachine>
|
||||||
|
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,6 +39,7 @@ export const XServiceProvider: React.FC = ({ children }) => {
|
|||||||
buildInfoXService: useInterpret(buildInfoMachine),
|
buildInfoXService: useInterpret(buildInfoMachine),
|
||||||
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
|
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
|
||||||
workspaceXService: useInterpret(workspaceMachine),
|
workspaceXService: useInterpret(workspaceMachine),
|
||||||
|
siteRolesXService: useInterpret(siteRolesMachine),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
75
site/src/xServices/roles/siteRolesXService.ts
Normal file
75
site/src/xServices/roles/siteRolesXService.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { assign, createMachine } from "xstate"
|
||||||
|
import * as API from "../../api"
|
||||||
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
|
import { displayError } from "../../components/GlobalSnackbar/utils"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
getRolesError: "Error on get the roles.",
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteRolesContext = {
|
||||||
|
roles?: TypesGen.Role[]
|
||||||
|
getRolesError: Error | unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteRolesEvent = {
|
||||||
|
type: "GET_ROLES"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const siteRolesMachine = createMachine(
|
||||||
|
{
|
||||||
|
id: "siteRolesState",
|
||||||
|
initial: "idle",
|
||||||
|
schema: {
|
||||||
|
context: {} as SiteRolesContext,
|
||||||
|
events: {} as SiteRolesEvent,
|
||||||
|
services: {
|
||||||
|
getRoles: {
|
||||||
|
data: {} as TypesGen.Role[],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tsTypes: {} as import("./siteRolesXService.typegen").Typegen0,
|
||||||
|
states: {
|
||||||
|
idle: {
|
||||||
|
on: {
|
||||||
|
GET_ROLES: "gettingRoles",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gettingRoles: {
|
||||||
|
entry: "clearGetRolesError",
|
||||||
|
invoke: {
|
||||||
|
id: "getRoles",
|
||||||
|
src: "getRoles",
|
||||||
|
onDone: {
|
||||||
|
target: "idle",
|
||||||
|
actions: ["assignRoles"],
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "idle",
|
||||||
|
actions: ["assignGetRolesError", "displayGetRolesError"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: {
|
||||||
|
assignRoles: assign({
|
||||||
|
roles: (_, event) => event.data,
|
||||||
|
}),
|
||||||
|
assignGetRolesError: assign({
|
||||||
|
getRolesError: (_, event) => event.data,
|
||||||
|
}),
|
||||||
|
displayGetRolesError: () => {
|
||||||
|
displayError(Language.getRolesError)
|
||||||
|
},
|
||||||
|
clearGetRolesError: assign({
|
||||||
|
getRolesError: (_) => undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
getRoles: () => API.getSiteRoles(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
@ -12,6 +12,8 @@ export const Language = {
|
|||||||
suspendUserError: "Error on suspend the user.",
|
suspendUserError: "Error on suspend the user.",
|
||||||
resetUserPasswordSuccess: "Successfully updated the user password.",
|
resetUserPasswordSuccess: "Successfully updated the user password.",
|
||||||
resetUserPasswordError: "Error on reset the user password.",
|
resetUserPasswordError: "Error on reset the user password.",
|
||||||
|
updateUserRolesSuccess: "Successfully updated the user roles.",
|
||||||
|
updateUserRolesError: "Error on update the user roles.",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersContext {
|
export interface UsersContext {
|
||||||
@ -27,6 +29,9 @@ export interface UsersContext {
|
|||||||
userIdToResetPassword?: TypesGen.User["id"]
|
userIdToResetPassword?: TypesGen.User["id"]
|
||||||
resetUserPasswordError?: Error | unknown
|
resetUserPasswordError?: Error | unknown
|
||||||
newUserPassword?: string
|
newUserPassword?: string
|
||||||
|
// Update user roles
|
||||||
|
userIdToUpdateRoles?: TypesGen.User["id"]
|
||||||
|
updateUserRolesError?: Error | unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UsersEvent =
|
export type UsersEvent =
|
||||||
@ -40,6 +45,8 @@ export type UsersEvent =
|
|||||||
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
|
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
|
||||||
| { type: "CONFIRM_USER_PASSWORD_RESET" }
|
| { type: "CONFIRM_USER_PASSWORD_RESET" }
|
||||||
| { type: "CANCEL_USER_PASSWORD_RESET" }
|
| { type: "CANCEL_USER_PASSWORD_RESET" }
|
||||||
|
// Update roles events
|
||||||
|
| { type: "UPDATE_USER_ROLES"; userId: TypesGen.User["id"]; roles: TypesGen.Role["name"][] }
|
||||||
|
|
||||||
export const usersMachine = createMachine(
|
export const usersMachine = createMachine(
|
||||||
{
|
{
|
||||||
@ -60,6 +67,9 @@ export const usersMachine = createMachine(
|
|||||||
updateUserPassword: {
|
updateUserPassword: {
|
||||||
data: undefined
|
data: undefined
|
||||||
}
|
}
|
||||||
|
updateUserRoles: {
|
||||||
|
data: TypesGen.User
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: "usersState",
|
id: "usersState",
|
||||||
@ -80,6 +90,10 @@ export const usersMachine = createMachine(
|
|||||||
target: "confirmUserPasswordReset",
|
target: "confirmUserPasswordReset",
|
||||||
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
|
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
|
||||||
},
|
},
|
||||||
|
UPDATE_USER_ROLES: {
|
||||||
|
target: "updatingUserRoles",
|
||||||
|
actions: ["assignUserIdToUpdateRoles"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
gettingUsers: {
|
gettingUsers: {
|
||||||
@ -166,6 +180,21 @@ export const usersMachine = createMachine(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
updatingUserRoles: {
|
||||||
|
entry: "clearUpdateUserRolesError",
|
||||||
|
invoke: {
|
||||||
|
src: "updateUserRoles",
|
||||||
|
id: "updateUserRoles",
|
||||||
|
onDone: {
|
||||||
|
target: "idle",
|
||||||
|
actions: ["updateUserRolesInTheList"],
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "idle",
|
||||||
|
actions: ["assignUpdateRolesError", "displayUpdateRolesErrorMessage"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
on: {
|
on: {
|
||||||
GET_USERS: "gettingUsers",
|
GET_USERS: "gettingUsers",
|
||||||
@ -198,6 +227,13 @@ export const usersMachine = createMachine(
|
|||||||
|
|
||||||
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
|
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
|
||||||
},
|
},
|
||||||
|
updateUserRoles: (context, event) => {
|
||||||
|
if (!context.userIdToUpdateRoles) {
|
||||||
|
throw new Error("userIdToUpdateRoles is undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
return API.updateUserRoles(event.roles, context.userIdToUpdateRoles)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
isFormError: (_, event) => isApiError(event.data),
|
isFormError: (_, event) => isApiError(event.data),
|
||||||
@ -215,6 +251,9 @@ export const usersMachine = createMachine(
|
|||||||
assignUserIdToResetPassword: assign({
|
assignUserIdToResetPassword: assign({
|
||||||
userIdToResetPassword: (_, event) => event.userId,
|
userIdToResetPassword: (_, event) => event.userId,
|
||||||
}),
|
}),
|
||||||
|
assignUserIdToUpdateRoles: assign({
|
||||||
|
userIdToUpdateRoles: (_, event) => event.userId,
|
||||||
|
}),
|
||||||
clearGetUsersError: assign((context: UsersContext) => ({
|
clearGetUsersError: assign((context: UsersContext) => ({
|
||||||
...context,
|
...context,
|
||||||
getUsersError: undefined,
|
getUsersError: undefined,
|
||||||
@ -232,6 +271,9 @@ export const usersMachine = createMachine(
|
|||||||
assignResetUserPasswordError: assign({
|
assignResetUserPasswordError: assign({
|
||||||
resetUserPasswordError: (_, event) => event.data,
|
resetUserPasswordError: (_, event) => event.data,
|
||||||
}),
|
}),
|
||||||
|
assignUpdateRolesError: assign({
|
||||||
|
updateUserRolesError: (_, event) => event.data,
|
||||||
|
}),
|
||||||
clearCreateUserError: assign((context: UsersContext) => ({
|
clearCreateUserError: assign((context: UsersContext) => ({
|
||||||
...context,
|
...context,
|
||||||
createUserError: undefined,
|
createUserError: undefined,
|
||||||
@ -242,6 +284,9 @@ export const usersMachine = createMachine(
|
|||||||
clearResetUserPasswordError: assign({
|
clearResetUserPasswordError: assign({
|
||||||
resetUserPasswordError: (_) => undefined,
|
resetUserPasswordError: (_) => undefined,
|
||||||
}),
|
}),
|
||||||
|
clearUpdateUserRolesError: assign({
|
||||||
|
updateUserRolesError: (_) => undefined,
|
||||||
|
}),
|
||||||
displayCreateUserSuccess: () => {
|
displayCreateUserSuccess: () => {
|
||||||
displaySuccess(Language.createUserSuccess)
|
displaySuccess(Language.createUserSuccess)
|
||||||
},
|
},
|
||||||
@ -257,9 +302,23 @@ export const usersMachine = createMachine(
|
|||||||
displayResetPasswordErrorMessage: () => {
|
displayResetPasswordErrorMessage: () => {
|
||||||
displayError(Language.resetUserPasswordError)
|
displayError(Language.resetUserPasswordError)
|
||||||
},
|
},
|
||||||
|
displayUpdateRolesErrorMessage: () => {
|
||||||
|
displayError(Language.updateUserRolesError)
|
||||||
|
},
|
||||||
generateRandomPassword: assign({
|
generateRandomPassword: assign({
|
||||||
newUserPassword: (_) => generateRandomString(12),
|
newUserPassword: (_) => generateRandomString(12),
|
||||||
}),
|
}),
|
||||||
|
updateUserRolesInTheList: assign({
|
||||||
|
users: ({ users }, event) => {
|
||||||
|
if (!users) {
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.map((u) => {
|
||||||
|
return u.id === event.data.id ? event.data : u
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user