From 32cba8367ce1d6cce65edfe68c7ca3dd30f3fa3e Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 6 May 2022 17:24:03 +0000 Subject: [PATCH 01/17] Load roles and show them in the table --- site/src/api/index.ts | 5 + .../components/TableHeaders/TableHeaders.tsx | 10 +- site/src/components/UsersTable/UsersTable.tsx | 92 ++++++++++++------- site/src/pages/UsersPage/UsersPage.tsx | 27 +++++- site/src/pages/UsersPage/UsersPageView.tsx | 9 +- site/src/xServices/StateContext.tsx | 3 + site/src/xServices/roles/rolesXService.ts | 82 +++++++++++++++++ 7 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 site/src/xServices/roles/rolesXService.ts diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 5384d95304b63..d4508d590652b 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -158,3 +158,8 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise => axios.put(`/api/v2/users/${userId}/password`, { password }) + +export const getOrganizationRoles = async (organizationId: string): Promise> => { + const response = await axios.get>(`/api/v2/organizations/${organizationId}/members/roles`) + return response.data +} 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.tsx b/site/src/components/UsersTable/UsersTable.tsx index bf5fb2298dd10..d5305ffc3e80b 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -1,8 +1,15 @@ +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 { EmptyState } from "../EmptyState/EmptyState" -import { Column, Table } from "../Table/Table" +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 +19,63 @@ 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 + roles: string[] } -export const UsersTable: React.FC = ({ users, onSuspendUser, onResetUserPassword }) => { +export const UsersTable: React.FC = ({ users, roles, onSuspendUser, onResetUserPassword }) => { return ( - ( - - )} - /> +
+ + + + {Language.usernameLabel} + {Language.rolesLabel} + {/* 1% is a trick to make the table cell width fit the content */} + + + + + {users.map((u) => ( + + + {" "} + + {roles} + + + + + ))} + + {users.length === 0 && ( + + + + + + + + )} + +
) } diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 0ce09831728f7..d70d8fdac305f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -13,6 +13,29 @@ export const Language = { suspendDialogMessagePrefix: "Do you want to suspend the user", } +const useRoles = () => { + const xServices = useContext(XServiceContext) + const [authState] = useActor(xServices.authXService) + const [rolesState, rolesSend] = useActor(xServices.rolesXService) + const { roles } = rolesState.context + const { me } = authState.context + + useEffect(() => { + if (!me) { + throw new Error("User is not logged in") + } + + const organizationId = me.organization_ids[0] + + rolesSend({ + type: "GET_ROLES", + organizationId, + }) + }, [me, rolesSend]) + + return roles +} + export const UsersPage: React.FC = () => { const xServices = useContext(XServiceContext) const [usersState, usersSend] = useActor(xServices.usersXService) @@ -20,6 +43,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 +52,13 @@ export const UsersPage: React.FC = () => { usersSend("GET_USERS") }, [usersSend]) - if (!users) { + if (!users || !roles) { return } else { return ( <> { navigate("/users/create") diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 4872e02d76085..7bf7abc0bf06b 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -16,11 +16,13 @@ export interface UsersPageViewProps { openUserCreationDialog: () => void onSuspendUser: (user: UserResponse) => void onResetUserPassword: (user: UserResponse) => void + roles: string[] error?: unknown } export const UsersPageView: React.FC = ({ users, + roles, openUserCreationDialog, onSuspendUser, onResetUserPassword, @@ -33,7 +35,12 @@ export const UsersPageView: React.FC = ({ {error ? ( ) : ( - + )} diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index a94c4ced3494d..716dfb52fdabb 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 { rolesMachine } from "./roles/rolesXService" import { usersMachine } from "./users/usersXService" import { workspaceMachine } from "./workspace/workspaceXService" @@ -12,6 +13,7 @@ interface XServiceContextType { buildInfoXService: ActorRefFrom usersXService: ActorRefFrom workspaceXService: ActorRefFrom + rolesXService: ActorRefFrom } /** @@ -37,6 +39,7 @@ export const XServiceProvider: React.FC = ({ children }) => { buildInfoXService: useInterpret(buildInfoMachine), usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })), workspaceXService: useInterpret(workspaceMachine), + rolesXService: useInterpret(rolesMachine), }} > {children} diff --git a/site/src/xServices/roles/rolesXService.ts b/site/src/xServices/roles/rolesXService.ts new file mode 100644 index 0000000000000..4f70fe9621cd0 --- /dev/null +++ b/site/src/xServices/roles/rolesXService.ts @@ -0,0 +1,82 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api" +import { displayError } from "../../components/GlobalSnackbar/utils" + +type RolesContext = { + roles?: string[] + getRolesError: Error | unknown + organizationId?: string +} + +type RolesEvent = { + type: "GET_ROLES" + organizationId: string +} + +export const rolesMachine = createMachine( + { + id: "rolesState", + initial: "idle", + schema: { + context: {} as RolesContext, + events: {} as RolesEvent, + services: { + getRoles: { + data: {} as string[], + }, + }, + }, + tsTypes: {} as import("./rolesXService.typegen").Typegen0, + states: { + idle: { + on: { + GET_ROLES: { + target: "gettingRoles", + actions: ["assignOrganizationId"], + }, + }, + }, + gettingRoles: { + 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, + }), + assignOrganizationId: assign({ + organizationId: (_, event) => event.organizationId, + }), + displayGetRolesError: () => { + displayError("Error on get the roles.") + }, + }, + services: { + getRoles: (ctx) => { + const { organizationId } = ctx + + if (!organizationId) { + throw new Error("organizationId not defined") + } + + return API.getOrganizationRoles(organizationId) + }, + }, + }, +) From b1679959ff9cd38d13a47bef6aafbafbc193edc2 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 6 May 2022 19:26:31 +0000 Subject: [PATCH 02/17] Update types --- site/src/api/typesGenerated.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e3fa4e53fc0d5..3f9577878632c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -195,6 +195,12 @@ export interface ProvisionerJobLog { readonly output: string } +// From codersdk/roles.go:13:6 +export interface Role { + readonly name: string + readonly display_name: string +} + // From codersdk/templates.go:17:6 export interface Template { readonly id: string From cabe38559c2c8390fd8b7230f7e7a371e72ed0f7 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 6 May 2022 19:29:38 +0000 Subject: [PATCH 03/17] Update API --- site/src/api/index.ts | 4 ++-- site/src/components/UsersTable/UsersTable.tsx | 5 +++-- site/src/pages/UsersPage/UsersPageView.tsx | 3 ++- site/src/xServices/roles/rolesXService.ts | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index d4508d590652b..4cbc4852a5640 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -159,7 +159,7 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise => axios.put(`/api/v2/users/${userId}/password`, { password }) -export const getOrganizationRoles = async (organizationId: string): Promise> => { - const response = await axios.get>(`/api/v2/organizations/${organizationId}/members/roles`) +export const getOrganizationRoles = async (organizationId: string): Promise> => { + const response = await axios.get>(`/api/v2/organizations/${organizationId}/members/roles`) return response.data } diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index d5305ffc3e80b..352f5dec97dc8 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -6,6 +6,7 @@ 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 { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TableRowMenu } from "../TableRowMenu/TableRowMenu" @@ -26,7 +27,7 @@ export interface UsersTableProps { users: UserResponse[] onSuspendUser: (user: UserResponse) => void onResetUserPassword: (user: UserResponse) => void - roles: string[] + roles: TypesGen.Role[] } export const UsersTable: React.FC = ({ users, roles, onSuspendUser, onResetUserPassword }) => { @@ -47,7 +48,7 @@ export const UsersTable: React.FC = ({ users, roles, onSuspendU {" "} - {roles} + {roles.map((r) => r.display_name)} void onSuspendUser: (user: UserResponse) => void onResetUserPassword: (user: UserResponse) => void - roles: string[] + roles: TypesGen.Role[] error?: unknown } diff --git a/site/src/xServices/roles/rolesXService.ts b/site/src/xServices/roles/rolesXService.ts index 4f70fe9621cd0..98edfd99fcc23 100644 --- a/site/src/xServices/roles/rolesXService.ts +++ b/site/src/xServices/roles/rolesXService.ts @@ -1,9 +1,10 @@ import { assign, createMachine } from "xstate" import * as API from "../../api" +import * as TypesGen from "../../api/typesGenerated" import { displayError } from "../../components/GlobalSnackbar/utils" type RolesContext = { - roles?: string[] + roles?: TypesGen.Role[] getRolesError: Error | unknown organizationId?: string } @@ -22,7 +23,7 @@ export const rolesMachine = createMachine( events: {} as RolesEvent, services: { getRoles: { - data: {} as string[], + data: {} as TypesGen.Role[], }, }, }, From b71ede5e2bc5f6642499c457d9e536176d5b00cb Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 6 May 2022 19:45:49 +0000 Subject: [PATCH 04/17] Update roles to fetch the site roles --- site/src/api/index.ts | 4 +-- site/src/components/UsersTable/UsersTable.tsx | 12 ++++++- site/src/pages/UsersPage/UsersPage.tsx | 2 +- site/src/xServices/StateContext.tsx | 6 ++-- ...{rolesXService.ts => siteRolesXService.ts} | 33 +++++-------------- 5 files changed, 26 insertions(+), 31 deletions(-) rename site/src/xServices/roles/{rolesXService.ts => siteRolesXService.ts} (62%) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 4cbc4852a5640..d1a3615959563 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -159,7 +159,7 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise => axios.put(`/api/v2/users/${userId}/password`, { password }) -export const getOrganizationRoles = async (organizationId: string): Promise> => { - const response = await axios.get>(`/api/v2/organizations/${organizationId}/members/roles`) +export const getSiteRoles = async (): Promise> => { + const response = await axios.get>(`/api/v2/users/roles`) return response.data } diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 352f5dec97dc8..4c2c5f2040097 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -1,4 +1,6 @@ import Box from "@material-ui/core/Box" +import MenuItem from "@material-ui/core/MenuItem" +import Select from "@material-ui/core/Select" import Table from "@material-ui/core/Table" import TableBody from "@material-ui/core/TableBody" import TableCell from "@material-ui/core/TableCell" @@ -48,7 +50,15 @@ export const UsersTable: React.FC = ({ users, roles, onSuspendU {" "} - {roles.map((r) => r.display_name)} + + + { const xServices = useContext(XServiceContext) const [authState] = useActor(xServices.authXService) - const [rolesState, rolesSend] = useActor(xServices.rolesXService) + const [rolesState, rolesSend] = useActor(xServices.siteRolesXService) const { roles } = rolesState.context const { me } = authState.context diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index 716dfb52fdabb..7606b626c881b 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router" import { ActorRefFrom } from "xstate" import { authMachine } from "./auth/authXService" import { buildInfoMachine } from "./buildInfo/buildInfoXService" -import { rolesMachine } from "./roles/rolesXService" +import { siteRolesMachine } from "./roles/siteRolesXService" import { usersMachine } from "./users/usersXService" import { workspaceMachine } from "./workspace/workspaceXService" @@ -13,7 +13,7 @@ interface XServiceContextType { buildInfoXService: ActorRefFrom usersXService: ActorRefFrom workspaceXService: ActorRefFrom - rolesXService: ActorRefFrom + siteRolesXService: ActorRefFrom } /** @@ -39,7 +39,7 @@ export const XServiceProvider: React.FC = ({ children }) => { buildInfoXService: useInterpret(buildInfoMachine), usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })), workspaceXService: useInterpret(workspaceMachine), - rolesXService: useInterpret(rolesMachine), + siteRolesXService: useInterpret(siteRolesMachine), }} > {children} diff --git a/site/src/xServices/roles/rolesXService.ts b/site/src/xServices/roles/siteRolesXService.ts similarity index 62% rename from site/src/xServices/roles/rolesXService.ts rename to site/src/xServices/roles/siteRolesXService.ts index 98edfd99fcc23..f2e002b6f0e2b 100644 --- a/site/src/xServices/roles/rolesXService.ts +++ b/site/src/xServices/roles/siteRolesXService.ts @@ -3,38 +3,34 @@ import * as API from "../../api" import * as TypesGen from "../../api/typesGenerated" import { displayError } from "../../components/GlobalSnackbar/utils" -type RolesContext = { +type SiteRolesContext = { roles?: TypesGen.Role[] getRolesError: Error | unknown - organizationId?: string } -type RolesEvent = { +type SiteRolesEvent = { type: "GET_ROLES" organizationId: string } -export const rolesMachine = createMachine( +export const siteRolesMachine = createMachine( { - id: "rolesState", + id: "siteRolesState", initial: "idle", schema: { - context: {} as RolesContext, - events: {} as RolesEvent, + context: {} as SiteRolesContext, + events: {} as SiteRolesEvent, services: { getRoles: { data: {} as TypesGen.Role[], }, }, }, - tsTypes: {} as import("./rolesXService.typegen").Typegen0, + tsTypes: {} as import("./siteRolesXService.typegen").Typegen0, states: { idle: { on: { - GET_ROLES: { - target: "gettingRoles", - actions: ["assignOrganizationId"], - }, + GET_ROLES: "gettingRoles", }, }, gettingRoles: { @@ -61,23 +57,12 @@ export const rolesMachine = createMachine( assignGetRolesError: assign({ getRolesError: (_, event) => event.data, }), - assignOrganizationId: assign({ - organizationId: (_, event) => event.organizationId, - }), displayGetRolesError: () => { displayError("Error on get the roles.") }, }, services: { - getRoles: (ctx) => { - const { organizationId } = ctx - - if (!organizationId) { - throw new Error("organizationId not defined") - } - - return API.getOrganizationRoles(organizationId) - }, + getRoles: () => API.getSiteRoles(), }, }, ) From 1e99a0406d3b32b7a6c21897bbef35bf7e4e480c Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 9 May 2022 17:45:11 +0000 Subject: [PATCH 05/17] Add UI for update user roles --- site/src/api/index.ts | 5 ++ site/src/components/UsersTable/RoleSelect.tsx | 49 +++++++++++++++ site/src/components/UsersTable/UsersTable.tsx | 27 +++++--- site/src/pages/UsersPage/UsersPage.tsx | 8 +++ site/src/pages/UsersPage/UsersPageView.tsx | 6 ++ site/src/xServices/users/usersXService.ts | 61 +++++++++++++++++++ 6 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 site/src/components/UsersTable/RoleSelect.tsx diff --git a/site/src/api/index.ts b/site/src/api/index.ts index d1a3615959563..3825c5631d048 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -163,3 +163,8 @@ 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 => axios.put(`/api/v2/users/${userId}/roles`, { roles }) diff --git a/site/src/components/UsersTable/RoleSelect.tsx b/site/src/components/UsersTable/RoleSelect.tsx new file mode 100644 index 0000000000000..3214e7d754b32 --- /dev/null +++ b/site/src/components/UsersTable/RoleSelect.tsx @@ -0,0 +1,49 @@ +import Checkbox from "@material-ui/core/Checkbox" +import MenuItem from "@material-ui/core/MenuItem" +import Select from "@material-ui/core/Select" +import makeStyles from "@material-ui/styles/makeStyles" +import React from "react" +import { Role } from "../../api/typesGenerated" + +export interface RoleSelectProps { + roles: Role[] + selectedRoles: Role[] + onChange: (roles: Role["name"][]) => void + loading?: boolean +} + +export const RoleSelect: React.FC = ({ roles, selectedRoles, loading, onChange }) => { + const styles = useStyles() + const value = selectedRoles.map((r) => r.name) + const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ") + + return ( + + ) +} + +const useStyles = makeStyles(() => ({ + select: { + margin: 0, + }, +})) diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 4c2c5f2040097..a0b0b2c8c1781 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -1,6 +1,4 @@ import Box from "@material-ui/core/Box" -import MenuItem from "@material-ui/core/MenuItem" -import Select from "@material-ui/core/Select" import Table from "@material-ui/core/Table" import TableBody from "@material-ui/core/TableBody" import TableCell from "@material-ui/core/TableCell" @@ -14,6 +12,7 @@ import { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TableRowMenu } from "../TableRowMenu/TableRowMenu" import { TableTitle } from "../TableTitle/TableTitle" import { UserCell } from "../UserCell/UserCell" +import { RoleSelect } from "./RoleSelect" export const Language = { pageTitle: "Users", @@ -29,10 +28,19 @@ 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, roles, onSuspendUser, onResetUserPassword }) => { +export const UsersTable: React.FC = ({ + users, + roles, + onSuspendUser, + onResetUserPassword, + onUpdateUserRoles, + isUpdatingUserRoles, +}) => { return ( @@ -51,13 +59,12 @@ export const UsersTable: React.FC = ({ users, roles, onSuspendU {" "} - + onUpdateUserRoles(u, roles)} + /> { 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")} /> 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 = ({ @@ -27,7 +29,9 @@ export const UsersPageView: React.FC = ({ openUserCreationDialog, onSuspendUser, onResetUserPassword, + onUpdateUserRoles, error, + isUpdatingUserRoles, }) => { return ( @@ -40,7 +44,9 @@ export const UsersPageView: React.FC = ({ users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} + onUpdateUserRoles={onUpdateUserRoles} roles={roles} + isUpdatingUserRoles={isUpdatingUserRoles} /> )} diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index e1493bfaa139c..63182e19e1101 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: ["displayUpdateRolesSuccess", "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,25 @@ export const usersMachine = createMachine( displayResetPasswordErrorMessage: () => { displayError(Language.resetUserPasswordError) }, + displayUpdateRolesSuccess: () => { + displayError(Language.updateUserRolesSuccess) + }, + displayUpdateRolesErrorMessage: () => { + displayError(Language.updateUserRolesError) + }, generateRandomPassword: assign({ newUserPassword: (_) => generateRandomString(12), }), + updateUserRolesInTheList: assign({ + users: ({ users }, event) => { + if (!users) { + return users + } + + const updatedUser = event.data + return users.map((u) => (u.id === updatedUser.id ? updatedUser : u)) + }, + }), }, }, ) From 46add4173af26d7521a1bab5e918edee41eea9fb Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 9 May 2022 17:49:46 +0000 Subject: [PATCH 06/17] Set fixed RoleSelect width --- site/src/components/UsersTable/RoleSelect.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/components/UsersTable/RoleSelect.tsx b/site/src/components/UsersTable/RoleSelect.tsx index 3214e7d754b32..8f0dbb7d5fe77 100644 --- a/site/src/components/UsersTable/RoleSelect.tsx +++ b/site/src/components/UsersTable/RoleSelect.tsx @@ -1,7 +1,7 @@ import Checkbox from "@material-ui/core/Checkbox" import MenuItem from "@material-ui/core/MenuItem" import Select from "@material-ui/core/Select" -import makeStyles from "@material-ui/styles/makeStyles" +import { makeStyles, Theme } from "@material-ui/core/styles" import React from "react" import { Role } from "../../api/typesGenerated" @@ -42,8 +42,11 @@ export const RoleSelect: React.FC = ({ roles, selectedRoles, lo ) } -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles((theme: Theme) => ({ select: { margin: 0, + // Set a fix width for the select. It avoids selects having different sizes + // depending on how much roles they have selected. + width: theme.spacing(25), }, })) From 16d1e7b0b54e08c86ce1a31eb500696e5f90c118 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 9 May 2022 18:26:20 +0000 Subject: [PATCH 07/17] Fix UI to update user roles --- site/src/components/UsersTable/RoleSelect.tsx | 5 +++-- site/src/xServices/users/usersXService.ts | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/components/UsersTable/RoleSelect.tsx b/site/src/components/UsersTable/RoleSelect.tsx index 8f0dbb7d5fe77..9278120939165 100644 --- a/site/src/components/UsersTable/RoleSelect.tsx +++ b/site/src/components/UsersTable/RoleSelect.tsx @@ -16,6 +16,7 @@ export const RoleSelect: React.FC = ({ roles, selectedRoles, lo 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 ( Date: Tue, 10 May 2022 14:20:45 +0000 Subject: [PATCH 10/17] Add missing tests --- .../RoleSelect/RoleSelect.stories.tsx | 24 +++++++ .../{UsersTable => RoleSelect}/RoleSelect.tsx | 4 ++ .../UsersTable/RoleSelect.stories.tsx | 67 ------------------- site/src/components/UsersTable/UsersTable.tsx | 2 +- site/src/pages/UsersPage/UsersPage.test.tsx | 60 ++++++++++++++++- site/src/pages/UsersPage/UsersPage.tsx | 11 +-- site/src/testHelpers/entities.ts | 23 ++++++- site/src/testHelpers/handlers.ts | 3 + site/src/xServices/roles/siteRolesXService.ts | 1 - 9 files changed, 112 insertions(+), 83 deletions(-) create mode 100644 site/src/components/RoleSelect/RoleSelect.stories.tsx rename site/src/components/{UsersTable => RoleSelect}/RoleSelect.tsx (95%) delete mode 100644 site/src/components/UsersTable/RoleSelect.stories.tsx 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/UsersTable/RoleSelect.tsx b/site/src/components/RoleSelect/RoleSelect.tsx similarity index 95% rename from site/src/components/UsersTable/RoleSelect.tsx rename to site/src/components/RoleSelect/RoleSelect.tsx index 63c27a5c67cd8..e016a6563804e 100644 --- a/site/src/components/UsersTable/RoleSelect.tsx +++ b/site/src/components/RoleSelect/RoleSelect.tsx @@ -5,6 +5,9 @@ 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[] @@ -21,6 +24,7 @@ export const RoleSelect: React.FC = ({ roles, selectedRoles, lo return (