chore: clean up groups page (#16259)

This commit is contained in:
ケイラ
2025-01-29 16:13:52 -07:00
committed by GitHub
parent 3897ea458b
commit f3916a68d6
82 changed files with 521 additions and 1605 deletions
+10 -4
View File
@@ -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;
};
+3 -1
View File
@@ -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`);
+6 -1
View File
@@ -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();
+3 -1
View File
@@ -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`);
+3 -1
View File
@@ -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`);
+14 -2
View File
@@ -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 }) => {
+1 -1
View File
@@ -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`,
+1 -1
View File
@@ -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" />
+7 -12
View File
@@ -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 {
+10 -3
View File
@@ -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;
+132 -130
View File
@@ -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&hellip;
</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&hellip;
</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
+52 -5
View File
@@ -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,
},
};
+66 -86
View File
@@ -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&hellip;
</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;
-44
View File
@@ -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>
</>
);
};
-4
View File
@@ -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>
+19 -15
View File
@@ -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
View File
@@ -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 />}>
+4
View File
@@ -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: {