Skip to content

Commit 38b5738

Browse files
authored
feat(site): edit organization member roles (coder#13977)
1 parent 15fda23 commit 38b5738

30 files changed

+504
-141
lines changed

coderd/apidoc/docs.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/organizationmembers.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
-- - Use both to get a specific org member row
66
SELECT
77
sqlc.embed(organization_members),
8-
users.username, users.avatar_url, users.name, users.rbac_roles as "global_roles"
8+
users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles"
99
FROM
1010
organization_members
1111
INNER JOIN

coderd/members.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto
319319
Username: rows[i].Username,
320320
AvatarURL: rows[i].AvatarURL,
321321
Name: rows[i].Name,
322+
Email: rows[i].Email,
322323
GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles),
323324
OrganizationMember: convertedMembers[i],
324325
})

codersdk/organizations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type OrganizationMemberWithUserData struct {
7474
Username string `table:"username,default_sort" json:"username"`
7575
Name string `table:"name" json:"name"`
7676
AvatarURL string `json:"avatar_url"`
77+
Email string `json:"email"`
7778
GlobalRoles []SlimRole `json:"global_roles"`
7879
OrganizationMember `table:"m,recursive_inline"`
7980
}

docs/api/members.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/api/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,27 @@ class ApiMethods {
549549
return response.data;
550550
};
551551

552+
getOrganizationRoles = async (organizationId: string) => {
553+
const response = await this.axios.get<TypesGen.AssignableRoles[]>(
554+
`/api/v2/organizations/${organizationId}/members/roles`,
555+
);
556+
557+
return response.data;
558+
};
559+
560+
updateOrganizationMemberRoles = async (
561+
organizationId: string,
562+
userId: string,
563+
roles: TypesGen.SlimRole["name"][],
564+
): Promise<TypesGen.User> => {
565+
const response = await this.axios.put<TypesGen.User>(
566+
`/api/v2/organizations/${organizationId}/members/${userId}/roles`,
567+
{ roles },
568+
);
569+
570+
return response.data;
571+
};
572+
552573
addOrganizationMember = async (organizationId: string, userId: string) => {
553574
const response = await this.axios.post<TypesGen.OrganizationMember>(
554575
`/api/v2/organizations/${organizationId}/members/${userId}`,

site/src/api/queries/organizations.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const deleteOrganization = (queryClient: QueryClient) => {
4949
export const organizationMembers = (id: string) => {
5050
return {
5151
queryFn: () => API.getOrganizationMembers(id),
52-
key: ["organization", id, "members"],
52+
queryKey: ["organization", id, "members"],
5353
};
5454
};
5555

@@ -80,6 +80,25 @@ export const removeOrganizationMember = (
8080
};
8181
};
8282

83+
export const updateOrganizationMemberRoles = (
84+
queryClient: QueryClient,
85+
organizationId: string,
86+
) => {
87+
return {
88+
mutationFn: ({ userId, roles }: { userId: string; roles: string[] }) => {
89+
return API.updateOrganizationMemberRoles(organizationId, userId, roles);
90+
},
91+
92+
onSuccess: async () => {
93+
await queryClient.invalidateQueries([
94+
"organization",
95+
organizationId,
96+
"members",
97+
]);
98+
},
99+
};
100+
};
101+
83102
export const organizationsKey = ["organizations"] as const;
84103

85104
export const organizations = () => {

site/src/api/queries/roles.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ export const roles = () => {
66
queryFn: API.getRoles,
77
};
88
};
9+
10+
export const organizationRoles = (organizationId: string) => {
11+
return {
12+
queryKey: ["organization", organizationId, "roles"],
13+
queryFn: () => API.getOrganizationRoles(organizationId),
14+
};
15+
};

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { fireEvent, screen, within } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { HttpResponse, http } from "msw";
4+
import type { SlimRole } from "api/typesGenerated";
5+
import { MockUser, MockOrganizationAuditorRole } from "testHelpers/entities";
6+
import {
7+
renderWithTemplateSettingsLayout,
8+
waitForLoaderToBeRemoved,
9+
} from "testHelpers/renderHelpers";
10+
import { server } from "testHelpers/server";
11+
import OrganizationMembersPage from "./OrganizationMembersPage";
12+
13+
jest.spyOn(console, "error").mockImplementation(() => {});
14+
15+
beforeAll(() => {
16+
server.use(
17+
http.get("/api/v2/experiments", () => {
18+
return HttpResponse.json(["multi-organization"]);
19+
}),
20+
);
21+
});
22+
23+
const renderPage = async () => {
24+
renderWithTemplateSettingsLayout(<OrganizationMembersPage />, {
25+
route: `/organizations/my-organization/members`,
26+
path: `/organizations/:organization/members`,
27+
});
28+
await waitForLoaderToBeRemoved();
29+
};
30+
31+
const removeMember = async () => {
32+
const user = userEvent.setup();
33+
// Click on the "More options" button to display the "Remove" option
34+
const moreButtons = await screen.findAllByLabelText("More options");
35+
// get MockUser2
36+
const selectedMoreButton = moreButtons[0];
37+
38+
await user.click(selectedMoreButton);
39+
40+
const removeButton = screen.getByText(/Remove/);
41+
await user.click(removeButton);
42+
};
43+
44+
const updateUserRole = async (role: SlimRole) => {
45+
// Get the first user in the table
46+
const users = await screen.findAllByText(/.*@coder.com/);
47+
const userRow = users[0].closest("tr");
48+
if (!userRow) {
49+
throw new Error("Error on get the first user row");
50+
}
51+
52+
// Click on the "edit icon" to display the role options
53+
const editButton = within(userRow).getByTitle("Edit user roles");
54+
fireEvent.click(editButton);
55+
56+
// Click on the role option
57+
const fieldset = await screen.findByTitle("Available roles");
58+
const roleOption = within(fieldset).getByText(role.display_name);
59+
fireEvent.click(roleOption);
60+
61+
return {
62+
userRow,
63+
};
64+
};
65+
66+
describe("OrganizationMembersPage", () => {
67+
describe("remove member", () => {
68+
describe("when it is success", () => {
69+
it("shows a success message", async () => {
70+
await renderPage();
71+
await removeMember();
72+
await screen.findByText("Member removed.");
73+
});
74+
});
75+
});
76+
77+
describe("Update user role", () => {
78+
describe("when it is success", () => {
79+
it("updates the roles", async () => {
80+
server.use(
81+
http.put(
82+
`/api/v2/organizations/:organizationId/members/${MockUser.id}/roles`,
83+
async () => {
84+
return HttpResponse.json({
85+
...MockUser,
86+
roles: [...MockUser.roles, MockOrganizationAuditorRole],
87+
});
88+
},
89+
),
90+
);
91+
92+
await renderPage();
93+
await updateUserRole(MockOrganizationAuditorRole);
94+
await screen.findByText("Roles updated successfully.");
95+
});
96+
});
97+
98+
describe("when it fails", () => {
99+
it("shows an error message", async () => {
100+
server.use(
101+
http.put(
102+
`/api/v2/organizations/:organizationId/members/${MockUser.id}/roles`,
103+
() => {
104+
return HttpResponse.json(
105+
{ message: "Error on updating the user roles." },
106+
{ status: 400 },
107+
);
108+
},
109+
),
110+
);
111+
112+
await renderPage();
113+
await updateUserRole(MockOrganizationAuditorRole);
114+
await screen.findByText("Error on updating the user roles.");
115+
});
116+
});
117+
});
118+
});

0 commit comments

Comments
 (0)