mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: clean up groups page (#16259)
This commit is contained in:
+10
-4
@@ -292,16 +292,22 @@ export const createTemplate = async (
|
||||
* createGroup navigates to the /groups/create page and creates a group with a
|
||||
* random name.
|
||||
*/
|
||||
export const createGroup = async (page: Page): Promise<string> => {
|
||||
await page.goto("/deployment/groups/create", {
|
||||
export const createGroup = async (
|
||||
page: Page,
|
||||
organization?: string,
|
||||
): Promise<string> => {
|
||||
const prefix = organization
|
||||
? `/organizations/${organization}`
|
||||
: "/deployment";
|
||||
await page.goto(`${prefix}/groups/create`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expectUrl(page).toHavePathName("/deployment/groups/create");
|
||||
await expectUrl(page).toHavePathName(`${prefix}/groups/create`);
|
||||
|
||||
const name = randomName();
|
||||
await page.getByLabel("Name", { exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: /save/i }).click();
|
||||
await expectUrl(page).toHavePathName(`/deployment/groups/${name}`);
|
||||
await expectUrl(page).toHavePathName(`${prefix}/groups/${name}`);
|
||||
return name;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCurrentOrgId,
|
||||
setupApiCalls,
|
||||
} from "../../api";
|
||||
import { defaultOrganizationName } from "../../constants";
|
||||
import { requiresLicense } from "../../helpers";
|
||||
import { login } from "../../helpers";
|
||||
import { beforeCoderTest } from "../../hooks";
|
||||
@@ -18,6 +19,7 @@ test.beforeEach(async ({ page }) => {
|
||||
test("add members", async ({ page, baseURL }) => {
|
||||
requiresLicense();
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
const orgId = await getCurrentOrgId();
|
||||
const group = await createGroup(orgId);
|
||||
const numberOfMembers = 3;
|
||||
@@ -25,7 +27,7 @@ test("add members", async ({ page, baseURL }) => {
|
||||
Array.from({ length: numberOfMembers }, () => createUser(orgId)),
|
||||
);
|
||||
|
||||
await page.goto(`${baseURL}/groups/${group.name}`, {
|
||||
await page.goto(`${baseURL}/organizations/${orgName}/groups/${group.name}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveTitle(`${group.display_name} - Coder`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createUser, getCurrentOrgId, setupApiCalls } from "../../api";
|
||||
import { defaultOrganizationName } from "../../constants";
|
||||
import { requiresLicense } from "../../helpers";
|
||||
import { login } from "../../helpers";
|
||||
import { beforeCoderTest } from "../../hooks";
|
||||
@@ -17,16 +18,20 @@ test(`Every user should be automatically added to the default '${DEFAULT_GROUP_N
|
||||
}) => {
|
||||
requiresLicense();
|
||||
await setupApiCalls(page);
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
const orgId = await getCurrentOrgId();
|
||||
const numberOfMembers = 3;
|
||||
const users = await Promise.all(
|
||||
Array.from({ length: numberOfMembers }, () => createUser(orgId)),
|
||||
);
|
||||
|
||||
await page.goto(`${baseURL}/groups`, { waitUntil: "domcontentloaded" });
|
||||
await page.goto(`${baseURL}/organizations/${orgName}/groups`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveTitle("Groups - Coder");
|
||||
|
||||
const groupRow = page.getByRole("row", { name: DEFAULT_GROUP_NAME });
|
||||
const groupRow = page.getByText(DEFAULT_GROUP_NAME);
|
||||
await groupRow.click();
|
||||
await expect(page).toHaveTitle(`${DEFAULT_GROUP_NAME} - Coder`);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { defaultOrganizationName } from "../../constants";
|
||||
import { randomName, requiresLicense } from "../../helpers";
|
||||
import { login } from "../../helpers";
|
||||
import { beforeCoderTest } from "../../hooks";
|
||||
@@ -11,7 +12,11 @@ test.beforeEach(async ({ page }) => {
|
||||
test("create group", async ({ page, baseURL }) => {
|
||||
requiresLicense();
|
||||
|
||||
await page.goto(`${baseURL}/groups`, { waitUntil: "domcontentloaded" });
|
||||
const orgName = defaultOrganizationName;
|
||||
|
||||
await page.goto(`${baseURL}/organizations/${orgName}/groups`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveTitle("Groups - Coder");
|
||||
|
||||
await page.getByText("Create group").click();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createGroup, getCurrentOrgId, setupApiCalls } from "../../api";
|
||||
import { defaultOrganizationName } from "../../constants";
|
||||
import { requiresLicense } from "../../helpers";
|
||||
import { login } from "../../helpers";
|
||||
import { beforeCoderTest } from "../../hooks";
|
||||
@@ -13,10 +14,11 @@ test.beforeEach(async ({ page }) => {
|
||||
test("remove group", async ({ page, baseURL }) => {
|
||||
requiresLicense();
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
const orgId = await getCurrentOrgId();
|
||||
const group = await createGroup(orgId);
|
||||
|
||||
await page.goto(`${baseURL}/groups/${group.name}`, {
|
||||
await page.goto(`${baseURL}/organizations/${orgName}/groups/${group.name}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveTitle(`${group.display_name} - Coder`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getCurrentOrgId,
|
||||
setupApiCalls,
|
||||
} from "../../api";
|
||||
import { defaultOrganizationName } from "../../constants";
|
||||
import { requiresLicense } from "../../helpers";
|
||||
import { login } from "../../helpers";
|
||||
import { beforeCoderTest } from "../../hooks";
|
||||
@@ -19,6 +20,7 @@ test.beforeEach(async ({ page }) => {
|
||||
test("remove member", async ({ page, baseURL }) => {
|
||||
requiresLicense();
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
const orgId = await getCurrentOrgId();
|
||||
const [group, member] = await Promise.all([
|
||||
createGroup(orgId),
|
||||
@@ -26,7 +28,7 @@ test("remove member", async ({ page, baseURL }) => {
|
||||
]);
|
||||
await API.addMember(group.id, member.id);
|
||||
|
||||
await page.goto(`${baseURL}/groups/${group.name}`, {
|
||||
await page.goto(`${baseURL}/organizations/${orgName}/groups/${group.name}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveTitle(`${group.display_name} - Coder`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createUser,
|
||||
setupApiCalls,
|
||||
} from "../api";
|
||||
import { defaultOrganizationName } from "../constants";
|
||||
import { expectUrl } from "../expectUrl";
|
||||
import { login, randomName, requiresLicense } from "../helpers";
|
||||
import { beforeCoderTest } from "../hooks";
|
||||
@@ -15,6 +16,17 @@ test.beforeEach(async ({ page }) => {
|
||||
await setupApiCalls(page);
|
||||
});
|
||||
|
||||
test("redirects", async ({ page }) => {
|
||||
requiresLicense();
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
await page.goto("/groups");
|
||||
await expectUrl(page).toHavePathName(`/organizations/${orgName}/groups`);
|
||||
|
||||
await page.goto("/deployment/groups");
|
||||
await expectUrl(page).toHavePathName(`/organizations/${orgName}/groups`);
|
||||
});
|
||||
|
||||
test("create group", async ({ page }) => {
|
||||
requiresLicense();
|
||||
|
||||
@@ -24,7 +36,7 @@ test("create group", async ({ page }) => {
|
||||
|
||||
// Navigate to groups page
|
||||
await page.getByRole("link", { name: "Groups" }).click();
|
||||
await expect(page).toHaveTitle(`Groups - Org ${org.name} - Coder`);
|
||||
await expect(page).toHaveTitle("Groups - Coder");
|
||||
|
||||
// Create a new group
|
||||
await page.getByText("Create group").click();
|
||||
@@ -72,7 +84,7 @@ test("create group", async ({ page }) => {
|
||||
await expect(page.getByText("Group deleted successfully.")).toBeVisible();
|
||||
|
||||
await expectUrl(page).toHavePathName(`/organizations/${org.name}/groups`);
|
||||
await expect(page).toHaveTitle(`Groups - Org ${org.name} - Coder`);
|
||||
await expect(page).toHaveTitle("Groups - Coder");
|
||||
});
|
||||
|
||||
test("change quota settings", async ({ page }) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ test("add and remove a group", async ({ page }) => {
|
||||
|
||||
const orgName = defaultOrganizationName;
|
||||
const templateName = await createTemplate(page);
|
||||
const groupName = await createGroup(page);
|
||||
const groupName = await createGroup(page, orgName);
|
||||
|
||||
await page.goto(
|
||||
`/templates/${orgName}/${templateName}/settings/permissions`,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const CoderIcon: FC<SvgIconProps> = ({ className, ...props }) => (
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Coder logo</title>
|
||||
<g clip-path="url(#clip0_103_80)">
|
||||
<g clipPath="url(#clip0_103_80)">
|
||||
<path d="M66.3575 21.3584C65.0024 21.3584 64.099 20.5638 64.099 18.9328V9.5647C64.099 3.58419 61.6353 0.280273 55.2705 0.280273H52.314V6.59536H53.2174C55.7222 6.59536 56.913 7.97547 56.913 10.443V18.7237C56.913 22.3203 57.9807 23.7841 60.3212 24.5369C57.9807 25.2479 56.913 26.7534 56.913 30.3501C56.913 32.3994 56.913 34.4486 56.913 36.4979C56.913 38.2126 56.913 39.8855 56.4613 41.6002C56.0097 43.1894 55.2705 44.695 54.244 45.9914C53.6691 46.7442 53.0121 47.3716 52.2729 47.9571V48.7935H55.2295C61.5942 48.7935 64.058 45.4896 64.058 39.5091V30.141C64.058 28.4681 64.9203 27.7153 66.3164 27.7153H68V21.4003H66.3575V21.3584Z" />
|
||||
<path d="M46.2367 9.81532H37.1208C36.9155 9.81532 36.7512 9.64804 36.7512 9.43893V8.72796C36.7512 8.51885 36.9155 8.35156 37.1208 8.35156H46.2778C46.4831 8.35156 46.6473 8.51885 46.6473 8.72796V9.43893C46.6473 9.64804 46.442 9.81532 46.2367 9.81532Z" />
|
||||
<path d="M47.7971 18.8485H41.145C40.9396 18.8485 40.7754 18.6812 40.7754 18.4721V17.7612C40.7754 17.5521 40.9396 17.3848 41.145 17.3848H47.7971C48.0024 17.3848 48.1667 17.5521 48.1667 17.7612V18.4721C48.1667 18.6394 48.0024 18.8485 47.7971 18.8485Z" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import type { ElementType, FC, ReactNode } from "react";
|
||||
import { Link, NavLink, useMatch } from "react-router-dom";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -61,21 +61,16 @@ export const SettingsSidebarNavItem: FC<SettingsSidebarNavItemProps> = ({
|
||||
href,
|
||||
end,
|
||||
}) => {
|
||||
// 2025-01-10: useMatch is a workaround for a bug we encountered when you
|
||||
// pass a render function to NavLink's className prop, and try to access
|
||||
// NavLinks's isActive state value for the conditional styling. isActive
|
||||
// wasn't always evaluating to true when it should be, but useMatch worked
|
||||
const matchResult = useMatch(href);
|
||||
return (
|
||||
<NavLink
|
||||
end={end}
|
||||
to={href}
|
||||
className={cn(
|
||||
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150",
|
||||
{
|
||||
"font-semibold text-content-primary": matchResult !== null,
|
||||
},
|
||||
)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150",
|
||||
isActive && "font-semibold text-content-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import type { FC } from "react";
|
||||
import { DeploymentSidebarView } from "./DeploymentSidebarView";
|
||||
|
||||
@@ -7,6 +8,15 @@ import { DeploymentSidebarView } from "./DeploymentSidebarView";
|
||||
*/
|
||||
export const DeploymentSidebar: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { entitlements, showOrganizations } = useDashboard();
|
||||
const hasPremiumLicense =
|
||||
entitlements.features.multiple_organizations.enabled;
|
||||
|
||||
return <DeploymentSidebarView permissions={permissions} />;
|
||||
return (
|
||||
<DeploymentSidebarView
|
||||
permissions={permissions}
|
||||
showOrganizations={showOrganizations}
|
||||
hasPremiumLicense={hasPremiumLicense}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +1,18 @@
|
||||
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
|
||||
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
|
||||
import {
|
||||
Sidebar as BaseSidebar,
|
||||
SettingsSidebarNavItem as SidebarNavItem,
|
||||
} from "components/Sidebar/Sidebar";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { Permissions } from "contexts/auth/permissions";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
export interface OrganizationWithPermissions extends Organization {
|
||||
permissions: AuthorizationResponse;
|
||||
}
|
||||
|
||||
interface DeploymentSidebarProps {
|
||||
interface DeploymentSidebarViewProps {
|
||||
/** Site-wide permissions. */
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* A combined deployment settings and organization menu.
|
||||
*/
|
||||
export const DeploymentSidebarView: FC<DeploymentSidebarProps> = ({
|
||||
permissions,
|
||||
}) => {
|
||||
const { multiple_organizations: hasPremiumLicense } = useFeatureVisibility();
|
||||
|
||||
return (
|
||||
<BaseSidebar>
|
||||
<DeploymentSettingsNavigation
|
||||
permissions={permissions}
|
||||
isPremium={hasPremiumLicense}
|
||||
/>
|
||||
</BaseSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
interface DeploymentSettingsNavigationProps {
|
||||
/** Site-wide permissions. */
|
||||
permissions: Permissions;
|
||||
isPremium: boolean;
|
||||
showOrganizations: boolean;
|
||||
hasPremiumLicense: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,12 +22,13 @@ interface DeploymentSettingsNavigationProps {
|
||||
* Menu items are shown based on the permissions. If organizations can be
|
||||
* viewed, groups are skipped since they will show under each org instead.
|
||||
*/
|
||||
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
|
||||
permissions,
|
||||
isPremium,
|
||||
showOrganizations,
|
||||
hasPremiumLicense,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<BaseSidebar>
|
||||
<div className="flex flex-col gap-1">
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavItem href="/deployment/general">General</SidebarNavItem>
|
||||
@@ -100,7 +75,11 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
<SidebarNavItem href="/deployment/users">Users</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewAnyGroup && (
|
||||
<SidebarNavItem href="/deployment/groups">Groups</SidebarNavItem>
|
||||
<SidebarNavItem href="/deployment/groups">
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
Groups {showOrganizations && <ArrowUpRight size={16} />}
|
||||
</Stack>
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewNotificationTemplate && (
|
||||
<SidebarNavItem href="/deployment/notifications">
|
||||
@@ -115,10 +94,10 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
IdP Organization Sync
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{!isPremium && (
|
||||
{!hasPremiumLicense && (
|
||||
<SidebarNavItem href="/deployment/premium">Premium</SidebarNavItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,10 +60,9 @@ const OrganizationSettingsLayout: FC = () => {
|
||||
const canViewOrganizationSettingsPage =
|
||||
permissions.viewDeploymentValues || permissions.editAnyOrganization;
|
||||
|
||||
const organization =
|
||||
organizations && orgName
|
||||
? organizations.find((org) => org.name === orgName)
|
||||
: undefined;
|
||||
const organization = orgName
|
||||
? organizations.find((org) => org.name === orgName)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={canViewOrganizationSettingsPage}>
|
||||
|
||||
@@ -60,7 +60,9 @@ export const OrganizationSidebarView: FC<SidebarProps> = ({
|
||||
};
|
||||
|
||||
function urlForSubpage(organizationName: string, subpage = ""): string {
|
||||
return `/organizations/${organizationName}/${subpage}`;
|
||||
return [`/organizations/${organizationName}`, subpage]
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
interface OrganizationsSettingsNavigationProps {
|
||||
|
||||
@@ -2,14 +2,17 @@ import { createGroup } from "api/queries/groups";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import CreateGroupPageView from "./CreateGroupPageView";
|
||||
|
||||
export const CreateGroupPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const createGroupMutation = useMutation(createGroup(queryClient, "default"));
|
||||
const { organization } = useParams() as { organization: string };
|
||||
const createGroupMutation = useMutation(
|
||||
createGroup(queryClient, organization ?? "default"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +22,11 @@ export const CreateGroupPage: FC = () => {
|
||||
<CreateGroupPageView
|
||||
onSubmit={async (data) => {
|
||||
const newGroup = await createGroupMutation.mutateAsync(data);
|
||||
navigate(`/deployment/groups/${newGroup.name}`);
|
||||
navigate(
|
||||
organization
|
||||
? `/organizations/${organization}/groups/${newGroup.name}`
|
||||
: `/deployment/groups/${newGroup.name}`,
|
||||
);
|
||||
}}
|
||||
error={createGroupMutation.error}
|
||||
isLoading={createGroupMutation.isLoading}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mockApiError } from "testHelpers/entities";
|
||||
import { CreateGroupPageView } from "./CreateGroupPageView";
|
||||
|
||||
const meta: Meta<typeof CreateGroupPageView> = {
|
||||
title: "pages/GroupsPage/CreateGroupPageView",
|
||||
title: "pages/OrganizationGroupsPage/CreateGroupPageView",
|
||||
component: CreateGroupPageView,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,15 @@ export const WithError: Story = {
|
||||
message: "A group named new-group already exists.",
|
||||
validations: [{ field: "name", detail: "Group names must be unique" }],
|
||||
}),
|
||||
initialTouched: { name: true },
|
||||
},
|
||||
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();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@ import { isApiValidationError } from "api/errors";
|
||||
import type { CreateGroupRequest } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FormFooter } from "components/Form/Form";
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "components/Form/Form";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { type FormikTouched, useFormik } from "formik";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -27,15 +30,12 @@ export type CreateGroupPageViewProps = {
|
||||
onSubmit: (data: CreateGroupRequest) => void;
|
||||
error?: unknown;
|
||||
isLoading: boolean;
|
||||
// Helpful to show field errors on Storybook
|
||||
initialTouched?: FormikTouched<CreateGroupRequest>;
|
||||
};
|
||||
|
||||
export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
onSubmit,
|
||||
error,
|
||||
isLoading,
|
||||
initialTouched,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const form = useFormik<CreateGroupRequest>({
|
||||
@@ -47,16 +47,23 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
initialTouched,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<CreateGroupRequest>(form, error);
|
||||
const onCancel = () => navigate("/deployment/groups");
|
||||
const onCancel = () => navigate(-1);
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<FullPageForm title="Create group">
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack spacing={2.5}>
|
||||
<>
|
||||
<SettingsHeader
|
||||
title="New Group"
|
||||
description="Create a group in this organization."
|
||||
/>
|
||||
|
||||
<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} />
|
||||
)}
|
||||
@@ -84,21 +91,21 @@ export const CreateGroupPageView: FC<CreateGroupPageViewProps> = ({
|
||||
label="Avatar URL"
|
||||
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
|
||||
/>
|
||||
</Stack>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter className="mt-8">
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<FormFooter>
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <Spinner />}
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</form>
|
||||
</FullPageForm>
|
||||
</Margins>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Spinner loading={isLoading} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</HorizontalForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CreateGroupPageView;
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
groupPermissions,
|
||||
removeMember,
|
||||
} from "api/queries/groups";
|
||||
import type { Group, ReducedUser, User } from "api/typesGenerated";
|
||||
import type {
|
||||
Group,
|
||||
OrganizationMemberWithUserData,
|
||||
ReducedUser,
|
||||
} from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
@@ -27,7 +31,6 @@ 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,
|
||||
@@ -35,17 +38,13 @@ import {
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
PaginationStatus,
|
||||
TableToolbar,
|
||||
} from "components/TableToolbar/TableToolbar";
|
||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { type FC, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -54,19 +53,19 @@ import { isEveryoneGroup } from "utils/groups";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
||||
export const GroupPage: FC = () => {
|
||||
const { groupName } = useParams() as {
|
||||
const { organization = "default", groupName } = useParams() as {
|
||||
organization?: string;
|
||||
groupName: string;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const groupQuery = useQuery(group("default", groupName));
|
||||
const groupQuery = useQuery(group(organization, groupName));
|
||||
const groupData = groupQuery.data;
|
||||
const { data: permissions } = useQuery(
|
||||
groupData !== undefined
|
||||
? groupPermissions(groupData.id)
|
||||
: { enabled: false },
|
||||
groupData ? 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;
|
||||
@@ -100,106 +99,115 @@ export const GroupPage: FC = () => {
|
||||
<>
|
||||
{helmet}
|
||||
|
||||
<Margins>
|
||||
<PageHeader
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>
|
||||
{groupData?.display_name || groupData?.name}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>
|
||||
{/* Show the name if it differs from the display name. */}
|
||||
{groupData?.display_name &&
|
||||
groupData?.display_name !== groupData?.name
|
||||
? groupData?.name
|
||||
: ""}{" "}
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<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."));
|
||||
}
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title={groupData?.display_name || groupData?.name}
|
||||
description="Manage members for this group."
|
||||
/>
|
||||
{canUpdateGroup && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
role="button"
|
||||
component={RouterLink}
|
||||
startIcon={<SettingsOutlined />}
|
||||
to="settings"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
disabled={groupData?.id === groupData?.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={groupData?.members.length ?? 0}
|
||||
total={groupData?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
</TableToolbar>
|
||||
startIcon={<DeleteOutline />}
|
||||
css={styles.removeButton}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<Stack spacing={1}>
|
||||
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
|
||||
<AddGroupMember
|
||||
isLoading={addMemberMutation.isLoading}
|
||||
organizationId={groupData.organization_id}
|
||||
onSubmit={async (member, reset) => {
|
||||
try {
|
||||
await addMemberMutation.mutateAsync({
|
||||
groupId,
|
||||
userId: member.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%" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{groupData?.members.length === 0 ? (
|
||||
<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}
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Margins>
|
||||
</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>
|
||||
|
||||
{groupQuery.data && (
|
||||
<DeleteDialog
|
||||
@@ -211,7 +219,7 @@ export const GroupPage: FC = () => {
|
||||
try {
|
||||
await deleteGroupMutation.mutateAsync(groupId);
|
||||
displaySuccess("Group deleted successfully.");
|
||||
navigate("/deployment/groups");
|
||||
navigate("..");
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to delete group."));
|
||||
}
|
||||
@@ -227,11 +235,17 @@ export const GroupPage: FC = () => {
|
||||
|
||||
interface AddGroupMemberProps {
|
||||
isLoading: boolean;
|
||||
onSubmit: (user: User, reset: () => void) => void;
|
||||
onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const AddGroupMember: FC<AddGroupMemberProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
organizationId,
|
||||
}) => {
|
||||
const [selectedUser, setSelectedUser] =
|
||||
useState<OrganizationMemberWithUserData | null>(null);
|
||||
|
||||
const resetValues = () => {
|
||||
setSelectedUser(null);
|
||||
@@ -248,9 +262,10 @@ const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<UserAutocomplete
|
||||
<MemberAutocomplete
|
||||
css={styles.autoComplete}
|
||||
value={selectedUser}
|
||||
organizationId={organizationId}
|
||||
onChange={(newValue) => {
|
||||
setSelectedUser(newValue);
|
||||
}}
|
||||
@@ -274,16 +289,15 @@ interface GroupMemberRowProps {
|
||||
member: ReducedUser;
|
||||
group: Group;
|
||||
canUpdate: boolean;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
member,
|
||||
group,
|
||||
canUpdate,
|
||||
onRemove,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const removeMemberMutation = useMutation(removeMember(queryClient));
|
||||
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width="59%">
|
||||
@@ -309,19 +323,7 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
danger
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
groupId: group.id,
|
||||
userId: member.id,
|
||||
});
|
||||
displaySuccess("Member removed successfully.");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to remove member."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClick={onRemove}
|
||||
disabled={group.id === group.organization_id}
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import GroupAdd from "@mui/icons-material/GroupAddOutlined";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { groupsByOrganization } from "api/queries/groups";
|
||||
import { organizationPermissions } from "api/queries/organizations";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
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 { pageTitle } from "utils/page";
|
||||
import { useGroupsSettings } from "./GroupsPageProvider";
|
||||
import GroupsPageView from "./GroupsPageView";
|
||||
|
||||
export const GroupsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
|
||||
const groupsQuery = useQuery(groupsByOrganization("default"));
|
||||
const { template_rbac: groupsEnabled } = useFeatureVisibility();
|
||||
const { organization, showOrganizations } = useGroupsSettings();
|
||||
const groupsQuery = useQuery(
|
||||
organization ? groupsByOrganization(organization.name) : { enabled: false },
|
||||
);
|
||||
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupsQuery.error) {
|
||||
@@ -22,16 +33,52 @@ export const GroupsPage: FC = () => {
|
||||
}
|
||||
}, [groupsQuery.error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(permissionsQuery.error, "Unable to load permissions."),
|
||||
);
|
||||
}
|
||||
}, [permissionsQuery.error]);
|
||||
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const permissions = permissionsQuery.data;
|
||||
if (!permissions) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Groups")}</title>
|
||||
</Helmet>
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title="Groups"
|
||||
description={`Manage groups for this ${showOrganizations ? "organization" : "deployment"}.`}
|
||||
/>
|
||||
{groupsEnabled && permissions.createGroup && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<GroupAdd />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<GroupsPageView
|
||||
groups={groupsQuery.data}
|
||||
canCreateGroup={permissions.createGroup}
|
||||
isTemplateRBACEnabled={isTemplateRBACEnabled}
|
||||
groupsEnabled={groupsEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { RequirePermission } from "contexts/auth/RequirePermission";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { Navigate, Outlet, useParams } from "react-router-dom";
|
||||
|
||||
export const GroupsPageContext = createContext<
|
||||
OrganizationSettingsValue | undefined
|
||||
>(undefined);
|
||||
|
||||
type OrganizationSettingsValue = Readonly<{
|
||||
organization?: Organization;
|
||||
showOrganizations: boolean;
|
||||
}>;
|
||||
|
||||
export const useGroupsSettings = (): OrganizationSettingsValue => {
|
||||
const context = useContext(GroupsPageContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useGroupsSettings should be used inside of GroupsPageContext",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
const GroupsPageProvider: FC = () => {
|
||||
const { organizations, showOrganizations } = useDashboard();
|
||||
const { organization: orgName } = useParams() as {
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
const organization = orgName
|
||||
? organizations.find((org) => org.name === orgName)
|
||||
: getOrganizationByDefault(organizations);
|
||||
|
||||
if (
|
||||
location.pathname.startsWith("/deployment/groups") &&
|
||||
showOrganizations &&
|
||||
organization
|
||||
) {
|
||||
return (
|
||||
<Navigate to={`/organizations/${organization.name}/groups`} replace />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupsPageContext.Provider value={{ organization, showOrganizations }}>
|
||||
<Outlet />
|
||||
</GroupsPageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPageProvider;
|
||||
|
||||
const getOrganizationByDefault = (organizations: readonly Organization[]) => {
|
||||
return organizations.find((org) => org.is_default);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { MockGroup } from "testHelpers/entities";
|
||||
import { GroupsPageView } from "./GroupsPageView";
|
||||
|
||||
const meta: Meta<typeof GroupsPageView> = {
|
||||
title: "pages/GroupsPage",
|
||||
title: "pages/OrganizationGroupsPage",
|
||||
component: GroupsPageView,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const NotEnabled: Story = {
|
||||
args: {
|
||||
groups: [MockGroup],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: false,
|
||||
groupsEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export const WithGroups: Story = {
|
||||
args: {
|
||||
groups: [MockGroup],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export const WithDisplayGroup: Story = {
|
||||
args: {
|
||||
groups: [{ ...MockGroup, name: "front-end" }],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export const EmptyGroup: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: false,
|
||||
isTemplateRBACEnabled: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,6 +46,6 @@ export const EmptyGroupWithPermission: Story = {
|
||||
args: {
|
||||
groups: [],
|
||||
canCreateGroup: true,
|
||||
isTemplateRBACEnabled: true,
|
||||
groupsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -14,6 +13,7 @@ import type { Group } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { useClickableTableRow } from "hooks";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
@@ -28,25 +29,24 @@ import { docs } from "utils/docs";
|
||||
export type GroupsPageViewProps = {
|
||||
groups: Group[] | undefined;
|
||||
canCreateGroup: boolean;
|
||||
isTemplateRBACEnabled: boolean;
|
||||
groupsEnabled: boolean;
|
||||
};
|
||||
|
||||
export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
groups,
|
||||
canCreateGroup,
|
||||
isTemplateRBACEnabled,
|
||||
groupsEnabled,
|
||||
}) => {
|
||||
const isLoading = Boolean(groups === undefined);
|
||||
const isEmpty = Boolean(groups && groups.length === 0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChooseOne>
|
||||
<Cond condition={!isTemplateRBACEnabled}>
|
||||
<Cond condition={!groupsEnabled}>
|
||||
<Paywall
|
||||
message="Groups"
|
||||
description="Organize users into groups with restricted access to templates. You need an Premium license to use this feature."
|
||||
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
/>
|
||||
</Cond>
|
||||
@@ -78,13 +78,11 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
}
|
||||
cta={
|
||||
canCreateGroup && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/deployment/groups/create"
|
||||
startIcon={<AddOutlined />}
|
||||
variant="contained"
|
||||
>
|
||||
Create group
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<AddOutlined />
|
||||
Create group
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -94,63 +92,9 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{groups?.map((group) => {
|
||||
const groupPageLink = `/deployment/groups/${group.name}`;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
hover
|
||||
key={group.id}
|
||||
data-testid={`group-${group.id}`}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
navigate(groupPageLink);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
navigate(groupPageLink);
|
||||
}
|
||||
}}
|
||||
css={styles.clickableTableRow}
|
||||
>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
avatar={
|
||||
<Avatar
|
||||
fallback={group.display_name || group.name}
|
||||
src={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", gap: 4 }}
|
||||
>
|
||||
{group.members.map((member) => (
|
||||
<Avatar
|
||||
key={member.username}
|
||||
fallback={member.username}
|
||||
src={member.avatar_url}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div css={styles.arrowCell}>
|
||||
<KeyboardArrowRight css={styles.arrowRight} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{groups?.map((group) => (
|
||||
<GroupRow key={group.id} group={group} />
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
@@ -162,7 +106,58 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader = () => {
|
||||
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={
|
||||
<Avatar
|
||||
fallback={group.display_name || group.name}
|
||||
src={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", gap: 8 }}
|
||||
>
|
||||
{group.members.map((member) => (
|
||||
<Avatar
|
||||
key={member.username}
|
||||
fallback={member.username}
|
||||
src={member.avatar_url}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div css={styles.arrowCell}>
|
||||
<KeyboardArrowRight css={styles.arrowRight} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader: FC = () => {
|
||||
return (
|
||||
<TableLoaderSkeleton>
|
||||
<TableRowSkeleton>
|
||||
@@ -183,21 +178,6 @@ const TableLoader = () => {
|
||||
};
|
||||
|
||||
const styles = {
|
||||
clickableTableRow: (theme) => ({
|
||||
cursor: "pointer",
|
||||
|
||||
"&:hover td": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
|
||||
"&:focus": {
|
||||
outline: `1px solid ${theme.palette.primary.main}`,
|
||||
},
|
||||
|
||||
"& .MuiTableCell-root:last-child": {
|
||||
paddingRight: "16px !important",
|
||||
},
|
||||
}),
|
||||
arrowRight: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
width: 20,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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 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 { pageTitle } from "utils/page";
|
||||
import SettingsGroupPageView from "./SettingsGroupPageView";
|
||||
|
||||
export const SettingsGroupPage: FC = () => {
|
||||
const { groupName } = useParams() as { groupName: string };
|
||||
const queryClient = useQueryClient();
|
||||
const groupQuery = useQuery(group("default", groupName));
|
||||
const patchGroupMutation = useMutation(patchGroup(queryClient));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToGroup = () => {
|
||||
navigate(`/deployment/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}
|
||||
|
||||
<SettingsGroupPageView
|
||||
onCancel={navigateToGroup}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchGroupMutation.mutateAsync({
|
||||
groupId,
|
||||
...data,
|
||||
add_users: [],
|
||||
remove_users: [],
|
||||
});
|
||||
navigate(`/deployment/groups/${data.name}`, { replace: true });
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to update group"));
|
||||
}
|
||||
}}
|
||||
group={groupQuery.data}
|
||||
formErrors={groupQuery.error}
|
||||
isLoading={groupQuery.isLoading}
|
||||
isUpdating={patchGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SettingsGroupPage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockGroup } from "testHelpers/entities";
|
||||
import { SettingsGroupPageView } from "./SettingsGroupPageView";
|
||||
|
||||
const meta: Meta<typeof SettingsGroupPageView> = {
|
||||
title: "pages/GroupsPage/SettingsGroupPageView",
|
||||
component: SettingsGroupPageView,
|
||||
args: {
|
||||
onCancel: action("onCancel"),
|
||||
group: MockGroup,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsGroupPageView>;
|
||||
|
||||
const Example: Story = {};
|
||||
|
||||
export { Example as SettingsGroupPageView };
|
||||
@@ -1,159 +0,0 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import type { Group } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FormFooter } from "components/Form/Form";
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "utils/formUtils";
|
||||
import { isEveryoneGroup } from "utils/groups";
|
||||
import * as Yup from "yup";
|
||||
|
||||
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 (
|
||||
<FullPageForm title="Group settings">
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack spacing={2.5}>
|
||||
<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.",
|
||||
})}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<FormFooter>
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Spinner loading={isLoading} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</form>
|
||||
</FullPageForm>
|
||||
);
|
||||
};
|
||||
|
||||
export type SettingsGroupPageViewProps = {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: FormData) => void;
|
||||
group: Group | undefined;
|
||||
formErrors: unknown;
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
export const SettingsGroupPageView: FC<SettingsGroupPageViewProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
group,
|
||||
formErrors,
|
||||
isLoading,
|
||||
isUpdating,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<UpdateGroupForm
|
||||
group={group!}
|
||||
onCancel={onCancel}
|
||||
errors={formErrors}
|
||||
isLoading={isUpdating}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsGroupPageView;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createGroup } from "api/queries/groups";
|
||||
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 { 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 ?? "default"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Create Group")}</title>
|
||||
</Helmet>
|
||||
<CreateGroupPageView
|
||||
onSubmit={async (data) => {
|
||||
const newGroup = await createGroupMutation.mutateAsync(data);
|
||||
navigate(
|
||||
organization
|
||||
? `/organizations/${organization}/groups/${newGroup.name}`
|
||||
: `/deployment/groups/${newGroup.name}`,
|
||||
);
|
||||
}}
|
||||
error={createGroupMutation.error}
|
||||
isLoading={createGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CreateGroupPage;
|
||||
@@ -1,42 +0,0 @@
|
||||
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();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidName: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const input = await body.findByLabelText("Name");
|
||||
await user.type(input, "$om3 !nv@lid Name");
|
||||
input.blur();
|
||||
},
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { isApiValidationError } from "api/errors";
|
||||
import type { CreateGroupRequest } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "components/Form/Form";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
getFormHelpers,
|
||||
nameValidator,
|
||||
onChangeTrimmed,
|
||||
} from "utils/formUtils";
|
||||
import * as Yup from "yup";
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("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 (
|
||||
<>
|
||||
<SettingsHeader
|
||||
title="New Group"
|
||||
description="Create a group in this organization."
|
||||
/>
|
||||
|
||||
<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"
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name", {
|
||||
helperText: "Optional: keep empty to default to the name.",
|
||||
})}
|
||||
fullWidth
|
||||
label="Display Name"
|
||||
autoComplete="display_name"
|
||||
/>
|
||||
<IconField
|
||||
{...getFieldHelpers("avatar_url")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label="Avatar URL"
|
||||
onPickEmoji={(value) => form.setFieldValue("avatar_url", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter>
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Spinner loading={isLoading} />
|
||||
Save
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</HorizontalForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default CreateGroupPageView;
|
||||
@@ -1,357 +0,0 @@
|
||||
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 { getErrorMessage } from "api/errors";
|
||||
import {
|
||||
addMember,
|
||||
deleteGroup,
|
||||
group,
|
||||
groupPermissions,
|
||||
removeMember,
|
||||
} from "api/queries/groups";
|
||||
import type {
|
||||
Group,
|
||||
OrganizationMemberWithUserData,
|
||||
ReducedUser,
|
||||
} from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/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 {
|
||||
MoreMenu,
|
||||
MoreMenuContent,
|
||||
MoreMenuItem,
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
PaginationStatus,
|
||||
TableToolbar,
|
||||
} from "components/TableToolbar/TableToolbar";
|
||||
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
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 { isEveryoneGroup } from "utils/groups";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
||||
export const GroupPage: FC = () => {
|
||||
const { organization = "default", 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 ? 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}
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title={groupData?.display_name || groupData?.name}
|
||||
description="Manage members for this group."
|
||||
/>
|
||||
{canUpdateGroup && (
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
role="button"
|
||||
component={RouterLink}
|
||||
startIcon={<SettingsOutlined />}
|
||||
to="settings"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
disabled={groupData?.id === groupData?.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
startIcon={<DeleteOutline />}
|
||||
css={styles.removeButton}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
|
||||
<AddGroupMember
|
||||
isLoading={addMemberMutation.isLoading}
|
||||
organizationId={groupData.organization_id}
|
||||
onSubmit={async (member, reset) => {
|
||||
try {
|
||||
await addMemberMutation.mutateAsync({
|
||||
groupId,
|
||||
userId: member.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%" />
|
||||
</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>
|
||||
|
||||
{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: OrganizationMemberWithUserData, reset: () => void) => void;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const AddGroupMember: FC<AddGroupMemberProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
organizationId,
|
||||
}) => {
|
||||
const [selectedUser, setSelectedUser] =
|
||||
useState<OrganizationMemberWithUserData | null>(null);
|
||||
|
||||
const resetValues = () => {
|
||||
setSelectedUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedUser) {
|
||||
onSubmit(selectedUser, resetValues);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<MemberAutocomplete
|
||||
css={styles.autoComplete}
|
||||
value={selectedUser}
|
||||
organizationId={organizationId}
|
||||
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={<Avatar fallback={member.username} src={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;
|
||||
@@ -1,108 +0,0 @@
|
||||
import GroupAdd from "@mui/icons-material/GroupAddOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { groupsByOrganization } from "api/queries/groups";
|
||||
import { organizationPermissions } from "api/queries/organizations";
|
||||
import type { Organization } from "api/typesGenerated";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
import { Navigate, Link as RouterLink, useParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import GroupsPageView from "./GroupsPageView";
|
||||
|
||||
export const GroupsPage: FC = () => {
|
||||
const feats = useFeatureVisibility();
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const groupsQuery = useQuery(groupsByOrganization(organizationName));
|
||||
const { organizations } = useOrganizationSettings();
|
||||
const organization = organizations?.find((o) => o.name === organizationName);
|
||||
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(groupsQuery.error, "Unable to load groups."),
|
||||
);
|
||||
}
|
||||
}, [groupsQuery.error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(permissionsQuery.error, "Unable to load permissions."),
|
||||
);
|
||||
}
|
||||
}, [permissionsQuery.error]);
|
||||
|
||||
if (!organizations) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (!organizationName) {
|
||||
const defaultName = getOrganizationNameByDefault(organizations);
|
||||
if (defaultName) {
|
||||
return <Navigate to={`/organizations/${defaultName}/groups`} replace />;
|
||||
}
|
||||
// We expect there to always be a default organization.
|
||||
throw new Error("No default organization found");
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const permissions = permissionsQuery.data;
|
||||
if (!permissions) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle("Groups", organization.display_name || organization.name)}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title="Groups"
|
||||
description="Manage groups for this organization."
|
||||
/>
|
||||
{permissions.createGroup && feats.template_rbac && (
|
||||
<Button component={RouterLink} startIcon={<GroupAdd />} to="create">
|
||||
Create group
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<GroupsPageView
|
||||
groups={groupsQuery.data}
|
||||
canCreateGroup={permissions.createGroup}
|
||||
isTemplateRBACEnabled={feats.template_rbac}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
||||
|
||||
export const getOrganizationNameByDefault = (
|
||||
organizations: readonly Organization[],
|
||||
) => {
|
||||
return organizations.find((org) => org.is_default)?.name;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
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 { Group } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { useClickableTableRow } from "hooks";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
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 a Premium license to use this feature."
|
||||
documentationLink={docs("/admin/users/groups-roles")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50%">Name</TableCell>
|
||||
<TableCell width="49%">Users</TableCell>
|
||||
<TableCell width="1%" />
|
||||
</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={
|
||||
<Avatar
|
||||
fallback={group.display_name || group.name}
|
||||
src={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", gap: 8 }}
|
||||
>
|
||||
{group.members.map((member) => (
|
||||
<Avatar
|
||||
key={member.username}
|
||||
fallback={member.username}
|
||||
src={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;
|
||||
@@ -1,44 +0,0 @@
|
||||
import GroupAdd from "@mui/icons-material/GroupAddOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { type FC, Suspense } from "react";
|
||||
import { Outlet, Link as RouterLink } from "react-router-dom";
|
||||
|
||||
export const UsersLayout: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const feats = useFeatureVisibility();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<div>
|
||||
{permissions.createGroup && feats.template_rbac && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
startIcon={<GroupAdd />}
|
||||
to="/deployment/groups/create"
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>Groups</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
</Margins>
|
||||
|
||||
<Margins>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Margins>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -108,10 +108,6 @@ const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
|
||||
authMethodsQuery.isLoading ||
|
||||
groupsByUserIdQuery.isLoading;
|
||||
|
||||
if (location.pathname === "/users") {
|
||||
return <Navigate to={`/deployment/users${location.search}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { GroupsByUserId } from "api/queries/groups";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
|
||||
import {
|
||||
PaginationContainer,
|
||||
type PaginationResult,
|
||||
} from "components/PaginationWidget/PaginationContainer";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { UserPlusIcon } from "lucide-react";
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
@@ -67,21 +68,24 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
css={{ paddingTop: 0 }}
|
||||
actions={
|
||||
canCreateUser && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<UserPlusIcon />
|
||||
Create user
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<PageHeaderTitle>Users</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
<SettingsHeader
|
||||
title="Users"
|
||||
description="Manage user accounts and permissions."
|
||||
/>
|
||||
{canCreateUser && (
|
||||
<Button asChild>
|
||||
<RouterLink to="create">
|
||||
<UserPlusIcon />
|
||||
Create user
|
||||
</RouterLink>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<UsersFilter {...filterProps} />
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { GroupsByUserId } from "api/queries/groups";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { FC } from "react";
|
||||
import { TableColumnHelpTooltip } from "../../ManagementSettingsPage/UserTable/TableColumnHelpTooltip";
|
||||
import { TableColumnHelpTooltip } from "../../OrganizationSettingsPage/UserTable/TableColumnHelpTooltip";
|
||||
import { UsersTableBody } from "./UsersTableBody";
|
||||
|
||||
export const Language = {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { FC } from "react";
|
||||
import { UserRoleCell } from "../../ManagementSettingsPage/UserTable/UserRoleCell";
|
||||
import { UserRoleCell } from "../../OrganizationSettingsPage/UserTable/UserRoleCell";
|
||||
import { UserGroupsCell } from "./UserGroupsCell";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
+34
-61
@@ -19,7 +19,6 @@ import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
|
||||
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
|
||||
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage";
|
||||
import UserSettingsLayout from "./pages/UserSettingsPage/Layout";
|
||||
import { UsersLayout } from "./pages/UsersPage/UsersLayout";
|
||||
import UsersPage from "./pages/UsersPage/UsersPage";
|
||||
import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
|
||||
import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
|
||||
@@ -98,13 +97,6 @@ const TemplateSummaryPage = lazy(
|
||||
const CreateWorkspacePage = lazy(
|
||||
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
|
||||
);
|
||||
const CreateGroupPage = lazy(
|
||||
() => import("./pages/GroupsPage/CreateGroupPage"),
|
||||
);
|
||||
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"));
|
||||
const SettingsGroupPage = lazy(
|
||||
() => import("./pages/GroupsPage/SettingsGroupPage"),
|
||||
);
|
||||
const GeneralSettingsPage = lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -237,39 +229,40 @@ const AddNewLicensePage = lazy(
|
||||
),
|
||||
);
|
||||
const CreateOrganizationPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/CreateOrganizationPage"),
|
||||
() => import("./pages/OrganizationSettingsPage/CreateOrganizationPage"),
|
||||
);
|
||||
const OrganizationSettingsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/OrganizationSettingsPage"),
|
||||
() => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"),
|
||||
);
|
||||
const OrganizationGroupsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupsPage"),
|
||||
const GroupsPageProvider = lazy(
|
||||
() => import("./pages/GroupsPage/GroupsPageProvider"),
|
||||
);
|
||||
const CreateOrganizationGroupPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/CreateGroupPage"),
|
||||
const GroupsPage = lazy(() => import("./pages/GroupsPage/GroupsPage"));
|
||||
const CreateGroupPage = lazy(
|
||||
() => import("./pages/GroupsPage/CreateGroupPage"),
|
||||
);
|
||||
const OrganizationGroupPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupPage"),
|
||||
);
|
||||
const OrganizationGroupSettingsPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage"),
|
||||
const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage"));
|
||||
const GroupSettingsPage = lazy(
|
||||
() => import("./pages/GroupsPage/GroupSettingsPage"),
|
||||
);
|
||||
const OrganizationMembersPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
|
||||
() => import("./pages/OrganizationSettingsPage/OrganizationMembersPage"),
|
||||
);
|
||||
const OrganizationCustomRolesPage = lazy(
|
||||
() =>
|
||||
import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"),
|
||||
import("./pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage"),
|
||||
);
|
||||
const OrganizationIdPSyncPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage"),
|
||||
() => import("./pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage"),
|
||||
);
|
||||
const CreateEditRolePage = lazy(
|
||||
() =>
|
||||
import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"),
|
||||
import(
|
||||
"./pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage"
|
||||
),
|
||||
);
|
||||
const OrganizationProvisionersPage = lazy(
|
||||
() => import("./pages/ManagementSettingsPage/OrganizationProvisionersPage"),
|
||||
() => import("./pages/OrganizationSettingsPage/OrganizationProvisionersPage"),
|
||||
);
|
||||
const TemplateEmbedPage = lazy(
|
||||
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
|
||||
@@ -281,7 +274,6 @@ const TemplateInsightsPage = lazy(
|
||||
const PremiumPage = lazy(
|
||||
() => import("./pages/DeploymentSettingsPage/PremiumPage/PremiumPage"),
|
||||
);
|
||||
const GroupsPage = lazy(() => import("./pages/GroupsPage/GroupsPage"));
|
||||
const IconsPage = lazy(() => import("./pages/IconsPage/IconsPage"));
|
||||
const AccessURLPage = lazy(() => import("./pages/HealthPage/AccessURLPage"));
|
||||
const DatabasePage = lazy(() => import("./pages/HealthPage/DatabasePage"));
|
||||
@@ -353,17 +345,16 @@ const templateRouter = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const organizationGroupsRouter = () => {
|
||||
const groupsRouter = () => {
|
||||
return (
|
||||
<Route path="groups">
|
||||
<Route index element={<OrganizationGroupsPage />} />
|
||||
<Route element={<GroupsPageProvider />}>
|
||||
<Route index element={<GroupsPage />} />
|
||||
|
||||
<Route path="create" element={<CreateOrganizationGroupPage />} />
|
||||
<Route path=":groupName" element={<OrganizationGroupPage />} />
|
||||
<Route
|
||||
path=":groupName/settings"
|
||||
element={<OrganizationGroupSettingsPage />}
|
||||
/>
|
||||
<Route path="create" element={<CreateGroupPage />} />
|
||||
<Route path=":groupName" element={<GroupPage />} />
|
||||
<Route path=":groupName/settings" element={<GroupSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
);
|
||||
};
|
||||
@@ -405,23 +396,15 @@ export const router = createBrowserRouter(
|
||||
{templateRouter()}
|
||||
</Route>
|
||||
|
||||
<Route path="/users">
|
||||
<Route element={<UsersLayout />}>
|
||||
<Route index element={<UsersPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/users/*"
|
||||
element={<Navigate to="/deployment/users" replace />}
|
||||
/>
|
||||
|
||||
<Route path="create" element={<CreateUserPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/groups">
|
||||
<Route element={<UsersLayout />}>
|
||||
<Route index element={<GroupsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="create" element={<CreateGroupPage />} />
|
||||
<Route path=":groupName" element={<GroupPage />} />
|
||||
<Route path=":groupName/settings" element={<SettingsGroupPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/groups/*"
|
||||
element={<Navigate to="/deployment/groups" replace />}
|
||||
/>
|
||||
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
|
||||
@@ -433,7 +416,7 @@ export const router = createBrowserRouter(
|
||||
|
||||
<Route path=":organization" element={<OrganizationSidebarLayout />}>
|
||||
<Route index element={<OrganizationMembersPage />} />
|
||||
{organizationGroupsRouter()}
|
||||
{groupsRouter()}
|
||||
<Route path="roles">
|
||||
<Route index element={<OrganizationCustomRolesPage />} />
|
||||
<Route path="create" element={<CreateEditRolePage />} />
|
||||
@@ -488,18 +471,8 @@ export const router = createBrowserRouter(
|
||||
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/create" element={<CreateUserPage />} />
|
||||
<Route path="groups">
|
||||
<Route element={<UsersLayout />}>
|
||||
<Route index element={<GroupsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="create" element={<CreateGroupPage />} />
|
||||
<Route path=":groupName" element={<GroupPage />} />
|
||||
<Route
|
||||
path=":groupName/settings"
|
||||
element={<SettingsGroupPage />}
|
||||
/>
|
||||
</Route>
|
||||
{groupsRouter()}
|
||||
</Route>
|
||||
|
||||
<Route path="/settings" element={<UserSettingsLayout />}>
|
||||
|
||||
@@ -79,6 +79,10 @@ export default defineConfig({
|
||||
target: process.env.CODER_HOST || "http://localhost:3000",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
"/healthz": {
|
||||
target: process.env.CODER_HOST || "http://localhost:3000",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user