feat: add organization-scoped permission checks to deployment settings (#14063)

* s/readAllUsers/viewAllUsers

Other frontend variables use the `view` syntax.  Arguably we should 
use `read` to match the backend, but `view` does seem more UI-like.

* Check license for organizations

All the checks now require both the experiment and license.

I also renamed the variable canViewOrganizations everywhere for
consistency.

* Allow any auditor to view the audit log

* Use fine-grained permissions on settings page

Since in addition to deployment settings this page now also includes
users, audit logs, groups, and orgs.

Since you might not be able to fetch deployment values, move all the
loaders to the individual pages instead of in the wrapping layout.

* Add stories for organization members page

Needed to break it out into a separate view to do this.

* Add stories for multi-org sidebar

* Remove multi-org check from management settings layout

We only use this layout when multi-org is enabled, so no need to run the
check a second time.

* Add more stories for deployment dropdown
This commit is contained in:
Asher
2024-08-05 17:55:35 -08:00
committed by GitHub
parent 0ad5f6067d
commit 097f739492
29 changed files with 1252 additions and 647 deletions
+61
View File
@@ -120,3 +120,64 @@ export const provisionerDaemons = (organization: string) => {
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
};
};
/**
* Fetch permissions for a single organization.
*
* If the ID is undefined, return a disabled query.
*/
export const organizationPermissions = (organizationId: string | undefined) => {
if (!organizationId) {
return { enabled: false };
}
return {
queryKey: ["organization", organizationId, "permissions"],
queryFn: () =>
API.checkAuthorization({
checks: {
viewMembers: {
object: {
resource_type: "organization_member",
organization_id: organizationId,
},
action: "read",
},
editMembers: {
object: {
resource_type: "organization_member",
organization_id: organizationId,
},
action: "update",
},
createGroup: {
object: {
resource_type: "group",
organization_id: organizationId,
},
action: "create",
},
viewGroups: {
object: {
resource_type: "group",
organization_id: organizationId,
},
action: "read",
},
editOrganization: {
object: {
resource_type: "organization",
organization_id: organizationId,
},
action: "update",
},
auditOrganization: {
object: {
resource_type: "audit_log",
organization_id: organizationId,
},
action: "read",
},
},
}),
};
};
+46 -8
View File
@@ -1,21 +1,26 @@
export const checks = {
readAllUsers: "readAllUsers",
viewAllUsers: "viewAllUsers",
updateUsers: "updateUsers",
createUser: "createUser",
createTemplates: "createTemplates",
updateTemplates: "updateTemplates",
deleteTemplates: "deleteTemplates",
viewAuditLog: "viewAuditLog",
viewAnyAuditLog: "viewAnyAuditLog",
viewDeploymentValues: "viewDeploymentValues",
createGroup: "createGroup",
editDeploymentValues: "editDeploymentValues",
viewUpdateCheck: "viewUpdateCheck",
viewExternalAuthConfig: "viewExternalAuthConfig",
viewDeploymentStats: "viewDeploymentStats",
editWorkspaceProxies: "editWorkspaceProxies",
createOrganization: "createOrganization",
editAnyOrganization: "editAnyOrganization",
viewAnyGroup: "viewAnyGroup",
createGroup: "createGroup",
viewAllLicenses: "viewAllLicenses",
} as const;
export const permissionsToCheck = {
[checks.readAllUsers]: {
[checks.viewAllUsers]: {
object: {
resource_type: "user",
},
@@ -51,9 +56,10 @@ export const permissionsToCheck = {
},
action: "delete",
},
[checks.viewAuditLog]: {
[checks.viewAnyAuditLog]: {
object: {
resource_type: "audit_log",
any_org: true,
},
action: "read",
},
@@ -63,11 +69,11 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.createGroup]: {
[checks.editDeploymentValues]: {
object: {
resource_type: "group",
resource_type: "deployment_config",
},
action: "create",
action: "update",
},
[checks.viewUpdateCheck]: {
object: {
@@ -93,6 +99,38 @@ export const permissionsToCheck = {
},
action: "create",
},
[checks.createOrganization]: {
object: {
resource_type: "organization",
},
action: "create",
},
[checks.editAnyOrganization]: {
object: {
resource_type: "organization",
any_org: true,
},
action: "update",
},
[checks.viewAnyGroup]: {
object: {
resource_type: "group",
org_id: "any",
},
action: "read",
},
[checks.createGroup]: {
object: {
resource_type: "group",
},
action: "create",
},
[checks.viewAllLicenses]: {
object: {
resource_type: "license",
},
action: "read",
},
} as const;
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>;
+3 -2
View File
@@ -16,12 +16,13 @@ export const Navbar: FC = () => {
const { user: me, permissions, signOut } = useAuthenticated();
const featureVisibility = useFeatureVisibility();
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog);
featureVisibility.audit_log && Boolean(permissions.viewAnyAuditLog);
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
const canViewOrganizations =
Boolean(permissions.editAnyOrganization) &&
featureVisibility.multiple_organizations &&
experiments.includes("multi-organization");
const canViewAllUsers = Boolean(permissions.readAllUsers);
const canViewAllUsers = Boolean(permissions.viewAllUsers);
const proxyContextValue = useProxy();
const canViewHealth = canViewDeployment;
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent } from "@storybook/test";
import { chromaticWithTablet } from "testHelpers/chromatic";
import { MockUser, MockUser2 } from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
@@ -10,10 +11,11 @@ const meta: Meta<typeof NavbarView> = {
component: NavbarView,
args: {
user: MockUser,
canViewAllUsers: true,
canViewAuditLog: true,
canViewDeployment: true,
canViewAllUsers: true,
canViewHealth: true,
canViewOrganizations: true,
},
decorators: [withDashboardProvider],
};
@@ -21,15 +23,51 @@ const meta: Meta<typeof NavbarView> = {
export default meta;
type Story = StoryObj<typeof NavbarView>;
export const ForAdmin: Story = {};
export const ForAdmin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Deployment" }));
},
};
export const ForAuditor: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Deployment" }));
},
};
export const ForOrgAdmin: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: true,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Deployment" }));
},
};
export const ForMember: Story = {
args: {
user: MockUser2,
canViewAllUsers: false,
canViewAuditLog: false,
canViewDeployment: false,
canViewAllUsers: false,
canViewHealth: false,
canViewOrganizations: false,
},
};
+7 -6
View File
@@ -17,10 +17,9 @@ import {
import { AuditPageView } from "./AuditPageView";
const AuditPage: FC = () => {
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
const feats = useFeatureVisibility();
const { experiments } = useDashboard();
const location = useLocation();
const isMultiOrg = experiments.includes("multi-organization");
/**
* There is an implicit link between auditsQuery and filter via the
@@ -75,7 +74,9 @@ const AuditPage: FC = () => {
// TODO: Once multi-org is stable, we should place this redirect into the
// router directly, if we still need to maintain it (for users who are
// typing the old URL manually or have it bookmarked).
if (isMultiOrg && location.pathname !== "/deployment/audit") {
const canViewOrganizations =
feats.multiple_organizations && experiments.includes("multi-organization");
if (canViewOrganizations && location.pathname !== "/deployment/audit") {
return <Navigate to={`/deployment/audit${location.search}`} replace />;
}
@@ -88,10 +89,10 @@ const AuditPage: FC = () => {
<AuditPageView
auditLogs={auditsQuery.data?.audit_logs}
isNonInitialPage={isNonInitialPage(searchParams)}
isAuditLogVisible={isAuditLogVisible}
isAuditLogVisible={feats.audit_log}
auditsQuery={auditsQuery}
error={auditsQuery.error}
showOrgDetails={isMultiOrg}
showOrgDetails={canViewOrganizations}
filterProps={{
filter,
error: auditsQuery.error,
@@ -99,7 +100,7 @@ const AuditPage: FC = () => {
user: userMenu,
action: actionMenu,
resourceType: resourceTypeMenu,
organization: isMultiOrg ? organizationsMenu : undefined,
organization: canViewOrganizations ? organizationsMenu : undefined,
},
}}
/>
@@ -9,11 +9,12 @@ import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout";
import { Sidebar } from "./Sidebar";
type DeploySettingsContextValue = {
deploymentValues: DeploymentConfig;
deploymentValues: DeploymentConfig | undefined;
};
export const DeploySettingsContext = createContext<
@@ -33,9 +34,11 @@ export const useDeploySettings = (): DeploySettingsContextValue => {
export const DeploySettingsLayout: FC = () => {
const { experiments } = useDashboard();
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
const feats = useFeatureVisibility();
const canViewOrganizations =
feats.multiple_organizations && experiments.includes("multi-organization");
return multiOrgExperimentEnabled ? (
return canViewOrganizations ? (
<ManagementSettingsLayout />
) : (
<DeploySettingsLayoutInner />
@@ -52,19 +55,15 @@ const DeploySettingsLayoutInner: FC = () => {
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
<Sidebar />
<main css={{ maxWidth: 800, width: "100%" }}>
{deploymentConfigQuery.data ? (
<DeploySettingsContext.Provider
value={{
deploymentValues: deploymentConfigQuery.data,
}}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
) : (
<Loader />
)}
<DeploySettingsContext.Provider
value={{
deploymentValues: deploymentConfigQuery.data,
}}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
</main>
</Stack>
</Margins>
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Loader } from "components/Loader/Loader";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
import { ExternalAuthSettingsPageView } from "./ExternalAuthSettingsPageView";
@@ -13,7 +14,11 @@ const ExternalAuthSettingsPage: FC = () => {
<title>{pageTitle("External Authentication Settings")}</title>
</Helmet>
<ExternalAuthSettingsPageView config={deploymentValues.config} />
{deploymentValues ? (
<ExternalAuthSettingsPageView config={deploymentValues.config} />
) : (
<Loader />
)}
</>
);
};
@@ -4,6 +4,7 @@ import { useQuery } from "react-query";
import { deploymentDAUs } from "api/queries/deployment";
import { entitlements } from "api/queries/entitlements";
import { availableExperiments, experiments } from "api/queries/experiments";
import { Loader } from "components/Loader/Loader";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
@@ -29,14 +30,18 @@ const GeneralSettingsPage: FC = () => {
<Helmet>
<title>{pageTitle("General Settings")}</title>
</Helmet>
<GeneralSettingsPageView
deploymentOptions={deploymentValues.options}
deploymentDAUs={deploymentDAUsQuery.data}
deploymentDAUsError={deploymentDAUsQuery.error}
entitlements={entitlementsQuery.data}
invalidExperiments={invalidExperiments}
safeExperiments={safeExperiments}
/>
{deploymentValues ? (
<GeneralSettingsPageView
deploymentOptions={deploymentValues.options}
deploymentDAUs={deploymentDAUsQuery.data}
deploymentDAUsError={deploymentDAUsQuery.error}
entitlements={entitlementsQuery.data}
invalidExperiments={invalidExperiments}
safeExperiments={safeExperiments}
/>
) : (
<Loader />
)}
</>
);
};
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Loader } from "components/Loader/Loader";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
import { NetworkSettingsPageView } from "./NetworkSettingsPageView";
@@ -13,7 +14,11 @@ const NetworkSettingsPage: FC = () => {
<title>{pageTitle("Network Settings")}</title>
</Helmet>
<NetworkSettingsPageView options={deploymentValues.options} />
{deploymentValues ? (
<NetworkSettingsPageView options={deploymentValues.options} />
) : (
<Loader />
)}
</>
);
};
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
@@ -15,10 +16,14 @@ const ObservabilitySettingsPage: FC = () => {
<title>{pageTitle("Observability Settings")}</title>
</Helmet>
<ObservabilitySettingsPageView
options={deploymentValues.options}
featureAuditLogEnabled={entitlements.features["audit_log"].enabled}
/>
{deploymentValues ? (
<ObservabilitySettingsPageView
options={deploymentValues.options}
featureAuditLogEnabled={entitlements.features["audit_log"].enabled}
/>
) : (
<Loader />
)}
</>
);
};
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
@@ -15,12 +16,16 @@ const SecuritySettingsPage: FC = () => {
<title>{pageTitle("Security Settings")}</title>
</Helmet>
<SecuritySettingsPageView
options={deploymentValues.options}
featureBrowserOnlyEnabled={
entitlements.features["browser_only"].enabled
}
/>
{deploymentValues ? (
<SecuritySettingsPageView
options={deploymentValues.options}
featureBrowserOnlyEnabled={
entitlements.features["browser_only"].enabled
}
/>
) : (
<Loader />
)}
</>
);
};
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Loader } from "components/Loader/Loader";
import { pageTitle } from "utils/page";
import { useDeploySettings } from "../DeploySettingsLayout";
import { UserAuthSettingsPageView } from "./UserAuthSettingsPageView";
@@ -13,7 +14,11 @@ const UserAuthSettingsPage: FC = () => {
<title>{pageTitle("User Authentication Settings")}</title>
</Helmet>
<UserAuthSettingsPageView options={deploymentValues.options} />
{deploymentValues ? (
<UserAuthSettingsPageView options={deploymentValues.options} />
) : (
<Loader />
)}
</>
);
};
+2 -3
View File
@@ -11,14 +11,13 @@ import GroupsPageView from "./GroupsPageView";
export const GroupsPage: FC = () => {
const { permissions } = useAuthenticated();
const { createGroup: canCreateGroup } = permissions;
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
const groupsQuery = useQuery(groups("default"));
useEffect(() => {
if (groupsQuery.error) {
displayError(
getErrorMessage(groupsQuery.error, "Error on loading groups."),
getErrorMessage(groupsQuery.error, "Unable to load groups."),
);
}
}, [groupsQuery.error]);
@@ -31,7 +30,7 @@ export const GroupsPage: FC = () => {
<GroupsPageView
groups={groupsQuery.data}
canCreateGroup={canCreateGroup}
canCreateGroup={permissions.createGroup}
isTemplateRBACEnabled={isTemplateRBACEnabled}
/>
</>
@@ -3,53 +3,68 @@ import Button from "@mui/material/Button";
import { type FC, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import {
Navigate,
Link as RouterLink,
useLocation,
useParams,
} from "react-router-dom";
import { Navigate, Link as RouterLink, useParams } from "react-router-dom";
import { getErrorMessage } from "api/errors";
import { groups } 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 { 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";
import GroupsPageView from "./GroupsPageView";
export const GroupsPage: FC = () => {
const { permissions } = useAuthenticated();
const { createGroup: canCreateGroup } = permissions;
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 feats = useFeatureVisibility();
const { organization: organizationName } = useParams() as {
organization?: string;
};
const groupsQuery = useQuery(
organizationName ? groups(organizationName) : { enabled: false },
);
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, "Error on loading groups."),
getErrorMessage(groupsQuery.error, "Unable to load groups."),
);
}
}, [groupsQuery.error]);
if (
organizationsEnabled &&
experiments.includes("multi-organization") &&
location.pathname === "/deployment/groups"
) {
const defaultName =
getOrganizationNameByDefault(organizations) ?? "default";
return <Navigate to={`/organizations/${defaultName}/groups`} replace />;
useEffect(() => {
if (permissionsQuery.error) {
displayError(
getErrorMessage(permissionsQuery.error, "Unable to load permissions."),
);
}
}, [permissionsQuery.error]);
if (!organizations) {
return <Loader />;
}
if (!organizationName) {
const defaultName = getOrganizationNameByDefault(organizations);
if (defaultName) {
return <Navigate to={`/organizations/${defaultName}/groups`} replace />;
}
// We expect there to always be a default organization.
throw new Error("No default organization found");
}
if (!organization) {
return <EmptyState message="Organization not found" />;
}
const permissions = permissionsQuery.data;
if (!permissions) {
return <Loader />;
}
return (
@@ -61,7 +76,7 @@ export const GroupsPage: FC = () => {
<PageHeader
actions={
<>
{canCreateGroup && isTemplateRBACEnabled && (
{permissions.createGroup && feats.template_rbac && (
<Button
component={RouterLink}
startIcon={<GroupAdd />}
@@ -78,8 +93,8 @@ export const GroupsPage: FC = () => {
<GroupsPageView
groups={groupsQuery.data}
canCreateGroup={canCreateGroup}
isTemplateRBACEnabled={isTemplateRBACEnabled}
canCreateGroup={permissions.createGroup}
isTemplateRBACEnabled={feats.template_rbac}
/>
</>
);
@@ -9,45 +9,57 @@ import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import NotFoundPage from "pages/404Page/404Page";
import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayout";
import { Sidebar } from "./Sidebar";
type OrganizationSettingsValue = { organizations: Organization[] };
type OrganizationSettingsValue = {
organizations: Organization[] | undefined;
};
export const useOrganizationSettings = (): OrganizationSettingsValue => {
const { organizations } = useDashboard();
return { organizations };
};
/**
* A multi-org capable settings page layout.
*
* If multi-org is not enabled or licensed, this is the wrong layout to use.
* See DeploySettingsLayoutInner instead.
*/
export const ManagementSettingsLayout: FC = () => {
const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const deploymentConfigQuery = useQuery(deploymentConfig());
const deploymentConfigQuery = useQuery(
// TODO: This is probably normally fine because we will not show links to
// pages that need this data, but if you manually visit the page you
// will see an endless loader when maybe we should show a "permission
// denied" error or at least a 404 instead.
permissions.viewDeploymentValues ? deploymentConfig() : { enabled: false },
);
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
if (!multiOrgExperimentEnabled) {
return <NotFoundPage />;
}
// The deployment settings page also contains users, audit logs, groups and
// organizations, so this page must be visible if you can see any of these.
const canViewDeploymentSettingsPage =
permissions.viewDeploymentValues ||
permissions.viewAllUsers ||
permissions.editAnyOrganization ||
permissions.viewAnyAuditLog;
return (
<RequirePermission isFeatureVisible={permissions.viewDeploymentValues}>
<RequirePermission isFeatureVisible={canViewDeploymentSettingsPage}>
<Margins>
<Stack css={{ padding: "48px 0" }} direction="row" spacing={6}>
<Sidebar />
<main css={{ width: "100%" }}>
{deploymentConfigQuery.data ? (
<DeploySettingsContext.Provider
value={{ deploymentValues: deploymentConfigQuery.data }}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
) : (
<Loader />
)}
<DeploySettingsContext.Provider
value={{
deploymentValues: deploymentConfigQuery.data,
}}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
</main>
</Stack>
</Margins>
@@ -2,9 +2,14 @@ import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import type { SlimRole } from "api/typesGenerated";
import { MockUser, MockOrganizationAuditorRole } from "testHelpers/entities";
import {
renderWithTemplateSettingsLayout,
MockEntitlementsWithMultiOrg,
MockUser,
MockOrganization,
MockOrganizationAuditorRole,
} from "testHelpers/entities";
import {
renderWithManagementSettingsLayout,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
@@ -12,17 +17,27 @@ import OrganizationMembersPage from "./OrganizationMembersPage";
jest.spyOn(console, "error").mockImplementation(() => {});
beforeAll(() => {
beforeEach(() => {
server.use(
http.get("/api/v2/experiments", () => {
return HttpResponse.json(["multi-organization"]);
}),
http.get("/api/v2/entitlements", () => {
return HttpResponse.json(MockEntitlementsWithMultiOrg);
}),
http.post("/api/v2/authcheck", async () => {
return HttpResponse.json({
editMembers: true,
viewMembers: true,
viewDeploymentValues: true,
});
}),
);
});
const renderPage = async () => {
renderWithTemplateSettingsLayout(<OrganizationMembersPage />, {
route: `/organizations/my-organization/members`,
renderWithManagementSettingsLayout(<OrganizationMembersPage />, {
route: `/organizations/${MockOrganization.name}/members`,
path: `/organizations/:organization/members`,
});
await waitForLoaderToBeRemoved();
@@ -69,7 +84,7 @@ describe("OrganizationMembersPage", () => {
it("shows a success message", async () => {
await renderPage();
await removeMember();
await screen.findByText("Member removed.");
await screen.findByText("Member removed successfully.");
});
});
});
@@ -1,234 +1,82 @@
import type { Interpolation, Theme } from "@emotion/react";
import PersonAdd from "@mui/icons-material/PersonAdd";
import LoadingButton from "@mui/lab/LoadingButton";
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 FC, useState } from "react";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router-dom";
import { getErrorMessage } from "api/errors";
import {
addOrganizationMember,
organizationMembers,
organizationPermissions,
removeOrganizationMember,
updateOrganizationMemberRoles,
} from "api/queries/organizations";
import { organizationRoles } from "api/queries/roles";
import type { User } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { AvatarData } from "components/AvatarData/AvatarData";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import {
MoreMenu,
MoreMenuTrigger,
MoreMenuContent,
MoreMenuItem,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import type { OrganizationMemberWithUserData, User } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip";
import { UserRoleCell } from "./UserTable/UserRoleCell";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
import { OrganizationMembersPageView } from "./OrganizationMembersPageView";
const OrganizationMembersPage: FC = () => {
const queryClient = useQueryClient();
const { organization } = useParams() as { organization: string };
const { organization: organizationName } = useParams() as {
organization: string;
};
const { user: me } = useAuthenticated();
const membersQuery = useQuery(organizationMembers(organization));
const organizationRolesQuery = useQuery(organizationRoles(organization));
const membersQuery = useQuery(organizationMembers(organizationName));
const organizationRolesQuery = useQuery(organizationRoles(organizationName));
const addMemberMutation = useMutation(
addOrganizationMember(queryClient, organization),
addOrganizationMember(queryClient, organizationName),
);
const removeMemberMutation = useMutation(
removeOrganizationMember(queryClient, organization),
removeOrganizationMember(queryClient, organizationName),
);
const updateMemberRolesMutation = useMutation(
updateOrganizationMemberRoles(queryClient, organization),
updateOrganizationMemberRoles(queryClient, organizationName),
);
const error =
membersQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error;
const members = membersQuery.data;
const { organizations } = useOrganizationSettings();
const organization = organizations?.find((o) => o.name === organizationName);
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
const permissions = permissionsQuery.data;
if (!permissions) {
return <Loader />;
}
return (
<div>
<PageHeader>
<PageHeaderTitle>Organization members</PageHeaderTitle>
</PageHeader>
<Stack>
{Boolean(error) && <ErrorAlert error={error} />}
<AddOrganizationMember
isLoading={addMemberMutation.isLoading}
onSubmit={async (user) => {
await addMemberMutation.mutateAsync(user.id);
void membersQuery.refetch();
}}
/>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">User</TableCell>
<TableCell width="49%">
<Stack direction="row" spacing={1} alignItems="center">
<span>Roles</span>
<TableColumnHelpTooltip variant="roles" />
</Stack>
</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{members?.map((member) => (
<TableRow key={member.user_id}>
<TableCell>
<AvatarData
avatar={
<UserAvatar
username={member.username}
avatarURL={member.avatar_url}
/>
}
title={member.name || member.username}
subtitle={member.email}
/>
</TableCell>
<UserRoleCell
inheritedRoles={member.global_roles}
roles={member.roles}
allAvailableRoles={organizationRolesQuery.data}
oidcRoleSyncEnabled={false}
isLoading={updateMemberRolesMutation.isLoading}
canEditUsers
onEditRoles={async (newRoleNames) => {
try {
await updateMemberRolesMutation.mutateAsync({
userId: member.user_id,
roles: newRoleNames,
});
displaySuccess("Roles updated successfully.");
} catch (e) {
displayError(
getErrorMessage(e, "Failed to update roles."),
);
}
}}
/>
<TableCell>
{member.user_id !== me.id && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={async () => {
try {
await removeMemberMutation.mutateAsync(
member.user_id,
);
void membersQuery.refetch();
displaySuccess("Member removed.");
} catch (e) {
displayError(
getErrorMessage(
e,
"Failed to remove member.",
),
);
}
}}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</div>
<OrganizationMembersPageView
allAvailableRoles={organizationRolesQuery.data}
canEditMembers={permissions.editMembers}
error={
membersQuery.error ??
addMemberMutation.error ??
removeMemberMutation.error ??
updateMemberRolesMutation.error
}
isAddingMember={addMemberMutation.isLoading}
isUpdatingMemberRoles={updateMemberRolesMutation.isLoading}
me={me}
members={membersQuery.data}
addMember={async (user: User) => {
await addMemberMutation.mutateAsync(user.id);
void membersQuery.refetch();
}}
removeMember={async (member: OrganizationMemberWithUserData) => {
await removeMemberMutation.mutateAsync(member.user_id);
void membersQuery.refetch();
}}
updateMemberRoles={async (
member: OrganizationMemberWithUserData,
newRoles: string[],
) => {
await updateMemberRolesMutation.mutateAsync({
userId: member.user_id,
roles: newRoles,
});
}}
/>
);
};
export default OrganizationMembersPage;
interface AddOrganizationMemberProps {
isLoading: boolean;
onSubmit: (user: User) => Promise<void>;
}
const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
isLoading,
onSubmit,
}) => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
if (selectedUser) {
try {
await onSubmit(selectedUser);
setSelectedUser(null);
} catch (error) {
displayError(getErrorMessage(error, "Failed to add member."));
}
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocomplete
css={styles.autoComplete}
value={selectedUser}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<LoadingButton
loadingPosition="start"
disabled={!selectedUser}
type="submit"
startIcon={<PersonAdd />}
loading={isLoading}
>
Add user
</LoadingButton>
</Stack>
</form>
);
};
const styles = {
role: (theme) => ({
backgroundColor: theme.roles.info.background,
borderColor: theme.roles.info.outline,
}),
globalRole: (theme) => ({
backgroundColor: theme.roles.inactive.background,
borderColor: theme.roles.inactive.outline,
}),
autoComplete: {
width: 300,
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockUser,
MockOrganizationMember,
MockOrganizationMember2,
} from "testHelpers/entities";
import { OrganizationMembersPageView } from "./OrganizationMembersPageView";
const meta: Meta<typeof OrganizationMembersPageView> = {
title: "pages/OrganizationMembersPageView",
component: OrganizationMembersPageView,
args: {
canEditMembers: true,
error: undefined,
isAddingMember: false,
isUpdatingMemberRoles: false,
me: MockUser,
members: [MockOrganizationMember, MockOrganizationMember2],
addMember: () => Promise.resolve(),
removeMember: () => Promise.resolve(),
updateMemberRoles: () => Promise.resolve(),
},
};
export default meta;
type Story = StoryObj<typeof OrganizationMembersPageView>;
export const Default: Story = {};
export const NoMembers: Story = {
args: {
members: [],
},
};
export const Error: Story = {
args: {
error: "Something went wrong",
},
};
export const NoEdit: Story = {
args: {
canEditMembers: false,
},
};
export const AddingMember: Story = {
args: {
isAddingMember: true,
},
};
export const UpdatingMember: Story = {
args: {
isUpdatingMemberRoles: true,
},
};
@@ -0,0 +1,216 @@
import type { Interpolation, Theme } from "@emotion/react";
import PersonAdd from "@mui/icons-material/PersonAdd";
import LoadingButton from "@mui/lab/LoadingButton";
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 FC, useState } from "react";
import { getErrorMessage } from "api/errors";
import type {
User,
OrganizationMemberWithUserData,
SlimRole,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { AvatarData } from "components/AvatarData/AvatarData";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import {
MoreMenu,
MoreMenuTrigger,
MoreMenuContent,
MoreMenuItem,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip";
import { UserRoleCell } from "./UserTable/UserRoleCell";
interface OrganizationMembersPageViewProps {
allAvailableRoles: readonly SlimRole[] | undefined;
canEditMembers: boolean;
error: unknown;
isAddingMember: boolean;
isUpdatingMemberRoles: boolean;
me: User;
members: OrganizationMemberWithUserData[] | undefined;
addMember: (user: User) => Promise<void>;
removeMember: (member: OrganizationMemberWithUserData) => Promise<void>;
updateMemberRoles: (
member: OrganizationMemberWithUserData,
newRoles: string[],
) => Promise<void>;
}
export const OrganizationMembersPageView: FC<
OrganizationMembersPageViewProps
> = (props) => {
return (
<div>
<PageHeader>
<PageHeaderTitle>Organization members</PageHeaderTitle>
</PageHeader>
<Stack>
{Boolean(props.error) && <ErrorAlert error={props.error} />}
{props.canEditMembers && (
<AddOrganizationMember
isLoading={props.isAddingMember}
onSubmit={props.addMember}
/>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">User</TableCell>
<TableCell width="49%">
<Stack direction="row" spacing={1} alignItems="center">
<span>Roles</span>
<TableColumnHelpTooltip variant="roles" />
</Stack>
</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.members?.map((member) => (
<TableRow key={member.user_id}>
<TableCell>
<AvatarData
avatar={
<UserAvatar
username={member.username}
avatarURL={member.avatar_url}
/>
}
title={member.name || member.username}
subtitle={member.email}
/>
</TableCell>
<UserRoleCell
inheritedRoles={member.global_roles}
roles={member.roles}
allAvailableRoles={props.allAvailableRoles}
oidcRoleSyncEnabled={false}
isLoading={props.isUpdatingMemberRoles}
canEditUsers={props.canEditMembers}
onEditRoles={async (roles) => {
try {
await props.updateMemberRoles(member, roles);
displaySuccess("Roles updated successfully.");
} catch (error) {
displayError(
getErrorMessage(error, "Failed to update roles."),
);
}
}}
/>
<TableCell>
{member.user_id !== props.me.id && props.canEditMembers && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
onClick={async () => {
try {
await props.removeMember(member);
displaySuccess("Member removed successfully.");
} catch (error) {
displayError(
getErrorMessage(
error,
"Failed to remove member.",
),
);
}
}}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</div>
);
};
interface AddOrganizationMemberProps {
isLoading: boolean;
onSubmit: (user: User) => Promise<void>;
}
const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
isLoading,
onSubmit,
}) => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
return (
<form
onSubmit={async (event) => {
event.preventDefault();
if (selectedUser) {
try {
await onSubmit(selectedUser);
setSelectedUser(null);
} catch (error) {
displayError(getErrorMessage(error, "Failed to add member."));
}
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocomplete
css={styles.autoComplete}
value={selectedUser}
onChange={(newValue) => {
setSelectedUser(newValue);
}}
/>
<LoadingButton
loadingPosition="start"
disabled={!selectedUser}
type="submit"
startIcon={<PersonAdd />}
loading={isLoading}
>
Add user
</LoadingButton>
</Stack>
</form>
);
};
const styles = {
role: (theme) => ({
backgroundColor: theme.roles.info.background,
borderColor: theme.roles.info.outline,
}),
globalRole: (theme) => ({
backgroundColor: theme.roles.inactive.background,
borderColor: theme.roles.inactive.outline,
}),
autoComplete: {
width: 300,
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,13 +1,15 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import {
updateOrganization,
deleteOrganization,
organizationPermissions,
} from "api/queries/organizations";
import type { Organization } from "api/typesGenerated";
import { EmptyState } from "components/EmptyState/EmptyState";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
@@ -26,32 +28,54 @@ const OrganizationSettingsPage: FC = () => {
deleteOrganization(queryClient),
);
const org = organizationName
? getOrganizationByName(organizations, organizationName)
: getOrganizationByDefault(organizations);
const organization =
organizations && organizationName
? getOrganizationByName(organizations, organizationName)
: undefined;
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
if (!organizations) {
return <Loader />;
}
// Redirect /organizations => /organizations/default-org
if (!organizationName) {
const defaultOrg = getOrganizationByDefault(organizations);
if (defaultOrg) {
return <Navigate to={`/organizations/${defaultOrg.name}`} replace />;
}
// We expect there to always be a default organization.
throw new Error("No default organization found");
}
if (!organization) {
return <EmptyState message="Organization not found" />;
}
const permissions = permissionsQuery.data;
if (!permissions) {
return <Loader />;
}
const error =
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
if (!org) {
return <EmptyState message="Organization not found" />;
}
return (
<OrganizationSettingsPageView
organization={org}
canEdit={permissions.editOrganization}
organization={organization}
error={error}
onSubmit={async (values) => {
const updatedOrganization =
await updateOrganizationMutation.mutateAsync({
organizationId: org.id,
organizationId: organization.id,
req: values,
});
navigate(`/organizations/${updatedOrganization.name}`);
displaySuccess("Organization settings updated.");
}}
onDeleteOrganization={() => {
deleteOrganizationMutation.mutate(org.id);
deleteOrganizationMutation.mutate(organization.id);
displaySuccess("Organization deleted.");
navigate("/organizations");
}}
@@ -10,6 +10,7 @@ const meta: Meta<typeof OrganizationSettingsPageView> = {
component: OrganizationSettingsPageView,
args: {
organization: MockOrganization,
canEdit: true,
},
};
@@ -23,3 +24,9 @@ export const DefaultOrg: Story = {
organization: MockDefaultOrganization,
},
};
export const CannotEdit: Story = {
args: {
canEdit: false,
},
};
@@ -44,11 +44,12 @@ interface OrganizationSettingsPageViewProps {
error: unknown;
onSubmit: (values: UpdateOrganizationRequest) => Promise<void>;
onDeleteOrganization: () => void;
canEdit: boolean;
}
export const OrganizationSettingsPageView: FC<
OrganizationSettingsPageViewProps
> = ({ organization, error, onSubmit, onDeleteOrganization }) => {
> = ({ organization, error, onSubmit, onDeleteOrganization, canEdit }) => {
const form = useFormik<UpdateOrganizationRequest>({
initialValues: {
name: organization.name,
@@ -82,10 +83,10 @@ export const OrganizationSettingsPageView: FC<
>
<FormSection
title="Info"
description="Change the name or description of the organization."
description="The name and description of the organization."
>
<fieldset
disabled={form.isSubmitting}
disabled={form.isSubmitting || !canEdit}
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
>
<FormFields>
@@ -117,10 +118,10 @@ export const OrganizationSettingsPageView: FC<
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
{canEdit && <FormFooter isLoading={form.isSubmitting} />}
</HorizontalForm>
{!organization.is_default && (
{canEdit && !organization.is_default && (
<HorizontalContainer css={{ marginTop: 48 }}>
<HorizontalSection
title="Settings"
+32 -290
View File
@@ -1,299 +1,41 @@
import { cx } from "@emotion/css";
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, 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 { linkToAuditing, linkToUsers, withFilter } from "modules/navigation";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { organizationPermissions } from "api/queries/organizations";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useOrganizationSettings } from "./ManagementSettingsLayout";
import { SidebarView } from "./SidebarView";
/**
* A combined deployment settings and organization menu.
*
* This should only be used with multi-org support. If multi-org support is
* disabled or not licensed, this is the wrong sidebar to use. See
* DeploySettingsPage/Sidebar instead.
*/
export const Sidebar: FC = () => {
const { permissions } = useAuthenticated();
const { organizations } = useOrganizationSettings();
const { organization } = useParams() as { organization?: string };
const { multiple_organizations: organizationsEnabled } =
useFeatureVisibility();
const { organization: organizationName } = useParams() as {
organization?: string;
};
let organizationName = organization;
if (location.pathname === "/organizations") {
organizationName = getOrganizationNameByDefault(organizations);
}
// TODO: Do something nice to scroll to the active org.
// If there is no organization name, the settings page will load, and it will
// redirect to the default organization, so eventually there will always be an
// organization name.
const activeOrganization = organizations?.find(
(o) => o.name === organizationName,
);
const activeOrgPermissionsQuery = useQuery(
organizationPermissions(activeOrganization?.id),
);
return (
<BaseSidebar>
{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 === organizationName}
/>
))}
</>
)}
</BaseSidebar>
<SidebarView
activeOrganization={activeOrganization}
activeOrgPermissions={activeOrgPermissionsQuery.data}
organizations={organizations}
permissions={permissions}
/>
);
};
interface DeploymentSettingsNavigationProps {
organizationsEnabled?: boolean;
}
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
organizationsEnabled,
}) => {
const location = useLocation();
const active = location.pathname.startsWith("/deployment");
return (
<div css={{ paddingBottom: 12 }}>
<SidebarNavItem
active={active}
href="/deployment/general"
// 24px matches the width of the organization icons, and the component is smart enough
// to keep the icon itself square. It looks too big if it's 24x24.
icon={<SettingsIcon css={{ width: 24, height: 20 }} />}
>
Deployment
</SidebarNavItem>
{active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
<SidebarNavSubItem href="general">General</SidebarNavSubItem>
<SidebarNavSubItem href="licenses">Licenses</SidebarNavSubItem>
<SidebarNavSubItem href="appearance">Appearance</SidebarNavSubItem>
<SidebarNavSubItem href="userauth">
User Authentication
</SidebarNavSubItem>
<SidebarNavSubItem href="external-auth">
External Authentication
</SidebarNavSubItem>
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavSubItem href="oauth2-provider/ap>
OAuth2 Applications
</SidebarNavSubItem>*/}
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
<SidebarNavSubItem href="workspace-proxies">
Workspace Proxies
</SidebarNavSubItem>
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
<SidebarNavSubItem href="observability">
Observability
</SidebarNavSubItem>
<SidebarNavSubItem href={linkToUsers.slice(1)}>
Users
</SidebarNavSubItem>
{!organizationsEnabled && (
<SidebarNavSubItem href="groups">Groups</SidebarNavSubItem>
)}
<SidebarNavSubItem href={linkToAuditing.slice(1)}>
Auditing
</SidebarNavSubItem>
</Stack>
)}
</div>
);
};
function urlForSubpage(organizationName: string, subpage: string = ""): string {
return `/organizations/${organizationName}/${subpage}`;
}
interface OrganizationSettingsNavigationProps {
organization: Organization;
active: boolean;
}
export const OrganizationSettingsNavigation: FC<
OrganizationSettingsNavigationProps
> = ({ organization, active }) => {
return (
<>
<SidebarNavItem
active={active}
href={urlForSubpage(organization.name)}
icon={
<UserAvatar
key={organization.id}
size="sm"
username={organization.display_name}
avatarURL={organization.icon}
/>
}
>
{organization.display_name}
</SidebarNavItem>
{active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
<SidebarNavSubItem end href={urlForSubpage(organization.name)}>
Organization settings
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "members")}>
Members
</SidebarNavSubItem>
<SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}>
Groups
</SidebarNavSubItem>
{/* For now redirect to the site-wide audit page with the organization
pre-filled into the filter. Based on user feedback we might want
to serve a copy of the audit page or even delete this link. */}
<SidebarNavSubItem
href={`/deployment${withFilter(
linkToAuditing,
`organization:${organization.name}`,
)}`}
>
Auditing
</SidebarNavSubItem>
</Stack>
)}
</>
);
};
interface SidebarNavItemProps {
active?: boolean | "auto";
children?: ReactNode;
icon?: ReactNode;
href: string;
}
export const SidebarNavItem: FC<SidebarNavItemProps> = ({
active,
children,
href,
icon,
}) => {
const link = useClassName(classNames.link, []);
const activeLink = useClassName(classNames.activeLink, []);
const content = (
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
);
if (active === "auto") {
return (
<NavLink
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{content}
</NavLink>
);
}
return (
<Link to={href} className={cx([link, active && activeLink])}>
{content}
</Link>
);
};
interface SidebarNavSubItemProps {
children?: ReactNode;
href: string;
end?: boolean;
}
export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
children,
href,
end,
}) => {
const link = useClassName(classNames.subLink, []);
const activeLink = useClassName(classNames.activeSubLink, []);
return (
<NavLink
end={end}
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{children}
</NavLink>
);
};
const styles = {
sidebarHeader: {
textTransform: "uppercase",
letterSpacing: "0.15em",
fontSize: 11,
fontWeight: 500,
paddingBottom: 4,
},
} satisfies Record<string, Interpolation<Theme>>;
const classNames = {
link: (css, theme) => css`
color: inherit;
display: block;
font-size: 14px;
text-decoration: none;
padding: 10px 12px 10px 16px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
border-left: 3px solid transparent;
`,
activeLink: (css, theme) => css`
border-left-color: ${theme.palette.primary.main};
border-top-left-radius: 0;
border-bottom-left-radius: 0;
`,
subLink: (css, theme) => css`
color: inherit;
text-decoration: none;
display: block;
font-size: 13px;
margin-left: 44px;
padding: 4px 12px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
margin-bottom: 1px;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
`,
activeSubLink: (css) => css`
font-weight: 600;
`,
} satisfies Record<string, ClassName>;
const getOrganizationNameByDefault = (organizations: Organization[]) =>
organizations.find((org) => org.is_default)?.name;
@@ -0,0 +1,117 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockOrganization,
MockOrganization2,
MockPermissions,
} from "testHelpers/entities";
import { SidebarView } from "./SidebarView";
const meta: Meta<typeof SidebarView> = {
title: "components/MultiOrgSidebarView",
component: SidebarView,
args: {
activeOrganization: undefined,
activeOrgPermissions: undefined,
organizations: [MockOrganization, MockOrganization2],
permissions: MockPermissions,
},
};
export default meta;
type Story = StoryObj<typeof SidebarView>;
export const Default: Story = {};
export const NoCreateOrg: Story = {
args: {
permissions: {
...MockPermissions,
createOrganization: false,
},
},
};
export const NoViewUsers: Story = {
args: {
permissions: {
...MockPermissions,
viewAllUsers: false,
},
},
};
export const NoAuditLog: Story = {
args: {
permissions: {
...MockPermissions,
viewAnyAuditLog: false,
},
},
};
export const NoLicenses: Story = {
args: {
permissions: {
...MockPermissions,
viewAllLicenses: false,
},
},
};
export const NoDeploymentValues: Story = {
args: {
permissions: {
...MockPermissions,
viewDeploymentValues: false,
editDeploymentValues: false,
},
},
};
export const NoPermissions: Story = {
args: {
permissions: {},
},
};
export const SelectedOrgLoading: Story = {
args: {
activeOrganization: MockOrganization,
},
};
export const SelectedOrgAdmin: Story = {
args: {
activeOrganization: MockOrganization,
activeOrgPermissions: {
editOrganization: true,
viewMembers: true,
viewGroups: true,
auditOrganization: true,
},
},
};
export const SelectedOrgAuditor: Story = {
args: {
activeOrganization: MockOrganization,
activeOrgPermissions: {
editOrganization: false,
viewMembers: false,
viewGroups: false,
auditOrganization: true,
},
},
};
export const SelectedOrgNoPerms: Story = {
args: {
activeOrganization: MockOrganization,
activeOrgPermissions: {
editOrganization: false,
viewMembers: false,
viewGroups: false,
auditOrganization: false,
},
},
};
@@ -0,0 +1,368 @@
import { cx } from "@emotion/css";
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 } from "react-router-dom";
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
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 { linkToAuditing, linkToUsers, withFilter } from "modules/navigation";
interface SidebarProps {
/**
* The active org if an org is being viewed. If there is no active
* organization, assume one of the deployment settings pages are being viewed.
*/
activeOrganization: Organization | undefined;
/**
* The permissions for the active org or undefined if still fetching (or if
* there is no active org).
*/
activeOrgPermissions: AuthorizationResponse | undefined;
/** The list of organizations or undefined if still fetching. */
organizations: Organization[] | undefined;
/** Site-wide permissions. */
permissions: AuthorizationResponse;
}
/**
* A combined deployment settings and organization menu.
*/
export const SidebarView: FC<SidebarProps> = (props) => {
// TODO: Do something nice to scroll to the active org.
return (
<BaseSidebar>
<header css={styles.sidebarHeader}>Deployment</header>
<DeploymentSettingsNavigation
active={!props.activeOrganization}
permissions={props.permissions}
/>
{props.organizations ? (
<>
<header css={styles.sidebarHeader}>Organizations</header>
{props.permissions.createOrganization && (
<SidebarNavItem
active="auto"
href="/organizations/new"
icon={<AddIcon />}
>
New organization
</SidebarNavItem>
)}
{props.organizations.map((org) => {
const orgActive =
Boolean(props.activeOrganization) &&
org.name === props.activeOrganization?.name;
return (
<OrganizationSettingsNavigation
key={org.id}
organization={org}
permissions={orgActive ? props.activeOrgPermissions : undefined}
active={orgActive}
/>
);
})}
</>
) : (
<Loader />
)}
</BaseSidebar>
);
};
interface DeploymentSettingsNavigationProps {
/** Whether a deployment setting page is being viewed. */
active: boolean;
/** Site-wide permissions. */
permissions: AuthorizationResponse;
}
/**
* Displays navigation for deployment settings. If active, highlight the main
* menu heading.
*
* Menu items are shown based on the permissions. If organizations can be
* viewed, groups are skipped since they will show under each org instead.
*/
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = (
props,
) => {
return (
<div css={{ paddingBottom: 12 }}>
<SidebarNavItem
active={props.active}
href={
props.permissions.viewDeploymentValues
? "/deployment/general"
: "/deployment/workspace-proxies"
}
// 24px matches the width of the organization icons, and the component
// is smart enough to keep the icon itself square. It looks too big if
// it's 24x24.
icon={<SettingsIcon css={{ width: 24, height: 20 }} />}
>
Deployment
</SidebarNavItem>
{props.active && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="general">General</SidebarNavSubItem>
)}
{props.permissions.viewAllLicenses && (
<SidebarNavSubItem href="licenses">Licenses</SidebarNavSubItem>
)}
{props.permissions.editDeploymentValues && (
<SidebarNavSubItem href="appearance">Appearance</SidebarNavSubItem>
)}
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="userauth">
User Authentication
</SidebarNavSubItem>
)}
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="external-auth">
External Authentication
</SidebarNavSubItem>
)}
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavSubItem href="oauth2-provider/ap>
OAuth2 Applications
</SidebarNavSubItem>*/}
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
)}
{/* All users can view workspace regions. */}
<SidebarNavSubItem href="workspace-proxies">
Workspace Proxies
</SidebarNavSubItem>
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
)}
{props.permissions.viewDeploymentValues && (
<SidebarNavSubItem href="observability">
Observability
</SidebarNavSubItem>
)}
{props.permissions.viewAllUsers && (
<SidebarNavSubItem href={linkToUsers.slice(1)}>
Users
</SidebarNavSubItem>
)}
{props.permissions.viewAnyAuditLog && (
<SidebarNavSubItem href={linkToAuditing.slice(1)}>
Auditing
</SidebarNavSubItem>
)}
</Stack>
)}
</div>
);
};
function urlForSubpage(organizationName: string, subpage: string = ""): string {
return `/organizations/${organizationName}/${subpage}`;
}
interface OrganizationSettingsNavigationProps {
active: boolean;
organization: Organization;
permissions: AuthorizationResponse | undefined;
}
/**
* Displays navigation for an organization.
*
* If inactive, no sub-menu items will be shown, just the organization name.
*
* If active, it will show a loader until the permissions are defined, then the
* sub-menu items are shown as appropriate.
*/
const OrganizationSettingsNavigation: FC<
OrganizationSettingsNavigationProps
> = (props) => {
return (
<>
<SidebarNavItem
active={props.active}
href={urlForSubpage(props.organization.name)}
icon={
<UserAvatar
key={props.organization.id}
size="sm"
username={props.organization.display_name}
avatarURL={props.organization.icon}
/>
}
>
{props.organization.display_name}
</SidebarNavItem>
{props.active && !props.permissions && <Loader />}
{props.active && props.permissions && (
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
{props.permissions.editOrganization && (
<SidebarNavSubItem
end
href={urlForSubpage(props.organization.name)}
>
Organization settings
</SidebarNavSubItem>
)}
{props.permissions.viewMembers && (
<SidebarNavSubItem
href={urlForSubpage(props.organization.name, "members")}
>
Members
</SidebarNavSubItem>
)}
{props.permissions.viewGroups && (
<SidebarNavSubItem
href={urlForSubpage(props.organization.name, "groups")}
>
Groups
</SidebarNavSubItem>
)}
{/* For now redirect to the site-wide audit page with the organization
pre-filled into the filter. Based on user feedback we might want
to serve a copy of the audit page or even delete this link. */}
{props.permissions.auditOrganization && (
<SidebarNavSubItem
href={`/deployment${withFilter(
linkToAuditing,
`organization:${props.organization.name}`,
)}`}
>
Auditing
</SidebarNavSubItem>
)}
</Stack>
)}
</>
);
};
interface SidebarNavItemProps {
active?: boolean | "auto";
children?: ReactNode;
icon?: ReactNode;
href: string;
}
const SidebarNavItem: FC<SidebarNavItemProps> = ({
active,
children,
href,
icon,
}) => {
const link = useClassName(classNames.link, []);
const activeLink = useClassName(classNames.activeLink, []);
const content = (
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
);
if (active === "auto") {
return (
<NavLink
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{content}
</NavLink>
);
}
return (
<Link to={href} className={cx([link, active && activeLink])}>
{content}
</Link>
);
};
interface SidebarNavSubItemProps {
children?: ReactNode;
href: string;
end?: boolean;
}
const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({
children,
href,
end,
}) => {
const link = useClassName(classNames.subLink, []);
const activeLink = useClassName(classNames.activeSubLink, []);
return (
<NavLink
end={end}
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{children}
</NavLink>
);
};
const styles = {
sidebarHeader: {
textTransform: "uppercase",
letterSpacing: "0.15em",
fontSize: 11,
fontWeight: 500,
paddingBottom: 4,
},
} satisfies Record<string, Interpolation<Theme>>;
const classNames = {
link: (css, theme) => css`
color: inherit;
display: block;
font-size: 14px;
text-decoration: none;
padding: 10px 12px 10px 16px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
border-left: 3px solid transparent;
`,
activeLink: (css, theme) => css`
border-left-color: ${theme.palette.primary.main};
border-top-left-radius: 0;
border-bottom-left-radius: 0;
`,
subLink: (css, theme) => css`
color: inherit;
text-decoration: none;
display: block;
font-size: 13px;
margin-left: 44px;
padding: 4px 12px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
margin-bottom: 1px;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
`,
activeSubLink: (css) => css`
font-weight: 600;
`,
} satisfies Record<string, ClassName>;
+6 -7
View File
@@ -20,14 +20,13 @@ import { linkToUsers } from "modules/navigation";
export const UsersLayout: FC = () => {
const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { createUser: canCreateUser, createGroup: canCreateGroup } =
permissions;
const navigate = useNavigate();
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
const feats = useFeatureVisibility();
const location = useLocation();
const activeTab = location.pathname.endsWith("groups") ? "groups" : "users";
const isMultiOrg = experiments.includes("multi-organization");
const canViewOrganizations =
feats.multiple_organizations && experiments.includes("multi-organization");
return (
<>
@@ -35,7 +34,7 @@ export const UsersLayout: FC = () => {
<PageHeader
actions={
<>
{canCreateUser && (
{permissions.createUser && (
<Button
onClick={() => {
navigate("/users/create");
@@ -45,7 +44,7 @@ export const UsersLayout: FC = () => {
Create user
</Button>
)}
{canCreateGroup && isTemplateRBACEnabled && (
{permissions.createGroup && feats.template_rbac && (
<Button
component={RouterLink}
startIcon={<GroupAdd />}
@@ -61,7 +60,7 @@ export const UsersLayout: FC = () => {
</PageHeader>
</Margins>
{!isMultiOrg && (
{!canViewOrganizations && (
<Tabs
css={{ marginBottom: 40, marginTop: -TAB_PADDING_Y }}
active={activeTab}
+6 -6
View File
@@ -29,6 +29,7 @@ import { isNonInitialPage } from "components/PaginationWidget/utils";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { pageTitle } from "utils/page";
import { generateRandomString } from "utils/random";
import { ResetPasswordDialog } from "./ResetPasswordDialog";
@@ -42,7 +43,7 @@ const UsersPage: FC = () => {
const searchParamsResult = useSearchParams();
const { entitlements, experiments } = useDashboard();
const [searchParams] = searchParamsResult;
const isMultiOrg = experiments.includes("multi-organization");
const feats = useFeatureVisibility();
const groupsByUserIdQuery = useQuery(groupsByUserId("default"));
const authMethodsQuery = useQuery(authMethods());
@@ -103,10 +104,9 @@ const UsersPage: FC = () => {
authMethodsQuery.isLoading ||
groupsByUserIdQuery.isLoading;
if (
experiments.includes("multi-organization") &&
location.pathname !== "/deployment/users"
) {
const canViewOrganizations =
feats.multiple_organizations && experiments.includes("multi-organization");
if (canViewOrganizations && location.pathname !== "/deployment/users") {
return <Navigate to={`/deployment/users${location.search}`} replace />;
}
@@ -164,7 +164,7 @@ const UsersPage: FC = () => {
menus: { status: statusMenu },
}}
usersQuery={usersQuery}
isMultiOrg={isMultiOrg}
canViewOrganizations={canViewOrganizations}
canCreateUser={canCreateUser}
/>
+3 -3
View File
@@ -38,7 +38,7 @@ export interface UsersPageViewProps {
usersQuery: PaginationResult;
// TODO: Refactor these out once we remove the multi-organization experiment.
isMultiOrg?: boolean;
canViewOrganizations?: boolean;
canCreateUser?: boolean;
}
@@ -63,14 +63,14 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
authMethods,
groupsByUserId,
usersQuery,
isMultiOrg,
canViewOrganizations,
canCreateUser,
}) => {
const navigate = useNavigate();
return (
<>
{isMultiOrg && (
{canViewOrganizations && (
<PageHeader
css={{ paddingTop: 0 }}
actions={
+19 -3
View File
@@ -2209,6 +2209,17 @@ export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = {
}),
};
export const MockEntitlementsWithMultiOrg: TypesGen.Entitlements = {
...MockEntitlements,
has_license: true,
features: withDefaultFeatures({
multiple_organizations: {
enabled: true,
entitlement: "entitled",
},
}),
};
export const MockExperiments: TypesGen.Experiment[] = [];
/**
@@ -2472,19 +2483,24 @@ export const MockTemplateExample2: TypesGen.TemplateExample = {
};
export const MockPermissions: Permissions = {
createGroup: true,
createTemplates: true,
createUser: true,
deleteTemplates: true,
updateTemplates: true,
readAllUsers: true,
viewAllUsers: true,
updateUsers: true,
viewAuditLog: true,
viewAnyAuditLog: true,
viewDeploymentValues: true,
editDeploymentValues: true,
viewUpdateCheck: true,
viewDeploymentStats: true,
viewExternalAuthConfig: true,
editWorkspaceProxies: true,
createOrganization: true,
editAnyOrganization: true,
viewAnyGroup: true,
createGroup: true,
viewAllLicenses: true,
};
export const MockDeploymentConfig: DeploymentConfig = {