diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81612260969a3..7aa44834c6dc1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10625,6 +10625,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "email": { + "type": "string" + }, "global_roles": { "type": "array", "items": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 82b52b95b3123..92b904f272e67 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9567,6 +9567,9 @@ "type": "string", "format": "date-time" }, + "email": { + "type": "string" + }, "global_roles": { "type": "array", "items": { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2b54d1dd96c40..9205a81d5d3e4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4293,7 +4293,7 @@ func (q *sqlQuerier) InsertOrganizationMember(ctx context.Context, arg InsertOrg const organizationMembers = `-- name: OrganizationMembers :many SELECT organization_members.user_id, organization_members.organization_id, organization_members.created_at, organization_members.updated_at, organization_members.roles, - users.username, users.avatar_url, users.name, users.rbac_roles as "global_roles" + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles" FROM organization_members INNER JOIN @@ -4323,6 +4323,7 @@ type OrganizationMembersRow struct { Username string `db:"username" json:"username"` AvatarURL string `db:"avatar_url" json:"avatar_url"` Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` GlobalRoles pq.StringArray `db:"global_roles" json:"global_roles"` } @@ -4348,6 +4349,7 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe &i.Username, &i.AvatarURL, &i.Name, + &i.Email, &i.GlobalRoles, ); err != nil { return nil, err diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 8cf6a804e2682..71304c8883602 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -5,7 +5,7 @@ -- - Use both to get a specific org member row SELECT sqlc.embed(organization_members), - users.username, users.avatar_url, users.name, users.rbac_roles as "global_roles" + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles" FROM organization_members INNER JOIN diff --git a/coderd/members.go b/coderd/members.go index e27f5f8840733..4c28d4b6434f6 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -319,6 +319,7 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, + Email: rows[i].Email, GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), OrganizationMember: convertedMembers[i], }) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 2039aa415ce5b..02bc818312ee5 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -74,6 +74,7 @@ type OrganizationMemberWithUserData struct { Username string `table:"username,default_sort" json:"username"` Name string `table:"name" json:"name"` AvatarURL string `json:"avatar_url"` + Email string `json:"email"` GlobalRoles []SlimRole `json:"global_roles"` OrganizationMember `table:"m,recursive_inline"` } diff --git a/docs/api/members.md b/docs/api/members.md index 6a06efdce7f77..1ecf490738f00 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -28,6 +28,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members { "avatar_url": "string", "created_at": "2019-08-24T14:15:22Z", + "email": "string", "global_roles": [ { "display_name": "string", @@ -66,6 +67,7 @@ Status Code **200** | `[array item]` | array | false | | | | `» avatar_url` | string | false | | | | `» created_at` | string(date-time) | false | | | +| `» email` | string | false | | | | `» global_roles` | array | false | | | | `»» display_name` | string | false | | | | `»» name` | string | false | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 447b148651e8a..ccd3c7bcaa6b7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3622,6 +3622,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "avatar_url": "string", "created_at": "2019-08-24T14:15:22Z", + "email": "string", "global_roles": [ { "display_name": "string", @@ -3650,6 +3651,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | | `avatar_url` | string | false | | | | `created_at` | string | false | | | +| `email` | string | false | | | | `global_roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | | `name` | string | false | | | | `organization_id` | string | false | | | diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b408e290e1273..07010543a63e5 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -549,6 +549,27 @@ class ApiMethods { return response.data; }; + getOrganizationRoles = async (organizationId: string) => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/members/roles`, + ); + + return response.data; + }; + + updateOrganizationMemberRoles = async ( + organizationId: string, + userId: string, + roles: TypesGen.SlimRole["name"][], + ): Promise => { + const response = await this.axios.put( + `/api/v2/organizations/${organizationId}/members/${userId}/roles`, + { roles }, + ); + + return response.data; + }; + addOrganizationMember = async (organizationId: string, userId: string) => { const response = await this.axios.post( `/api/v2/organizations/${organizationId}/members/${userId}`, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 1dc44a2a1c9a3..98c3c9a61e66a 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -49,7 +49,7 @@ export const deleteOrganization = (queryClient: QueryClient) => { export const organizationMembers = (id: string) => { return { queryFn: () => API.getOrganizationMembers(id), - key: ["organization", id, "members"], + queryKey: ["organization", id, "members"], }; }; @@ -80,6 +80,25 @@ export const removeOrganizationMember = ( }; }; +export const updateOrganizationMemberRoles = ( + queryClient: QueryClient, + organizationId: string, +) => { + return { + mutationFn: ({ userId, roles }: { userId: string; roles: string[] }) => { + return API.updateOrganizationMemberRoles(organizationId, userId, roles); + }, + + onSuccess: async () => { + await queryClient.invalidateQueries([ + "organization", + organizationId, + "members", + ]); + }, + }; +}; + export const organizationsKey = ["organizations"] as const; export const organizations = () => { diff --git a/site/src/api/queries/roles.ts b/site/src/api/queries/roles.ts index 2a6c1700b53a7..e51805e72c527 100644 --- a/site/src/api/queries/roles.ts +++ b/site/src/api/queries/roles.ts @@ -6,3 +6,10 @@ export const roles = () => { queryFn: API.getRoles, }; }; + +export const organizationRoles = (organizationId: string) => { + return { + queryKey: ["organization", organizationId, "roles"], + queryFn: () => API.getOrganizationRoles(organizationId), + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bc4ff5a038ccb..04759ccc10e8b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -874,6 +874,7 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { readonly username: string; readonly name: string; readonly avatar_url: string; + readonly email: string; readonly global_roles: readonly SlimRole[]; } diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx new file mode 100644 index 0000000000000..3aa7f4a606e29 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HttpResponse, http } from "msw"; +import type { SlimRole } from "api/typesGenerated"; +import { MockUser, MockOrganizationAuditorRole } from "testHelpers/entities"; +import { + renderWithTemplateSettingsLayout, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; +import { server } from "testHelpers/server"; +import OrganizationMembersPage from "./OrganizationMembersPage"; + +jest.spyOn(console, "error").mockImplementation(() => {}); + +beforeAll(() => { + server.use( + http.get("/api/v2/experiments", () => { + return HttpResponse.json(["multi-organization"]); + }), + ); +}); + +const renderPage = async () => { + renderWithTemplateSettingsLayout(, { + route: `/organizations/my-organization/members`, + path: `/organizations/:organization/members`, + }); + await waitForLoaderToBeRemoved(); +}; + +const removeMember = async () => { + const user = userEvent.setup(); + // Click on the "More options" button to display the "Remove" option + const moreButtons = await screen.findAllByLabelText("More options"); + // get MockUser2 + const selectedMoreButton = moreButtons[0]; + + await user.click(selectedMoreButton); + + const removeButton = screen.getByText(/Remove/); + await user.click(removeButton); +}; + +const updateUserRole = async (role: SlimRole) => { + // Get the first user in the table + const users = await screen.findAllByText(/.*@coder.com/); + const userRow = users[0].closest("tr"); + if (!userRow) { + throw new Error("Error on get the first user row"); + } + + // Click on the "edit icon" to display the role options + const editButton = within(userRow).getByTitle("Edit user roles"); + fireEvent.click(editButton); + + // Click on the role option + const fieldset = await screen.findByTitle("Available roles"); + const roleOption = within(fieldset).getByText(role.display_name); + fireEvent.click(roleOption); + + return { + userRow, + }; +}; + +describe("OrganizationMembersPage", () => { + describe("remove member", () => { + describe("when it is success", () => { + it("shows a success message", async () => { + await renderPage(); + await removeMember(); + await screen.findByText("Member removed."); + }); + }); + }); + + describe("Update user role", () => { + describe("when it is success", () => { + it("updates the roles", async () => { + server.use( + http.put( + `/api/v2/organizations/:organizationId/members/${MockUser.id}/roles`, + async () => { + return HttpResponse.json({ + ...MockUser, + roles: [...MockUser.roles, MockOrganizationAuditorRole], + }); + }, + ), + ); + + await renderPage(); + await updateUserRole(MockOrganizationAuditorRole); + await screen.findByText("Roles updated successfully."); + }); + }); + + describe("when it fails", () => { + it("shows an error message", async () => { + server.use( + http.put( + `/api/v2/organizations/:organizationId/members/${MockUser.id}/roles`, + () => { + return HttpResponse.json( + { message: "Error on updating the user roles." }, + { status: 400 }, + ); + }, + ), + ); + + await renderPage(); + await updateUserRole(MockOrganizationAuditorRole); + await screen.findByText("Error on updating the user roles."); + }); + }); + }); +}); diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 467ee9cedaa10..e3ce5e7b20e7f 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -7,7 +7,6 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; -import Tooltip from "@mui/material/Tooltip"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; @@ -16,11 +15,13 @@ import { addOrganizationMember, organizationMembers, removeOrganizationMember, + updateOrganizationMemberRoles, } from "api/queries/organizations"; -import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; +import { organizationRoles } from "api/queries/roles"; +import type { User } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { AvatarData } from "components/AvatarData/AvatarData"; -import { displayError } from "components/GlobalSnackbar/utils"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { MoreMenu, MoreMenuTrigger, @@ -29,11 +30,12 @@ import { ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; -import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; +import { UserRoleCell } from "./UserTable/UserRoleCell"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -41,12 +43,17 @@ const OrganizationMembersPage: FC = () => { const { user: me } = useAuthenticated(); const membersQuery = useQuery(organizationMembers(organization)); + const organizationRolesQuery = useQuery(organizationRoles(organization)); + const addMemberMutation = useMutation( addOrganizationMember(queryClient, organization), ); const removeMemberMutation = useMutation( removeOrganizationMember(queryClient, organization), ); + const updateMemberRolesMutation = useMutation( + updateOrganizationMemberRoles(queryClient, organization), + ); const error = membersQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error; @@ -61,7 +68,7 @@ const OrganizationMembersPage: FC = () => { {Boolean(error) && } - { await addMemberMutation.mutateAsync(user.id); @@ -74,7 +81,12 @@ const OrganizationMembersPage: FC = () => { User - Roles + + + Roles + + + @@ -89,26 +101,31 @@ const OrganizationMembersPage: FC = () => { avatarURL={member.avatar_url} /> } - title={member.name} - subtitle={member.username} + title={member.name || member.username} + subtitle={member.email} /> - - {getMemberRoles(member).map((role) => ( - - {role.global ? ( - - {role.name}* - - ) : ( - role.name - )} - - ))} - + { + try { + await updateMemberRolesMutation.mutateAsync({ + userId: member.user_id, + roles: newRoleNames, + }); + displaySuccess("Roles updated successfully."); + } catch (e) { + displayError( + getErrorMessage(e, "Failed to update roles."), + ); + } + }} + /> {member.user_id !== me.id && ( @@ -119,10 +136,20 @@ const OrganizationMembersPage: FC = () => { { - await removeMemberMutation.mutateAsync( - member.user_id, - ); - void membersQuery.refetch(); + try { + await removeMemberMutation.mutateAsync( + member.user_id, + ); + void membersQuery.refetch(); + displaySuccess("Member removed."); + } catch (e) { + displayError( + getErrorMessage( + e, + "Failed to remove member.", + ), + ); + } }} > Remove @@ -141,33 +168,17 @@ const OrganizationMembersPage: FC = () => { ); }; -function getMemberRoles(member: OrganizationMemberWithUserData) { - const roles = new Map(); - - for (const role of member.global_roles) { - roles.set(role.name, { - name: role.display_name || role.name, - global: true, - }); - } - for (const role of member.roles) { - if (roles.has(role.name)) { - continue; - } - roles.set(role.name, { name: role.display_name || role.name }); - } - - return [...roles.values()]; -} - export default OrganizationMembersPage; -interface AddGroupMemberProps { +interface AddOrganizationMemberProps { isLoading: boolean; onSubmit: (user: User) => Promise; } -const AddGroupMember: FC = ({ isLoading, onSubmit }) => { +const AddOrganizationMember: FC = ({ + isLoading, + onSubmit, +}) => { const [selectedUser, setSelectedUser] = useState(null); return ( diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.stories.tsx b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.stories.tsx similarity index 100% rename from site/src/pages/UsersPage/UsersTable/EditRolesButton.stories.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.stories.tsx diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx similarity index 99% rename from site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx index 82cc574fb13a1..a3c9286fe8362 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx @@ -73,7 +73,7 @@ export interface EditRolesButtonProps { selectedRoleNames: Set; onChange: (roles: SlimRole["name"][]) => void; oidcRoleSync: boolean; - userLoginType: string; + userLoginType?: string; } export const EditRolesButton: FC = ({ diff --git a/site/src/pages/UsersPage/UsersTable/TableColumnHelpTooltip.tsx b/site/src/pages/ManagementSettingsPage/UserTable/TableColumnHelpTooltip.tsx similarity index 100% rename from site/src/pages/UsersPage/UsersTable/TableColumnHelpTooltip.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/TableColumnHelpTooltip.tsx diff --git a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx similarity index 57% rename from site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index 398354f94ee69..9b774d20a3f2e 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -13,11 +13,12 @@ * went with a simpler design. If we decide we really do need to display the * users like that, though, know that it will be painful */ -import { useTheme } from "@emotion/react"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import Stack from "@mui/material/Stack"; import TableCell from "@mui/material/TableCell"; +import Tooltip from "@mui/material/Tooltip"; import type { FC } from "react"; -import type { SlimRole, User } from "api/typesGenerated"; +import type { LoginType, SlimRole } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; import { Popover, @@ -27,27 +28,34 @@ import { import { EditRolesButton } from "./EditRolesButton"; type UserRoleCellProps = { - canEditUsers: boolean; - allAvailableRoles: SlimRole[] | undefined; - user: User; isLoading: boolean; + canEditUsers: boolean; + allAvailableRoles: readonly SlimRole[] | undefined; + userLoginType?: LoginType; + inheritedRoles?: readonly SlimRole[]; + roles: readonly SlimRole[]; oidcRoleSyncEnabled: boolean; - onUserRolesUpdate: (user: User, newRoleNames: string[]) => void; + onEditRoles: (newRoleNames: string[]) => void; }; export const UserRoleCell: FC = ({ + isLoading, canEditUsers, allAvailableRoles, - user, - isLoading, + userLoginType, + inheritedRoles, + roles, oidcRoleSyncEnabled, - onUserRolesUpdate, + onEditRoles, }) => { - const theme = useTheme(); - + const mergedRoles = getTieredRoles(inheritedRoles ?? [], roles); const [mainDisplayRole = fallbackRole, ...extraRoles] = - sortRolesByAccessLevel(user.roles ?? []); - const hasOwnerRole = mainDisplayRole.name === "owner"; + sortRolesByAccessLevel(mergedRoles ?? []); + const hasOwnerRole = + mainDisplayRole.name === "owner" || + mainDisplayRole.name === "organization-admin"; + + const displayName = mainDisplayRole.display_name || mainDisplayRole.name; return ( @@ -55,9 +63,9 @@ export const UserRoleCell: FC = ({ {canEditUsers && ( { // Remove the fallback role because it is only for the UI @@ -65,22 +73,27 @@ export const UserRoleCell: FC = ({ (role) => role !== fallbackRole.name, ); - onUserRolesUpdate(user, rolesWithoutFallback); + onEditRoles(rolesWithoutFallback); }} /> )} - {mainDisplayRole.display_name} + {mainDisplayRole.global ? ( + + {displayName}* + + ) : ( + displayName + )} {extraRoles.length > 0 && } @@ -90,7 +103,7 @@ export const UserRoleCell: FC = ({ }; type OverflowRolePillProps = { - roles: readonly SlimRole[]; + roles: readonly TieredSlimRole[]; }; const OverflowRolePill: FC = ({ roles }) => { @@ -105,7 +118,7 @@ const OverflowRolePill: FC = ({ roles }) => { borderColor: theme.palette.divider, }} > - {`+${roles.length} more`} + +{roles.length} more @@ -135,12 +148,15 @@ const OverflowRolePill: FC = ({ roles }) => { {roles.map((role) => ( - {role.display_name || role.name} + {role.global ? ( + + {role.display_name || role.name}* + + ) : ( + role.display_name || role.name + )} ))} @@ -148,21 +164,40 @@ const OverflowRolePill: FC = ({ roles }) => { ); }; -const fallbackRole: SlimRole = { +const styles = { + globalRoleBadge: (theme) => ({ + backgroundColor: theme.roles.active.background, + borderColor: theme.roles.active.outline, + }), + ownerRoleBadge: (theme) => ({ + backgroundColor: theme.roles.info.background, + borderColor: theme.roles.info.outline, + }), + roleBadge: (theme) => ({ + backgroundColor: theme.experimental.l2.background, + borderColor: theme.experimental.l2.outline, + }), +} satisfies Record>; + +const fallbackRole: TieredSlimRole = { name: "member", display_name: "Member", } as const; const roleNamesByAccessLevel: readonly string[] = [ "owner", + "organization-admin", "user-admin", + "organization-user-admin", "template-admin", + "organization-template-admin", "auditor", + "organization-auditor", ]; -function sortRolesByAccessLevel( - roles: readonly SlimRole[], -): readonly SlimRole[] { +function sortRolesByAccessLevel( + roles: readonly T[], +): readonly T[] { if (roles.length === 0) { return roles; } @@ -182,3 +217,29 @@ function getSelectedRoleNames(roles: readonly SlimRole[]) { return roleNameSet; } + +interface TieredSlimRole extends SlimRole { + global?: boolean; +} + +function getTieredRoles( + globalRoles: readonly SlimRole[], + localRoles: readonly SlimRole[], +) { + const roles = new Map(); + + for (const role of globalRoles) { + roles.set(role.name, { + ...role, + global: true, + }); + } + for (const role of localRoles) { + if (roles.has(role.name)) { + continue; + } + roles.set(role.name, role); + } + + return [...roles.values()]; +} diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 2e9aa072e699a..6af149bd20462 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { setGroupRole, setUserRole, templateACL } from "api/queries/templates"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Paywall } from "components/Paywall/Paywall"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; @@ -12,7 +11,6 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"; export const TemplatePermissionsPage: FC = () => { - const { organizationId } = useDashboard(); const { template, permissions } = useTemplateSettings(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); const templateACLQuery = useQuery(templateACL(template.id)); @@ -39,7 +37,6 @@ export const TemplatePermissionsPage: FC = () => { /> ) : ( = { @@ -32,6 +28,5 @@ export const WithUpdatePermissions: Story = { args: { templateACL: MockTemplateACL, canUpdatePermissions: true, - organizationId: MockOrganization.id, }, }; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 6f75099bbfb2d..e7e169f80ae85 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -39,7 +39,6 @@ import { } from "./UserOrGroupAutocomplete"; type AddTemplateUserOrGroupProps = { - organizationId: string; templateID: string; isLoading: boolean; templateACL: TemplateACL | undefined; @@ -56,9 +55,9 @@ type AddTemplateUserOrGroupProps = { const AddTemplateUserOrGroup: FC = ({ isLoading, - onSubmit, templateID, templateACL, + onSubmit, }) => { const [selectedOption, setSelectedOption] = useState(null); @@ -161,7 +160,6 @@ const RoleSelect: FC = (props) => { export interface TemplatePermissionsPageViewProps { templateACL: TemplateACL | undefined; templateID: string; - organizationId: string; canUpdatePermissions: boolean; // User onAddUser: ( @@ -190,7 +188,6 @@ export const TemplatePermissionsPageView: FC< > = ({ templateACL, canUpdatePermissions, - organizationId, templateID, // User onAddUser, @@ -222,7 +219,6 @@ export const TemplatePermissionsPageView: FC< "members" in value diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index edbc0118b09f2..f9d620ce509f2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -12,7 +12,9 @@ import { import { renderWithAuth } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { Language as ResetPasswordDialogLanguage } from "./ResetPasswordDialog"; -import { UsersPage } from "./UsersPage"; +import UsersPage from "./UsersPage"; + +jest.spyOn(console, "error").mockImplementation(() => {}); const renderPage = () => { return renderWithAuth(); @@ -116,16 +118,14 @@ const updateUserRole = async (role: SlimRole) => { // Click on the role option const fieldset = await screen.findByTitle("Available roles"); - const auditorOption = within(fieldset).getByText(role.display_name); - fireEvent.click(auditorOption); + const roleOption = within(fieldset).getByText(role.display_name); + fireEvent.click(roleOption); return { userRow, }; }; -jest.spyOn(console, "error").mockImplementation(() => {}); - describe("UsersPage", () => { describe("suspend user", () => { describe("when it is success", () => { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 8ddc42e630aff..bdc6b31bc5d6f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -30,7 +30,7 @@ import { ResetPasswordDialog } from "./ResetPasswordDialog"; import { useStatusFilterMenu } from "./UsersFilter"; import { UsersPageView } from "./UsersPageView"; -export const UsersPage: FC = () => { +const UsersPage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -125,12 +125,9 @@ export const UsersPage: FC = () => { newPassword: generateRandomString(12), }); }} - onUpdateUserRoles={async (user, roles) => { + onUpdateUserRoles={async (userId, roles) => { try { - await updateRolesMutation.mutateAsync({ - userId: user.id, - roles, - }); + await updateRolesMutation.mutateAsync({ userId, roles }); displaySuccess("Successfully updated the user roles."); } catch (e) { displayError( diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index be5f50b6ff9b8..41aa255ea9cd8 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -24,7 +24,7 @@ export interface UsersPageViewProps { onActivateUser: (user: TypesGen.User) => void; onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( - user: TypesGen.User, + userId: string, roles: TypesGen.SlimRole["name"][], ) => void; filterProps: ComponentProps; diff --git a/site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx b/site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx index 071dc6c798b96..b348319355e7d 100644 --- a/site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx +++ b/site/src/pages/UsersPage/UsersTable/UserGroupsCell.tsx @@ -3,6 +3,7 @@ import GroupIcon from "@mui/icons-material/Group"; import List from "@mui/material/List"; import ListItem from "@mui/material/ListItem"; import TableCell from "@mui/material/TableCell"; +import type { FC } from "react"; import type { Group } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { OverflowY } from "components/OverflowY/OverflowY"; @@ -17,7 +18,7 @@ type GroupsCellProps = { userGroups: readonly Group[] | undefined; }; -export function UserGroupsCell({ userGroups }: GroupsCellProps) { +export const UserGroupsCell: FC = ({ userGroups }) => { const theme = useTheme(); return ( @@ -123,4 +124,4 @@ export function UserGroupsCell({ userGroups }: GroupsCellProps) { )} ); -} +}; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index d3748f2d8ea95..c27de3e05588c 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -8,7 +8,7 @@ import type { FC } from "react"; import type { GroupsByUserId } from "api/queries/groups"; import type * as TypesGen from "api/typesGenerated"; import { Stack } from "components/Stack/Stack"; -import { TableColumnHelpTooltip } from "./TableColumnHelpTooltip"; +import { TableColumnHelpTooltip } from "../../ManagementSettingsPage/UserTable/TableColumnHelpTooltip"; import { UsersTableBody } from "./UsersTableBody"; export const Language = { @@ -35,7 +35,7 @@ export interface UsersTableProps { onViewActivity: (user: TypesGen.User) => void; onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( - user: TypesGen.User, + userId: string, roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 03a99bd423bf9..fdcb88b447dbf 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -30,8 +30,8 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; +import { UserRoleCell } from "../../ManagementSettingsPage/UserTable/UserRoleCell"; import { UserGroupsCell } from "./UserGroupsCell"; -import { UserRoleCell } from "./UserRoleCell"; dayjs.extend(relativeTime); @@ -51,7 +51,7 @@ interface UsersTableBodyProps { onActivateUser: (user: TypesGen.User) => void; onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( - user: TypesGen.User, + userId: string, roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; @@ -156,10 +156,11 @@ export const UsersTableBody: FC = ({ onUpdateUserRoles(user.id, roles)} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 043cc405df7d3..453f1455615ec 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -12,7 +12,7 @@ import type { FileTree } from "utils/filetree"; import type { TemplateVersionFiles } from "utils/templateVersion"; export const MockOrganization: TypesGen.Organization = { - id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", + id: "my-organization-id", name: "my-organization", display_name: "My Organization", description: "An organization that gets used for stuff.", @@ -27,6 +27,17 @@ export const MockDefaultOrganization: TypesGen.Organization = { is_default: true, }; +export const MockOrganization2: TypesGen.Organization = { + id: "my-organization-2-id", + name: "my-organization-2", + display_name: "My Organization 2", + description: "Another organization that gets used for stuff.", + icon: "/emojis/1f957.png", + created_at: "", + updated_at: "", + is_default: false, +}; + export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { tz_hour_offset: 0, entries: [ @@ -265,18 +276,54 @@ export const MockTemplateAdminRole: TypesGen.Role = { organization_id: "", }; +export const MockAuditorRole: TypesGen.Role = { + name: "auditor", + display_name: "Auditor", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + organization_id: "", +}; + export const MockMemberRole: TypesGen.SlimRole = { name: "member", display_name: "Member", }; -export const MockAuditorRole: TypesGen.Role = { - name: "auditor", - display_name: "Auditor", +export const MockOrganizationAdminRole: TypesGen.Role = { + name: "organization-admin", + display_name: "Organization Admin", site_permissions: [], organization_permissions: [], user_permissions: [], - organization_id: "", + organization_id: MockOrganization.id, +}; + +export const MockOrganizationUserAdminRole: TypesGen.Role = { + name: "organization-user-admin", + display_name: "Organization User Admin", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + organization_id: MockOrganization.id, +}; + +export const MockOrganizationTemplateAdminRole: TypesGen.Role = { + name: "organization-template-admin", + display_name: "Organization Template Admin", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + organization_id: MockOrganization.id, +}; + +export const MockOrganizationAuditorRole: TypesGen.Role = { + name: "organization-auditor", + display_name: "Organization Auditor", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + organization_id: MockOrganization.id, }; // assignableRole takes a role and a boolean. The boolean implies if the @@ -319,19 +366,8 @@ export const MockUser: TypesGen.User = { }; export const MockUserAdmin: TypesGen.User = { - id: "test-user", - username: "TestUser", - email: "test@coder.com", - created_at: "", - updated_at: "", - status: "active", - organization_ids: [MockOrganization.id], + ...MockUser, roles: [MockUserAdminRole], - avatar_url: "", - last_seen_at: "", - login_type: "password", - theme_preference: "", - name: "", }; export const MockUser2: TypesGen.User = { @@ -366,6 +402,33 @@ export const SuspendedMockUser: TypesGen.User = { name: "", }; +export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { + organization_id: MockOrganization.id, + user_id: MockUser.id, + username: MockUser.username, + email: MockUser.email, + created_at: "", + updated_at: "", + name: MockUser.name, + avatar_url: MockUser.avatar_url, + global_roles: MockUser.roles, + roles: [], +}; + +export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = + { + organization_id: MockOrganization.id, + user_id: MockUser2.id, + username: MockUser2.username, + email: MockUser2.email, + created_at: "", + updated_at: "", + name: MockUser2.name, + avatar_url: MockUser2.avatar_url, + global_roles: MockUser2.roles, + roles: [], + }; + export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", id: "test-provisioner", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 9f74737ce5e4c..4624a58f0ce4e 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -41,10 +41,13 @@ export const handlers = [ }), // organizations + http.get("/api/v2/organizations", () => { + return HttpResponse.json([M.MockDefaultOrganization, M.MockOrganization2]); + }), http.get("/api/v2/organizations/:organizationId", () => { return HttpResponse.json(M.MockOrganization); }), - http.get("api/v2/organizations/:organizationId/templates/examples", () => { + http.get("/api/v2/organizations/:organizationId/templates/examples", () => { return HttpResponse.json([M.MockTemplateExample, M.MockTemplateExample2]); }), http.get( @@ -56,6 +59,26 @@ export const handlers = [ http.get("/api/v2/organizations/:organizationId/templates", () => { return HttpResponse.json([M.MockTemplate]); }), + http.get("/api/v2/organizations/:organizationId/members/roles", () => { + return HttpResponse.json([ + M.MockOrganizationAdminRole, + M.MockOrganizationUserAdminRole, + M.MockOrganizationTemplateAdminRole, + M.MockOrganizationAuditorRole, + ]); + }), + http.get("/api/v2/organizations/:organizationId/members", () => { + return HttpResponse.json([ + M.MockOrganizationMember, + M.MockOrganizationMember2, + ]); + }), + http.delete( + `/api/v2/organizations/:organizationId/members/:userId`, + async () => { + return new HttpResponse(null, { status: 204 }); + }, + ), // templates http.get("/api/v2/templates/:templateId", () => { diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 3697e6136075a..6abb5e93cff62 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -14,6 +14,7 @@ import { AppProviders } from "App"; import { RequireAuth } from "contexts/auth/RequireAuth"; import { ThemeProvider } from "contexts/ThemeProvider"; import { DashboardLayout } from "modules/dashboard/DashboardLayout"; +import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout"; import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"; import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; import { MockUser } from "./entities"; @@ -186,6 +187,43 @@ export function renderWithWorkspaceSettingsLayout( }; } +export function renderWithManagementSettingsLayout( + element: JSX.Element, + { + path = "/", + route = "/", + extraRoutes = [], + nonAuthenticatedRoutes = [], + }: RenderWithAuthOptions = {}, +) { + const routes: RouteObject[] = [ + { + element: , + children: [ + { + element: , + children: [ + { + element: , + children: [{ element, path }, ...extraRoutes], + }, + ], + }, + ], + }, + ...nonAuthenticatedRoutes, + ]; + + const renderResult = renderWithRouter( + createMemoryRouter(routes, { initialEntries: [route] }), + ); + + return { + user: MockUser, + ...renderResult, + }; +} + export const waitForLoaderToBeRemoved = async (): Promise => { return waitFor( () => {