feat: add members settings page for organizations (#13817)

This commit is contained in:
Kayla Washburn-Love
2024-07-16 12:25:36 -06:00
committed by GitHub
parent b697c6939a
commit a3f40d5ef8
18 changed files with 408 additions and 90 deletions
+1 -1
View File
@@ -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(),
)
+16 -10
View File
@@ -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"
+16 -8
View File
@@ -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"
+22 -19
View File
@@ -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)
+7 -1
View File
@@ -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
View File
@@ -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],
})
}
+1 -1
View File
@@ -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
}
+5 -2
View File
@@ -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
View File
@@ -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)
}
+20 -29
View File
@@ -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).
+13 -1
View File
@@ -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 | | |
+22
View File
@@ -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",
+34
View File
@@ -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"]);
},
};
};
+4 -1
View File
@@ -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
View File
@@ -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 />}