From c09f530e8a24456a49892d3ccea9126b7d8f2e65 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 8 Jul 2024 14:47:31 +0000 Subject: [PATCH 01/17] whatever I have so far --- coderd/apidoc/docs.go | 14 ++---- coderd/apidoc/swagger.json | 14 ++---- coderd/apikey.go | 2 +- coderd/database/queries.sql.go | 6 ++- .../database/queries/organizationmembers.sql | 2 +- coderd/debug.go | 2 +- coderd/externalauth.go | 2 +- coderd/identityprovider/revoke.go | 2 +- coderd/members.go | 16 ++++--- coderd/oauth2.go | 4 +- coderd/templates.go | 2 +- coderd/users.go | 8 ++-- coderd/workspaces.go | 4 +- codersdk/organizations.go | 4 +- codersdk/users.go | 4 +- docs/api/members.md | 27 ++---------- docs/api/users.md | 34 ++------------- site/src/api/api.ts | 16 +++++++ site/src/api/queries/organizations.ts | 19 ++++++++ site/src/api/typesGenerated.ts | 4 +- .../OrganizationMembersPage.tsx | 43 +++++++++++++++++++ site/src/router.tsx | 8 ++-- 22 files changed, 130 insertions(+), 107 deletions(-) create mode 100644 site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index cb59b53023644..ec20d8b37d022 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2427,11 +2427,8 @@ const docTemplate = `{ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.OrganizationMember" - } + "204": { + "description": "No Content" } } } @@ -4658,11 +4655,8 @@ const docTemplate = `{ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.User" - } + "204": { + "description": "No Content" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ee6dde53c0258..37ee182c9e2b0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2125,11 +2125,8 @@ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.OrganizationMember" - } + "204": { + "description": "No Content" } } } @@ -4106,11 +4103,8 @@ } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.User" - } + "204": { + "description": "No Content" } } } diff --git a/coderd/apikey.go b/coderd/apikey.go index fe32b771e61ef..8676b5e1ba6c0 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -333,7 +333,7 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } // @Summary Get token config diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff7b7f6f955bd..94f28124a89e2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4244,7 +4244,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 FROM organization_members INNER JOIN @@ -4272,6 +4272,8 @@ 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"` } // Arguments are optional with uuid.Nil to ignore. @@ -4294,6 +4296,8 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe &i.OrganizationMember.UpdatedAt, pq.Array(&i.OrganizationMember.Roles), &i.Username, + &i.AvatarURL, + &i.Name, ); err != nil { return nil, err } diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 4722973d38589..d8041f1e00efb 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 FROM organization_members INNER JOIN diff --git a/coderd/debug.go b/coderd/debug.go index b1f17f29e0102..f13656886295e 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -235,7 +235,7 @@ func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Requ if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { // See: https://www.rfc-editor.org/rfc/rfc7231#section-6.3.5 - httpapi.Write(r.Context(), rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) return } diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 8f8514fa17442..25f362e7372cf 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -197,7 +197,7 @@ func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Reque return } } - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } // @Summary Get external auth device by ID. diff --git a/coderd/identityprovider/revoke.go b/coderd/identityprovider/revoke.go index cddc150bbe364..78acb9ea0de22 100644 --- a/coderd/identityprovider/revoke.go +++ b/coderd/identityprovider/revoke.go @@ -39,6 +39,6 @@ func RevokeApp(db database.Store) http.HandlerFunc { httpapi.InternalServerError(rw, err) return } - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } } diff --git a/coderd/members.go b/coderd/members.go index 24f712b8154c7..ae8928e241c17 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -86,7 +86,7 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) // @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 ( @@ -118,7 +118,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 @@ -127,7 +127,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 ( @@ -148,7 +148,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 @@ -292,7 +292,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) @@ -306,10 +306,12 @@ 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, OrganizationMember: convertedMembers[i], }) } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index ef68e93a1fc47..da102faf9138c 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -207,7 +207,7 @@ func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) }) return } - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } // @Summary Get OAuth2 application secrets. @@ -324,7 +324,7 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re }) return } - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } // @Summary OAuth2 authorization request. diff --git a/coderd/templates.go b/coderd/templates.go index ffb45fd2e08e4..f11517d8a6180 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -800,7 +800,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if updated.UpdatedAt.IsZero() { aReq.New = template - httpapi.Write(ctx, rw, http.StatusNotModified, nil) + rw.WriteHeader(http.StatusNotModified) return } aReq.New = updated diff --git a/coderd/users.go b/coderd/users.go index 5ef0b2f8316e8..2a43292fe9ee9 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -504,7 +504,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Users // @Param user path string true "User ID, name, or me" -// @Success 200 {object} codersdk.User +// @Success 204 // @Router /users/{user} [delete] func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -558,9 +558,7 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { } user.Deleted = true aReq.New = user - httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ - Message: "User has been deleted!", - }) + rw.WriteHeader(http.StatusNoContent) } // Returns the parameterized user requested. All validation @@ -1008,7 +1006,7 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { newUser.HashedPassword = []byte(hashedPassword) aReq.New = newUser - httpapi.Write(ctx, rw, http.StatusNoContent, nil) + rw.WriteHeader(http.StatusNoContent) } // @Summary Get user roles diff --git a/coderd/workspaces.go b/coderd/workspaces.go index bed982d5e2511..9f1ca970e609e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -927,9 +927,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { // If the workspace is already in the desired state do nothing! if workspace.DormantAt.Valid == req.Dormant { - httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ - Message: "Nothing to do!", - }) + rw.WriteHeader(http.StatusNotModified) return } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index e494018258e48..933d3e7c796ce 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -65,8 +65,10 @@ type OrganizationMember struct { Roles []SlimRole `table:"organization_roles" json:"roles"` } -type OrganizationMemberWithName struct { +type OrganizationMemberWithUserData struct { Username string `table:"username,default_sort" json:"username"` + Name string `table:"name" json:"name"` + AvatarURL string `json:"avatar_url"` OrganizationMember `table:"m,recursive_inline"` } diff --git a/codersdk/users.go b/codersdk/users.go index dd6779e3a0342..9ef50b9e8dc15 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -409,7 +409,7 @@ func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uu } // 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..543b887e96e0f 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -370,7 +370,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 +382,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/users.md b/docs/api/users.md index 22d1c7b9cfca8..ac3305af96c86 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -410,7 +410,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/users/{user} \ - -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -422,38 +421,11 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ | ------ | ---- | ------ | -------- | -------------------- | | `user` | path | string | true | User ID, name, or me | -### Example responses - -> 200 Response - -```json -{ - "avatar_url": "http://example.com", - "created_at": "2019-08-24T14:15:22Z", - "email": "user@example.com", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "last_seen_at": "2019-08-24T14:15:22Z", - "login_type": "", - "name": "string", - "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "roles": [ - { - "display_name": "string", - "name": "string", - "organization_id": "string" - } - ], - "status": "active", - "theme_preference": "string", - "username": "string" -} -``` - ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) | +| 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/site/src/api/api.ts b/site/src/api/api.ts index 75a476adcf559..dd00fb5e61f72 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -541,6 +541,22 @@ class ApiMethods { return response.data; }; + getOrganizationMembers = async (organizationId: string) => { + const response = await this.axios.get< + TypesGen.OrganizationMemberWithName[] + >(`/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; + }; + 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..25aba9029c844 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -44,3 +44,22 @@ 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) => { + API.addOrganizationMember(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 e878b25e1f452..73f1ee6ea0c1f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -801,8 +801,10 @@ 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; } // From codersdk/pagination.go diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx new file mode 100644 index 0000000000000..79516478bca8a --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -0,0 +1,43 @@ +import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { organizationMembers } from "api/queries/organizations"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Margins } from "components/Margins/Margins"; +import { useOrganizationSettings } from "./ManagementSettingsLayout"; +import { me } from "api/queries/users"; + +const OrganizationMembersPage: FC = () => { + const queryClient = useQueryClient(); + + const membersQuery = useQuery(organizationMembers("default")); + + const { currentOrganizationId, organizations } = useOrganizationSettings(); + + const org = organizations.find((org) => org.id === currentOrganizationId)!; + + const error = membersQuery.error; + + return ( + + {Boolean(error) && } + +

Organization settings

+ +

Name: {org.name}

+

Display name: {org.display_name}

+

Members: {membersQuery.data?.map((member) => member.username)}

+ + {membersQuery.data && ( +
    + {membersQuery.data.map((member) => ( +
  • + {member.name} {member.avatar_url} +
  • + ))} +
+ )} +
+ ); +}; + +export default OrganizationMembersPage; 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={} /> - } - /> + } /> } From 75287eb7aae31f602fb3c490c420e47212d1fa4e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 8 Jul 2024 16:36:52 +0000 Subject: [PATCH 02/17] table tho --- cli/organizationmembers.go | 2 +- coderd/apidoc/docs.go | 10 ++- coderd/apidoc/swagger.json | 10 ++- docs/api/members.md | 10 ++- docs/api/schemas.md | 6 +- site/src/api/api.ts | 2 +- .../ManagementSettingsLayout.tsx | 1 - .../OrganizationMembersPage.tsx | 76 ++++++++++++++----- 8 files changed, 85 insertions(+), 32 deletions(-) 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 636470b708283..3b6cd4dc1e1f5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2275,7 +2275,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" } } } @@ -10315,13 +10315,19 @@ const docTemplate = `{ } } }, - "codersdk.OrganizationMemberWithName": { + "codersdk.OrganizationMemberWithUserData": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, "created_at": { "type": "string", "format": "date-time" }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f1f45ad51f7db..63dc0d69e04a7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1989,7 +1989,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + "$ref": "#/definitions/codersdk.OrganizationMemberWithUserData" } } } @@ -9279,13 +9279,19 @@ } } }, - "codersdk.OrganizationMemberWithName": { + "codersdk.OrganizationMemberWithUserData": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, "created_at": { "type": "string", "format": "date-time" }, + "name": { + "type": "string" + }, "organization_id": { "type": "string", "format": "uuid" diff --git a/docs/api/members.md b/docs/api/members.md index 543b887e96e0f..40c5129545370 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -26,7 +26,9 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members ```json [ { + "avatar_url": "string", "created_at": "2019-08-24T14:15:22Z", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ { @@ -44,9 +46,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,7 +57,9 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | | -------------------- | ----------------- | -------- | ------------ | ----------- | | `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | | `» created_at` | string(date-time) | false | | | +| `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | | `» roles` | array | false | | | | `»» display_name` | string | false | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5e2eaf7b74784..33d363291886d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3442,11 +3442,13 @@ 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", + "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ { @@ -3465,7 +3467,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | Name | Type | Required | Restrictions | Description | | ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | | `created_at` | string | 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 dd00fb5e61f72..d1ebbf09eb8bb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -543,7 +543,7 @@ class ApiMethods { getOrganizationMembers = async (organizationId: string) => { const response = await this.axios.get< - TypesGen.OrganizationMemberWithName[] + TypesGen.OrganizationMemberWithUserData[] >(`/api/v2/organizations/${organizationId}/members`); return response.data; 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 index 79516478bca8a..b935c2395fdef 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -5,38 +5,72 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Margins } from "components/Margins/Margins"; import { useOrganizationSettings } from "./ManagementSettingsLayout"; import { me } from "api/queries/users"; +import { AvatarIcon } from "components/Avatar/Avatar"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { AvatarData } from "components/AvatarData/AvatarData"; +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 { Navigate, useParams } from "react-router-dom"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); + const { organization } = useParams() as { organization: string }; - const membersQuery = useQuery(organizationMembers("default")); + const membersQuery = useQuery(organizationMembers(organization)); const { currentOrganizationId, organizations } = useOrganizationSettings(); - const org = organizations.find((org) => org.id === currentOrganizationId)!; - const error = membersQuery.error; return ( - - {Boolean(error) && } - -

Organization settings

- -

Name: {org.name}

-

Display name: {org.display_name}

-

Members: {membersQuery.data?.map((member) => member.username)}

- - {membersQuery.data && ( -
    - {membersQuery.data.map((member) => ( -
  • - {member.name} {member.avatar_url} -
  • - ))} -
+
+ + Organization members + + + {Boolean(error) && ( +
+ +
)} - + + + + + + Name + Users + + + + + {membersQuery.data?.map((member) => ( + + + + } + title={member.name} + subtitle={member.username} + /> + + + + + ))} + +
+
+
); }; From 14f44c66899e3770c836367e3fc6221f61c94eeb Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 8 Jul 2024 21:37:34 +0000 Subject: [PATCH 03/17] :) --- coderd/apidoc/docs.go | 6 ++ coderd/apidoc/swagger.json | 6 ++ coderd/database/db2sdk/db2sdk.go | 11 ++-- coderd/database/queries.sql.go | 4 +- .../database/queries/organizationmembers.sql | 2 +- coderd/members.go | 25 ++++++++ coderd/notifications/manager_test.go | 3 +- codersdk/organizations.go | 7 +- docs/api/members.md | 14 +++- docs/api/schemas.md | 8 +++ site/src/api/typesGenerated.ts | 1 + .../OrganizationMembersPage.tsx | 64 +++++++++++++++++-- 12 files changed, 131 insertions(+), 20 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b6cd4dc1e1f5..266b6289d989d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10325,6 +10325,12 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "global_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, "name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 63dc0d69e04a7..2252bf1c3d1e3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9289,6 +9289,12 @@ "type": "string", "format": "date-time" }, + "global_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, "name": { "type": "string" }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 53e0cd53ad3e9..9fd67207d1a39 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -166,24 +166,23 @@ 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)), + Roles: make([]codersdk.SlimRole, len(user.RBACRoles)), } - for _, roleName := range user.RBACRoles { + for i, 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)) + convertedUser.Roles[i] = 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, - }) + convertedUser.Roles[i] = codersdk.SlimRole{Name: roleName} } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0405b49503398..a5792d1ae6225 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4291,7 +4291,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.username, users.avatar_url, users.name, users.rbac_roles as "global_roles" FROM organization_members INNER JOIN @@ -4321,6 +4321,7 @@ type OrganizationMembersRow struct { 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. @@ -4345,6 +4346,7 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe &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 d8041f1e00efb..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.avatar_url, users.name + 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 ae8928e241c17..aec5f25a73f1d 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -312,9 +312,34 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, + GlobalRoles: convertRbacRoleNamesToSlimRoles(rows[i].GlobalRoles), OrganizationMember: convertedMembers[i], }) } return converted, nil } + +// Taken from `func db2sdk.User`. We should probably deduplicate this. +func convertRbacRoleNamesToSlimRoles(names []string) []codersdk.SlimRole { + convertedRoles := make([]codersdk.SlimRole, len(names)) + + for i, roleName := range names { + // 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 { + convertedRoles[i] = db2sdk.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. + convertedRoles[i] = codersdk.SlimRole{Name: roleName} + } + } + + return convertedRoles +} diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index d0d6355f0c68c..52ea84e123d6e 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,12 +7,13 @@ import ( "testing" "time" - "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/serpent" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 2dab4b5885aba..b5425f21de41f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -67,9 +67,10 @@ type OrganizationMember struct { } type OrganizationMemberWithUserData struct { - Username string `table:"username,default_sort" json:"username"` - Name string `table:"name" json:"name"` - AvatarURL string `json:"avatar_url"` + 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/docs/api/members.md b/docs/api/members.md index 40c5129545370..7cb23306a4742 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -28,6 +28,13 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members { "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": [ @@ -59,12 +66,13 @@ Status Code **200** | `[array item]` | array | false | | | | `» avatar_url` | string | false | | | | `» created_at` | string(date-time) | false | | | -| `» name` | string | 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 | | | diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 33d363291886d..84b574fd92e8a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3448,6 +3448,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o { "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": [ @@ -3469,6 +3476,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | | `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 | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 813c2fe2c3427..5439b5fe625a6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -833,6 +833,7 @@ 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/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index b935c2395fdef..b61f103a99539 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -15,17 +15,29 @@ 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 { Navigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; +import { roles } from "api/queries/roles"; +import type { + OrganizationMemberWithUserData, + SlimRole, +} from "api/typesGenerated"; +import { Pill } from "components/Pill/Pill"; +import theme from "theme"; +import { useTheme } from "@emotion/react"; +import { Tooltip } from "@mui/material"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); const { organization } = useParams() as { organization: string }; + const rolesQuery = useQuery(roles()); const membersQuery = useQuery(organizationMembers(organization)); const { currentOrganizationId, organizations } = useOrganizationSettings(); - const error = membersQuery.error; + const error = rolesQuery.error ?? membersQuery.error; + + const theme = useTheme(); return (
@@ -43,8 +55,8 @@ const OrganizationMembersPage: FC = () => { - Name - Users + User + Roles @@ -63,7 +75,27 @@ const OrganizationMembersPage: FC = () => { subtitle={member.username} /> - + + {getMemberRoles(member).map((role) => ( + + {role.name} + {role.global && ( + + * + + )} + + ))} + ))} @@ -74,4 +106,26 @@ const OrganizationMembersPage: FC = () => { ); }; +function getMemberRoles(member: OrganizationMemberWithUserData) { + const roles = new Map< + string, + { name: string; global?: boolean; tooltip?: string } + >(); + + 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; From 87d5f5b77103bfa32127c9e07aa2873e9ff1d69a Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 8 Jul 2024 21:39:43 +0000 Subject: [PATCH 04/17] some lint --- site/src/api/queries/organizations.ts | 2 +- .../OrganizationMembersPage.tsx | 26 +++++++------------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 25aba9029c844..cf7a0373db200 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -55,7 +55,7 @@ export const organizationMembers = (id: string) => { export const addOrganizationMember = (queryClient: QueryClient, id: string) => { return { mutationFn: (userId: string) => { - API.addOrganizationMember(id, userId); + return API.addOrganizationMember(id, userId); }, onSuccess: async () => { diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index b61f103a99539..dcb0c388bde60 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -1,30 +1,23 @@ -import type { FC } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { organizationMembers } from "api/queries/organizations"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { Margins } from "components/Margins/Margins"; -import { useOrganizationSettings } from "./ManagementSettingsLayout"; -import { me } from "api/queries/users"; -import { AvatarIcon } from "components/Avatar/Avatar"; +import { useTheme } from "@emotion/react"; +import { Tooltip } from "@mui/material"; +import Table from "@mui/material/Table"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { AvatarData } from "components/AvatarData/AvatarData"; -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 type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; +import { organizationMembers } from "api/queries/organizations"; import { roles } from "api/queries/roles"; -import type { - OrganizationMemberWithUserData, - SlimRole, -} from "api/typesGenerated"; +import type { OrganizationMemberWithUserData } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Pill } from "components/Pill/Pill"; -import theme from "theme"; -import { useTheme } from "@emotion/react"; -import { Tooltip } from "@mui/material"; +import { useOrganizationSettings } from "./ManagementSettingsLayout"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -78,6 +71,7 @@ const OrganizationMembersPage: FC = () => { {getMemberRoles(member).map((role) => ( Date: Tue, 9 Jul 2024 19:02:28 +0000 Subject: [PATCH 05/17] remove member --- site/src/api/api.ts | 6 ++++++ site/src/api/queries/organizations.ts | 15 +++++++++++++++ .../OrganizationMembersPage.tsx | 12 +++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d1ebbf09eb8bb..90c22e3993256 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -557,6 +557,12 @@ class ApiMethods { return response.data; }; + removeOrganizationMember = async (organizationId: string, userId: string) => { + const response = 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 cf7a0373db200..3be956e5164ba 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -63,3 +63,18 @@ export const addOrganizationMember = (queryClient: QueryClient, id: string) => { }, }; }; + +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/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index dcb0c388bde60..5b84313cad237 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -12,7 +12,11 @@ import TableRow from "@mui/material/TableRow"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; -import { organizationMembers } from "api/queries/organizations"; +import { + addOrganizationMember, + organizationMembers, + removeOrganizationMember, +} from "api/queries/organizations"; import { roles } from "api/queries/roles"; import type { OrganizationMemberWithUserData } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; @@ -25,6 +29,12 @@ const OrganizationMembersPage: FC = () => { const rolesQuery = useQuery(roles()); const membersQuery = useQuery(organizationMembers(organization)); + const addMemberMutation = useMutation( + addOrganizationMember(queryClient, organization), + ); + const removeMemberMutation = useMutation( + removeOrganizationMember(queryClient, organization), + ); const { currentOrganizationId, organizations } = useOrganizationSettings(); From bf757e1b1a7eda64cca45960d4e28147bfc13883 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 9 Jul 2024 21:32:20 +0000 Subject: [PATCH 06/17] messy --- site/src/api/api.ts | 2 +- .../OrganizationMembersPage.tsx | 247 +++++++++++++----- 2 files changed, 176 insertions(+), 73 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 90c22e3993256..e60da675ccaa9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -558,7 +558,7 @@ class ApiMethods { }; removeOrganizationMember = async (organizationId: string, userId: string) => { - const response = await this.axios.delete( + await this.axios.delete( `/api/v2/organizations/${organizationId}/members/${userId}`, ); }; diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 5b84313cad237..6508d67376756 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -1,15 +1,13 @@ -import { useTheme } from "@emotion/react"; -import { Tooltip } from "@mui/material"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import Table from "@mui/material/Table"; -import { UserAvatar } from "components/UserAvatar/UserAvatar"; -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; -import { AvatarData } from "components/AvatarData/AvatarData"; 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 type { FC } from "react"; +import Tooltip from "@mui/material/Tooltip"; +import PersonAdd from "@mui/icons-material/PersonAdd"; +import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; import { @@ -17,17 +15,35 @@ import { organizationMembers, removeOrganizationMember, } from "api/queries/organizations"; -import { roles } from "api/queries/roles"; -import type { OrganizationMemberWithUserData } from "api/typesGenerated"; +import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { Pill } from "components/Pill/Pill"; -import { useOrganizationSettings } from "./ManagementSettingsLayout"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { + MoreMenu, + MoreMenuTrigger, + MoreMenuContent, + MoreMenuItem, + ThreeDotsButton, +} from "components/MoreMenu/MoreMenu"; +import Divider from "@mui/material/Divider"; +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { getErrorMessage } from "api/errors"; +import LoadingButton from "@mui/lab/LoadingButton"; +import { Stack } from "components/Stack/Stack"; + +function doNothingTemporarily() { + console.log("ok"); +} const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); + const theme = useTheme(); const { organization } = useParams() as { organization: string }; - const rolesQuery = useQuery(roles()); const membersQuery = useQuery(organizationMembers(organization)); const addMemberMutation = useMutation( addOrganizationMember(queryClient, organization), @@ -36,11 +52,8 @@ const OrganizationMembersPage: FC = () => { removeOrganizationMember(queryClient, organization), ); - const { currentOrganizationId, organizations } = useOrganizationSettings(); - - const error = rolesQuery.error ?? membersQuery.error; - - const theme = useTheme(); + const error = + membersQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error; return (
@@ -48,64 +61,104 @@ const OrganizationMembersPage: FC = () => { Organization members - {Boolean(error) && ( -
- -
- )} - - -
- - - User - Roles - - - - - {membersQuery.data?.map((member) => ( - - - - } - title={member.name} - subtitle={member.username} - /> - - - {getMemberRoles(member).map((role) => ( - - {role.name} - {role.global && ( - - * - - )} - - ))} - - + + {Boolean(error) && } + + { + try { + await addMemberMutation.mutateAsync(user.id); + void membersQuery.refetch(); + } catch (error) { + displayError(getErrorMessage(error, "Failed to add member.")); + } + }} + /> + + +
+ + + User + Roles + - ))} - -
- + + + {membersQuery.data?.map((member) => ( + + + + } + title={member.name} + subtitle={member.username} + /> + + + {getMemberRoles(member).map((role) => ( + + {role.global ? ( + + {role.name}* + + ) : ( + role.name + )} + + ))} + + + + + + + + doNothingTemporarily()}> + View workspaces + + doNothingTemporarily()}> + View activity + + doNothingTemporarily()}> + Reset password… + + + { + await removeMemberMutation.mutateAsync( + member.user_id, + ); + membersQuery.refetch(); + }} + danger + > + Delete… + + + + + + ))} + + + +
); }; @@ -133,3 +186,53 @@ function getMemberRoles(member: OrganizationMemberWithUserData) { } export default OrganizationMembersPage; + +interface AddGroupMemberProps { + isLoading: boolean; + onSubmit: (user: User) => void; +} + +const AddGroupMember: FC = ({ isLoading, onSubmit }) => { + const [selectedUser, setSelectedUser] = useState(null); + + return ( +
{ + e.preventDefault(); + + if (selectedUser) { + try { + await onSubmit(selectedUser); + setSelectedUser(null); + } catch {} + } + }} + > + + { + setSelectedUser(newValue); + }} + /> + + } + loading={isLoading} + > + Add user + + +
+ ); +}; + +const styles = { + autoComplete: { + width: 300, + }, +} satisfies Record>; From 25a5c89ec5b3c9f637296e81bf69e519fc2bcc7b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 10 Jul 2024 17:24:11 +0000 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderd/database/db2sdk/db2sdk.go | 20 ++------------ coderd/members.go | 27 ++----------------- coderd/members_test.go | 2 +- coderd/notifications/manager_test.go | 3 +-- coderd/rbac/rolestore/rolestore.go | 20 ++++++++++++++ .../OrganizationMembersPage.tsx | 6 ++--- 6 files changed, 29 insertions(+), 49 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 9fd67207d1a39..6936e7e7dfaf0 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" @@ -166,24 +167,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: make([]codersdk.SlimRole, len(user.RBACRoles)), - } - - for i, 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[i] = 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[i] = codersdk.SlimRole{Name: roleName} - } + Roles: rolestore.ExpandFromGlobalNamesToSlimRole(user.RBACRoles), } return convertedUser diff --git a/coderd/members.go b/coderd/members.go index aec5f25a73f1d..2333bc637afe3 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -312,34 +313,10 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, - GlobalRoles: convertRbacRoleNamesToSlimRoles(rows[i].GlobalRoles), + GlobalRoles: rolestore.ExpandFromGlobalNamesToSlimRole(rows[i].GlobalRoles), OrganizationMember: convertedMembers[i], }) } return converted, nil } - -// Taken from `func db2sdk.User`. We should probably deduplicate this. -func convertRbacRoleNamesToSlimRoles(names []string) []codersdk.SlimRole { - convertedRoles := make([]codersdk.SlimRole, len(names)) - - for i, roleName := range names { - // 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 { - convertedRoles[i] = db2sdk.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. - convertedRoles[i] = codersdk.SlimRole{Name: roleName} - } - } - - return convertedRoles -} 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/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 52ea84e123d6e..d0d6355f0c68c 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,13 +7,12 @@ import ( "testing" "time" + "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/serpent" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 610b04c06aa19..238dc0d0b2e75 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -8,8 +8,10 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/syncmap" + "github.com/coder/coder/v2/codersdk" ) type customRoleCtxKey struct{} @@ -38,6 +40,24 @@ func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { return c } +func ExpandFromGlobalNamesToSlimRole(names []string) []codersdk.SlimRole { + convertedRoles := make([]codersdk.SlimRole, 0, len(names)) + + for _, roleName := range names { + // TODO: Currently the api only returns site wide roles. + // Should it return organization roles? + rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: roleName}) + + if err == nil { + convertedRoles = append(convertedRoles, db2sdk.SlimRole(rbacRole)) + } else { + convertedRoles = append(convertedRoles, codersdk.SlimRole{Name: roleName}) + } + } + + return convertedRoles +} + // Expand will expand built in roles, and fetch custom roles from the database. // If a custom role is defined, but does not exist, the role will be omitted on // the response. This means deleted roles are silently dropped. diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 6508d67376756..b39dd728ed84b 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -114,7 +114,7 @@ const OrganizationMembersPage: FC = () => { }} > {role.global ? ( - + {role.name}* ) : ( @@ -144,7 +144,7 @@ const OrganizationMembersPage: FC = () => { await removeMemberMutation.mutateAsync( member.user_id, ); - membersQuery.refetch(); + void membersQuery.refetch(); }} danger > @@ -189,7 +189,7 @@ export default OrganizationMembersPage; interface AddGroupMemberProps { isLoading: boolean; - onSubmit: (user: User) => void; + onSubmit: (user: User) => Promise; } const AddGroupMember: FC = ({ isLoading, onSubmit }) => { From d6969efd4240dbbf1030298d442906f6eae03c80 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 10 Jul 2024 21:26:57 +0000 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=A7=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderd/database/db2sdk/db2sdk.go | 24 +++++++++- coderd/members.go | 3 +- coderd/notifications/manager_test.go | 3 +- coderd/rbac/rolestore/rolestore.go | 20 --------- .../OrganizationMembersPage.tsx | 45 ++++++------------- 5 files changed, 39 insertions(+), 56 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 6936e7e7dfaf0..5858d2b3f93bf 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -17,7 +17,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" @@ -167,7 +166,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: rolestore.ExpandFromGlobalNamesToSlimRole(user.RBACRoles), + Roles: SlimRolesFromNames(user.RBACRoles), } return convertedUser @@ -519,6 +518,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/members.go b/coderd/members.go index 2333bc637afe3..d2e741060fb19 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -14,7 +14,6 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -313,7 +312,7 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, - GlobalRoles: rolestore.ExpandFromGlobalNamesToSlimRole(rows[i].GlobalRoles), + GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), OrganizationMember: convertedMembers[i], }) } diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index d0d6355f0c68c..52ea84e123d6e 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,12 +7,13 @@ import ( "testing" "time" - "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/serpent" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 238dc0d0b2e75..610b04c06aa19 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -8,10 +8,8 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/syncmap" - "github.com/coder/coder/v2/codersdk" ) type customRoleCtxKey struct{} @@ -40,24 +38,6 @@ func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { return c } -func ExpandFromGlobalNamesToSlimRole(names []string) []codersdk.SlimRole { - convertedRoles := make([]codersdk.SlimRole, 0, len(names)) - - for _, roleName := range names { - // TODO: Currently the api only returns site wide roles. - // Should it return organization roles? - rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: roleName}) - - if err == nil { - convertedRoles = append(convertedRoles, db2sdk.SlimRole(rbacRole)) - } else { - convertedRoles = append(convertedRoles, codersdk.SlimRole{Name: roleName}) - } - } - - return convertedRoles -} - // Expand will expand built in roles, and fetch custom roles from the database. // If a custom role is defined, but does not exist, the role will be omitted on // the response. This means deleted roles are silently dropped. diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index b39dd728ed84b..58c96b4e0838b 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -1,4 +1,6 @@ import { type Interpolation, type Theme, useTheme } 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"; @@ -6,10 +8,10 @@ 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 PersonAdd from "@mui/icons-material/PersonAdd"; 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, @@ -18,9 +20,7 @@ import { import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { AvatarData } from "components/AvatarData/AvatarData"; -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; -import { Pill } from "components/Pill/Pill"; -import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { displayError } from "components/GlobalSnackbar/utils"; import { MoreMenu, MoreMenuTrigger, @@ -28,16 +28,11 @@ import { MoreMenuItem, ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; -import Divider from "@mui/material/Divider"; -import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { getErrorMessage } from "api/errors"; -import LoadingButton from "@mui/lab/LoadingButton"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; - -function doNothingTemporarily() { - console.log("ok"); -} +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -67,12 +62,8 @@ const OrganizationMembersPage: FC = () => { { - try { - await addMemberMutation.mutateAsync(user.id); - void membersQuery.refetch(); - } catch (error) { - displayError(getErrorMessage(error, "Failed to add member.")); - } + await addMemberMutation.mutateAsync(user.id); + void membersQuery.refetch(); }} /> @@ -129,24 +120,14 @@ const OrganizationMembersPage: FC = () => { - doNothingTemporarily()}> - View workspaces - - doNothingTemporarily()}> - View activity - - doNothingTemporarily()}> - Reset password… - - { await removeMemberMutation.mutateAsync( member.user_id, ); void membersQuery.refetch(); }} - danger > Delete… @@ -204,7 +185,9 @@ const AddGroupMember: FC = ({ isLoading, onSubmit }) => { try { await onSubmit(selectedUser); setSelectedUser(null); - } catch {} + } catch (error) { + displayError(getErrorMessage(error, "Failed to add member.")); + } } }} > From e1dd5b8485a2d23fa74bf717afec080bc971e89c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 10 Jul 2024 21:33:48 +0000 Subject: [PATCH 09/17] fkwjmregoijawer --- coderd/notifications/manager_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 1a6e8b8f047da..fe161cc2cd8f6 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,12 +7,13 @@ import ( "testing" "time" - "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" From 608ea6972502f546d1b41602ab47f70526c541e3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 10 Jul 2024 22:07:36 +0000 Subject: [PATCH 10/17] split out the body stuff into a different branch --- coderd/apidoc/docs.go | 7 +++++-- coderd/apidoc/swagger.json | 7 +++++-- coderd/apikey.go | 2 +- coderd/debug.go | 2 +- coderd/externalauth.go | 2 +- coderd/identityprovider/revoke.go | 2 +- coderd/oauth2.go | 4 ++-- coderd/templates.go | 2 +- coderd/users.go | 8 +++++--- coderd/workspaces.go | 4 +++- docs/api/users.md | 34 ++++++++++++++++++++++++++++--- 11 files changed, 56 insertions(+), 18 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 90da902a3c805..d4e1a51aea5e4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4720,8 +4720,11 @@ const docTemplate = `{ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.User" + } } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d37bbaaaeecf3..a71e00e7b2da8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4158,8 +4158,11 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.User" + } } } } diff --git a/coderd/apikey.go b/coderd/apikey.go index 8676b5e1ba6c0..fe32b771e61ef 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -333,7 +333,7 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { return } - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary Get token config diff --git a/coderd/debug.go b/coderd/debug.go index f13656886295e..b1f17f29e0102 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -235,7 +235,7 @@ func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Requ if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { // See: https://www.rfc-editor.org/rfc/rfc7231#section-6.3.5 - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(r.Context(), rw, http.StatusNoContent, nil) return } diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 25f362e7372cf..8f8514fa17442 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -197,7 +197,7 @@ func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Reque return } } - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary Get external auth device by ID. diff --git a/coderd/identityprovider/revoke.go b/coderd/identityprovider/revoke.go index 78acb9ea0de22..cddc150bbe364 100644 --- a/coderd/identityprovider/revoke.go +++ b/coderd/identityprovider/revoke.go @@ -39,6 +39,6 @@ func RevokeApp(db database.Store) http.HandlerFunc { httpapi.InternalServerError(rw, err) return } - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } } diff --git a/coderd/oauth2.go b/coderd/oauth2.go index da102faf9138c..ef68e93a1fc47 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -207,7 +207,7 @@ func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) }) return } - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary Get OAuth2 application secrets. @@ -324,7 +324,7 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re }) return } - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary OAuth2 authorization request. diff --git a/coderd/templates.go b/coderd/templates.go index 5bf32871dcbc1..78f821a382a8d 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -791,7 +791,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if updated.UpdatedAt.IsZero() { aReq.New = template - rw.WriteHeader(http.StatusNotModified) + httpapi.Write(ctx, rw, http.StatusNotModified, nil) return } aReq.New = updated diff --git a/coderd/users.go b/coderd/users.go index 6331cd171ff9e..4372a4f7ded33 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -504,7 +504,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Users // @Param user path string true "User ID, name, or me" -// @Success 204 +// @Success 200 {object} codersdk.User // @Router /users/{user} [delete] func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -558,7 +558,9 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { } user.Deleted = true aReq.New = user - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "User has been deleted!", + }) } // Returns the parameterized user requested. All validation @@ -1011,7 +1013,7 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { newUser.HashedPassword = []byte(hashedPassword) aReq.New = newUser - rw.WriteHeader(http.StatusNoContent) + httpapi.Write(ctx, rw, http.StatusNoContent, nil) } // @Summary Get user roles diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9f1ca970e609e..bed982d5e2511 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -927,7 +927,9 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { // If the workspace is already in the desired state do nothing! if workspace.DormantAt.Valid == req.Dormant { - rw.WriteHeader(http.StatusNotModified) + httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ + Message: "Nothing to do!", + }) return } diff --git a/docs/api/users.md b/docs/api/users.md index ac3305af96c86..22d1c7b9cfca8 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -410,6 +410,7 @@ 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/users/{user} \ + -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -421,11 +422,38 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \ | ------ | ---- | ------ | -------- | -------------------- | | `user` | path | string | true | User ID, name, or me | +### Example responses + +> 200 Response + +```json +{ + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "username": "string" +} +``` + ### Responses -| Status | Meaning | Description | Schema | -| ------ | --------------------------------------------------------------- | ----------- | ------ | -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.User](schemas.md#codersdkuser) | To perform this operation, you must be authenticated. [Learn more](authentication.md). From f234db5e0a172a2fd1e3db5c22230b8e0caeb7a3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 10 Jul 2024 22:19:56 +0000 Subject: [PATCH 11/17] does not produce --- coderd/members.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/members.go b/coderd/members.go index 93fa6ba3a82d3..4ad38358074dd 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -163,7 +163,6 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @ID assign-role-to-organization-member // @Security CoderSessionToken // @Accept json -// @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" From 194aae705af0123be558bdf7bd9c6116e52146c0 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 11 Jul 2024 18:25:58 +0000 Subject: [PATCH 12/17] ... --- coderd/apidoc/docs.go | 3 --- coderd/apidoc/swagger.json | 1 - coderd/members.go | 2 +- codersdk/users.go | 2 +- docs/api/members.md | 18 +----------------- 5 files changed, 3 insertions(+), 23 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4e1a51aea5e4..328a14be6cace 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2508,9 +2508,6 @@ const docTemplate = `{ "consumes": [ "application/json" ], - "produces": [ - "application/json" - ], "tags": [ "Members" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a71e00e7b2da8..e151abc7a6c67 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2194,7 +2194,6 @@ } ], "consumes": ["application/json"], - "produces": ["application/json"], "tags": ["Members"], "summary": "Assign role to organization member", "operationId": "assign-role-to-organization-member", diff --git a/coderd/members.go b/coderd/members.go index 4ad38358074dd..2da830eff7718 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -83,7 +83,6 @@ 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" @@ -163,6 +162,7 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @ID assign-role-to-organization-member // @Security CoderSessionToken // @Accept json +// @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" diff --git a/codersdk/users.go b/codersdk/users.go index 9ef50b9e8dc15..6dd3a9ff82933 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -402,7 +402,7 @@ 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 diff --git a/docs/api/members.md b/docs/api/members.md index 7cb23306a4742..3a9575446ebe0 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -410,7 +410,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio # Example request using curl curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/roles \ -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ + -H 'Accept: */*' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -436,22 +436,6 @@ curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members > 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 | From 035004522e342ac5587bd897e3080e3381da8f6f Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 11 Jul 2024 18:34:12 +0000 Subject: [PATCH 13/17] me oh my --- coderd/apidoc/docs.go | 6 +++--- coderd/apidoc/swagger.json | 2 +- docs/api/members.md | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 328a14be6cace..40f8cd6f20ad9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2467,9 +2467,6 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "produces": [ - "application/json" - ], "tags": [ "Members" ], @@ -2508,6 +2505,9 @@ const docTemplate = `{ "consumes": [ "application/json" ], + "produces": [ + "application/json" + ], "tags": [ "Members" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e151abc7a6c67..8e1d3e9980599 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2159,7 +2159,6 @@ "CoderSessionToken": [] } ], - "produces": ["application/json"], "tags": ["Members"], "summary": "Remove organization member", "operationId": "remove-organization-member", @@ -2194,6 +2193,7 @@ } ], "consumes": ["application/json"], + "produces": ["application/json"], "tags": ["Members"], "summary": "Assign role to organization member", "operationId": "assign-role-to-organization-member", diff --git a/docs/api/members.md b/docs/api/members.md index 3a9575446ebe0..7cb23306a4742 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -410,7 +410,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio # Example request using curl curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/roles \ -H 'Content-Type: application/json' \ - -H 'Accept: */*' \ + -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` @@ -436,6 +436,22 @@ curl -X PUT http://coder-server:8080/api/v2/organizations/{organization}/members > 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 | From 5bb3e50aa9097d090c9ab5010ccb8336bd1af2ec Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 11 Jul 2024 19:08:30 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OrganizationMembersPage.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 58c96b4e0838b..518d8071a9bda 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -95,14 +95,7 @@ const OrganizationMembersPage: FC = () => { {getMemberRoles(member).map((role) => ( {role.global ? ( @@ -129,7 +122,7 @@ const OrganizationMembersPage: FC = () => { void membersQuery.refetch(); }} > - Delete… + Remove… @@ -145,10 +138,7 @@ const OrganizationMembersPage: FC = () => { }; function getMemberRoles(member: OrganizationMemberWithUserData) { - const roles = new Map< - string, - { name: string; global?: boolean; tooltip?: string } - >(); + const roles = new Map(); for (const role of member.global_roles) { roles.set(role.name, { @@ -215,6 +205,14 @@ const AddGroupMember: FC = ({ isLoading, onSubmit }) => { }; 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, }, From e90d7c3f63ec9a8c131c4d493aa572d06f6b178b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 11 Jul 2024 19:08:59 +0000 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=A7=B9=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ManagementSettingsPage/OrganizationMembersPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 518d8071a9bda..d0eec00efe839 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -1,4 +1,4 @@ -import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +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"; @@ -36,7 +36,6 @@ import { UserAvatar } from "components/UserAvatar/UserAvatar"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); - const theme = useTheme(); const { organization } = useParams() as { organization: string }; const membersQuery = useQuery(organizationMembers(organization)); From 625588ff04009abaa356df5efe236792d0895bf1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 15 Jul 2024 19:25:20 +0000 Subject: [PATCH 16/17] can't delete self --- coderd/members.go | 6 +++ .../OrganizationMembersPage.tsx | 43 +++++++++++-------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/coderd/members.go b/coderd/members.go index 2da830eff7718..e27f5f8840733 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -91,6 +91,7 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) 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() @@ -105,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, diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index d0eec00efe839..6e367b0f6ea43 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -33,10 +33,12 @@ 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( @@ -48,6 +50,7 @@ const OrganizationMembersPage: FC = () => { const error = membersQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error; + const members = membersQuery.data; return (
@@ -76,7 +79,7 @@ const OrganizationMembersPage: FC = () => { - {membersQuery.data?.map((member) => ( + {members?.map((member) => ( { ))} - - - - - - { - await removeMemberMutation.mutateAsync( - member.user_id, - ); - void membersQuery.refetch(); - }} - > - Remove… - - - + {member.user_id !== me.id && ( + + + + + + { + await removeMemberMutation.mutateAsync( + member.user_id, + ); + void membersQuery.refetch(); + }} + > + Remove… + + + + )} ))} From b5e83c1ec7b8cb7d274d3b8d90c27c9500283ac3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 15 Jul 2024 19:34:51 +0000 Subject: [PATCH 17/17] don't require a second step for "remove" --- .../pages/ManagementSettingsPage/OrganizationMembersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx index 6e367b0f6ea43..467ee9cedaa10 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx @@ -125,7 +125,7 @@ const OrganizationMembersPage: FC = () => { void membersQuery.refetch(); }} > - Remove… + Remove