feat: create and modify organization groups (#13887)

This commit is contained in:
Kayla Washburn-Love
2024-07-22 09:47:14 -06:00
committed by GitHub
parent dd99457a04
commit 0a71c34d46
24 changed files with 1205 additions and 89 deletions

View File

@ -2,6 +2,7 @@ package coderd
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -170,9 +171,9 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
OrganizationID: group.OrganizationID, OrganizationID: group.OrganizationID,
UserID: uuid.MustParse(id), UserID: uuid.MustParse(id),
})) }))
if xerrors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ 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 return
} }
@ -364,7 +365,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
) )
users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID) 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) httpapi.InternalServerError(rw, err)
return return
} }
@ -391,7 +392,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
) )
groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID) 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) httpapi.InternalServerError(rw, err)
return return
} }

View File

@ -515,19 +515,19 @@ class ApiMethods {
}; };
updateOrganization = async ( updateOrganization = async (
orgId: string, organizationId: string,
params: TypesGen.UpdateOrganizationRequest, params: TypesGen.UpdateOrganizationRequest,
) => { ) => {
const response = await this.axios.patch<TypesGen.Organization>( const response = await this.axios.patch<TypesGen.Organization>(
`/api/v2/organizations/${orgId}`, `/api/v2/organizations/${organizationId}`,
params, params,
); );
return response.data; return response.data;
}; };
deleteOrganization = async (orgId: string) => { deleteOrganization = async (organizationId: string) => {
await this.axios.delete<TypesGen.Organization>( await this.axios.delete<TypesGen.Organization>(
`/api/v2/organizations/${orgId}`, `/api/v2/organizations/${organizationId}`,
); );
}; };
@ -1485,9 +1485,12 @@ class ApiMethods {
return response.data; 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( const response = await this.axios.get(
`/api/v2/organizations/default/groups/${groupName}`, `/api/v2/organizations/${organizationId}/groups/${groupName}`,
); );
return response.data; return response.data;
}; };

View File

