mirror of https://github.com/coder/coder.git
feat: Add update user roles action (#1361)
This commit is contained in:
parent
c96d439f3d
commit
2df92e6fd3
|
@ -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> =>
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
|
||||
export const TableHeaderRow: React.FC = ({ children }) => {
|
||||
const styles = useStyles()
|
||||
return <TableRow className={styles.root}>{children}</TableRow>
|
||||
}
|
||||
|
||||
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
|
||||
return (
|
||||
<TableRow className={styles.root}>
|
||||
<TableHeaderRow>
|
||||
{columns.map((c, idx) => (
|
||||
<TableCell key={idx} size="small">
|
||||
{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 */}
|
||||
{hasMenu && <TableCell width="1%" />}
|
||||
</TableRow>
|
||||
</TableHeaderRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockUser, MockUser2 } from "../../testHelpers"
|
||||
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
|
||||
import { UsersTable, UsersTableProps } from "./UsersTable"
|
||||
|
||||
export default {
|
||||
|
@ -13,9 +13,11 @@ const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
|
|||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
users: [MockUser, MockUser2],
|
||||
roles: MockSiteRoles,
|
||||
}
|
||||
|
||||
export const Empty = Template.bind({})
|
||||
Empty.args = {
|
||||
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 { UserResponse } from "../../api/types"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
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 { TableTitle } from "../TableTitle/TableTitle"
|
||||
import { UserCell } from "../UserCell/UserCell"
|
||||
|
||||
export const Language = {
|
||||
|
@ -12,48 +21,79 @@ export const Language = {
|
|||
usernameLabel: "User",
|
||||
suspendMenuItem: "Suspend",
|
||||
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 {
|
||||
users: UserResponse[]
|
||||
onSuspendUser: (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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
title={Language.usersTitle}
|
||||
emptyState={emptyState}
|
||||
rowMenu={(user) => (
|
||||
<TableRowMenu
|
||||
data={user}
|
||||
menuItems={[
|
||||
{
|
||||
label: Language.suspendMenuItem,
|
||||
onClick: onSuspendUser,
|
||||
},
|
||||
{
|
||||
label: Language.resetPasswordMenuItem,
|
||||
onClick: onResetUserPassword,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableTitle title={Language.usersTitle} />
|
||||
<TableHeaderRow>
|
||||
<TableCell size="small">{Language.usernameLabel}</TableCell>
|
||||
<TableCell size="small">{Language.rolesLabel}</TableCell>
|
||||
{/* 1% is a trick to make the table cell width fit the content */}
|
||||
<TableCell size="small" width="1%" />
|
||||
</TableHeaderRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
|
||||
</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 React from "react"
|
||||
import * as API from "../../api"
|
||||
import { Role } from "../../api/typesGenerated"
|
||||
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
|
||||
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 { MockUser, MockUser2, render } from "../../testHelpers"
|
||||
import { MockAuditorRole, MockUser, MockUser2, render } from "../../testHelpers"
|
||||
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
|
||||
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
|
||||
|
||||
|
@ -62,6 +64,34 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
|
|||
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", () => {
|
||||
it("shows users", async () => {
|
||||
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",
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [usersState, usersSend] = useActor(xServices.usersXService)
|
||||
|
@ -20,6 +37,7 @@ export const UsersPage: React.FC = () => {
|
|||
const navigate = useNavigate()
|
||||
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
|
||||
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
|
||||
const roles = useRoles()
|
||||
|
||||
/**
|
||||
* Fetch users on component mount
|
||||
|
@ -28,12 +46,13 @@ export const UsersPage: React.FC = () => {
|
|||
usersSend("GET_USERS")
|
||||
}, [usersSend])
|
||||
|
||||
if (!users) {
|
||||
if (!users || !roles) {
|
||||
return <FullScreenLoader />
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<UsersPageView
|
||||
roles={roles}
|
||||
users={users}
|
||||
openUserCreationDialog={() => {
|
||||
navigate("/users/create")
|
||||
|
@ -44,7 +63,15 @@ export const UsersPage: React.FC = () => {
|
|||
onResetUserPassword={(user) => {
|
||||
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
|
||||
}}
|
||||
onUpdateUserRoles={(user, roles) => {
|
||||
usersSend({
|
||||
type: "UPDATE_USER_ROLES",
|
||||
userId: user.id,
|
||||
roles,
|
||||
})
|
||||
}}
|
||||
error={getUsersError}
|
||||
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockUser, MockUser2 } from "../../testHelpers"
|
||||
import { MockSiteRoles, MockUser, MockUser2 } from "../../testHelpers"
|
||||
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
|
||||
|
||||
export default {
|
||||
|
@ -13,8 +13,10 @@ const Template: Story<UsersPageViewProps> = (args) => <UsersPageView {...args} /
|
|||
export const Ready = Template.bind({})
|
||||
Ready.args = {
|
||||
users: [MockUser, MockUser2],
|
||||
roles: MockSiteRoles,
|
||||
}
|
||||
export const Empty = Template.bind({})
|
||||
Empty.args = {
|
||||
users: [],
|
||||
roles: MockSiteRoles,
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react"
|
||||
import { UserResponse } from "../../api/types"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { Header } from "../../components/Header/Header"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
|
@ -16,15 +17,21 @@ export interface UsersPageViewProps {
|
|||
openUserCreationDialog: () => void
|
||||
onSuspendUser: (user: UserResponse) => void
|
||||
onResetUserPassword: (user: UserResponse) => void
|
||||
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
|
||||
roles: TypesGen.Role[]
|
||||
error?: unknown
|
||||
isUpdatingUserRoles?: boolean
|
||||
}
|
||||
|
||||
export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
||||
users,
|
||||
roles,
|
||||
openUserCreationDialog,
|
||||
onSuspendUser,
|
||||
onResetUserPassword,
|
||||
onUpdateUserRoles,
|
||||
error,
|
||||
isUpdatingUserRoles,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
|
@ -33,7 +40,14 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
|||
{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>
|
||||
</Stack>
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
WorkspaceAutostartRequest,
|
||||
WorkspaceResource,
|
||||
} from "../api/types"
|
||||
import { AuthMethods } from "../api/typesGenerated"
|
||||
import { AuthMethods, Role } from "../api/typesGenerated"
|
||||
|
||||
export const MockSessionToken = { session_token: "my-session-token" }
|
||||
|
||||
|
@ -21,6 +21,23 @@ export const MockBuildInfo: BuildInfoResponse = {
|
|||
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 = {
|
||||
id: "test-user",
|
||||
username: "TestUser",
|
||||
|
@ -28,7 +45,7 @@ export const MockUser: UserResponse = {
|
|||
created_at: "",
|
||||
status: "active",
|
||||
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||
roles: [],
|
||||
roles: [MockAdminRole, MockMemberRole],
|
||||
}
|
||||
|
||||
export const MockUser2: UserResponse = {
|
||||
|
@ -38,7 +55,7 @@ export const MockUser2: UserResponse = {
|
|||
created_at: "",
|
||||
status: "active",
|
||||
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||
roles: [],
|
||||
roles: [MockMemberRole],
|
||||
}
|
||||
|
||||
export const MockOrganization: Organization = {
|
||||
|
|
|
@ -51,6 +51,9 @@ export const handlers = [
|
|||
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
|
||||
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
|
||||
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 { authMachine } from "./auth/authXService"
|
||||
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
||||
import { siteRolesMachine } from "./roles/siteRolesXService"
|
||||
import { usersMachine } from "./users/usersXService"
|
||||
import { workspaceMachine } from "./workspace/workspaceXService"
|
||||
|
||||
|
@ -12,6 +13,7 @@ interface XServiceContextType {
|
|||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||
usersXService: ActorRefFrom<typeof usersMachine>
|
||||
workspaceXService: ActorRefFrom<typeof workspaceMachine>
|
||||
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -37,6 +39,7 @@ export const XServiceProvider: React.FC = ({ children }) => {
|
|||
buildInfoXService: useInterpret(buildInfoMachine),
|
||||
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
|
||||
workspaceXService: useInterpret(workspaceMachine),
|
||||
siteRolesXService: useInterpret(siteRolesMachine),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -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.",
|
||||
resetUserPasswordSuccess: "Successfully updated 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 {
|
||||
|
@ -27,6 +29,9 @@ export interface UsersContext {
|
|||
userIdToResetPassword?: TypesGen.User["id"]
|
||||
resetUserPasswordError?: Error | unknown
|
||||
newUserPassword?: string
|
||||
// Update user roles
|
||||
userIdToUpdateRoles?: TypesGen.User["id"]
|
||||
updateUserRolesError?: Error | unknown
|
||||
}
|
||||
|
||||
export type UsersEvent =
|
||||
|
@ -40,6 +45,8 @@ export type UsersEvent =
|
|||
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
|
||||
| { type: "CONFIRM_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(
|
||||
{
|
||||
|
@ -60,6 +67,9 @@ export const usersMachine = createMachine(
|
|||
updateUserPassword: {
|
||||
data: undefined
|
||||
}
|
||||
updateUserRoles: {
|
||||
data: TypesGen.User
|
||||
}
|
||||
},
|
||||
},
|
||||
id: "usersState",
|
||||
|
@ -80,6 +90,10 @@ export const usersMachine = createMachine(
|
|||
target: "confirmUserPasswordReset",
|
||||
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
|
||||
},
|
||||
UPDATE_USER_ROLES: {
|
||||
target: "updatingUserRoles",
|
||||
actions: ["assignUserIdToUpdateRoles"],
|
||||
},
|
||||
},
|
||||
},
|
||||
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: {
|
||||
on: {
|
||||
GET_USERS: "gettingUsers",
|
||||
|
@ -198,6 +227,13 @@ export const usersMachine = createMachine(
|
|||
|
||||
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: {
|
||||
isFormError: (_, event) => isApiError(event.data),
|
||||
|
@ -215,6 +251,9 @@ export const usersMachine = createMachine(
|
|||
assignUserIdToResetPassword: assign({
|
||||
userIdToResetPassword: (_, event) => event.userId,
|
||||
}),
|
||||
assignUserIdToUpdateRoles: assign({
|
||||
userIdToUpdateRoles: (_, event) => event.userId,
|
||||
}),
|
||||
clearGetUsersError: assign((context: UsersContext) => ({
|
||||
...context,
|
||||
getUsersError: undefined,
|
||||
|
@ -232,6 +271,9 @@ export const usersMachine = createMachine(
|
|||
assignResetUserPasswordError: assign({
|
||||
resetUserPasswordError: (_, event) => event.data,
|
||||
}),
|
||||
assignUpdateRolesError: assign({
|
||||
updateUserRolesError: (_, event) => event.data,
|
||||
}),
|
||||
clearCreateUserError: assign((context: UsersContext) => ({
|
||||
...context,
|
||||
createUserError: undefined,
|
||||
|
@ -242,6 +284,9 @@ export const usersMachine = createMachine(
|
|||
clearResetUserPasswordError: assign({
|
||||
resetUserPasswordError: (_) => undefined,
|
||||
}),
|
||||
clearUpdateUserRolesError: assign({
|
||||
updateUserRolesError: (_) => undefined,
|
||||
}),
|
||||
displayCreateUserSuccess: () => {
|
||||
displaySuccess(Language.createUserSuccess)
|
||||
},
|
||||
|
@ -257,9 +302,23 @@ export const usersMachine = createMachine(
|
|||
displayResetPasswordErrorMessage: () => {
|
||||
displayError(Language.resetUserPasswordError)
|
||||
},
|
||||
displayUpdateRolesErrorMessage: () => {
|
||||
displayError(Language.updateUserRolesError)
|
||||
},
|
||||
generateRandomPassword: assign({
|
||||
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
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue