feat(site): edit organization member roles (#13977)

This commit is contained in:
Kayla Washburn-Love
2024-07-24 11:36:45 -06:00
committed by GitHub
parent 15fda232b7
commit 38b573857b
30 changed files with 505 additions and 142 deletions
+3
View File
@@ -10625,6 +10625,9 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
"global_roles": {
"type": "array",
"items": {
+3
View File
@@ -9567,6 +9567,9 @@
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
"global_roles": {
"type": "array",
"items": {
+3 -1
View File
@@ -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
@@ -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
+1
View File
@@ -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],
})
+1
View File
@@ -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"`
}
+2
View File
@@ -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 | | |
+2
View File
@@ -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 | | |
+21
View File
@@ -549,6 +549,27 @@ class ApiMethods {
return response.data;
};
getOrganizationRoles = async (organizationId: string) => {
const response = await this.axios.get<TypesGen.AssignableRoles[]>(
`/api/v2/organizations/${organizationId}/members/roles`,
);
return response.data;
};
updateOrganizationMemberRoles = async (
organizationId: string,
userId: string,
roles: TypesGen.SlimRole["name"][],
): Promise<TypesGen.User> => {
const response = await this.axios.put<TypesGen.User>(
`/api/v2/organizations/${organizationId}/members/${userId}/roles`,
{ roles },
);
return response.data;
};
addOrganizationMember = async (organizationId: string, userId: string) => {
const response = await this.axios.post<TypesGen.OrganizationMember>(
`/api/v2/organizations/${organizationId}/members/${userId}`,
+20 -1
View File
@@ -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 = () => {
+7
View File
@@ -6,3 +6,10 @@ export const roles = () => {
queryFn: API.getRoles,
};
};
export const organizationRoles = (organizationId: string) => {
return {
queryKey: ["organization", organizationId, "roles"],
queryFn: () => API.getOrganizationRoles(organizationId),
};
};
+1
View File
@@ -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[];
}
@@ -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(<OrganizationMembersPage />, {
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.");
});
});
});
});
@@ -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 = () => {
<Stack>
{Boolean(error) && <ErrorAlert error={error} />}
<AddGroupMember
<AddOrganizationMember
isLoading={addMemberMutation.isLoading}
onSubmit={async (user) => {
await addMemberMutation.mutateAsync(user.id);
@@ -74,7 +81,12 @@ const OrganizationMembersPage: FC = () => {
<TableHead>
<TableRow>
<TableCell width="50%">User</TableCell>
<TableCell width="49%">Roles</TableCell>
<TableCell width="49%">
<Stack direction="row" spacing={1} alignItems="center">
<span>Roles</span>
<TableColumnHelpTooltip variant="roles" />
</Stack>
</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
@@ -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}
/>
</TableCell>
<TableCell>
{getMemberRoles(member).map((role) => (
<Pill
key={role.name}
css={role.global ? styles.globalRole : styles.role}
>
{role.global ? (
<Tooltip title="This user has this role for all organizations.">
<span>{role.name}*</span>
</Tooltip>
) : (
role.name
)}
</Pill>
))}
</TableCell>
<UserRoleCell
inheritedRoles={member.global_roles}
roles={member.roles}
allAvailableRoles={organizationRolesQuery.data}
oidcRoleSyncEnabled={false}
isLoading={updateMemberRolesMutation.isLoading}
canEditUsers
onEditRoles={async (newRoleNames) => {
try {
await updateMemberRolesMutation.mutateAsync({
userId: member.user_id,
roles: newRoleNames,
});
displaySuccess("Roles updated successfully.");
} catch (e) {
displayError(
getErrorMessage(e, "Failed to update roles."),
);
}
}}
/>
<TableCell>
{member.user_id !== me.id && (
<MoreMenu>
@@ -119,10 +136,20 @@ const OrganizationMembersPage: FC = () => {
<MoreMenuItem
danger
onClick={async () => {
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<string, { name: string; global?: boolean }>();
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<void>;
}
const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
isLoading,
onSubmit,
}) => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
return (
@@ -73,7 +73,7 @@ export interface EditRolesButtonProps {
selectedRoleNames: Set<string>;
onChange: (roles: SlimRole["name"][]) => void;
oidcRoleSync: boolean;
userLoginType: string;
userLoginType?: string;
}
export const EditRolesButton: FC<EditRolesButtonProps> = ({
@@ -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<UserRoleCellProps> = ({
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 (
<TableCell>
@@ -55,9 +63,9 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
{canEditUsers && (
<EditRolesButton
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
selectedRoleNames={getSelectedRoleNames(user.roles)}
selectedRoleNames={getSelectedRoleNames(roles)}
isLoading={isLoading}
userLoginType={user.login_type}
userLoginType={userLoginType}
oidcRoleSync={oidcRoleSyncEnabled}
onChange={(roles) => {
// Remove the fallback role because it is only for the UI
@@ -65,22 +73,27 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
(role) => role !== fallbackRole.name,
);
onUserRolesUpdate(user, rolesWithoutFallback);
onEditRoles(rolesWithoutFallback);
}}
/>
)}
<Pill
css={{
backgroundColor: hasOwnerRole
? theme.roles.info.background
: theme.experimental.l2.background,
borderColor: hasOwnerRole
? theme.roles.info.outline
: theme.experimental.l2.outline,
}}
css={
hasOwnerRole
? styles.ownerRoleBadge
: mainDisplayRole.global
? styles.globalRoleBadge
: styles.roleBadge
}
>
{mainDisplayRole.display_name}
{mainDisplayRole.global ? (
<Tooltip title="This user has this role for all organizations.">
<span>{displayName}*</span>
</Tooltip>
) : (
displayName
)}
</Pill>
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
@@ -90,7 +103,7 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
};
type OverflowRolePillProps = {
roles: readonly SlimRole[];
roles: readonly TieredSlimRole[];
};
const OverflowRolePill: FC<OverflowRolePillProps> = ({ roles }) => {
@@ -105,7 +118,7 @@ const OverflowRolePill: FC<OverflowRolePillProps> = ({ roles }) => {
borderColor: theme.palette.divider,
}}
>
{`+${roles.length} more`}
+{roles.length} more
</Pill>
</PopoverTrigger>
@@ -135,12 +148,15 @@ const OverflowRolePill: FC<OverflowRolePillProps> = ({ roles }) => {
{roles.map((role) => (
<Pill
key={role.name}
css={{
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
}}
css={role.global ? styles.globalRoleBadge : styles.roleBadge}
>
{role.display_name || role.name}
{role.global ? (
<Tooltip title="This user has this role for all organizations.">
<span>{role.display_name || role.name}*</span>
</Tooltip>
) : (
role.display_name || role.name
)}
</Pill>
))}
</PopoverContent>
@@ -148,21 +164,40 @@ const OverflowRolePill: FC<OverflowRolePillProps> = ({ 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<string, Interpolation<Theme>>;
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<T extends SlimRole>(
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<string, TieredSlimRole>();
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()];
}
@@ -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 = () => {
/>
) : (
<TemplatePermissionsPageView
organizationId={organizationId}
templateID={template.id}
templateACL={templateACLQuery.data}
canUpdatePermissions={Boolean(permissions?.canUpdateTemplate)}
@@ -1,9 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockOrganization,
MockTemplateACL,
MockTemplateACLEmpty,
} from "testHelpers/entities";
import { MockTemplateACL, MockTemplateACLEmpty } from "testHelpers/entities";
import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView";
const meta: Meta<typeof TemplatePermissionsPageView> = {
@@ -32,6 +28,5 @@ export const WithUpdatePermissions: Story = {
args: {
templateACL: MockTemplateACL,
canUpdatePermissions: true,
organizationId: MockOrganization.id,
},
};
@@ -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<AddTemplateUserOrGroupProps> = ({
isLoading,
onSubmit,
templateID,
templateACL,
onSubmit,
}) => {
const [selectedOption, setSelectedOption] =
useState<UserOrGroupAutocompleteValue>(null);
@@ -161,7 +160,6 @@ const RoleSelect: FC<SelectProps> = (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<
<AddTemplateUserOrGroup
templateACL={templateACL}
templateID={templateID}
organizationId={organizationId}
isLoading={isAddingUser || isAddingGroup}
onSubmit={(value, role, resetAutocomplete) =>
"members" in value
+5 -5
View File
@@ -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(<UsersPage />);
@@ -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", () => {
+3 -6
View File
@@ -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(
+1 -1
View File
@@ -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<typeof UsersFilter>;
@@ -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<GroupsCellProps> = ({ userGroups }) => {
const theme = useTheme();
return (
@@ -123,4 +124,4 @@ export function UserGroupsCell({ userGroups }: GroupsCellProps) {
)}
</TableCell>
);
}
};
@@ -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;
@@ -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<UsersTableBodyProps> = ({
<UserRoleCell
canEditUsers={canEditUsers}
allAvailableRoles={roles}
user={user}
userLoginType={user.login_type}
roles={user.roles}
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
isLoading={Boolean(isUpdatingUserRoles)}
onUserRolesUpdate={onUpdateUserRoles}
onEditRoles={(roles) => onUpdateUserRoles(user.id, roles)}
/>
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
+81 -18
View File
@@ -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,11 +276,6 @@ export const MockTemplateAdminRole: TypesGen.Role = {
organization_id: "",
};
export const MockMemberRole: TypesGen.SlimRole = {
name: "member",
display_name: "Member",
};
export const MockAuditorRole: TypesGen.Role = {
name: "auditor",
display_name: "Auditor",
@@ -279,6 +285,47 @@ export const MockAuditorRole: TypesGen.Role = {
organization_id: "",
};
export const MockMemberRole: TypesGen.SlimRole = {
name: "member",
display_name: "Member",
};
export const MockOrganizationAdminRole: TypesGen.Role = {
name: "organization-admin",
display_name: "Organization Admin",
site_permissions: [],
organization_permissions: [],
user_permissions: [],
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
// actor can assign (add/remove) the role from other users.
export function assignableRole(
@@ -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",
+24 -1
View File
@@ -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", () => {
+38
View File
@@ -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: <RequireAuth />,
children: [
{
element: <DashboardLayout />,
children: [
{
element: <ManagementSettingsLayout />,
children: [{ element, path }, ...extraRoutes],
},
],
},
],
},
...nonAuthenticatedRoutes,
];
const renderResult = renderWithRouter(
createMemoryRouter(routes, { initialEntries: [route] }),
);
return {
user: MockUser,
...renderResult,
};
}
export const waitForLoaderToBeRemoved = async (): Promise<void> => {
return waitFor(
() => {