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 + }) + }, + }), }, }, ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy