mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add members settings page for organizations (#13817)
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
Generated
+16
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10500,13 +10494,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"
|
||||
|
||||
Generated
+16
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9446,13 +9442,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-8
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Generated
+20
-29
@@ -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) |
|
||||
|
||||
<h3 id="list-organization-members-responseschema">Response Schema</h3>
|
||||
|
||||
@@ -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 | | |
|
||||
@@ -372,7 +384,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'
|
||||
```
|
||||
|
||||
@@ -385,31 +396,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).
|
||||
|
||||
|
||||
Generated
+13
-1
@@ -3470,11 +3470,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": [
|
||||
{
|
||||
@@ -3493,7 +3502,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 | | |
|
||||
|
||||
@@ -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<TypesGen.OrganizationMember>(
|
||||
`/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<TypesGen.Organization[]> => {
|
||||
const response = await this.axios.get<TypesGen.Organization[]>(
|
||||
"/api/v2/users/me/organizations",
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+4
-1
@@ -844,8 +844,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
|
||||
|
||||
@@ -29,7 +29,6 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => {
|
||||
throw new Error(
|
||||
"useOrganizationSettings should be used inside of OrganizationSettingsLayout",
|
||||
);
|
||||
return { organizations: [] };
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>Organization members</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
|
||||
<Stack>
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
<AddGroupMember
|
||||
isLoading={addMemberMutation.isLoading}
|
||||
onSubmit={async (user) => {
|
||||
await addMemberMutation.mutateAsync(user.id);
|
||||
void membersQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50%">User</TableCell>
|
||||
<TableCell width="49%">Roles</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{members?.map((member) => (
|
||||
<TableRow key={member.user_id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<UserAvatar
|
||||
username={member.username}
|
||||
avatarURL={member.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={member.name}
|
||||
subtitle={member.username}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getMemberRoles(member).map((role) => (
|
||||
<Pill
|
||||
key={role.name}
|
||||
css={role.global ? styles.globalRole : styles.role}
|
||||
>
|
||||
{role.global ? (
|
||||
<Tooltip title="This user has this role for all organizations.">
|
||||
<span>{role.name}*</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
role.name
|
||||
)}
|
||||
</Pill>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{member.user_id !== me.id && (
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton />
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
danger
|
||||
onClick={async () => {
|
||||
await removeMemberMutation.mutateAsync(
|
||||
member.user_id,
|
||||
);
|
||||
void membersQuery.refetch();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getMemberRoles(member: OrganizationMemberWithUserData) {
|
||||
const roles = new Map<string, { name: string; global?: boolean }>();
|
||||
|
||||
for (const role of member.global_roles) {
|
||||
roles.set(role.name, {
|
||||
name: role.display_name || role.name,
|
||||
global: true,
|
||||
});
|
||||
}
|
||||
for (const role of member.roles) {
|
||||
if (roles.has(role.name)) {
|
||||
continue;
|
||||
}
|
||||
roles.set(role.name, { name: role.display_name || role.name });
|
||||
}
|
||||
|
||||
return [...roles.values()];
|
||||
}
|
||||
|
||||
export default OrganizationMembersPage;
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
isLoading: boolean;
|
||||
onSubmit: (user: User) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedUser) {
|
||||
try {
|
||||
await onSubmit(selectedUser);
|
||||
setSelectedUser(null);
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to add member."));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<UserAutocomplete
|
||||
css={styles.autoComplete}
|
||||
value={selectedUser}
|
||||
onChange={(newValue) => {
|
||||
setSelectedUser(newValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
loadingPosition="start"
|
||||
disabled={!selectedUser}
|
||||
type="submit"
|
||||
startIcon={<PersonAdd />}
|
||||
loading={isLoading}
|
||||
>
|
||||
Add user
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
+4
-4
@@ -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={<OrganizationSettingsPlaceholder />}
|
||||
/>
|
||||
<Route
|
||||
path="members"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
/>
|
||||
<Route path="members" element={<OrganizationMembersPage />} />
|
||||
<Route
|
||||
path="groups"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
|
||||
Reference in New Issue
Block a user