feat: manage groups from deployment settings for single-org deployments (#14016)

This commit is contained in:
Kayla Washburn-Love
2024-07-26 13:10:13 -06:00
committed by GitHub
parent 68fa34feae
commit d68340b125
11 changed files with 137 additions and 125 deletions
+13 -7
View File
@@ -6,22 +6,28 @@ import type {
PatchGroupRequest,
} from "api/typesGenerated";
const GROUPS_QUERY_KEY = ["groups"];
type GroupSortOrder = "asc" | "desc";
const getGroupQueryKey = (organizationId: string, groupName: string) => [
const getGroupsQueryKey = (organizationId: string) => [
"organization",
organizationId,
"group",
groupName,
"groups",
];
export const groups = (organizationId: string) => {
return {
queryKey: GROUPS_QUERY_KEY,
queryKey: getGroupsQueryKey(organizationId),
queryFn: () => API.getGroups(organizationId),
} satisfies UseQueryOptions<Group[]>;
};
const getGroupQueryKey = (organizationId: string, groupName: string) => [
"organization",
organizationId,
"group",
groupName,
];
export const group = (organizationId: string, groupName: string) => {
return {
queryKey: getGroupQueryKey(organizationId, groupName),
@@ -97,7 +103,7 @@ export const createGroup = (
mutationFn: (request: CreateGroupRequest) =>
API.createGroup(organizationId, request),
onSuccess: async () => {
await queryClient.invalidateQueries(GROUPS_QUERY_KEY);
await queryClient.invalidateQueries(getGroupsQueryKey(organizationId));
},
};
};
@@ -146,7 +152,7 @@ export const invalidateGroup = (
groupId: string,
) =>
Promise.all([
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
queryClient.invalidateQueries(getGroupsQueryKey(organizationId)),
queryClient.invalidateQueries(getGroupQueryKey(organizationId, groupId)),
]);
+4 -1
View File
@@ -18,6 +18,9 @@ export const Navbar: FC = () => {
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog);
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
const canViewOrganizations =
featureVisibility.multiple_organizations &&
experiments.includes("multi-organization");
const canViewAllUsers = Boolean(permissions.readAllUsers);
const proxyContextValue = useProxy();
const canViewHealth = canViewDeployment;
@@ -30,7 +33,7 @@ export const Navbar: FC = () => {
supportLinks={appearance.support_links}
onSignOut={signOut}
canViewDeployment={canViewDeployment}
canViewOrganizations={experiments.includes("multi-organization")}
canViewOrganizations={canViewOrganizations}
canViewAllUsers={canViewAllUsers}
canViewHealth={canViewHealth}
canViewAuditLog={canViewAuditLog}
@@ -11,7 +11,7 @@ export const CreateGroupPage: FC = () => {
const navigate = useNavigate();
const { organization } = useParams() as { organization: string };
const createGroupMutation = useMutation(
createGroup(queryClient, organization),
createGroup(queryClient, organization ?? "default"),
);
return (
@@ -22,7 +22,11 @@ export const CreateGroupPage: FC = () => {
<CreateGroupPageView
onSubmit={async (data) => {
const newGroup = await createGroupMutation.mutateAsync(data);
navigate(`/organizations/${organization}/groups/${newGroup.name}`);
navigate(
organization
? `/organizations/${organization}/groups/${newGroup.name}`
: `/deployment/groups/${newGroup.name}`,
);
}}
error={createGroupMutation.error}
isLoading={createGroupMutation.isLoading}
@@ -50,7 +50,7 @@ import { isEveryoneGroup } from "utils/groups";
import { pageTitle } from "utils/page";
export const GroupPage: FC = () => {
const { organization, groupName } = useParams() as {
const { organization = "default", groupName } = useParams() as {
organization: string;
groupName: string;
};
@@ -11,7 +11,7 @@ import { pageTitle } from "utils/page";
import GroupSettingsPageView from "./GroupSettingsPageView";
export const GroupSettingsPage: FC = () => {
const { organization, groupName } = useParams() as {
const { organization = "default", groupName } = useParams() as {
organization: string;
groupName: string;
};
@@ -3,12 +3,19 @@ import Button from "@mui/material/Button";
import { type FC, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import {
Navigate,
Link as RouterLink,
useLocation,
useParams,
} from "react-router-dom";
import { getErrorMessage } from "api/errors";
import { groups } from "api/queries/groups";
import type { Organization } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { pageTitle } from "utils/page";
import { useOrganizationSettings } from "../ManagementSettingsLayout";
@@ -16,10 +23,16 @@ import GroupsPageView from "./GroupsPageView";
export const GroupsPage: FC = () => {
const { permissions } = useAuthenticated();
const { currentOrganizationId } = useOrganizationSettings();
const { createGroup: canCreateGroup } = permissions;
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
const groupsQuery = useQuery(groups(currentOrganizationId!));
const {
multiple_organizations: organizationsEnabled,
template_rbac: isTemplateRBACEnabled,
} = useFeatureVisibility();
const { experiments } = useDashboard();
const location = useLocation();
const { organization = "default" } = useParams() as { organization: string };
const groupsQuery = useQuery(groups(organization));
const { organizations } = useOrganizationSettings();
useEffect(() => {
if (groupsQuery.error) {
@@ -29,6 +42,16 @@ export const GroupsPage: FC = () => {
}
}, [groupsQuery.error]);
if (
organizationsEnabled &&
experiments.includes("multi-organization") &&
location.pathname === "/deployment/groups"
) {
const defaultName =
getOrganizationNameByDefault(organizations) ?? "default";
return <Navigate to={`/organizations/${defaultName}/groups`} replace />;
}
return (
<>
<Helmet>
@@ -63,3 +86,6 @@ export const GroupsPage: FC = () => {
};
export default GroupsPage;
export const getOrganizationNameByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.name;
@@ -1,6 +1,6 @@
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useLocation, useParams } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { deploymentConfig } from "api/queries/deployment";
import { organizations } from "api/queries/organizations";
import type { Organization } from "api/typesGenerated";
@@ -15,7 +15,6 @@ import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayou
import { Sidebar } from "./Sidebar";
type OrganizationSettingsContextValue = {
currentOrganizationId?: string;
organizations: Organization[];
};
@@ -34,19 +33,13 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => {
};
export const ManagementSettingsLayout: FC = () => {
const location = useLocation();
const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { organization } = useParams() as { organization: string };
const deploymentConfigQuery = useQuery(deploymentConfig());
const organizationsQuery = useQuery(organizations());
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
const inOrganizationSettings =
location.pathname.startsWith("/organizations") &&
location.pathname !== "/organizations/new";
if (!multiOrgExperimentEnabled) {
return <NotFoundPage />;
}
@@ -57,17 +50,7 @@ export const ManagementSettingsLayout: FC = () => {
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
{organizationsQuery.data ? (
<OrganizationSettingsContext.Provider
value={{
currentOrganizationId: !inOrganizationSettings
? undefined
: !organization
? getOrganizationIdByDefault(organizationsQuery.data)
: getOrganizationIdByName(
organizationsQuery.data,
organization,
),
organizations: organizationsQuery.data,
}}
value={{ organizations: organizationsQuery.data }}
>
<Sidebar />
<main css={{ width: "100%" }}>
@@ -94,9 +77,3 @@ export const ManagementSettingsLayout: FC = () => {
</RequirePermission>
);
};
const getOrganizationIdByName = (organizations: Organization[], name: string) =>
organizations.find((org) => org.name === name)?.id;
const getOrganizationIdByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.id;
@@ -1,18 +1,23 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
updateOrganization,
deleteOrganization,
} from "api/queries/organizations";
import type { Organization } from "api/typesGenerated";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
const OrganizationSettingsPage: FC = () => {
const navigate = useNavigate();
const { organization: organizationName } = useParams() as {
organization?: string;
};
const { organizations } = useOrganizationSettings();
const navigate = useNavigate();
const queryClient = useQueryClient();
const updateOrganizationMutation = useMutation(
updateOrganization(queryClient),
@@ -21,14 +26,14 @@ const OrganizationSettingsPage: FC = () => {
deleteOrganization(queryClient),
);
const { currentOrganizationId, organizations } = useOrganizationSettings();
const org = organizations.find((org) => org.id === currentOrganizationId);
const org = organizationName
? getOrganizationByName(organizations, organizationName)
: getOrganizationByDefault(organizations);
const error =
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
if (!currentOrganizationId || !org) {
if (!org) {
return <EmptyState message="Organization not found" />;
}
@@ -55,3 +60,9 @@ const OrganizationSettingsPage: FC = () => {
};
export default OrganizationSettingsPage;
const getOrganizationByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default);
const getOrganizationByName = (organizations: Organization[], name: string) =>
organizations.find((org) => org.name === name);
@@ -1,37 +0,0 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import {
createOrganization,
deleteOrganization,
} from "api/queries/organizations";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Margins } from "components/Margins/Margins";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
const OrganizationSettingsPage: FC = () => {
const queryClient = useQueryClient();
const addOrganizationMutation = useMutation(createOrganization(queryClient));
const deleteOrganizationMutation = useMutation(
deleteOrganization(queryClient),
);
const { currentOrganizationId, organizations } = useOrganizationSettings();
const org = organizations.find((org) => org.id === currentOrganizationId)!;
const error =
addOrganizationMutation.error ?? deleteOrganizationMutation.error;
return (
<Margins css={{ marginTop: 48, marginBottom: 48 }}>
{Boolean(error) && <ErrorAlert error={error} />}
<h1>Organization settings</h1>
<p>Name: {org.name}</p>
<p>Display name: {org.display_name}</p>
</Margins>
);
};
export default OrganizationSettingsPage;
@@ -3,44 +3,63 @@ import type { Interpolation, Theme } from "@emotion/react";
import AddIcon from "@mui/icons-material/Add";
import SettingsIcon from "@mui/icons-material/Settings";
import type { FC, ReactNode } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { Link, NavLink, useLocation, useParams } from "react-router-dom";
import type { Organization } from "api/typesGenerated";
import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
import { Stack } from "components/Stack/Stack";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { type ClassName, useClassName } from "hooks/useClassName";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { USERS_LINK } from "modules/navigation";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
export const Sidebar: FC = () => {
const { currentOrganizationId, organizations } = useOrganizationSettings();
const { organizations } = useOrganizationSettings();
const { organization = getOrganizationNameByDefault(organizations) } =
useParams() as { organization: string };
const { multiple_organizations: organizationsEnabled } =
useFeatureVisibility();
// TODO: Do something nice to scroll to the active org.
return (
<BaseSidebar>
<header css={styles.sidebarHeader}>Deployment</header>
<DeploymentSettingsNavigation />
<header css={styles.sidebarHeader}>Organizations</header>
<SidebarNavItem
active="auto"
href="/organizations/new"
icon={<AddIcon />}
>
New organization
</SidebarNavItem>
{organizations.map((organization) => (
<OrganizationSettingsNavigation
key={organization.id}
organization={organization}
active={organization.id === currentOrganizationId}
/>
))}
{organizationsEnabled && (
<header css={styles.sidebarHeader}>Deployment</header>
)}
<DeploymentSettingsNavigation
organizationsEnabled={organizationsEnabled}
/>
{organizationsEnabled && (
<>
<header css={styles.sidebarHeader}>Organizations</header>
<SidebarNavItem
active="auto"
href="/organizations/new"
icon={<AddIcon />}
>
New organization
</SidebarNavItem>
{organizations.map((org) => (
<OrganizationSettingsNavigation
key={org.id}
organization={org}
active={org.name === organization}
/>
))}
</>
)}
</BaseSidebar>
);
};
const DeploymentSettingsNavigation: FC = () => {
interface DeploymentSettingsNavigationProps {
organizationsEnabled?: boolean;
}
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
organizationsEnabled,
}) => {
const location = useLocation();
const active = location.pathname.startsWith("/deployment");
@@ -81,6 +100,9 @@ const DeploymentSettingsNavigation: FC = () => {
<SidebarNavSubItem href={USERS_LINK.slice(1)}>
Users
</SidebarNavSubItem>
{!organizationsEnabled && (
<SidebarNavSubItem href="groups">Groups</SidebarNavSubItem>
)}
</Stack>
)}
</div>
@@ -259,3 +281,6 @@ const classNames = {
font-weight: 600;
`,
} satisfies Record<string, ClassName>;
const getOrganizationNameByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.name;
+18 -21
View File
@@ -242,10 +242,6 @@ const OrganizationGroupSettingsPage = lazy(
const OrganizationMembersPage = lazy(
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
);
const OrganizationSettingsPlaceholder = lazy(
() =>
import("./pages/ManagementSettingsPage/OrganizationSettingsPlaceholder"),
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
);
@@ -275,6 +271,21 @@ const RoutesWithSuspense = () => {
);
};
const groupsRouter = () => {
return (
<Route path="groups">
<Route index element={<OrganizationGroupsPage />} />
<Route path="create" element={<CreateOrganizationGroupPage />} />
<Route path=":groupName" element={<OrganizationGroupPage />} />
<Route
path=":groupName/settings"
element={<OrganizationGroupSettingsPage />}
/>
</Route>
);
};
export const router = createBrowserRouter(
createRoutesFromChildren(
<Route element={<RoutesWithSuspense />}>
@@ -360,23 +371,8 @@ export const router = createBrowserRouter(
<Route path=":organization">
<Route index element={<OrganizationSettingsPage />} />
<Route path="members" element={<OrganizationMembersPage />} />
<Route path="groups">
<Route index element={<OrganizationGroupsPage />} />
<Route
path="create"
element={<CreateOrganizationGroupPage />}
/>
<Route path=":groupName" element={<OrganizationGroupPage />} />
<Route
path=":groupName/settings"
element={<OrganizationGroupSettingsPage />}
/>
</Route>
<Route
path="auditing"
element={<OrganizationSettingsPlaceholder />}
/>
{groupsRouter()}
<Route path="auditing" element={<></>} />
</Route>
</Route>
@@ -409,6 +405,7 @@ export const router = createBrowserRouter(
<Route path="workspace-proxies" element={<WorkspaceProxyPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="users/create" element={<CreateUserPage />} />
{groupsRouter()}
</Route>
<Route path="/settings" element={<UserSettingsLayout />}>