diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 4bb1010f311e6..49cad287c8dfa 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -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 => { - await page.goto("/deployment/groups/create", { +export const createGroup = async ( + page: Page, + organization?: string, +): Promise => { + 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; }; diff --git a/site/e2e/tests/groups/addMembers.spec.ts b/site/e2e/tests/groups/addMembers.spec.ts index 5b70e8910dc55..7f29f4a536385 100644 --- a/site/e2e/tests/groups/addMembers.spec.ts +++ b/site/e2e/tests/groups/addMembers.spec.ts @@ -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`); diff --git a/site/e2e/tests/groups/addUsersToDefaultGroup.spec.ts b/site/e2e/tests/groups/addUsersToDefaultGroup.spec.ts index 049049265d5ae..b1ece8705e2c6 100644 --- a/site/e2e/tests/groups/addUsersToDefaultGroup.spec.ts +++ b/site/e2e/tests/groups/addUsersToDefaultGroup.spec.ts @@ -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`); diff --git a/site/e2e/tests/groups/createGroup.spec.ts b/site/e2e/tests/groups/createGroup.spec.ts index 3ae7bbe2a317e..8df1cdbdcc9fb 100644 --- a/site/e2e/tests/groups/createGroup.spec.ts +++ b/site/e2e/tests/groups/createGroup.spec.ts @@ -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(); diff --git a/site/e2e/tests/groups/removeGroup.spec.ts b/site/e2e/tests/groups/removeGroup.spec.ts index 06d13fd0dfccf..736b86f7d386d 100644 --- a/site/e2e/tests/groups/removeGroup.spec.ts +++ b/site/e2e/tests/groups/removeGroup.spec.ts @@ -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`); diff --git a/site/e2e/tests/groups/removeMember.spec.ts b/site/e2e/tests/groups/removeMember.spec.ts index 3b5727cc42dba..81fb5ee4f4117 100644 --- a/site/e2e/tests/groups/removeMember.spec.ts +++ b/site/e2e/tests/groups/removeMember.spec.ts @@ -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`); diff --git a/site/e2e/tests/organizationGroups.spec.ts b/site/e2e/tests/organizationGroups.spec.ts index 2d0a41acafc02..dff12ab91c453 100644 --- a/site/e2e/tests/organizationGroups.spec.ts +++ b/site/e2e/tests/organizationGroups.spec.ts @@ -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 }) => { diff --git a/site/e2e/tests/updateTemplate.spec.ts b/site/e2e/tests/updateTemplate.spec.ts index b8f1192b461b5..33e85e40e3b6d 100644 --- a/site/e2e/tests/updateTemplate.spec.ts +++ b/site/e2e/tests/updateTemplate.spec.ts @@ -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`, diff --git a/site/src/components/Icons/CoderIcon.tsx b/site/src/components/Icons/CoderIcon.tsx index 3615f43dc968d..7dd2a7625734d 100644 --- a/site/src/components/Icons/CoderIcon.tsx +++ b/site/src/components/Icons/CoderIcon.tsx @@ -17,7 +17,7 @@ export const CoderIcon: FC = ({ className, ...props }) => ( xmlns="http://www.w3.org/2000/svg" > Coder logo - + diff --git a/site/src/components/Sidebar/Sidebar.tsx b/site/src/components/Sidebar/Sidebar.tsx index 880ceecec2265..7e3b09d811b1b 100644 --- a/site/src/components/Sidebar/Sidebar.tsx +++ b/site/src/components/Sidebar/Sidebar.tsx @@ -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 = ({ 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 ( + 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} diff --git a/site/src/modules/management/DeploymentSidebar.tsx b/site/src/modules/management/DeploymentSidebar.tsx index 1153ab226bda2..7600a075b97e3 100644 --- a/site/src/modules/management/DeploymentSidebar.tsx +++ b/site/src/modules/management/DeploymentSidebar.tsx @@ -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 ; + return ( + + ); }; diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 052dcf8329b11..4783133a872bb 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -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 { - /** Site-wide permissions. */ - permissions: Permissions; -} - -/** - * A combined deployment settings and organization menu. - */ -export const DeploymentSidebarView: FC = ({ - permissions, -}) => { - const { multiple_organizations: hasPremiumLicense } = useFeatureVisibility(); - - return ( - - - - ); -}; - -interface DeploymentSettingsNavigationProps { +interface DeploymentSidebarViewProps { /** 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 = ({ +export const DeploymentSidebarView: FC = ({ permissions, - isPremium, + showOrganizations, + hasPremiumLicense, }) => { return ( -
+
{permissions.viewDeploymentValues && ( General @@ -100,7 +75,11 @@ const DeploymentSettingsNavigation: FC = ({ Users )} {permissions.viewAnyGroup && ( - Groups + + + Groups {showOrganizations && } + + )} {permissions.viewNotificationTemplate && ( @@ -115,10 +94,10 @@ const DeploymentSettingsNavigation: FC = ({ IdP Organization Sync )} - {!isPremium && ( + {!hasPremiumLicense && ( Premium )}
-
+ ); }; diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index d2d25cc4a41bd..11d692c0021da 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -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 ( diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 17a9c097b9c62..ef805861d1543 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -60,7 +60,9 @@ export const OrganizationSidebarView: FC = ({ }; function urlForSubpage(organizationName: string, subpage = ""): string { - return `/organizations/${organizationName}/${subpage}`; + return [`/organizations/${organizationName}`, subpage] + .filter(Boolean) + .join("/"); } interface OrganizationsSettingsNavigationProps { diff --git a/site/src/pages/GroupsPage/CreateGroupPage.tsx b/site/src/pages/GroupsPage/CreateGroupPage.tsx index 92f480d8ab959..257a404a3b7ea 100644 --- a/site/src/pages/GroupsPage/CreateGroupPage.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPage.tsx @@ -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 = () => { { 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} diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx index 735c4160c9f67..ea8dfcc3f3e02 100644 --- a/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx @@ -4,7 +4,7 @@ import { mockApiError } from "testHelpers/entities"; import { CreateGroupPageView } from "./CreateGroupPageView"; const meta: Meta = { - 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(); + }); }, }; diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.tsx index dd400459d0c2c..5557abd39dc1f 100644 --- a/site/src/pages/GroupsPage/CreateGroupPageView.tsx +++ b/site/src/pages/GroupsPage/CreateGroupPageView.tsx @@ -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; }; export const CreateGroupPageView: FC = ({ onSubmit, error, isLoading, - initialTouched, }) => { const navigate = useNavigate(); const form = useFormik({ @@ -47,16 +47,23 @@ export const CreateGroupPageView: FC = ({ }, validationSchema, onSubmit, - initialTouched, }); const getFieldHelpers = getFormHelpers(form, error); - const onCancel = () => navigate("/deployment/groups"); + const onCancel = () => navigate(-1); return ( - - -
- + <> + + + + + {Boolean(error) && !isApiValidationError(error) && ( )} @@ -84,21 +91,21 @@ export const CreateGroupPageView: FC = ({ label="Avatar URL" onPickEmoji={(value) => form.setFieldValue("avatar_url", value)} /> - + + - - + + - - - -
-
+ + + + ); }; export default CreateGroupPageView; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.stories.tsx rename to site/src/pages/GroupsPage/GroupPage.stories.tsx diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 913101518c61e..6c226a1dba9ff 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -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} - - - - - - ) - } - > - - {groupData?.display_name || groupData?.name} - - - {/* Show the name if it differs from the display name. */} - {groupData?.display_name && - groupData?.display_name !== groupData?.name - ? groupData?.name - : ""}{" "} - - - - - {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - { - try { - await addMemberMutation.mutateAsync({ - groupId, - userId: user.id, - }); - reset(); - await groupQuery.refetch(); - } catch (error) { - displayError(getErrorMessage(error, "Failed to add member.")); - } + + + {canUpdateGroup && ( + + + + + )} + - - - - - User - Status - - - + + {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( + { + try { + await addMemberMutation.mutateAsync({ + groupId, + userId: member.user_id, + }); + reset(); + await groupQuery.refetch(); + } catch (error) { + displayError(getErrorMessage(error, "Failed to add member.")); + } + }} + /> + )} + + + + + +
+ + + User + Status + + + - - {groupData?.members.length === 0 ? ( - - - - - - ) : ( - groupData?.members.map((member) => ( - + {groupData?.members.length === 0 ? ( + + + - )) - )} - -
-
-
-
+ + + ) : ( + groupData?.members.map((member) => ( + { + 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."), + ); + } + }} + /> + )) + )} + + + + {groupQuery.data && ( { 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 = ({ isLoading, onSubmit }) => { - const [selectedUser, setSelectedUser] = useState(null); +const AddGroupMember: FC = ({ + isLoading, + onSubmit, + organizationId, +}) => { + const [selectedUser, setSelectedUser] = + useState(null); const resetValues = () => { setSelectedUser(null); @@ -248,9 +262,10 @@ const AddGroupMember: FC = ({ isLoading, onSubmit }) => { }} > - { setSelectedUser(newValue); }} @@ -274,16 +289,15 @@ interface GroupMemberRowProps { member: ReducedUser; group: Group; canUpdate: boolean; + onRemove: () => void; } const GroupMemberRow: FC = ({ member, group, canUpdate, + onRemove, }) => { - const queryClient = useQueryClient(); - const removeMemberMutation = useMutation(removeMember(queryClient)); - return ( @@ -309,19 +323,7 @@ const GroupMemberRow: FC = ({ { - 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 diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/GroupsPage/GroupSettingsPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPage.tsx rename to site/src/pages/GroupsPage/GroupSettingsPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.stories.tsx rename to site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/GroupsPage/GroupSettingsPageView.tsx rename to site/src/pages/GroupsPage/GroupSettingsPageView.tsx diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 6313b8e450c9e..5e33e232227ef 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -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 ; + } + + const permissions = permissionsQuery.data; + if (!permissions) { + return ; + } + return ( <> {pageTitle("Groups")} + + + {groupsEnabled && permissions.createGroup && ( + + )} + + ); diff --git a/site/src/pages/GroupsPage/GroupsPageProvider.tsx b/site/src/pages/GroupsPage/GroupsPageProvider.tsx new file mode 100644 index 0000000000000..85ccd763be10a --- /dev/null +++ b/site/src/pages/GroupsPage/GroupsPageProvider.tsx @@ -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 ( + + ); + } + + return ( + + + + ); +}; + +export default GroupsPageProvider; + +const getOrganizationByDefault = (organizations: readonly Organization[]) => { + return organizations.find((org) => org.is_default); +}; diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index a179a830e4652..466ee2b149524 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -3,7 +3,7 @@ import { MockGroup } from "testHelpers/entities"; import { GroupsPageView } from "./GroupsPageView"; const meta: Meta = { - 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, }, }; diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index bd2d2ef981419..22ccd35515064 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -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 = ({ groups, canCreateGroup, - isTemplateRBACEnabled, + groupsEnabled, }) => { const isLoading = Boolean(groups === undefined); const isEmpty = Boolean(groups && groups.length === 0); - const navigate = useNavigate(); return ( <> - + @@ -78,13 +78,11 @@ export const GroupsPageView: FC = ({ } cta={ canCreateGroup && ( - ) } @@ -94,63 +92,9 @@ export const GroupsPageView: FC = ({ - {groups?.map((group) => { - const groupPageLink = `/deployment/groups/${group.name}`; - - return ( - { - navigate(groupPageLink); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - navigate(groupPageLink); - } - }} - css={styles.clickableTableRow} - > - - - } - title={group.display_name || group.name} - subtitle={`${group.members.length} members`} - /> - - - - {group.members.length === 0 && "-"} - - {group.members.map((member) => ( - - ))} - - - - -
- -
-
-
- ); - })} + {groups?.map((group) => ( + + ))}
@@ -162,7 +106,58 @@ export const GroupsPageView: FC = ({ ); }; -const TableLoader = () => { +interface GroupRowProps { + group: Group; +} + +const GroupRow: FC = ({ group }) => { + const navigate = useNavigate(); + const rowProps = useClickableTableRow({ + onClick: () => navigate(group.name), + }); + + return ( + + + + } + title={group.display_name || group.name} + subtitle={`${group.members.length} members`} + /> + + + + {group.members.length === 0 && "-"} + + {group.members.map((member) => ( + + ))} + + + + +
+ +
+
+
+ ); +}; + +const TableLoader: FC = () => { return ( @@ -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, diff --git a/site/src/pages/GroupsPage/SettingsGroupPage.tsx b/site/src/pages/GroupsPage/SettingsGroupPage.tsx deleted file mode 100644 index 5b44d5c99457f..0000000000000 --- a/site/src/pages/GroupsPage/SettingsGroupPage.tsx +++ /dev/null @@ -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 = ( - - {pageTitle("Settings Group")} - - ); - - if (groupQuery.error) { - return ; - } - - if (groupQuery.isLoading || !groupQuery.data) { - return ( - <> - {helmet} - - - ); - } - - const groupId = groupQuery.data.id; - - return ( - <> - {helmet} - - { - 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; diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx deleted file mode 100644 index 78f4ead3ef6d0..0000000000000 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx +++ /dev/null @@ -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 = { - title: "pages/GroupsPage/SettingsGroupPageView", - component: SettingsGroupPageView, - args: { - onCancel: action("onCancel"), - group: MockGroup, - isLoading: false, - }, -}; - -export default meta; -type Story = StoryObj; - -const Example: Story = {}; - -export { Example as SettingsGroupPageView }; diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx deleted file mode 100644 index 3877cabc0beb6..0000000000000 --- a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx +++ /dev/null @@ -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 = ({ - group, - errors, - onSubmit, - onCancel, - isLoading, -}) => { - const form = useFormik({ - initialValues: { - name: group.name, - display_name: group.display_name, - avatar_url: group.avatar_url, - quota_allowance: group.quota_allowance, - }, - validationSchema, - onSubmit, - }); - const getFieldHelpers = getFormHelpers(form, errors); - - return ( - -
- - - {isEveryoneGroup(group) ? ( - <> - ) : ( - <> - - form.setFieldValue("avatar_url", value)} - /> - - )} - - - - - - - - -
-
- ); -}; - -export type SettingsGroupPageViewProps = { - onCancel: () => void; - onSubmit: (data: FormData) => void; - group: Group | undefined; - formErrors: unknown; - isLoading: boolean; - isUpdating: boolean; -}; - -export const SettingsGroupPageView: FC = ({ - onCancel, - onSubmit, - group, - formErrors, - isLoading, - isUpdating, -}) => { - if (isLoading) { - return ; - } - - return ( - - - - ); -}; - -export default SettingsGroupPageView; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx deleted file mode 100644 index 257a404a3b7ea..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPage.tsx +++ /dev/null @@ -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 ( - <> - - {pageTitle("Create Group")} - - { - 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; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx deleted file mode 100644 index ea8dfcc3f3e02..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.stories.tsx +++ /dev/null @@ -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 = { - title: "pages/OrganizationGroupsPage/CreateGroupPageView", - component: CreateGroupPageView, -}; - -export default meta; -type Story = StoryObj; - -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(); - }, -}; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx deleted file mode 100644 index 5557abd39dc1f..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/CreateGroupPageView.tsx +++ /dev/null @@ -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 = ({ - onSubmit, - error, - isLoading, -}) => { - const navigate = useNavigate(); - const form = useFormik({ - initialValues: { - name: "", - display_name: "", - avatar_url: "", - quota_allowance: 0, - }, - validationSchema, - onSubmit, - }); - const getFieldHelpers = getFormHelpers(form, error); - const onCancel = () => navigate(-1); - - return ( - <> - - - - - - {Boolean(error) && !isApiValidationError(error) && ( - - )} - - - - form.setFieldValue("avatar_url", value)} - /> - - - - - - - - - - - ); -}; -export default CreateGroupPageView; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx deleted file mode 100644 index 6c226a1dba9ff..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupPage.tsx +++ /dev/null @@ -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 = ( - - - {pageTitle( - (groupData?.display_name || groupData?.name) ?? "Loading...", - )} - - - ); - - if (groupQuery.error) { - return ; - } - - if (isLoading) { - return ( - <> - {helmet} - - - ); - } - const groupId = groupData.id; - - return ( - <> - {helmet} - - - - {canUpdateGroup && ( - - - - - )} - - - - {canUpdateGroup && groupData && !isEveryoneGroup(groupData) && ( - { - try { - await addMemberMutation.mutateAsync({ - groupId, - userId: member.user_id, - }); - reset(); - await groupQuery.refetch(); - } catch (error) { - displayError(getErrorMessage(error, "Failed to add member.")); - } - }} - /> - )} - - - - - - - - - User - Status - - - - - - {groupData?.members.length === 0 ? ( - - - - - - ) : ( - groupData?.members.map((member) => ( - { - 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."), - ); - } - }} - /> - )) - )} - -
-
-
- - {groupQuery.data && ( - { - 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 = ({ - isLoading, - onSubmit, - organizationId, -}) => { - const [selectedUser, setSelectedUser] = - useState(null); - - const resetValues = () => { - setSelectedUser(null); - }; - - return ( -
{ - e.preventDefault(); - - if (selectedUser) { - onSubmit(selectedUser, resetValues); - } - }} - > - - { - setSelectedUser(newValue); - }} - /> - - } - loading={isLoading} - > - Add user - - -
- ); -}; - -interface GroupMemberRowProps { - member: ReducedUser; - group: Group; - canUpdate: boolean; - onRemove: () => void; -} - -const GroupMemberRow: FC = ({ - member, - group, - canUpdate, - onRemove, -}) => { - return ( - - - } - title={member.username} - subtitle={member.email} - /> - - -
{member.status}
- -
- - {canUpdate && ( - - - - - - - Remove - - - - )} - -
- ); -}; - -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>; - -export default GroupPage; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx deleted file mode 100644 index 0e31af80e359a..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPage.tsx +++ /dev/null @@ -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 ; - } - - if (!organizationName) { - const defaultName = getOrganizationNameByDefault(organizations); - if (defaultName) { - return ; - } - // We expect there to always be a default organization. - throw new Error("No default organization found"); - } - - if (!organization) { - return ; - } - - const permissions = permissionsQuery.data; - if (!permissions) { - return ; - } - - return ( - <> - - - {pageTitle("Groups", organization.display_name || organization.name)} - - - - - - {permissions.createGroup && feats.template_rbac && ( - - )} - - - - - ); -}; - -export default GroupsPage; - -export const getOrganizationNameByDefault = ( - organizations: readonly Organization[], -) => { - return organizations.find((org) => org.is_default)?.name; -}; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx deleted file mode 100644 index 8198243ca2de5..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { MockGroup } from "testHelpers/entities"; -import { GroupsPageView } from "./GroupsPageView"; - -const meta: Meta = { - title: "pages/OrganizationGroupsPage", - component: GroupsPageView, -}; - -export default meta; -type Story = StoryObj; - -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, - }, -}; diff --git a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx b/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx deleted file mode 100644 index fe109d0ea5718..0000000000000 --- a/site/src/pages/ManagementSettingsPage/GroupsPage/GroupsPageView.tsx +++ /dev/null @@ -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 = ({ - groups, - canCreateGroup, - isTemplateRBACEnabled, -}) => { - const isLoading = Boolean(groups === undefined); - const isEmpty = Boolean(groups && groups.length === 0); - - return ( - <> - - - - - - - - - - Name - Users - - - - - - - - - - - - - } - variant="contained" - > - Create group - - ) - } - /> - - - - - - {groups?.map((group) => ( - - ))} - - - -
-
-
-
- - ); -}; - -interface GroupRowProps { - group: Group; -} - -const GroupRow: FC = ({ group }) => { - const navigate = useNavigate(); - const rowProps = useClickableTableRow({ - onClick: () => navigate(group.name), - }); - - return ( - - - - } - title={group.display_name || group.name} - subtitle={`${group.members.length} members`} - /> - - - - {group.members.length === 0 && "-"} - - {group.members.map((member) => ( - - ))} - - - - -
- -
-
-
- ); -}; - -const TableLoader: FC = () => { - return ( - - - -
- -
-
- - - - - - -
-
- ); -}; - -const styles = { - arrowRight: (theme) => ({ - color: theme.palette.text.secondary, - width: 20, - height: 20, - }), - arrowCell: { - display: "flex", - }, -} satisfies Record>; - -export default GroupsPageView; diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx rename to site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx rename to site/src/pages/OrganizationSettingsPage/CreateOrganizationPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/PermissionPillsList.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/PermissionPillsList.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/PermissionPillsList.stories.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/PermissionPillsList.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/PermissionPillsList.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/PermissionPillsList.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/CustomRolesPage/PermissionPillsList.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/PermissionPillsList.tsx diff --git a/site/src/pages/ManagementSettingsPage/Horizontal.tsx b/site/src/pages/OrganizationSettingsPage/Horizontal.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/Horizontal.tsx rename to site/src/pages/OrganizationSettingsPage/Horizontal.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpMappingTable.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpMappingTable.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpMappingTable.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpMappingTable.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpPillList.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpPillList.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpPillList.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpPillList.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx rename to site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationMembersPage.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationMembersPageView.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationProvisionersPage.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationProvisionersPageView.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.stories.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.test.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.test.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.test.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSummaryPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSummaryPageView.stories.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSummaryPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/OrganizationSummaryPageView.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.stories.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.stories.tsx rename to site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx rename to site/src/pages/OrganizationSettingsPage/UserTable/EditRolesButton.tsx diff --git a/site/src/pages/ManagementSettingsPage/UserTable/TableColumnHelpTooltip.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/UserTable/TableColumnHelpTooltip.tsx rename to site/src/pages/OrganizationSettingsPage/UserTable/TableColumnHelpTooltip.tsx diff --git a/site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx b/site/src/pages/OrganizationSettingsPage/UserTable/UserRoleCell.tsx similarity index 100% rename from site/src/pages/ManagementSettingsPage/UserTable/UserRoleCell.tsx rename to site/src/pages/OrganizationSettingsPage/UserTable/UserRoleCell.tsx diff --git a/site/src/pages/UsersPage/UsersLayout.tsx b/site/src/pages/UsersPage/UsersLayout.tsx deleted file mode 100644 index c0400d23b8cea..0000000000000 --- a/site/src/pages/UsersPage/UsersLayout.tsx +++ /dev/null @@ -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 ( - <> - - - {permissions.createGroup && feats.template_rbac && ( - - )} - - } - > - Groups - - - - - }> - - - - - ); -}; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index e4337f9242216..7ee8e19c899ab 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -108,10 +108,6 @@ const UsersPage: FC = ({ defaultNewPassword }) => { authMethodsQuery.isLoading || groupsByUserIdQuery.isLoading; - if (location.pathname === "/users") { - return ; - } - return ( <> diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 029d7fc4f12d7..81334b709d251 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -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 = ({ }) => { return ( <> - - - - Create user - - - ) - } + - Users - + + {canCreateUser && ( + + )} +
diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index b075c295d61fa..1f47dd10d3291 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -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 = { diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 08a8aa99b182d..44b2baf69e798 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -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); diff --git a/site/src/router.tsx b/site/src/router.tsx index bb95fc1eb393a..acaf417cecbcd 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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"), -); -const OrganizationGroupsPage = lazy( - () => import("./pages/ManagementSettingsPage/GroupsPage/GroupsPage"), + () => import("./pages/OrganizationSettingsPage/OrganizationSettingsPage"), ); -const CreateOrganizationGroupPage = lazy( - () => import("./pages/ManagementSettingsPage/GroupsPage/CreateGroupPage"), +const GroupsPageProvider = lazy( + () => import("./pages/GroupsPage/GroupsPageProvider"), ); -const OrganizationGroupPage = lazy( - () => import("./pages/ManagementSettingsPage/GroupsPage/GroupPage"), +const GroupsPage = lazy(() => import("./pages/GroupsPage/GroupsPage")); +const CreateGroupPage = lazy( + () => import("./pages/GroupsPage/CreateGroupPage"), ); -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 ( - } /> + }> + } /> - } /> - } /> - } - /> + } /> + } /> + } /> + ); }; @@ -405,23 +396,15 @@ export const router = createBrowserRouter( {templateRouter()} - - }> - } /> - - - } /> - - - - }> - } /> - + } + /> - } /> - } /> - } /> - + } + /> } /> @@ -433,7 +416,7 @@ export const router = createBrowserRouter( }> } /> - {organizationGroupsRouter()} + {groupsRouter()} } /> } /> @@ -488,18 +471,8 @@ export const router = createBrowserRouter( } /> } /> - - }> - } /> - - } /> - } /> - } - /> - + {groupsRouter()} }> diff --git a/site/vite.config.mts b/site/vite.config.mts index 9da0221016cb1..4deaac0dd5365 100644 --- a/site/vite.config.mts +++ b/site/vite.config.mts @@ -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: {