mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
-1
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+9
-4
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user