@ -9,7 +9,11 @@ import type {
const GROUPS_QUERY_KEY = ["groups"]; const GROUPS_QUERY_KEY = ["groups"];
type GroupSortOrder = "asc" | "desc"; type GroupSortOrder = "asc" | "desc";
const getGroupQueryKey = (groupName: string) => ["group", groupName]; const getGroupQueryKey = (organizationId: string, groupName: string) => [
organizationId,
"group",
groupName,
];
export const groups = (organizationId: string) => { export const groups = (organizationId: string) => {
return { return {
@ -18,10 +22,10 @@ export const groups = (organizationId: string) => {
} satisfies UseQueryOptions<Group[]>; } satisfies UseQueryOptions<Group[]>;
}; };
export const group = (groupName: string) => { export const group = (organizationId: string, groupName: string) => {
return { return {
queryKey: getGroupQueryKey(groupName), queryKey: getGroupQueryKey(organizationId, groupName),
queryFn: () => API.getGroup(groupName), queryFn: () => API.getGroup(organizationId, groupName),
}; };
}; };
@ -69,7 +73,7 @@ export function groupsForUser(organizationId: string, userId: string) {
export const groupPermissions = (groupId: string) => { export const groupPermissions = (groupId: string) => {
return { return {
queryKey: [...getGroupQueryKey(groupId), "permissions"], queryKey: ["group", groupId, "permissions"],
queryFn: () => queryFn: () =>
API.checkAuthorization({ API.checkAuthorization({
checks: { checks: {
@ -85,12 +89,12 @@ export const groupPermissions = (groupId: string) => {
}; };
}; };
export const createGroup = (queryClient: QueryClient) => { export const createGroup = (
queryClient: QueryClient,
organizationId: string,
) => {
return { return {
mutationFn: ({ mutationFn: (request: CreateGroupRequest) =>
organizationId,
...request
}: CreateGroupRequest & { organizationId: string }) =>
API.createGroup(organizationId, request), API.createGroup(organizationId, request),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries(GROUPS_QUERY_KEY); await queryClient.invalidateQueries(GROUPS_QUERY_KEY);
@ -106,7 +110,7 @@ export const patchGroup = (queryClient: QueryClient) => {
}: PatchGroupRequest & { groupId: string }) => }: PatchGroupRequest & { groupId: string }) =>
API.patchGroup(groupId, request), API.patchGroup(groupId, request),
onSuccess: async (updatedGroup: Group) => onSuccess: async (updatedGroup: Group) =>
invalidateGroup(queryClient, updatedGroup.id), invalidateGroup(queryClient, "default", updatedGroup.id),
}; };
}; };
@ -114,7 +118,7 @@ export const deleteGroup = (queryClient: QueryClient) => {
return { return {
mutationFn: API.deleteGroup, mutationFn: API.deleteGroup,
onSuccess: async (_: void, groupId: string) => 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 }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
API.addMember(groupId, userId), API.addMember(groupId, userId),
onSuccess: async (updatedGroup: Group) => 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 }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
API.removeMember(groupId, userId), API.removeMember(groupId, userId),
onSuccess: async (updatedGroup: Group) => 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([ Promise.all([
queryClient.invalidateQueries(GROUPS_QUERY_KEY), queryClient.invalidateQueries(GROUPS_QUERY_KEY),
queryClient.invalidateQueries(getGroupQueryKey(groupId)), queryClient.invalidateQueries(getGroupQueryKey(organizationId, groupId)),
]); ]);
export function sortGroupsByName( export function sortGroupsByName(

View File

@ -19,14 +19,14 @@ export const createOrganization = (queryClient: QueryClient) => {
}; };
interface UpdateOrganizationVariables { interface UpdateOrganizationVariables {
orgId: string; organizationId: string;
req: UpdateOrganizationRequest; req: UpdateOrganizationRequest;
} }
export const updateOrganization = (queryClient: QueryClient) => { export const updateOrganization = (queryClient: QueryClient) => {
return { return {
mutationFn: (variables: UpdateOrganizationVariables) => mutationFn: (variables: UpdateOrganizationVariables) =>
API.updateOrganization(variables.orgId, variables.req), API.updateOrganization(variables.organizationId, variables.req),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries(organizationsKey); await queryClient.invalidateQueries(organizationsKey);
@ -36,7 +36,8 @@ export const updateOrganization = (queryClient: QueryClient) => {
export const deleteOrganization = (queryClient: QueryClient) => { export const deleteOrganization = (queryClient: QueryClient) => {
return { return {
mutationFn: (orgId: string) => API.deleteOrganization(orgId), mutationFn: (organizationId: string) =>
API.deleteOrganization(organizationId),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries(meKey); 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 = () => { export const organizations = () => {
return { return {

View File

@ -107,3 +107,23 @@ export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
</span> </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>
);
};

View File

@ -3,15 +3,13 @@ import { Helmet } from "react-helmet-async";
import { useMutation, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createGroup } from "api/queries/groups"; import { createGroup } from "api/queries/groups";
import { useDashboard } from "modules/dashboard/useDashboard";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import CreateGroupPageView from "./CreateGroupPageView"; import CreateGroupPageView from "./CreateGroupPageView";
export const CreateGroupPage: FC = () => { export const CreateGroupPage: FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { organizationId } = useDashboard(); const createGroupMutation = useMutation(createGroup(queryClient, "default"));
const createGroupMutation = useMutation(createGroup(queryClient));
return ( return (
<> <>
@ -20,10 +18,7 @@ export const CreateGroupPage: FC = () => {
</Helmet> </Helmet>
<CreateGroupPageView <CreateGroupPageView
onSubmit={async (data) => { onSubmit={async (data) => {
const newGroup = await createGroupMutation.mutateAsync({ const newGroup = await createGroupMutation.mutateAsync(data);
organizationId,
...data,
});
navigate(`/groups/${newGroup.name}`); navigate(`/groups/${newGroup.name}`);
}} }}
error={createGroupMutation.error} error={createGroupMutation.error}

View File

@ -54,10 +54,13 @@ import { isEveryoneGroup } from "utils/groups";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
export const GroupPage: FC = () => { export const GroupPage: FC = () => {
const { groupName } = useParams() as { groupName: string }; const { groupName, organization } = useParams() as {
organization: string;
groupName: string;
};
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const groupQuery = useQuery(group(groupName)); const groupQuery = useQuery(group(organization, groupName));
const groupData = groupQuery.data; const groupData = groupQuery.data;
const { data: permissions } = useQuery( const { data: permissions } = useQuery(
groupData !== undefined groupData !== undefined

View File

@ -13,8 +13,7 @@ import SettingsGroupPageView from "./SettingsGroupPageView";
export const SettingsGroupPage: FC = () => { export const SettingsGroupPage: FC = () => {
const { groupName } = useParams() as { groupName: string }; const { groupName } = useParams() as { groupName: string };
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const groupQuery = useQuery(group(groupName)); const groupQuery = useQuery(group("default", groupName));
const { data: groupData, isLoading, error } = useQuery(group(groupName));
const patchGroupMutation = useMutation(patchGroup(queryClient)); const patchGroupMutation = useMutation(patchGroup(queryClient));
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,11 +27,11 @@ export const SettingsGroupPage: FC = () => {
</Helmet> </Helmet>
); );
if (error) { if (groupQuery.error) {
return <ErrorAlert error={error} />; return <ErrorAlert error={groupQuery.error} />;
} }
if (isLoading || !groupData) { if (groupQuery.isLoading || !groupQuery.data) {
return ( return (
<> <>
{helmet} {helmet}
@ -40,7 +39,8 @@ export const SettingsGroupPage: FC = () => {
</> </>
); );
} }
const groupId = groupData.id;
const groupId = groupQuery.data.id;
return ( return (
<> <>

View File

@ -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;

View File

@ -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();
});
},
};

View File

@ -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;

View 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&hellip;
</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;

View File

@ -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;

View File

@ -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 };

View File

@ -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;

View File

@ -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;

View File

@ -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,
},
};

View File

@ -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;

View File

@ -61,7 +61,7 @@ export const ManagementSettingsLayout: FC = () => {
currentOrganizationId: !inOrganizationSettings currentOrganizationId: !inOrganizationSettings
? undefined ? undefined
: !organization : !organization
? organizationsQuery.data[0]?.id ? "00000000-0000-0000-0000-000000000000"
: organizationsQuery.data.find( : organizationsQuery.data.find(
(org) => org.name === organization, (org) => org.name === organization,
)?.id, )?.id,

View File

@ -37,10 +37,12 @@ const OrganizationSettingsPage: FC = () => {
organization={org} organization={org}
error={error} error={error}
onSubmit={async (values) => { onSubmit={async (values) => {
await updateOrganizationMutation.mutateAsync({ const updatedOrganization =
orgId: org.id, await updateOrganizationMutation.mutateAsync({
req: values, organizationId: org.id,
}); req: values,
});
navigate(`/organizations/${updatedOrganization.name}`);
displaySuccess("Organization settings updated."); displaySuccess("Organization settings updated.");
}} }}
onDeleteOrganization={() => { onDeleteOrganization={() => {

View File

@ -78,7 +78,6 @@ const DeploymentSettingsNavigation: FC = () => {
Observability Observability
</SidebarNavSubItem> </SidebarNavSubItem>
<SidebarNavSubItem href="/users">Users</SidebarNavSubItem> <SidebarNavSubItem href="/users">Users</SidebarNavSubItem>
<SidebarNavSubItem href="/groups">Groups</SidebarNavSubItem>
</Stack> </Stack>
)} )}
</div> </div>
@ -115,23 +114,15 @@ export const OrganizationSettingsNavigation: FC<
</SidebarNavItem> </SidebarNavItem>
{active && ( {active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}> <Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
<SidebarNavSubItem href={urlForSubpage(organization.name)}> <SidebarNavSubItem end href={urlForSubpage(organization.name)}>
Organization settings Organization settings
</SidebarNavSubItem> </SidebarNavSubItem>
<SidebarNavSubItem
href={urlForSubpage(organization.name, "external-auth")}
>
External authentication
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "members")}> <SidebarNavSubItem href={urlForSubpage(organization.name, "members")}>
Members Members
</SidebarNavSubItem> </SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}> <SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}>
Groups Groups
</SidebarNavSubItem> </SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "metrics")}>
Metrics
</SidebarNavSubItem>
<SidebarNavSubItem <SidebarNavSubItem
href={urlForSubpage(organization.name, "auditing")} href={urlForSubpage(organization.name, "auditing")}
> >
@ -187,18 +178,20 @@ export const SidebarNavItem: FC<SidebarNavItemProps> = ({
interface SidebarNavSubItemProps { interface SidebarNavSubItemProps {
children?: ReactNode; children?: ReactNode;
href: string; href: string;
end?: boolean;
} }
export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({ export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
children, children,
href, href,
end,
}) => { }) => {
const link = useClassName(classNames.subLink, []); const link = useClassName(classNames.subLink, []);
const activeLink = useClassName(classNames.activeSubLink, []); const activeLink = useClassName(classNames.activeSubLink, []);
return ( return (
<NavLink <NavLink
end end={end}
to={href} to={href}
className={({ isActive }) => cx([link, isActive && activeLink])} className={({ isActive }) => cx([link, isActive && activeLink])}
> >

View File

@ -13,11 +13,13 @@ import { Margins } from "components/Margins/Margins";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs"; import { TAB_PADDING_Y, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { USERS_LINK } from "modules/navigation"; import { USERS_LINK } from "modules/navigation";
export const UsersLayout: FC = () => { export const UsersLayout: FC = () => {
const { permissions } = useAuthenticated(); const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { createUser: canCreateUser, createGroup: canCreateGroup } = const { createUser: canCreateUser, createGroup: canCreateGroup } =
permissions; permissions;
const navigate = useNavigate(); const navigate = useNavigate();
@ -57,21 +59,23 @@ export const UsersLayout: FC = () => {
</PageHeader> </PageHeader>
</Margins> </Margins>
<Tabs {!experiments.includes("multi-organization") && (
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }} <Tabs
active={activeTab} css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
> active={activeTab}
<Margins> >
<TabsList> <Margins>
<TabLink to={USERS_LINK} value="users"> <TabsList>
Users <TabLink to={USERS_LINK} value="users">
</TabLink> Users
<TabLink to="/groups" value="groups"> </TabLink>
Groups <TabLink to="/groups" value="groups">
</TabLink> Groups
</TabsList> </TabLink>
</Margins> </TabsList>
</Tabs> </Margins>
</Tabs>
)}
<Margins> <Margins>
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>

View File

@ -78,7 +78,7 @@ export const WorkspaceSettingsForm: FC<WorkspaceSettingsFormProps> = ({
workspace.allow_renames workspace.allow_renames
? form.values.name !== form.initialValues.name && ? form.values.name !== form.initialValues.name &&
"Depending on the template, renaming your workspace may be destructive" "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> </FormFields>

View File

@ -227,6 +227,18 @@ const CreateOrganizationPage = lazy(
const OrganizationSettingsPage = lazy( const OrganizationSettingsPage = lazy(
() => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"), () => 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( const OrganizationMembersPage = lazy(
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"), () => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
); );
@ -347,19 +359,20 @@ export const router = createBrowserRouter(
<Route path=":organization"> <Route path=":organization">
<Route index element={<OrganizationSettingsPage />} /> <Route index element={<OrganizationSettingsPage />} />
<Route
path="external-auth"
element={<OrganizationSettingsPlaceholder />}
/>
<Route path="members" element={<OrganizationMembersPage />} /> <Route path="members" element={<OrganizationMembersPage />} />
<Route <Route path="groups">
path="groups" <Route index element={<OrganizationGroupsPage />} />
element={<OrganizationSettingsPlaceholder />}
/> <Route
<Route path="create"
path="metrics" element={<CreateOrganizationGroupPage />}
element={<OrganizationSettingsPlaceholder />} />
/> <Route path=":groupName" element={<OrganizationGroupPage />} />
<Route
path=":groupName/settings"
element={<OrganizationGroupSettingsPage />}
/>
</Route>
<Route <Route
path="auditing" path="auditing"
element={<OrganizationSettingsPlaceholder />} element={<OrganizationSettingsPlaceholder />}