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 (
|
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
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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(
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
@ -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
|
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,
|
||||||
|
@ -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={() => {
|
||||||
|
@ -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])}
|
||||||
>
|
>
|
||||||
|
@ -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 />}>
|
||||||
|
@ -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>
|
||||||
|
@ -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 />}
|
||||||
|
Reference in New Issue
Block a user