mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: manage groups from deployment settings for single-org deployments (#14016)
This commit is contained in:
@@ -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)),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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 />}>
|
||||
|
||||
Reference in New Issue
Block a user