diff --git a/cli/organizationmembers.go b/cli/organizationmembers.go index 99b0700c4688d..bbd4d8519e1d1 100644 --- a/cli/organizationmembers.go +++ b/cli/organizationmembers.go @@ -137,7 +137,7 @@ func (r *RootCmd) assignOrganizationRoles(orgContext *OrganizationContext) *serp func (r *RootCmd) listOrganizationMembers(orgContext *OrganizationContext) *serpent.Command { formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.OrganizationMemberWithName{}, []string{"username", "organization_roles"}), + cliui.TableFormat([]codersdk.OrganizationMemberWithUserData{}, []string{"username", "organization_roles"}), cliui.JSONFormat(), ) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0aecfdea8ff5e..4e3b374186554 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2340,7 +2340,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" } } } @@ -2467,9 +2467,6 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "produces": [ - "application/json" - ], "tags": [ "Members" ], @@ -2492,11 +2489,8 @@ const docTemplate = `{ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.OrganizationMember" - } + "204": { + "description": "No Content" } } } @@ -10388,13 +10382,25 @@ const docTemplate = `{ } } }, - "codersdk.OrganizationMemberWithName": { + "codersdk.OrganizationMemberWithUserData": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, "created_at": { "type": "string", "format": "date-time" }, + "global_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6d5ac99848dbf..e4500397821ba 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2044,7 +2044,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" } } } @@ -2159,7 +2159,6 @@ "CoderSessionToken": [] } ], - "produces": ["application/json"], "tags": ["Members"], "summary": "Remove organization member", "operationId": "remove-organization-member", @@ -2180,11 +2179,8 @@ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.OrganizationMember" - } + "204": { + "description": "No Content" } } } @@ -9344,13 +9340,25 @@ } } }, - "codersdk.OrganizationMemberWithName": { + "codersdk.OrganizationMemberWithUserData": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, "created_at": { "type": "string", "format": "date-time" }, + "global_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 7dc35bca22bda..3c39423e2d917 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -166,25 +166,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: make([]codersdk.SlimRole, 0, len(user.RBACRoles)), - } - - for _, roleName := range user.RBACRoles { - // TODO: Currently the api only returns site wide roles. - // Should it return organization roles? - rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{ - Name: roleName, - OrganizationID: uuid.Nil, - }) - if err == nil { - convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole)) - } else { - // TODO: Fix this for custom roles to display the actual display_name - // Requires plumbing either a cached role value, or the db. - convertedUser.Roles = append(convertedUser.Roles, codersdk.SlimRole{ - Name: roleName, - }) - } + Roles: SlimRolesFromNames(user.RBACRoles), } return convertedUser @@ -537,6 +519,27 @@ func SlimRole(role rbac.Role) codersdk.SlimRole { } } +func SlimRolesFromNames(names []string) []codersdk.SlimRole { + convertedRoles := make([]codersdk.SlimRole, 0, len(names)) + + for _, name := range names { + convertedRoles = append(convertedRoles, SlimRoleFromName(name)) + } + + return convertedRoles +} + +func SlimRoleFromName(name string) codersdk.SlimRole { + rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name}) + var convertedRole codersdk.SlimRole + if err == nil { + convertedRole = SlimRole(rbacRole) + } else { + convertedRole = codersdk.SlimRole{Name: name} + } + return convertedRole +} + func RBACRole(role rbac.Role) codersdk.Role { slim := SlimRole(role) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee439996e34dd..acd4c9341c4ef 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.username, users.avatar_url, users.name, users.rbac_roles as "global_roles" FROM organization_members INNER JOIN @@ -4311,6 +4311,9 @@ type OrganizationMembersParams struct { type OrganizationMembersRow struct { OrganizationMember OrganizationMember `db:"organization_member" json:"organization_member"` Username string `db:"username" json:"username"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` + Name string `db:"name" json:"name"` + GlobalRoles pq.StringArray `db:"global_roles" json:"global_roles"` } // Arguments are optional with uuid.Nil to ignore. @@ -4333,6 +4336,9 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe &i.OrganizationMember.UpdatedAt, pq.Array(&i.OrganizationMember.Roles), &i.Username, + &i.AvatarURL, + &i.Name, + &i.GlobalRoles, ); err != nil { return nil, err } diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 4722973d38589..8cf6a804e2682 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.username, users.avatar_url, users.name, users.rbac_roles as "global_roles" FROM organization_members INNER JOIN diff --git a/coderd/members.go b/coderd/members.go index f505645ec4d50..e27f5f8840733 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -83,15 +83,15 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) // @Summary Remove organization member // @ID remove-organization-member // @Security CoderSessionToken -// @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" -// @Success 200 {object} codersdk.OrganizationMember +// @Success 204 // @Router /organizations/{organization}/members/{user} [delete] func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() + apiKey = httpmw.APIKey(r) organization = httpmw.OrganizationParam(r) member = httpmw.OrganizationMemberParam(r) auditor = api.Auditor.Load() @@ -106,6 +106,11 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request aReq.Old = member.OrganizationMember.Auditable(member.Username) defer commitAudit() + if member.UserID == apiKey.UserID { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "cannot remove self from an organization"}) + return + } + err := api.Database.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ OrganizationID: organization.ID, UserID: member.UserID, @@ -120,7 +125,7 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request } aReq.New = database.AuditableOrganizationMember{} - httpapi.Write(ctx, rw, http.StatusOK, "organization member removed") + rw.WriteHeader(http.StatusNoContent) } // @Summary List organization members @@ -129,7 +134,7 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request // @Produce json // @Tags Members // @Param organization path string true "Organization ID" -// @Success 200 {object} []codersdk.OrganizationMemberWithName +// @Success 200 {object} []codersdk.OrganizationMemberWithUserData // @Router /organizations/{organization}/members [get] func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { var ( @@ -150,7 +155,7 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { return } - resp, err := convertOrganizationMemberRows(ctx, api.Database, members) + resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members) if err != nil { httpapi.InternalServerError(rw, err) return @@ -294,7 +299,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d return converted, nil } -func convertOrganizationMemberRows(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithName, error) { +func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow) ([]codersdk.OrganizationMemberWithUserData, error) { members := make([]database.OrganizationMember, 0) for _, row := range rows { members = append(members, row.OrganizationMember) @@ -308,10 +313,13 @@ func convertOrganizationMemberRows(ctx context.Context, db database.Store, rows return nil, xerrors.Errorf("conversion failed, mismatch slice lengths") } - converted := make([]codersdk.OrganizationMemberWithName, 0) + converted := make([]codersdk.OrganizationMemberWithUserData, 0) for i := range convertedMembers { - converted = append(converted, codersdk.OrganizationMemberWithName{ + converted = append(converted, codersdk.OrganizationMemberWithUserData{ Username: rows[i].Username, + AvatarURL: rows[i].AvatarURL, + Name: rows[i].Name, + GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), OrganizationMember: convertedMembers[i], }) } diff --git a/coderd/members_test.go b/coderd/members_test.go index 3db296ef6009a..3066e15a8f783 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -185,6 +185,6 @@ func TestRemoveMember(t *testing.T) { }) } -func onlyIDs(u codersdk.OrganizationMemberWithName) uuid.UUID { +func onlyIDs(u codersdk.OrganizationMemberWithUserData) uuid.UUID { return u.UserID } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 4cb59482d15e7..0841bdba8554f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -66,8 +66,11 @@ type OrganizationMember struct { Roles []SlimRole `table:"organization_roles" json:"roles"` } -type OrganizationMemberWithName struct { - Username string `table:"username,default_sort" json:"username"` +type OrganizationMemberWithUserData struct { + Username string `table:"username,default_sort" json:"username"` + Name string `table:"name" json:"name"` + AvatarURL string `json:"avatar_url"` + GlobalRoles []SlimRole `json:"global_roles"` OrganizationMember `table:"m,recursive_inline"` } diff --git a/codersdk/users.go b/codersdk/users.go index e56c9cc90d1c7..16b6a5df6489d 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -402,14 +402,14 @@ func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uu return err } defer res.Body.Close() - if res.StatusCode != http.StatusOK { + if res.StatusCode != http.StatusNoContent { return ReadBodyAsError(res) } return nil } // OrganizationMembers lists all members in an organization -func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) { +func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithUserData, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) if err != nil { return nil, err @@ -418,7 +418,7 @@ func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UU if res.StatusCode != http.StatusOK { return nil, ReadBodyAsError(res) } - var members []OrganizationMemberWithName + var members []OrganizationMemberWithUserData return members, json.NewDecoder(res.Body).Decode(&members) } diff --git a/docs/api/members.md b/docs/api/members.md index 1a9beae285157..7cb23306a4742 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -26,7 +26,16 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members ```json [ { + "avatar_url": "string", "created_at": "2019-08-24T14:15:22Z", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ { @@ -44,9 +53,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithName](schemas.md#codersdkorganizationmemberwithname) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) |

Response Schema

@@ -55,12 +64,15 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | | -------------------- | ----------------- | -------- | ------------ | ----------- | | `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | | `» created_at` | string(date-time) | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» roles` | array | false | | | +| `» global_roles` | array | false | | | | `»» display_name` | string | false | | | | `»» name` | string | false | | | | `»» organization_id` | string | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | | `» updated_at` | string(date-time) | false | | | | `» user_id` | string(uuid) | false | | | | `» username` | string | false | | | @@ -370,7 +382,6 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ - -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -383,31 +394,11 @@ curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/memb | `organization` | path | string | true | Organization ID | | `user` | path | string | true | User ID, name, or me | -### Example responses - -> 200 Response - -```json -{ - "created_at": "2019-08-24T14:15:22Z", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "updated_at": "2019-08-24T14:15:22Z", - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" -} -``` - ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a3e745b46fa17..a644669bf3e42 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3456,11 +3456,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `updated_at` | string | false | | | | `user_id` | string | false | | | -## codersdk.OrganizationMemberWithName +## codersdk.OrganizationMemberWithUserData ```json { + "avatar_url": "string", "created_at": "2019-08-24T14:15:22Z", + "global_roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ { @@ -3479,7 +3488,10 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | Name | Type | Required | Restrictions | Description | | ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | | `created_at` | string | false | | | +| `global_roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `name` | string | false | | | | `organization_id` | string | false | | | | `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | | `updated_at` | string | false | | | diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 75a476adcf559..e60da675ccaa9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -541,6 +541,28 @@ class ApiMethods { return response.data; }; + getOrganizationMembers = async (organizationId: string) => { + const response = await this.axios.get< + TypesGen.OrganizationMemberWithUserData[] + >(`/api/v2/organizations/${organizationId}/members`); + + return response.data; + }; + + addOrganizationMember = async (organizationId: string, userId: string) => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/members/${userId}`, + ); + + return response.data; + }; + + removeOrganizationMember = async (organizationId: string, userId: string) => { + await this.axios.delete( + `/api/v2/organizations/${organizationId}/members/${userId}`, + ); + }; + getOrganizations = async (): Promise => { const response = await this.axios.get( "/api/v2/users/me/organizations", diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index e9526e74ca3f2..3be956e5164ba 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -44,3 +44,37 @@ export const deleteOrganization = (queryClient: QueryClient) => { }, }; }; + +export const organizationMembers = (id: string) => { + return { + queryFn: () => API.getOrganizationMembers(id), + key: ["organization", id, "members"], + }; +}; + +export const addOrganizationMember = (queryClient: QueryClient, id: string) => { + return { + mutationFn: (userId: string) => { + return API.addOrganizationMember(id, userId); + }, + + onSuccess: async () => { + await queryClient.invalidateQueries(["organization", id, "members"]); + }, + }; +}; + +export const removeOrganizationMember = ( + queryClient: QueryClient, + id: string, +) => { + return { + mutationFn: (userId: string) => { + return API.removeOrganizationMember(id, userId); + }, + + onSuccess: async () => { + await queryClient.invalidateQueries(["organization", id, "members"]); + }, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d4bbc32bba10c..664ac93ecb720 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -834,8 +834,11 @@ export interface OrganizationMember { } // From codersdk/organizations.go -export interface OrganizationMemberWithName extends OrganizationMember { +export interface OrganizationMemberWithUserData extends OrganizationMember { readonly username: string; + readonly name: string; + readonly avatar_url: string; + readonly global_roles: readonly SlimRole[]; } // From codersdk/pagination.go diff --git a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx index 9eace9c20f2bd..84d16e93b7323 100644 --- a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx +++ b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx @@ -29,7 +29,6 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => { throw new Error( "useOrganizationSettings should be used inside of OrganizationSettingsLayout", ); - return { organizations: [] }; } return context; }; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx new file mode 100644 index 0000000000000..467ee9cedaa10 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -0,0 +1,223 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import PersonAdd from "@mui/icons-material/PersonAdd"; +import LoadingButton from "@mui/lab/LoadingButton"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +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"; +import { getErrorMessage } from "api/errors"; +import { + addOrganizationMember, + organizationMembers, + removeOrganizationMember, +} from "api/queries/organizations"; +import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { + MoreMenu, + MoreMenuTrigger, + MoreMenuContent, + MoreMenuItem, + 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"; + +const OrganizationMembersPage: FC = () => { + const queryClient = useQueryClient(); + const { organization } = useParams() as { organization: string }; + const { user: me } = useAuthenticated(); + + const membersQuery = useQuery(organizationMembers(organization)); + const addMemberMutation = useMutation( + addOrganizationMember(queryClient, organization), + ); + const removeMemberMutation = useMutation( + removeOrganizationMember(queryClient, organization), + ); + + const error = + membersQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error; + const members = membersQuery.data; + + return ( +
+ + Organization members + + + + {Boolean(error) && } + + { + await addMemberMutation.mutateAsync(user.id); + void membersQuery.refetch(); + }} + /> + + + + + + User + Roles + + + + + {members?.map((member) => ( + + + + } + title={member.name} + subtitle={member.username} + /> + + + {getMemberRoles(member).map((role) => ( + + {role.global ? ( + + {role.name}* + + ) : ( + role.name + )} + + ))} + + + {member.user_id !== me.id && ( + + + + + + { + await removeMemberMutation.mutateAsync( + member.user_id, + ); + void membersQuery.refetch(); + }} + > + Remove + + + + )} + + + ))} + +
+
+
+
+ ); +}; + +function getMemberRoles(member: OrganizationMemberWithUserData) { + const roles = new Map(); + + for (const role of member.global_roles) { + roles.set(role.name, { + name: role.display_name || role.name, + global: true, + }); + } + for (const role of member.roles) { + if (roles.has(role.name)) { + continue; + } + roles.set(role.name, { name: role.display_name || role.name }); + } + + return [...roles.values()]; +} + +export default OrganizationMembersPage; + +interface AddGroupMemberProps { + isLoading: boolean; + onSubmit: (user: User) => Promise; +} + +const AddGroupMember: FC = ({ isLoading, onSubmit }) => { + const [selectedUser, setSelectedUser] = useState(null); + + return ( +
{ + e.preventDefault(); + + if (selectedUser) { + try { + await onSubmit(selectedUser); + setSelectedUser(null); + } catch (error) { + displayError(getErrorMessage(error, "Failed to add member.")); + } + } + }} + > + + { + setSelectedUser(newValue); + }} + /> + + } + loading={isLoading} + > + Add user + + +
+ ); +}; + +const styles = { + role: (theme) => ({ + backgroundColor: theme.roles.info.background, + borderColor: theme.roles.info.outline, + }), + globalRole: (theme) => ({ + backgroundColor: theme.roles.inactive.background, + borderColor: theme.roles.inactive.outline, + }), + autoComplete: { + width: 300, + }, +} satisfies Record>; diff --git a/site/src/router.tsx b/site/src/router.tsx index d6aa16523e9f3..5b6f013f715c4 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -227,6 +227,9 @@ const CreateOrganizationPage = lazy( const OrganizationSettingsPage = lazy( () => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"), ); +const OrganizationMembersPage = lazy( + () => import("./pages/ManagementSettingsPage/OrganizationMembersPage"), +); const OrganizationSettingsPlaceholder = lazy( () => import("./pages/ManagementSettingsPage/OrganizationSettingsPlaceholder"), @@ -348,10 +351,7 @@ export const router = createBrowserRouter( path="external-auth" element={} /> - } - /> + } /> }