diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 5384d95304b63..d300c7aa8a660 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -158,3 +158,16 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise => axios.put(`/api/v2/users/${userId}/password`, { password }) + +export const getSiteRoles = async (): Promise> => { + const response = await axios.get>(`/api/v2/users/roles`) + return response.data +} + +export const updateUserRoles = async ( + roles: TypesGen.Role["name"][], + userId: TypesGen.User["id"], +): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/roles`, { roles }) + return response.data +} diff --git a/site/src/components/RoleSelect/RoleSelect.stories.tsx b/site/src/components/RoleSelect/RoleSelect.stories.tsx new file mode 100644 index 0000000000000..3fe6134a81580 --- /dev/null +++ b/site/src/components/RoleSelect/RoleSelect.stories.tsx @@ -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 + +const Template: Story = (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], +} diff --git a/site/src/components/RoleSelect/RoleSelect.tsx b/site/src/components/RoleSelect/RoleSelect.tsx new file mode 100644 index 0000000000000..2527521e4d794 --- /dev/null +++ b/site/src/components/RoleSelect/RoleSelect.tsx @@ -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 = ({ 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 ( + + ) +} + +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), + }, +})) diff --git a/site/src/components/TableHeaders/TableHeaders.tsx b/site/src/components/TableHeaders/TableHeaders.tsx index 6004939e449ba..eafcac500206c 100644 --- a/site/src/components/TableHeaders/TableHeaders.tsx +++ b/site/src/components/TableHeaders/TableHeaders.tsx @@ -8,10 +8,14 @@ export interface TableHeadersProps { hasMenu?: boolean } -export const TableHeaders: React.FC = ({ columns, hasMenu }) => { +export const TableHeaderRow: React.FC = ({ children }) => { const styles = useStyles() + return {children} +} + +export const TableHeaders: React.FC = ({ columns, hasMenu }) => { return ( - + {columns.map((c, idx) => ( {c} @@ -19,7 +23,7 @@ export const TableHeaders: React.FC = ({ columns, hasMenu }) ))} {/* 1% is a trick to make the table cell width fit the content */} {hasMenu && } - + ) } diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index e64e8163e1879..448a11cac0680 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -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 = (args) => export const Example = Template.bind({}) Example.args = { users: [MockUser, MockUser2], + roles: MockSiteRoles, } export const Empty = Template.bind({}) Empty.args = { users: [], + roles: MockSiteRoles, } diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index bf5fb2298dd10..913ec85ea7c08 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -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 = - -const columns: Column[] = [ - { - key: "username", - name: Language.usernameLabel, - renderer: (field, data) => { - return - }, - }, -] - 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 = ({ users, onSuspendUser, onResetUserPassword }) => { +export const UsersTable: React.FC = ({ + users, + roles, + onSuspendUser, + onResetUserPassword, + onUpdateUserRoles, + isUpdatingUserRoles, +}) => { return ( - ( - - )} - /> +
+ + + + {Language.usernameLabel} + {Language.rolesLabel} + {/* 1% is a trick to make the table cell width fit the content */} + + + + + {users.map((u) => ( + + + {" "} + + + onUpdateUserRoles(u, roles)} + /> + + + + + + ))} + + {users.length === 0 && ( + + + + + + + + )} + +
) } diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 2aeb7f21ba31f..04a9ef6e41c2c 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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() @@ -164,4 +194,55 @@ describe("Users Page", () => { }) }) }) + + describe("Update user role", () => { + describe("when it is success", () => { + it("updates the roles", async () => { + render( + <> + + + , + ) + + 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( + <> + + + , + ) + + 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) + }) + }) + }) }) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 0ce09831728f7..5c7d4c0e968e8 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 } else { return ( <> { 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")} /> = (args) => 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 = ({ users, + roles, openUserCreationDialog, onSuspendUser, onResetUserPassword, + onUpdateUserRoles, error, + isUpdatingUserRoles, }) => { return ( @@ -33,7 +40,14 @@ export const UsersPageView: React.FC = ({ {error ? ( ) : ( - + )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1ac27313d6092..cb978bc43cad2 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index dbc2334c1385d..1f708476f1149 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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) => { diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index a94c4ced3494d..7606b626c881b 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -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 usersXService: ActorRefFrom workspaceXService: ActorRefFrom + siteRolesXService: ActorRefFrom } /** @@ -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} diff --git a/site/src/xServices/roles/siteRolesXService.ts b/site/src/xServices/roles/siteRolesXService.ts new file mode 100644 index 0000000000000..d99ba3db8a235 --- /dev/null +++ b/site/src/xServices/roles/siteRolesXService.ts @@ -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(), + }, + }, +) diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index e1493bfaa139c..dd3d63277072f 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -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 + }) + }, + }), }, }, )