From 23216d1aa88f257eaebb759636f8f70e96a01194 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 22 Jul 2024 19:24:14 +0000 Subject: [PATCH 01/11] oh boy --- coderd/apidoc/docs.go | 3 +++ coderd/apidoc/swagger.json | 3 +++ coderd/database/queries.sql.go | 4 +++- .../database/queries/organizationmembers.sql | 2 +- coderd/members.go | 1 + codersdk/organizations.go | 1 + docs/api/members.md | 2 ++ docs/api/schemas.md | 2 ++ site/chromatic.config.json | 6 +++++ site/src/api/typesGenerated.ts | 1 + .../OrganizationMembersPage.tsx | 23 ++++++++++++++----- .../TableColumnHelpTooltip.tsx | 0 .../TemplatePermissionsPage.tsx | 2 -- .../TemplatePermissionsPageView.stories.tsx | 7 +----- .../TemplatePermissionsPageView.tsx | 6 +---- .../UsersPage/UsersTable/UserGroupsCell.tsx | 5 ++-- .../pages/UsersPage/UsersTable/UsersTable.tsx | 2 +- 17 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 site/chromatic.config.json rename site/src/pages/{UsersPage/UsersTable => ManagementSettingsPage}/TableColumnHelpTooltip.tsx (100%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1ebe7b806f3e4..0ea9a4c16738c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10600,6 +10600,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 b8c561568bf6f..e3aa5032e16f5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9544,6 +9544,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 511db6ae4dccf..c33a36ea272dc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4283,7 +4283,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 @@ -4313,6 +4313,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"` } @@ -4338,6 +4339,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 b1b5933781386..3d9bfe39fa9e6 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -70,6 +70,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 a1dd22f5be84e..ee1b40c01f89a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3589,6 +3589,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", @@ -3617,6 +3618,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/chromatic.config.json b/site/chromatic.config.json new file mode 100644 index 0000000000000..22f9facca65b9 --- /dev/null +++ b/site/chromatic.config.json @@ -0,0 +1,6 @@ +{ + "onlyChanged": true, + "projectId": "Project:624de63c6aacee003aa84340", + "storybookBaseDir": "site", + "zip": true +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b6ffcf1c79874..2a1c11bd74232 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -869,6 +869,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.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 467ee9cedaa10..e51ea9788b855 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -34,6 +34,8 @@ 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 "./TableColumnHelpTooltip"; +import { groupsByUserId } from "api/queries/groups"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -41,6 +43,7 @@ const OrganizationMembersPage: FC = () => { const { user: me } = useAuthenticated(); const membersQuery = useQuery(organizationMembers(organization)); + // const groupsByUserIdQuery = useQuery(groupsByUserId(organization)); const addMemberMutation = useMutation( addOrganizationMember(queryClient, organization), ); @@ -61,7 +64,7 @@ const OrganizationMembersPage: FC = () => { {Boolean(error) && } - { await addMemberMutation.mutateAsync(user.id); @@ -74,7 +77,12 @@ const OrganizationMembersPage: FC = () => { User - Roles + + + Roles + + + @@ -89,8 +97,8 @@ const OrganizationMembersPage: FC = () => { avatarURL={member.avatar_url} /> } - title={member.name} - subtitle={member.username} + title={member.name || member.username} + subtitle={member.email} /> @@ -162,12 +170,15 @@ function getMemberRoles(member: OrganizationMemberWithUserData) { 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/TableColumnHelpTooltip.tsx b/site/src/pages/ManagementSettingsPage/TableColumnHelpTooltip.tsx similarity index 100% rename from site/src/pages/UsersPage/UsersTable/TableColumnHelpTooltip.tsx rename to site/src/pages/ManagementSettingsPage/TableColumnHelpTooltip.tsx diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 2e9aa072e699a..77086bfc991c4 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -12,7 +12,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 +38,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/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..82beeedc654da 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/TableColumnHelpTooltip"; import { UsersTableBody } from "./UsersTableBody"; export const Language = { From 86c86adfc3a6ede26348d07f94210563252899e3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 22 Jul 2024 21:35:57 +0000 Subject: [PATCH 02/11] the stuff --- site/src/api/api.ts | 21 +++++ site/src/api/queries/organizations.ts | 21 ++++- site/src/api/queries/roles.ts | 7 ++ .../OrganizationMembersPage.tsx | 71 ++++++-------- .../UserTable}/EditRolesButton.stories.tsx | 0 .../UserTable}/EditRolesButton.tsx | 0 .../TableColumnHelpTooltip.tsx | 0 .../UserTable}/UserRoleCell.tsx | 93 +++++++++++++------ site/src/pages/UsersPage/UsersPage.tsx | 7 +- site/src/pages/UsersPage/UsersPageView.tsx | 2 +- .../pages/UsersPage/UsersTable/UsersTable.tsx | 4 +- .../UsersPage/UsersTable/UsersTableBody.tsx | 5 +- 12 files changed, 152 insertions(+), 79 deletions(-) rename site/src/pages/{UsersPage/UsersTable => ManagementSettingsPage/UserTable}/EditRolesButton.stories.tsx (100%) rename site/src/pages/{UsersPage/UsersTable => ManagementSettingsPage/UserTable}/EditRolesButton.tsx (100%) rename site/src/pages/ManagementSettingsPage/{ => UserTable}/TableColumnHelpTooltip.tsx (100%) rename site/src/pages/{UsersPage/UsersTable => ManagementSettingsPage/UserTable}/UserRoleCell.tsx (68%) 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..3fc0486fd61f3 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: ["organizationRoles"], + queryFn: () => API.getOrganizationRoles(organizationId), + }; +}; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index e51ea9788b855..1206cfd92a754 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -16,11 +16,12 @@ import { addOrganizationMember, organizationMembers, removeOrganizationMember, + updateOrganizationMemberRoles, } from "api/queries/organizations"; -import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; +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,13 +30,13 @@ 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 "./TableColumnHelpTooltip"; -import { groupsByUserId } from "api/queries/groups"; +import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; +import { organizationRoles } from "api/queries/roles"; +import { UserRoleCell } from "./UserTable/UserRoleCell"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -43,13 +44,17 @@ const OrganizationMembersPage: FC = () => { const { user: me } = useAuthenticated(); const membersQuery = useQuery(organizationMembers(organization)); - // const groupsByUserIdQuery = useQuery(groupsByUserId(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; @@ -101,22 +106,25 @@ const OrganizationMembersPage: FC = () => { subtitle={member.email} /> - - {getMemberRoles(member).map((role) => ( - - {role.global ? ( - - {role.name}* - - ) : ( - role.name - )} - - ))} - + { + await updateMemberRolesMutation.mutateAsync({ + userId, + roles: newRoleNames, + }); + displaySuccess("Roles updated successfully."); + }} + /> {member.user_id !== me.id && ( @@ -149,25 +157,6 @@ 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 AddOrganizationMemberProps { 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 100% rename from site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx diff --git a/site/src/pages/ManagementSettingsPage/TableColumnHelpTooltip.tsx b/site/src/pages/ManagementSettingsPage/UserTable/TableColumnHelpTooltip.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/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 68% rename from site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx rename to site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index 398354f94ee69..206659363877e 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -13,9 +13,10 @@ * 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 { Interpolation, 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 { Pill } from "components/Pill/Pill"; @@ -27,26 +28,29 @@ import { import { EditRolesButton } from "./EditRolesButton"; type UserRoleCellProps = { - canEditUsers: boolean; - allAvailableRoles: SlimRole[] | undefined; - user: User; isLoading: boolean; + canEditUsers: boolean; + allAvailableRoles: readonly SlimRole[] | undefined; + user: Pick; + inheritedRoles?: readonly SlimRole[]; + roles: readonly SlimRole[]; oidcRoleSyncEnabled: boolean; - onUserRolesUpdate: (user: User, newRoleNames: string[]) => void; + onUserRolesUpdate: (userId: string, newRoleNames: string[]) => void; }; export const UserRoleCell: FC = ({ + isLoading, canEditUsers, allAvailableRoles, user, - isLoading, + inheritedRoles, + roles, oidcRoleSyncEnabled, onUserRolesUpdate, }) => { - const theme = useTheme(); - + const theRolesForReal = getMergedRoles(inheritedRoles ?? [], roles); const [mainDisplayRole = fallbackRole, ...extraRoles] = - sortRolesByAccessLevel(user.roles ?? []); + sortRolesByAccessLevel(theRolesForReal ?? []); const hasOwnerRole = mainDisplayRole.name === "owner"; return ( @@ -55,7 +59,7 @@ export const UserRoleCell: FC = ({ {canEditUsers && ( = ({ (role) => role !== fallbackRole.name, ); - onUserRolesUpdate(user, rolesWithoutFallback); + onUserRolesUpdate(user.id, rolesWithoutFallback); }} /> )} - - {mainDisplayRole.display_name} + + {mainDisplayRole.global ? ( + + {mainDisplayRole.display_name}* + + ) : ( + mainDisplayRole.display_name + )} {extraRoles.length > 0 && } @@ -105,7 +106,7 @@ const OverflowRolePill: FC = ({ roles }) => { borderColor: theme.palette.divider, }} > - {`+${roles.length} more`} + +{roles.length} more @@ -148,9 +149,21 @@ const OverflowRolePill: FC = ({ roles }) => { ); }; -const fallbackRole: SlimRole = { +const styles = { + 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: MergedSlimRole = { name: "member", display_name: "Member", + global: false, } as const; const roleNamesByAccessLevel: readonly string[] = [ @@ -160,9 +173,9 @@ const roleNamesByAccessLevel: readonly string[] = [ "auditor", ]; -function sortRolesByAccessLevel( - roles: readonly SlimRole[], -): readonly SlimRole[] { +function sortRolesByAccessLevel( + roles: readonly T[], +): readonly T[] { if (roles.length === 0) { return roles; } @@ -182,3 +195,29 @@ function getSelectedRoleNames(roles: readonly SlimRole[]) { return roleNameSet; } + +interface MergedSlimRole extends SlimRole { + global?: boolean; +} + +function getMergedRoles( + 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/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 8ddc42e630aff..9537d4c2527c9 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 82beeedc654da..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 "../../ManagementSettingsPage/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..264238da93f4c 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -31,7 +31,7 @@ import { TableRowSkeleton, } from "components/TableLoader/TableLoader"; import { UserGroupsCell } from "./UserGroupsCell"; -import { UserRoleCell } from "./UserRoleCell"; +import { UserRoleCell } from "../../ManagementSettingsPage/UserTable/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; @@ -157,6 +157,7 @@ export const UsersTableBody: FC = ({ canEditUsers={canEditUsers} allAvailableRoles={roles} user={user} + roles={user.roles} oidcRoleSyncEnabled={oidcRoleSyncEnabled} isLoading={Boolean(isUpdatingUserRoles)} onUserRolesUpdate={onUpdateUserRoles} From a7053bb8b3aaeb493c3b46e9f8ca98a23a688571 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 22 Jul 2024 21:45:10 +0000 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/chromatic.config.json | 6 ------ .../ManagementSettingsPage/OrganizationMembersPage.tsx | 3 +-- .../pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx | 2 +- .../TemplatePermissionsPage/TemplatePermissionsPage.tsx | 1 - site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx | 2 +- 5 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 site/chromatic.config.json diff --git a/site/chromatic.config.json b/site/chromatic.config.json deleted file mode 100644 index 22f9facca65b9..0000000000000 --- a/site/chromatic.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "onlyChanged": true, - "projectId": "Project:624de63c6aacee003aa84340", - "storybookBaseDir": "site", - "zip": true -} diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 1206cfd92a754..5167824c90959 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"; @@ -18,6 +17,7 @@ import { removeOrganizationMember, updateOrganizationMemberRoles, } from "api/queries/organizations"; +import { organizationRoles } from "api/queries/roles"; import type { User } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { AvatarData } from "components/AvatarData/AvatarData"; @@ -35,7 +35,6 @@ import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; -import { organizationRoles } from "api/queries/roles"; import { UserRoleCell } from "./UserTable/UserRoleCell"; const OrganizationMembersPage: FC = () => { diff --git a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index 206659363877e..6e9fd208715de 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -13,7 +13,7 @@ * 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 { Interpolation, Theme, 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"; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 77086bfc991c4..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"; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 264238da93f4c..fac91ba477340 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 { UserGroupsCell } from "./UserGroupsCell"; import { UserRoleCell } from "../../ManagementSettingsPage/UserTable/UserRoleCell"; +import { UserGroupsCell } from "./UserGroupsCell"; dayjs.extend(relativeTime); From 42344efba15dff4e157fd5d5970ae030fde88e39 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 22 Jul 2024 22:29:38 +0000 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A7=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/api/queries/roles.ts | 2 +- .../OrganizationMembersPage.tsx | 8 ++------ .../UserTable/EditRolesButton.tsx | 2 +- .../UserTable/UserRoleCell.tsx | 19 +++++++++---------- .../UsersPage/UsersTable/UsersTableBody.tsx | 4 ++-- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/site/src/api/queries/roles.ts b/site/src/api/queries/roles.ts index 3fc0486fd61f3..e51805e72c527 100644 --- a/site/src/api/queries/roles.ts +++ b/site/src/api/queries/roles.ts @@ -9,7 +9,7 @@ export const roles = () => { export const organizationRoles = (organizationId: string) => { return { - queryKey: ["organizationRoles"], + queryKey: ["organization", organizationId, "roles"], queryFn: () => API.getOrganizationRoles(organizationId), }; }; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 5167824c90959..3cfe08883b75e 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -106,19 +106,15 @@ const OrganizationMembersPage: FC = () => { /> { + onEditRoles={async (newRoleNames) => { await updateMemberRolesMutation.mutateAsync({ - userId, + userId: member.user_id, roles: newRoleNames, }); displaySuccess("Roles updated successfully."); diff --git a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx index 82cc574fb13a1..a3c9286fe8362 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/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/ManagementSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index 6e9fd208715de..ddfa3a6d814c7 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -18,7 +18,7 @@ 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, @@ -31,26 +31,26 @@ type UserRoleCellProps = { isLoading: boolean; canEditUsers: boolean; allAvailableRoles: readonly SlimRole[] | undefined; - user: Pick; + userLoginType?: LoginType; inheritedRoles?: readonly SlimRole[]; roles: readonly SlimRole[]; oidcRoleSyncEnabled: boolean; - onUserRolesUpdate: (userId: string, newRoleNames: string[]) => void; + onEditRoles: (newRoleNames: string[]) => void; }; export const UserRoleCell: FC = ({ isLoading, canEditUsers, allAvailableRoles, - user, + userLoginType, inheritedRoles, roles, oidcRoleSyncEnabled, - onUserRolesUpdate, + onEditRoles, }) => { - const theRolesForReal = getMergedRoles(inheritedRoles ?? [], roles); + const mergedRoles = getMergedRoles(inheritedRoles ?? [], roles); const [mainDisplayRole = fallbackRole, ...extraRoles] = - sortRolesByAccessLevel(theRolesForReal ?? []); + sortRolesByAccessLevel(mergedRoles ?? []); const hasOwnerRole = mainDisplayRole.name === "owner"; return ( @@ -61,7 +61,7 @@ export const UserRoleCell: FC = ({ roles={sortRolesByAccessLevel(allAvailableRoles ?? [])} 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 @@ -69,7 +69,7 @@ export const UserRoleCell: FC = ({ (role) => role !== fallbackRole.name, ); - onUserRolesUpdate(user.id, rolesWithoutFallback); + onEditRoles(rolesWithoutFallback); }} /> )} @@ -163,7 +163,6 @@ const styles = { const fallbackRole: MergedSlimRole = { name: "member", display_name: "Member", - global: false, } as const; const roleNamesByAccessLevel: readonly string[] = [ diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index fac91ba477340..fdcb88b447dbf 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -156,11 +156,11 @@ export const UsersTableBody: FC = ({ onUpdateUserRoles(user.id, roles)} /> From 840e073e7ff4d1ccf09fff95001ac511cf08bdd3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 23 Jul 2024 21:13:57 +0000 Subject: [PATCH 05/11] fix error and loading states --- .../OrganizationMembersPage.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 3cfe08883b75e..05bfe80987b4a 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -110,14 +110,20 @@ const OrganizationMembersPage: FC = () => { roles={member.roles} allAvailableRoles={organizationRolesQuery.data} oidcRoleSyncEnabled={false} - isLoading={organizationRolesQuery.isLoading} + isLoading={updateMemberRolesMutation.isLoading} canEditUsers onEditRoles={async (newRoleNames) => { - await updateMemberRolesMutation.mutateAsync({ - userId: member.user_id, - roles: newRoleNames, - }); - displaySuccess("Roles updated successfully."); + try { + await updateMemberRolesMutation.mutateAsync({ + userId: member.user_id, + roles: newRoleNames, + }); + displaySuccess("Roles updated successfully."); + } catch (e) { + displayError( + getErrorMessage(e, "Failed to update roles."), + ); + } }} /> From 46a468fc784870b5b848425890b7aa56870447bf Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 01:15:06 +0000 Subject: [PATCH 06/11] tests --- .../OrganizationMembersPage.test.tsx | 123 ++++++++++++++++++ .../OrganizationMembersPage.tsx | 18 ++- site/src/pages/UsersPage/UsersPage.test.tsx | 10 +- site/src/pages/UsersPage/UsersPage.tsx | 2 +- site/src/testHelpers/entities.ts | 99 +++++++++++--- site/src/testHelpers/handlers.ts | 20 ++- site/src/testHelpers/renderHelpers.tsx | 38 ++++++ 7 files changed, 281 insertions(+), 29 deletions(-) create mode 100644 site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx new file mode 100644 index 0000000000000..4ccc435b2dc25 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -0,0 +1,123 @@ +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, + MockUser2, + MockOrganizationAuditorRole, +} from "testHelpers/entities"; +import { + renderWithTemplateSettingsLayout, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; +import { server } from "testHelpers/server"; +import OrganizationMembersPage from "./OrganizationMembersPage"; + +jest.spyOn(console, "error").mockImplementation(() => {}); + +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 () => { + server.use( + http.delete( + `/api/v2/organizations/:organizationId/members/${MockUser2.id}`, + async () => { + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + 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 05bfe80987b4a..e3ce5e7b20e7f 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -136,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 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 9537d4c2527c9..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(); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 043cc405df7d3..78fe9d5e4c066 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", @@ -2146,7 +2209,7 @@ export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { }), }; -export const MockExperiments: TypesGen.Experiment[] = []; +export const MockExperiments: TypesGen.Experiment[] = ["multi-organization"]; export const MockAuditLog: TypesGen.AuditLog = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 9f74737ce5e4c..1de174cdfd8f8 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -41,10 +41,14 @@ export const handlers = [ }), // organizations + http.get("/api/v2/organizations", () => { + console.log(" fuck and piss ================================== "); + 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 +60,20 @@ 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, + ]); + }), // templates http.get("/api/v2/templates/:templateId", () => { diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 3697e6136075a..50e1afe16baff 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -17,6 +17,7 @@ import { DashboardLayout } from "modules/dashboard/DashboardLayout"; import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"; import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; import { MockUser } from "./entities"; +import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout"; export function createTestQueryClient() { // Helps create one query client for each test case, to make sure that tests @@ -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( () => { From 2144da8248e65a5817f6b17670c6ed43e7ce0030 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 01:16:57 +0000 Subject: [PATCH 07/11] no --- site/src/testHelpers/handlers.ts | 1 - site/src/testHelpers/renderHelpers.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 1de174cdfd8f8..bf580f67a3bad 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -42,7 +42,6 @@ export const handlers = [ // organizations http.get("/api/v2/organizations", () => { - console.log(" fuck and piss ================================== "); return HttpResponse.json([M.MockDefaultOrganization, M.MockOrganization2]); }), http.get("/api/v2/organizations/:organizationId", () => { diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 50e1afe16baff..6abb5e93cff62 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -14,10 +14,10 @@ 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"; -import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout"; export function createTestQueryClient() { // Helps create one query client for each test case, to make sure that tests From 86973391cc123c4295d9401020aac38b42dfe2a9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 01:25:15 +0000 Subject: [PATCH 08/11] :) --- .../OrganizationMembersPage.test.tsx | 8 ++++++++ site/src/testHelpers/entities.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx index 4ccc435b2dc25..b9c8564cb8c79 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -59,6 +59,14 @@ const updateUserRole = async (role: SlimRole) => { }; }; +beforeAll(() => { + server.use( + http.get("/api/v2/experiments", () => { + return HttpResponse.json(["multi-organization"]); + }), + ); +}); + describe("OrganizationMembersPage", () => { describe("remove member", () => { describe("when it is success", () => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 78fe9d5e4c066..453f1455615ec 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2209,7 +2209,7 @@ export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { }), }; -export const MockExperiments: TypesGen.Experiment[] = ["multi-organization"]; +export const MockExperiments: TypesGen.Experiment[] = []; export const MockAuditLog: TypesGen.AuditLog = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", From 7a40e09543e41378d61b3dbfee67f1eaebb46f2e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 16:58:26 +0000 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OrganizationMembersPage.test.tsx | 25 +++------ .../UserTable/UserRoleCell.tsx | 53 +++++++++++++------ site/src/testHelpers/handlers.ts | 6 +++ 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx index b9c8564cb8c79..b9f1b76e8226b 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -16,6 +16,14 @@ 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`, @@ -59,27 +67,10 @@ const updateUserRole = async (role: SlimRole) => { }; }; -beforeAll(() => { - server.use( - http.get("/api/v2/experiments", () => { - return HttpResponse.json(["multi-organization"]); - }), - ); -}); - describe("OrganizationMembersPage", () => { describe("remove member", () => { describe("when it is success", () => { it("shows a success message", async () => { - server.use( - http.delete( - `/api/v2/organizations/:organizationId/members/${MockUser2.id}`, - async () => { - return new HttpResponse(null, { status: 204 }); - }, - ), - ); - await renderPage(); await removeMember(); await screen.findByText("Member removed."); diff --git a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index ddfa3a6d814c7..8c6e1010579d3 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -48,10 +48,14 @@ export const UserRoleCell: FC = ({ oidcRoleSyncEnabled, onEditRoles, }) => { - const mergedRoles = getMergedRoles(inheritedRoles ?? [], roles); + const mergedRoles = getTieredRoles(inheritedRoles ?? [], roles); const [mainDisplayRole = fallbackRole, ...extraRoles] = sortRolesByAccessLevel(mergedRoles ?? []); - const hasOwnerRole = mainDisplayRole.name === "owner"; + const hasOwnerRole = + mainDisplayRole.name === "owner" || + mainDisplayRole.name === "organization-admin"; + + const displayName = mainDisplayRole.display_name || mainDisplayRole.name; return ( @@ -74,13 +78,21 @@ export const UserRoleCell: FC = ({ /> )} - + {mainDisplayRole.global ? ( - {mainDisplayRole.display_name}* + {displayName}* ) : ( - mainDisplayRole.display_name + displayName )} @@ -91,7 +103,7 @@ export const UserRoleCell: FC = ({ }; type OverflowRolePillProps = { - roles: readonly SlimRole[]; + roles: readonly TieredSlimRole[]; }; const OverflowRolePill: FC = ({ roles }) => { @@ -136,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 + )} ))} @@ -150,6 +165,10 @@ const OverflowRolePill: FC = ({ roles }) => { }; const styles = { + globalRoleBadge: (theme) => ({ + backgroundColor: theme.roles.success.background, + borderColor: theme.roles.success.outline, + }), ownerRoleBadge: (theme) => ({ backgroundColor: theme.roles.info.background, borderColor: theme.roles.info.outline, @@ -160,16 +179,20 @@ const styles = { }), } satisfies Record>; -const fallbackRole: MergedSlimRole = { +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( @@ -195,15 +218,15 @@ function getSelectedRoleNames(roles: readonly SlimRole[]) { return roleNameSet; } -interface MergedSlimRole extends SlimRole { +interface TieredSlimRole extends SlimRole { global?: boolean; } -function getMergedRoles( +function getTieredRoles( globalRoles: readonly SlimRole[], localRoles: readonly SlimRole[], ) { - const roles = new Map(); + const roles = new Map(); for (const role of globalRoles) { roles.set(role.name, { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index bf580f67a3bad..4624a58f0ce4e 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -73,6 +73,12 @@ export const handlers = [ M.MockOrganizationMember2, ]); }), + http.delete( + `/api/v2/organizations/:organizationId/members/:userId`, + async () => { + return new HttpResponse(null, { status: 204 }); + }, + ), // templates http.get("/api/v2/templates/:templateId", () => { From a3e7a8a50d149d0382d576d9237e0d30716729d7 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 17:09:01 +0000 Subject: [PATCH 10/11] actually, use `active` --- .../pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx index 8c6e1010579d3..9b774d20a3f2e 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx @@ -166,8 +166,8 @@ const OverflowRolePill: FC = ({ roles }) => { const styles = { globalRoleBadge: (theme) => ({ - backgroundColor: theme.roles.success.background, - borderColor: theme.roles.success.outline, + backgroundColor: theme.roles.active.background, + borderColor: theme.roles.active.outline, }), ownerRoleBadge: (theme) => ({ backgroundColor: theme.roles.info.background, From 830979beb1bc78812f753e16f8f6f5d1c0359d6f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 24 Jul 2024 17:13:11 +0000 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ManagementSettingsPage/OrganizationMembersPage.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx index b9f1b76e8226b..3aa7f4a606e29 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -2,11 +2,7 @@ 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, - MockUser2, - MockOrganizationAuditorRole, -} from "testHelpers/entities"; +import { MockUser, MockOrganizationAuditorRole } from "testHelpers/entities"; import { renderWithTemplateSettingsLayout, waitForLoaderToBeRemoved,