feat: Add update user roles action (#1361)

This commit is contained in:
Bruno Quaresma
2022-05-10 14:13:07 -05:00
committed by GitHub
parent c96d439f3d
commit 2df92e6fd3
15 changed files with 469 additions and 46 deletions

View File

@ -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
}

View 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],
}

View 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),
},
}))

View File

@ -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>
) )
} }

View File

@ -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,
} }

View File

@ -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>
) )
} }

View File

@ -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)
})
})
})
}) })

View File

@ -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

View File

@ -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,
} }

View File

@ -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>

View File

@ -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 = {

View File

@ -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) => {

View File

@ -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}

View 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(),
},
},
)

View File

@ -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
})
},
}),
}, },
}, },
) )