mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: create and modify organization groups (#13887)
This commit is contained in:
committed by
GitHub
parent
dd99457a04
commit
0a71c34d46
@ -2,6 +2,7 @@ package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@ -170,9 +171,9 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
OrganizationID: group.OrganizationID,
|
||||
UserID: uuid.MustParse(id),
|
||||
}))
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID),
|
||||
Message: fmt.Sprintf("User must be a member of organization %q", group.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -364,7 +365,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
|
||||
users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
@ -391,7 +392,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
|
||||
groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
@ -515,19 +515,19 @@ class ApiMethods {
|
||||
};
|
||||
|
||||
updateOrganization = async (
|
||||
orgId: string,
|
||||
organizationId: string,
|
||||
params: TypesGen.UpdateOrganizationRequest,
|
||||
) => {
|
||||
const response = await this.axios.patch<TypesGen.Organization>(
|
||||
`/api/v2/organizations/${orgId}`,
|
||||
`/api/v2/organizations/${organizationId}`,
|
||||
params,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
deleteOrganization = async (orgId: string) => {
|
||||
deleteOrganization = async (organizationId: string) => {
|
||||
await this.axios.delete<TypesGen.Organization>(
|
||||
`/api/v2/organizations/${orgId}`,
|
||||
`/api/v2/organizations/${organizationId}`,
|
||||
);
|
||||
};
|
||||
|
||||
@ -1485,9 +1485,12 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getGroup = async (groupName: string): Promise<TypesGen.Group> => {
|
||||
getGroup = async (
|
||||
organizationId: string,
|
||||
groupName: string,
|
||||
): Promise<TypesGen.Group> => {
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/organizations/default/groups/${groupName}`,
|
||||
`/api/v2/organizations/${organizationId}/groups/${groupName}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
@ -9,7 +9,11 @@ import type {
|
||||
const GROUPS_QUERY_KEY = ["groups"];
|
||||
type GroupSortOrder = "asc" | "desc";
|
||||
|
||||
const getGroupQueryKey = (groupName: string) => ["group", groupName];
|
||||
const getGroupQueryKey = (organizationId: string, groupName: string) => [
|
||||
organizationId,
|
||||
"group",
|
||||
groupName,
|
||||
];
|
||||
|
||||
export const groups = (organizationId: string) => {
|
||||
return {
|
||||
@ -18,10 +22,10 @@ export const groups = (organizationId: string) => {
|
||||
} satisfies UseQueryOptions<Group[]>;
|
||||
};
|
||||
|
||||
export const group = (groupName: string) => {
|
||||
export const group = (organizationId: string, groupName: string) => {
|
||||
return {
|
||||
queryKey: getGroupQueryKey(groupName),
|
||||
queryFn: () => API.getGroup(groupName),
|
||||
queryKey: getGroupQueryKey(organizationId, groupName),
|
||||
queryFn: () => API.getGroup(organizationId, groupName),
|
||||
};
|
||||
};
|
||||
|
||||
@ -69,7 +73,7 @@ export function groupsForUser(organizationId: string, userId: string) {
|
||||
|
||||
export const groupPermissions = (groupId: string) => {
|
||||
return {
|
||||
queryKey: [...getGroupQueryKey(groupId), "permissions"],
|
||||
queryKey: ["group", groupId, "permissions"],
|
||||
queryFn: () =>
|
||||
API.checkAuthorization({
|
||||
checks: {
|
||||
@ -85,12 +89,12 @@ export const groupPermissions = (groupId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const createGroup = (queryClient: QueryClient) => {
|
||||
export const createGroup = (
|
||||
queryClient: QueryClient,
|
||||
organizationId: string,
|
||||
) => {
|
||||
return {
|
||||
mutationFn: ({
|
||||
organizationId,
|
||||
...request
|
||||
}: CreateGroupRequest & { organizationId: string }) =>
|
||||
mutationFn: (request: CreateGroupRequest) =>
|
||||
API.createGroup(organizationId, request),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(GROUPS_QUERY_KEY);
|
||||
@ -106,7 +110,7 @@ export const patchGroup = (queryClient: QueryClient) => {
|
||||
}: PatchGroupRequest & { groupId: string }) =>
|
||||
API.patchGroup(groupId, request),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
invalidateGroup(queryClient, "default", updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
@ -114,7 +118,7 @@ export const deleteGroup = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.deleteGroup,
|
||||
onSuccess: async (_: void, groupId: string) =>
|
||||
invalidateGroup(queryClient, groupId),
|
||||
invalidateGroup(queryClient, "default", groupId),
|
||||
};
|
||||
};
|
||||
|
||||
@ -123,7 +127,7 @@ export const addMember = (queryClient: QueryClient) => {
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
API.addMember(groupId, userId),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
invalidateGroup(queryClient, "default", updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
@ -132,14 +136,18 @@ export const removeMember = (queryClient: QueryClient) => {
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
API.removeMember(groupId, userId),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
invalidateGroup(queryClient, "default", updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const invalidateGroup = (queryClient: QueryClient, groupId: string) =>
|
||||
export const invalidateGroup = (
|
||||
queryClient: QueryClient,
|
||||
organizationId: string,
|
||||
groupId: string,
|
||||
) =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
|
||||
queryClient.invalidateQueries(getGroupQueryKey(groupId)),
|
||||
queryClient.invalidateQueries(getGroupQueryKey(organizationId, groupId)),
|
||||
]);
|
||||
|
||||
export function sortGroupsByName(
|
||||
|
@ -19,14 +19,14 @@ export const createOrganization = (queryClient: QueryClient) => {
|
||||
};
|
||||
|
||||
interface UpdateOrganizationVariables {
|
||||
orgId: string;
|
||||
organizationId: string;
|
||||
req: UpdateOrganizationRequest;
|
||||
}
|
||||
|
||||
export const updateOrganization = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: (variables: UpdateOrganizationVariables) =>
|
||||
API.updateOrganization(variables.orgId, variables.req),
|
||||
API.updateOrganization(variables.organizationId, variables.req),
|
||||
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(organizationsKey);
|
||||
@ -36,7 +36,8 @@ export const updateOrganization = (queryClient: QueryClient) => {
|
||||
|
||||
export const deleteOrganization = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: (orgId: string) => API.deleteOrganization(orgId),
|
||||
mutationFn: (organizationId: string) =>
|
||||
API.deleteOrganization(organizationId),
|
||||
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(meKey);
|
||||
@ -79,7 +80,7 @@ export const removeOrganizationMember = (
|
||||
};
|
||||
};
|
||||
|
||||
export const organizationsKey = ["organizations", "me"] as const;
|
||||
export const organizationsKey = ["organizations"] as const;
|
||||
|
||||
export const organizations = () => {
|
||||
return {
|
||||
|
@ -107,3 +107,23 @@ export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResourcePageHeaderProps extends Omit<PageHeaderProps, "children"> {
|
||||
displayName?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const ResourcePageHeader: FC<ResourcePageHeaderProps> = ({
|
||||
displayName,
|
||||
name,
|
||||
...props
|
||||
}) => {
|
||||
const title = displayName || name;
|
||||
|
||||
return (
|
||||
<PageHeader {...props}>
|
||||
<PageHeaderTitle>{title}</PageHeaderTitle>
|
||||
{name !== title && <PageHeaderSubtitle>{name}</PageHeaderSubtitle>}
|
||||
</PageHeader>
|
||||
);
|
||||
};
|
||||
|
@ -3,15 +3,13 @@ import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createGroup } from "api/queries/groups";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { pageTitle } from "utils/page";
|
||||
import CreateGroupPageView from "./CreateGroupPageView";
|
||||
|
||||
export const CreateGroupPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { organizationId } = useDashboard();
|
||||
const createGroupMutation = useMutation(createGroup(queryClient));
|
||||
const createGroupMutation = useMutation(createGroup(queryClient, "default"));
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -20,10 +18,7 @@ export const CreateGroupPage: FC = () => {
|
||||
</Helmet>
|
||||
<CreateGroupPageView
|
||||
onSubmit={async (data) => {
|
||||
const newGroup = await createGroupMutation.mutateAsync({
|
||||
organizationId,
|
||||
...data,
|
||||
});
|
||||
const newGroup = await createGroupMutation.mutateAsync(data);
|
||||
navigate(`/groups/${newGroup.name}`);
|
||||
}}
|
||||
error={createGroupMutation.error}
|
||||
|
@ -54,10 +54,13 @@ import { isEveryoneGroup } from "utils/groups";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
||||
export const GroupPage: FC = () => {
|
||||
const { groupName } = useParams() as { groupName: string };
|
||||
const { groupName, organization } = useParams() as {
|
||||
organization: string;
|
||||
groupName: string;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const groupQuery = useQuery(group(groupName));
|
||||
const groupQuery = useQuery(group(organization, groupName));
|
||||
const groupData = groupQuery.data;
|
||||
const { data: permissions } = useQuery(
|
||||
groupData !== undefined
|
||||
|
@ -13,8 +13,7 @@ import SettingsGroupPageView from "./SettingsGroupPageView";
|
||||
export const SettingsGroupPage: FC = () => {
|
||||
const { groupName } = useParams() as { groupName: string };
|
||||
const queryClient = useQueryClient();
|
||||
const groupQuery = useQuery(group(groupName));
|
||||
const { data: groupData, isLoading, error } = useQuery(group(groupName));
|
||||
const groupQuery = useQuery(group("default", groupName));
|
||||
const patchGroupMutation = useMutation(patchGroup(queryClient));
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -28,11 +27,11 @@ export const SettingsGroupPage: FC = () => {
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorAlert error={error} />;
|
||||
if (groupQuery.error) {
|
||||
return <ErrorAlert error={groupQuery.error} />;
|
||||
}
|
||||
|
||||
if (isLoading || !groupData) {
|
||||
if (groupQuery.isLoading || !groupQuery.data) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
@ -40,7 +39,8 @@ export const SettingsGroupPage: FC = () => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
const groupId = groupData.id;
|
||||
|
||||
const groupId = groupQuery.data.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -0,0 +1,33 @@
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { createGroup } from "api/queries/groups";
|
||||
import { pageTitle } from "utils/page";
|
||||
import CreateGroupPageView from "./CreateGroupPageView";
|
||||
|
||||
export const CreateGroupPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { organization } = useParams() as { organization: string };
|
||||
const createGroupMutation = useMutation(
|
||||
createGroup(queryClient, organization),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Create Group")}</title>
|
||||
</Helmet>
|
||||
<CreateGroupPageView
|
||||
onSubmit={async (data) => {
|
||||
const newGroup = await createGroupMutation.mutateAsync(data);
|
||||
navigate(`/organizations/${organization}/groups/${newGroup.name}`);
|
||||
}}
|
||||
error={createGroupMutation.error}
|
||||
isLoading={createGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CreateGroupPage;
|
@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { userEvent, within } from "@storybook/test";
|
||||
import { mockApiError } from "testHelpers/entities";
|
||||
import { CreateGroupPageView } from "./CreateGroupPageView";
|
||||
|
||||
const meta: Meta<typeof CreateGroupPageView> = {
|
||||
title: "pages/OrganizationGroupsPage/CreateGroupPageView",
|
||||
component: CreateGroupPageView,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateGroupPageView>;
|
||||
|
||||
export const Example: Story = {};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
error: mockApiError({
|
||||
message: "A group named new-group already exists.",
|
||||
validations: [{ field: "name", detail: "Group names must be unique" }],
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Enter name", async () => {
|
||||
const input = canvas.getByLabelText("Name");
|
||||
await userEvent.type(input, "new-group");
|
||||
input.blur();
|
||||
});
|
||||
},
|
||||
};
|
@ -0,0 +1,90 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as Yup from "yup";
|
||||
import { isApiValidationError } from "api/errors";
|
||||
import type { CreateGroupRequest } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "components/Form/Form";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: Yup.string().required().label("Name"),
|
||||
});
|
||||
|
||||
export type CreateGroupPageViewProps = {
|
||||
onSubmit: (data: CreateGroupRequest) => void;
|
||||
error?: unknown;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
onSubmit,
|
||||
error,
|
||||
isLoading,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const form = useFormik<CreateGroupRequest>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
display_name: "",
|
||||
avatar_url: "",
|
||||
quota_allowance: 0,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<CreateGroupRequest>(form, error);
|
||||
const onCancel = () => navigate(-1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader css={{ paddingTop: 8 }}>
|
||||
<PageHeaderTitle>Create a group</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||
<FormSection
|
||||
title="Group settings"
|
||||
description="Set a name and avatar for this group."
|
||||
>
|
||||
<FormFields>
|
||||
{Boolean(error) && !isApiValidationError(error) && (
|
||||
<ErrorAlert error={error} />
|
||||
)}
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name", {
|
||||
helperText: "Optional: keep empty to default to the name.",
|
||||
})}
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("avatar_url")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Avatar URL"
|
||||
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
<FormFooter onCancel={onCancel} isLoading={isLoading} />
|
||||
</HorizontalForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CreateGroupPageView;
|
350
site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx
Normal file
350
site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx
Normal file
@ -0,0 +1,350 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||
import PersonAdd from "@mui/icons-material/PersonAdd";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import Button from "@mui/material/Button";
|
||||
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, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import {
|
||||
addMember,
|
||||
deleteGroup,
|
||||
group,
|
||||
groupPermissions,
|
||||
removeMember,
|
||||
} from "api/queries/groups";
|
||||
import type { Group, ReducedUser, User } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { LastSeen } from "components/LastSeen/LastSeen";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import {
|
||||
MoreMenu,
|
||||
MoreMenuContent,
|
||||
MoreMenuItem,
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import { ResourcePageHeader } from "components/PageHeader/PageHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
PaginationStatus,
|
||||
TableToolbar,
|
||||
} from "components/TableToolbar/TableToolbar";
|
||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { isEveryoneGroup } from "utils/groups";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
||||
export const GroupPage: FC = () => {
|
||||
const { organization, groupName } = useParams() as {
|
||||
organization: string;
|
||||
groupName: string;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const groupQuery = useQuery(group(organization, groupName));
|
||||
const groupData = groupQuery.data;
|
||||
const { data: permissions } = useQuery(
|
||||
groupData !== undefined
|
||||
? groupPermissions(groupData.id)
|
||||
: { enabled: false },
|
||||
);
|
||||
const addMemberMutation = useMutation(addMember(queryClient));
|
||||
const removeMemberMutation = useMutation(removeMember(queryClient));
|
||||
const deleteGroupMutation = useMutation(deleteGroup(queryClient));
|
||||
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
|
||||
const isLoading = groupQuery.isLoading || !groupData || !permissions;
|
||||
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
(groupData?.display_name || groupData?.name) ?? "Loading...",
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (groupQuery.error) {
|
||||
return <ErrorAlert error={groupQuery.error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<Loader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const groupId = groupData.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
|
||||
<Margins>
|
||||
<ResourcePageHeader
|
||||
displayName={groupData?.display_name}
|
||||
name={groupData?.name}
|
||||
actions={
|
||||
canUpdateGroup && (
|
||||
<>
|
||||
<Button
|
||||
startIcon={<SettingsOutlined />}
|
||||
to="settings"
|
||||
component={RouterLink}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
disabled={groupData?.id === groupData?.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
startIcon={<DeleteOutline />}
|
||||
css={styles.removeButton}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
|
||||
<AddGroupMember
|
||||
isLoading={addMemberMutation.isLoading}
|
||||
onSubmit={async (user, reset) => {
|
||||
try {
|
||||
await addMemberMutation.mutateAsync({
|
||||
groupId,
|
||||
userId: user.id,
|
||||
});
|
||||
reset();
|
||||
await groupQuery.refetch();
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to add member."));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={groupData?.members.length ?? 0}
|
||||
total={groupData?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="59%">User</TableCell>
|
||||
<TableCell width="40">Status</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{groupData?.members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupData?.members.map((member) => (
|
||||
<GroupMemberRow
|
||||
member={member}
|
||||
group={groupData}
|
||||
key={member.id}
|
||||
canUpdate={canUpdateGroup}
|
||||
onRemove={async () => {
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
groupId: groupData.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await groupQuery.refetch();
|
||||
displaySuccess("Member removed successfully.");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to remove member."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Margins>
|
||||
|
||||
{groupQuery.data && (
|
||||
<DeleteDialog
|
||||
isOpen={isDeletingGroup}
|
||||
confirmLoading={deleteGroupMutation.isLoading}
|
||||
name={groupQuery.data.name}
|
||||
entity="group"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteGroupMutation.mutateAsync(groupId);
|
||||
displaySuccess("Group deleted successfully.");
|
||||
navigate("..");
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to delete group."));
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeletingGroup(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
isLoading: boolean;
|
||||
onSubmit: (user: User, reset: () => void) => void;
|
||||
}
|
||||
|
||||
const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const resetValues = () => {
|
||||
setSelectedUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedUser) {
|
||||
onSubmit(selectedUser, resetValues);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupMemberRowProps {
|
||||
member: ReducedUser;
|
||||
group: Group;
|
||||
canUpdate: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
member,
|
||||
group,
|
||||
canUpdate,
|
||||
onRemove,
|
||||
}) => {
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width="59%">
|
||||
<AvatarData
|
||||
avatar={
|
||||
<UserAvatar
|
||||
username={member.username}
|
||||
avatarURL={member.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={member.username}
|
||||
subtitle={member.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
width="40%"
|
||||
css={[styles.status, member.status === "suspended" && styles.suspended]}
|
||||
>
|
||||
<div>{member.status}</div>
|
||||
<LastSeen at={member.last_seen_at} css={{ fontSize: 12 }} />
|
||||
</TableCell>
|
||||
<TableCell width="1%">
|
||||
{canUpdate && (
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton />
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
danger
|
||||
onClick={onRemove}
|
||||
disabled={group.id === group.organization_id}
|
||||
>
|
||||
Remove
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
autoComplete: {
|
||||
width: 300,
|
||||
},
|
||||
removeButton: (theme) => ({
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}),
|
||||
status: {
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
suspended: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default GroupPage;
|
@ -0,0 +1,74 @@
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { group, patchGroup } from "api/queries/groups";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { pageTitle } from "utils/page";
|
||||
import GroupSettingsPageView from "./GroupSettingsPageView";
|
||||
|
||||
export const GroupSettingsPage: FC = () => {
|
||||
const { organization, groupName } = useParams() as {
|
||||
organization: string;
|
||||
groupName: string;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const groupQuery = useQuery(group(organization, groupName));
|
||||
const patchGroupMutation = useMutation(patchGroup(queryClient));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToGroup = () => {
|
||||
navigate(`/organizations/${organization}/groups/${groupName}`);
|
||||
};
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle("Settings Group")}</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (groupQuery.error) {
|
||||
return <ErrorAlert error={groupQuery.error} />;
|
||||
}
|
||||
|
||||
if (groupQuery.isLoading || !groupQuery.data) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<Loader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const groupId = groupQuery.data.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
|
||||
<GroupSettingsPageView
|
||||
onCancel={navigateToGroup}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchGroupMutation.mutateAsync({
|
||||
groupId,
|
||||
...data,
|
||||
add_users: [],
|
||||
remove_users: [],
|
||||
});
|
||||
navigate(`../${data.name}`);
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to update group"));
|
||||
}
|
||||
}}
|
||||
group={groupQuery.data}
|
||||
formErrors={groupQuery.error}
|
||||
isLoading={groupQuery.isLoading}
|
||||
isUpdating={patchGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default GroupSettingsPage;
|
@ -0,0 +1,21 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockGroup } from "testHelpers/entities";
|
||||
import GroupSettingsPageView from "./GroupSettingsPageView";
|
||||
|
||||
const meta: Meta<typeof GroupSettingsPageView> = {
|
||||
title: "pages/OrganizationGroupsPage/GroupSettingsPageView",
|
||||
component: GroupSettingsPageView,
|
||||
args: {
|
||||
onCancel: action("onCancel"),
|
||||
group: MockGroup,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupSettingsPageView>;
|
||||
|
||||
const Example: Story = {};
|
||||
|
||||
export { Example as GroupSettingsPageView };
|
@ -0,0 +1,163 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type { Group } from "api/typesGenerated";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "components/Form/Form";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { ResourcePageHeader } from "components/PageHeader/PageHeader";
|
||||
import {
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "utils/formUtils";
|
||||
import { isEveryoneGroup } from "utils/groups";
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
quota_allowance: number;
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
quota_allowance: Yup.number().required().min(0).integer(),
|
||||
});
|
||||
|
||||
interface UpdateGroupFormProps {
|
||||
group: Group;
|
||||
errors: unknown;
|
||||
onSubmit: (data: FormData) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
|
||||
group,
|
||||
errors,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}) => {
|
||||
const form = useFormik<FormData>({
|
||||
initialValues: {
|
||||
name: group.name,
|
||||
display_name: group.display_name,
|
||||
avatar_url: group.avatar_url,
|
||||
quota_allowance: group.quota_allowance,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<FormData>(form, errors);
|
||||
|
||||
return (
|
||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||
<FormSection
|
||||
title="Group settings"
|
||||
description="Set a name and avatar for this group."
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Name"
|
||||
disabled={isEveryoneGroup(group)}
|
||||
/>
|
||||
{!isEveryoneGroup(group) && (
|
||||
<>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name", {
|
||||
helperText: "Optional: keep empty to default to the name.",
|
||||
})}
|
||||
autoComplete="display_name"
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
disabled={isEveryoneGroup(group)}
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("avatar_url")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Avatar URL"
|
||||
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
title="Quota"
|
||||
description="You can use quotas to restrict how many resources a user can create."
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("quota_allowance", {
|
||||
helperText: `This group gives ${form.values.quota_allowance} quota credits to each
|
||||
of its members.`,
|
||||
})}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Quota Allowance"
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter onCancel={onCancel} isLoading={isLoading} />
|
||||
</HorizontalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export type SettingsGroupPageViewProps = {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: FormData) => void;
|
||||
group: Group | undefined;
|
||||
formErrors: unknown;
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
const GroupSettingsPageView: FC<SettingsGroupPageViewProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
group,
|
||||
formErrors,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePageHeader
|
||||
displayName={group!.display_name}
|
||||
name={group!.name}
|
||||
css={{ paddingTop: 8 }}
|
||||
/>
|
||||
<UpdateGroupForm
|
||||
group={group!}
|
||||
onCancel={onCancel}
|
||||
errors={formErrors}
|
||||
isLoading={isUpdating}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupSettingsPageView;
|
@ -0,0 +1,65 @@
|
||||
import GroupAdd from "@mui/icons-material/GroupAddOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { groups } from "api/queries/groups";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useOrganizationSettings } from "../ManagementSettingsLayout";
|
||||
import GroupsPageView from "./GroupsPageView";
|
||||
|
||||
export const GroupsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { currentOrganizationId } = useOrganizationSettings();
|
||||
const { createGroup: canCreateGroup } = permissions;
|
||||
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
|
||||
const groupsQuery = useQuery(groups(currentOrganizationId!));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(groupsQuery.error, "Error on loading groups."),
|
||||
);
|
||||
}
|
||||
}, [groupsQuery.error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Groups")}</title>
|
||||
</Helmet>
|
||||
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
{canCreateGroup && isTemplateRBACEnabled && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
startIcon={<GroupAdd />}
|
||||
to="create"
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>Groups</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
|
||||
<GroupsPageView
|
||||
groups={groupsQuery.data}
|
||||
canCreateGroup={canCreateGroup}
|
||||
isTemplateRBACEnabled={isTemplateRBACEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockGroup } from "testHelpers/entities";
|
||||
import { GroupsPageView } from "./GroupsPageView";
|
||||
|
||||
const meta: Meta<typeof GroupsPageView> = {
|
||||
title: "pages/OrganizationGroupsPage",
|
||||
component: GroupsPageView,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GroupsPageView>;
|
||||
|
||||
export const NotEnabled: Story = {
|
||||
args: {
|
||||
groups: [MockGroup],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithGroups: Story = {
|
||||
args: {
|
||||
groups: [MockGroup],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDisplayGroup: Story = {
|
||||
args: {
|
||||
groups: [{ ...MockGroup, name: "front-end" }],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyGroup: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: false,
|
||||
isTemplateRBACEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyGroupWithPermission: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
},
|
||||
};
|
@ -0,0 +1,194 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
|
||||
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||
import Button from "@mui/material/Button";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
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 { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import type { Group } from "api/typesGenerated";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { useClickableTableRow } from "hooks";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
export type GroupsPageViewProps = {
|
||||
groups: Group[] | undefined;
|
||||
canCreateGroup: boolean;
|
||||
isTemplateRBACEnabled: boolean;
|
||||
};
|
||||
|
||||
export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
isTemplateRBACEnabled,
|
||||
}) => {
|
||||
const isLoading = Boolean(groups === undefined);
|
||||
const isEmpty = Boolean(groups && groups.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isTemplateRBACEnabled}>
|
||||
<Paywall
|
||||
message="Groups"
|
||||
description="Organize users into groups with restricted access to templates. You need an Enterprise license to use this feature."
|
||||
documentationLink={docs("/admin/groups")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50%">Name</TableCell>
|
||||
<TableCell width="49%">Users</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No groups yet"
|
||||
description={
|
||||
canCreateGroup
|
||||
? "Create your first group"
|
||||
: "You don't have permission to create a group"
|
||||
}
|
||||
cta={
|
||||
canCreateGroup && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="create"
|
||||
startIcon={<AddOutlined />}
|
||||
variant="contained"
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{groups?.map((group) => (
|
||||
<GroupRow key={group.id} group={group} />
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupRowProps {
|
||||
group: Group;
|
||||
}
|
||||
|
||||
const GroupRow: FC<GroupRowProps> = ({ group }) => {
|
||||
const navigate = useNavigate();
|
||||
const rowProps = useClickableTableRow({
|
||||
onClick: () => navigate(group.name),
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow data-testid={`group-${group.id}`} {...rowProps}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<GroupAvatar
|
||||
name={group.display_name || group.name}
|
||||
avatarURL={group.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={group.display_name || group.name}
|
||||
subtitle={`${group.members.length} members`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{group.members.length === 0 && "-"}
|
||||
<AvatarGroup
|
||||
max={10}
|
||||
total={group.members.length}
|
||||
css={{ justifyContent: "flex-end" }}
|
||||
>
|
||||
{group.members.map((member) => (
|
||||
<UserAvatar
|
||||
key={member.username}
|
||||
username={member.username}
|
||||
avatarURL={member.avatar_url}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div css={styles.arrowCell}>
|
||||
<KeyboardArrowRight css={styles.arrowRight} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader: FC = () => {
|
||||
return (
|
||||
<TableLoaderSkeleton>
|
||||
<TableRowSkeleton>
|
||||
<TableCell>
|
||||
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<AvatarDataSkeleton />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton variant="text" width="25%" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton variant="text" width="25%" />
|
||||
</TableCell>
|
||||
</TableRowSkeleton>
|
||||
</TableLoaderSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
arrowRight: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
}),
|
||||
arrowCell: {
|
||||
display: "flex",
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default GroupsPageView;
|
@ -61,7 +61,7 @@ export const ManagementSettingsLayout: FC = () => {
|
||||
currentOrganizationId: !inOrganizationSettings
|
||||
? undefined
|
||||
: !organization
|
||||
? organizationsQuery.data[0]?.id
|
||||
? "00000000-0000-0000-0000-000000000000"
|
||||
: organizationsQuery.data.find(
|
||||
(org) => org.name === organization,
|
||||
)?.id,
|
||||
|
@ -37,10 +37,12 @@ const OrganizationSettingsPage: FC = () => {
|
||||
organization={org}
|
||||
error={error}
|
||||
onSubmit={async (values) => {
|
||||
const updatedOrganization =
|
||||
await updateOrganizationMutation.mutateAsync({
|
||||
orgId: org.id,
|
||||
organizationId: org.id,
|
||||
req: values,
|
||||
});
|
||||
navigate(`/organizations/${updatedOrganization.name}`);
|
||||
displaySuccess("Organization settings updated.");
|
||||
}}
|
||||
onDeleteOrganization={() => {
|
||||
|
@ -78,7 +78,6 @@ const DeploymentSettingsNavigation: FC = () => {
|
||||
Observability
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem href="/users">Users</SidebarNavSubItem>
|
||||
<SidebarNavSubItem href="/groups">Groups</SidebarNavSubItem>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
@ -115,23 +114,15 @@ export const OrganizationSettingsNavigation: FC<
|
||||
</SidebarNavItem>
|
||||
{active && (
|
||||
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
|
||||
<SidebarNavSubItem href={urlForSubpage(organization.name)}>
|
||||
<SidebarNavSubItem end href={urlForSubpage(organization.name)}>
|
||||
Organization settings
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(organization.name, "external-auth")}
|
||||
>
|
||||
External authentication
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem href={urlForSubpage(organization.name, "members")}>
|
||||
Members
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}>
|
||||
Groups
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem href={urlForSubpage(organization.name, "metrics")}>
|
||||
Metrics
|
||||
</SidebarNavSubItem>
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(organization.name, "auditing")}
|
||||
>
|
||||
@ -187,18 +178,20 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
|
||||
interface SidebarNavSubItemProps {
|
||||
children?: ReactNode;
|
||||
href: string;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
|
||||
children,
|
||||
href,
|
||||
end,
|
||||
}) => {
|
||||
const link = useClassName(classNames.subLink, []);
|
||||
const activeLink = useClassName(classNames.activeSubLink, []);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
end
|
||||
end={end}
|
||||
to={href}
|
||||
className={({ isActive }) => cx([link, isActive && activeLink])}
|
||||
>
|
||||
|
@ -13,11 +13,13 @@ import { Margins } from "components/Margins/Margins";
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||
import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { USERS_LINK } from "modules/navigation";
|
||||
|
||||
export const UsersLayout: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { experiments } = useDashboard();
|
||||
const { createUser: canCreateUser, createGroup: canCreateGroup } =
|
||||
permissions;
|
||||
const navigate = useNavigate();
|
||||
@ -57,6 +59,7 @@ export const UsersLayout: FC = () => {
|
||||
</PageHeader>
|
||||
</Margins>
|
||||
|
||||
{!experiments.includes("multi-organization") && (
|
||||
<Tabs
|
||||
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
|
||||
active={activeTab}
|
||||
@ -72,6 +75,7 @@ export const UsersLayout: FC = () => {
|
||||
</TabsList>
|
||||
</Margins>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Margins>
|
||||
<Suspense fallback={<Loader />}>
|
||||
|
@ -78,7 +78,7 @@ export const WorkspaceSettingsForm: FC<WorkspaceSettingsFormProps> = ({
|
||||
workspace.allow_renames
|
||||
? form.values.name !== form.initialValues.name &&
|
||||
"Depending on the template, renaming your workspace may be destructive"
|
||||
: "Renaming your workspace can be destructive and has not been enabled for this deployment."
|
||||
: "Renaming your workspace can be destructive and is disabled by the template."
|
||||
}
|
||||
/>
|
||||
</FormFields>
|
||||
|
@ -227,6 +227,18 @@ const CreateOrganizationPage = lazy(
|
||||
const OrganizationSettingsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"),
|
||||
);
|
||||
const OrganizationGroupsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupsPage"),
|
||||
);
|
||||
const CreateOrganizationGroupPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/CreateGroupPage"),
|
||||
);
|
||||
const OrganizationGroupPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupPage"),
|
||||
);
|
||||
const OrganizationGroupSettingsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage"),
|
||||
);
|
||||
const OrganizationMembersPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
|
||||
);
|
||||
@ -347,19 +359,20 @@ export const router = createBrowserRouter(
|
||||
|
||||
<Route path=":organization">
|
||||
<Route index element={<OrganizationSettingsPage />} />
|
||||
<Route
|
||||
path="external-auth"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
/>
|
||||
<Route path="members" element={<OrganizationMembersPage />} />
|
||||
<Route path="groups">
|
||||
<Route index element={<OrganizationGroupsPage />} />
|
||||
|
||||
<Route
|
||||
path="groups"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
path="create"
|
||||
element={<CreateOrganizationGroupPage />}
|
||||
/>
|
||||
<Route path=":groupName" element={<OrganizationGroupPage />} />
|
||||
<Route
|
||||
path="metrics"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
path=":groupName/settings"
|
||||
element={<OrganizationGroupSettingsPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="auditing"
|
||||
element={<OrganizationSettingsPlaceholder />}
|
||||
|
Reference in New Issue
Block a user