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( () => { 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