mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix: only show editable orgs on deployment page (#14193)
Also make sure the redirect from /organizations goes to an org that the user can edit, rather than always the default org.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { QueryClient } from "react-query";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
AuthorizationResponse,
|
||||
CreateOrganizationRequest,
|
||||
UpdateOrganizationRequest,
|
||||
} from "api/typesGenerated";
|
||||
@@ -133,15 +134,15 @@ export const organizationPermissions = (organizationId: string | undefined) => {
|
||||
return {
|
||||
queryKey: ["organization", organizationId, "permissions"],
|
||||
queryFn: () =>
|
||||
// Only request what we use on individual org settings, members, and group
|
||||
// pages, which at the moment is whether you can edit the members or roles
|
||||
// on the members page and whether you can see the create group button on
|
||||
// the groups page. The edit organization check for the settings page is
|
||||
// covered by the multi-org query at the moment, and the edit group check
|
||||
// on the group page is done on the group itself, not the org, so neither
|
||||
// show up here.
|
||||
API.checkAuthorization({
|
||||
checks: {
|
||||
viewMembers: {
|
||||
object: {
|
||||
resource_type: "organization_member",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
editMembers: {
|
||||
object: {
|
||||
resource_type: "organization_member",
|
||||
@@ -156,27 +157,6 @@ export const organizationPermissions = (organizationId: string | undefined) => {
|
||||
},
|
||||
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",
|
||||
},
|
||||
assignOrgRole: {
|
||||
object: {
|
||||
resource_type: "assign_org_role",
|
||||
@@ -188,3 +168,93 @@ export const organizationPermissions = (organizationId: string | undefined) => {
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch permissions for all provided organizations.
|
||||
*
|
||||
* If organizations are undefined, return a disabled query.
|
||||
*/
|
||||
export const organizationsPermissions = (
|
||||
organizationIds: string[] | undefined,
|
||||
) => {
|
||||
if (!organizationIds) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
queryKey: ["organizations", organizationIds.sort(), "permissions"],
|
||||
queryFn: async () => {
|
||||
// Only request what we need for the sidebar, which is one edit permission
|
||||
// per sub-link (audit, settings, groups, roles, and members pages) that
|
||||
// tells us whether to show that page, since we only show them if you can
|
||||
// edit (and not, at the moment if you can only view).
|
||||
const checks = (organizationId: string) => ({
|
||||
editMembers: {
|
||||
object: {
|
||||
resource_type: "organization_member",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
editGroups: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
editOrganization: {
|
||||
object: {
|
||||
resource_type: "organization",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
auditOrganization: {
|
||||
object: {
|
||||
resource_type: "audit_log",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
assignOrgRole: {
|
||||
object: {
|
||||
resource_type: "assign_org_role",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
});
|
||||
|
||||
// The endpoint takes a flat array, so to avoid collisions prepend each
|
||||
// check with the org ID (the key can be anything we want).
|
||||
const prefixedChecks = organizationIds
|
||||
.map((orgId) =>
|
||||
Object.entries(checks(orgId)).map(([key, val]) => [
|
||||
`${orgId}.${key}`,
|
||||
val,
|
||||
]),
|
||||
)
|
||||
.flat();
|
||||
|
||||
const response = await API.checkAuthorization({
|
||||
checks: Object.fromEntries(prefixedChecks),
|
||||
});
|
||||
|
||||
// Now we can unflatten by parsing out the org ID from each check.
|
||||
return Object.entries(response).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const index = key.indexOf(".");
|
||||
const orgId = key.substring(0, index);
|
||||
const perm = key.substring(index + 1);
|
||||
if (!acc[orgId]) {
|
||||
acc[orgId] = {};
|
||||
}
|
||||
acc[orgId][perm] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, AuthorizationResponse>,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type FC, Suspense } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { deploymentConfig } from "api/queries/deployment";
|
||||
import type { Organization } from "api/typesGenerated";
|
||||
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
@@ -21,6 +21,20 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => {
|
||||
return { organizations };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return true if the user can edit the organization settings or its members.
|
||||
*/
|
||||
export const canEditOrganization = (
|
||||
permissions: AuthorizationResponse | undefined,
|
||||
) => {
|
||||
return (
|
||||
permissions !== undefined &&
|
||||
(permissions.editOrganization ||
|
||||
permissions.editMembers ||
|
||||
permissions.editGroups)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A multi-org capable settings page layout.
|
||||
*
|
||||
|
||||
@@ -28,7 +28,6 @@ beforeEach(() => {
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
editMembers: true,
|
||||
viewMembers: true,
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization2,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
renderWithManagementSettingsLayout,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import OrganizationSettingsPage from "./OrganizationSettingsPage";
|
||||
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const renderRootPage = async () => {
|
||||
renderWithManagementSettingsLayout(<OrganizationSettingsPage />, {
|
||||
route: "/organizations",
|
||||
path: "/organizations/:organization?",
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
};
|
||||
|
||||
const renderPage = async (orgName: string) => {
|
||||
renderWithManagementSettingsLayout(<OrganizationSettingsPage />, {
|
||||
route: `/organizations/${orgName}`,
|
||||
path: "/organizations/:organization",
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
};
|
||||
|
||||
describe("OrganizationSettingsPage", () => {
|
||||
it("has no organizations", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/organizations", () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
[`${MockDefaultOrganization.id}.editOrganization`]: true,
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await renderRootPage();
|
||||
await screen.findByText("No organizations found");
|
||||
});
|
||||
|
||||
it("has no editable organizations", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/organizations", () => {
|
||||
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
|
||||
}),
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await renderRootPage();
|
||||
await screen.findByText("No organizations found");
|
||||
});
|
||||
|
||||
it("redirects to default organization", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/organizations", () => {
|
||||
// Default always preferred regardless of order.
|
||||
return HttpResponse.json([MockOrganization2, MockDefaultOrganization]);
|
||||
}),
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
[`${MockDefaultOrganization.id}.editOrganization`]: true,
|
||||
[`${MockOrganization2.id}.editOrganization`]: true,
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await renderRootPage();
|
||||
const form = screen.getByTestId("org-settings-form");
|
||||
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
|
||||
MockDefaultOrganization.name,
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to non-default organization", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/organizations", () => {
|
||||
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
|
||||
}),
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
[`${MockOrganization2.id}.editOrganization`]: true,
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await renderRootPage();
|
||||
const form = screen.getByTestId("org-settings-form");
|
||||
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
|
||||
MockOrganization2.name,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot find organization", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/organizations", () => {
|
||||
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
|
||||
}),
|
||||
http.post("/api/v2/authcheck", async () => {
|
||||
return HttpResponse.json({
|
||||
[`${MockOrganization2.id}.editOrganization`]: true,
|
||||
viewDeploymentValues: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await renderPage("the-endless-void");
|
||||
await screen.findByText("Organization not found");
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,16 @@ import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
updateOrganization,
|
||||
deleteOrganization,
|
||||
organizationPermissions,
|
||||
organizationsPermissions,
|
||||
} 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 {
|
||||
canEditOrganization,
|
||||
useOrganizationSettings,
|
||||
} from "./ManagementSettingsLayout";
|
||||
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
|
||||
|
||||
const OrganizationSettingsPage: FC = () => {
|
||||
@@ -32,37 +35,42 @@ const OrganizationSettingsPage: FC = () => {
|
||||
organizations && organizationName
|
||||
? getOrganizationByName(organizations, organizationName)
|
||||
: undefined;
|
||||
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
|
||||
const permissionsQuery = useQuery(
|
||||
organizationsPermissions(organizations?.map((o) => o.id)),
|
||||
);
|
||||
|
||||
if (!organizations) {
|
||||
const permissions = permissionsQuery.data;
|
||||
if (!organizations || !permissions) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Redirect /organizations => /organizations/default-org
|
||||
// Redirect /organizations => /organizations/default-org, or if they cannot edit
|
||||
// the default org, then the first org they can edit, if any.
|
||||
if (!organizationName) {
|
||||
const defaultOrg = getOrganizationByDefault(organizations);
|
||||
if (defaultOrg) {
|
||||
return <Navigate to={`/organizations/${defaultOrg.name}`} replace />;
|
||||
const editableOrg = organizations
|
||||
.sort((a, b) => {
|
||||
// Prefer default org (it may not be first).
|
||||
// JavaScript will happily subtract booleans, but use numbers to keep
|
||||
// the compiler happy.
|
||||
return (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
|
||||
})
|
||||
.find((org) => canEditOrganization(permissions[org.id]));
|
||||
if (editableOrg) {
|
||||
return <Navigate to={`/organizations/${editableOrg.name}`} replace />;
|
||||
}
|
||||
// We expect there to always be a default organization.
|
||||
throw new Error("No default organization found");
|
||||
return <EmptyState message="No organizations found" />;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const permissions = permissionsQuery.data;
|
||||
if (!permissions) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const error =
|
||||
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
|
||||
|
||||
return (
|
||||
<OrganizationSettingsPageView
|
||||
canEdit={permissions.editOrganization}
|
||||
canEdit={permissions[organization.id]?.editOrganization ?? false}
|
||||
organization={organization}
|
||||
error={error}
|
||||
onSubmit={async (values) => {
|
||||
@@ -85,8 +93,5 @@ const OrganizationSettingsPage: FC = () => {
|
||||
|
||||
export default OrganizationSettingsPage;
|
||||
|
||||
const getOrganizationByDefault = (organizations: Organization[]) =>
|
||||
organizations.find((org) => org.is_default);
|
||||
|
||||
const getOrganizationByName = (organizations: Organization[], name: string) =>
|
||||
organizations.find((org) => org.name === name);
|
||||
|
||||
@@ -78,6 +78,7 @@ export const OrganizationSettingsPageView: FC<
|
||||
)}
|
||||
|
||||
<HorizontalForm
|
||||
data-testid="org-settings-form"
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { organizationPermissions } from "api/queries/organizations";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { organizationsPermissions } from "api/queries/organizations";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useOrganizationSettings } from "./ManagementSettingsLayout";
|
||||
import { SidebarView } from "./SidebarView";
|
||||
import {
|
||||
canEditOrganization,
|
||||
useOrganizationSettings,
|
||||
} from "./ManagementSettingsLayout";
|
||||
import { type OrganizationWithPermissions, SidebarView } from "./SidebarView";
|
||||
|
||||
/**
|
||||
* A combined deployment settings and organization menu.
|
||||
@@ -14,27 +17,42 @@ import { SidebarView } from "./SidebarView";
|
||||
* DeploySettingsPage/Sidebar instead.
|
||||
*/
|
||||
export const Sidebar: FC = () => {
|
||||
const location = useLocation();
|
||||
const { permissions } = useAuthenticated();
|
||||
const { organizations } = useOrganizationSettings();
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
// 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),
|
||||
const orgPermissionsQuery = useQuery(
|
||||
organizationsPermissions(organizations?.map((o) => o.id)),
|
||||
);
|
||||
|
||||
// Sometimes a user can read an organization but cannot actually do anything
|
||||
// with it. For now, these are filtered out so you only see organizations you
|
||||
// can manage in some way.
|
||||
const editableOrgs = organizations
|
||||
?.map((org) => {
|
||||
return {
|
||||
...org,
|
||||
permissions: orgPermissionsQuery.data?.[org.id],
|
||||
};
|
||||
})
|
||||
// TypeScript is not able to infer whether permissions are defined on the
|
||||
// object even if we explicitly check org.permissions here, so add the `is`
|
||||
// here to help out (canEditOrganization does the actual check).
|
||||
.filter((org): org is OrganizationWithPermissions => {
|
||||
return canEditOrganization(org.permissions);
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarView
|
||||
activeOrganization={activeOrganization}
|
||||
activeOrgPermissions={activeOrgPermissionsQuery.data}
|
||||
organizations={organizations}
|
||||
// Both activeSettings and activeOrganizationName could be be falsey if
|
||||
// the user is on /organizations but has no editable organizations to
|
||||
// which we can redirect.
|
||||
activeSettings={location.pathname.startsWith("/deployment")}
|
||||
activeOrganizationName={organizationName}
|
||||
organizations={editableOrgs}
|
||||
permissions={permissions}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,28 @@ const meta: Meta<typeof SidebarView> = {
|
||||
component: SidebarView,
|
||||
decorators: [withDashboardProvider],
|
||||
args: {
|
||||
activeOrganization: undefined,
|
||||
activeOrgPermissions: undefined,
|
||||
organizations: [MockOrganization, MockOrganization2],
|
||||
activeSettings: true,
|
||||
activeOrganizationName: undefined,
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: true,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockOrganization2,
|
||||
permissions: {
|
||||
editOrganization: true,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
permissions: MockPermissions,
|
||||
},
|
||||
};
|
||||
@@ -22,7 +41,11 @@ const meta: Meta<typeof SidebarView> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SidebarView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const LoadingOrganizations: Story = {
|
||||
args: {
|
||||
organizations: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoCreateOrg: Story = {
|
||||
args: {
|
||||
@@ -76,45 +99,126 @@ export const NoPermissions: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedOrgLoading: Story = {
|
||||
export const NoSelected: Story = {
|
||||
args: {
|
||||
activeOrganization: MockOrganization,
|
||||
activeSettings: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedOrgNoMatch: Story = {
|
||||
args: {
|
||||
activeOrganizationName: MockOrganization.name,
|
||||
organizations: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedOrgAdmin: Story = {
|
||||
args: {
|
||||
activeOrganization: MockOrganization,
|
||||
activeOrgPermissions: {
|
||||
editOrganization: true,
|
||||
viewMembers: true,
|
||||
viewGroups: true,
|
||||
auditOrganization: true,
|
||||
assignOrgRole: true,
|
||||
},
|
||||
activeOrganizationName: MockOrganization.name,
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: true,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: true,
|
||||
assignOrgRole: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedOrgAuditor: Story = {
|
||||
args: {
|
||||
activeOrganization: MockOrganization,
|
||||
activeOrgPermissions: {
|
||||
editOrganization: false,
|
||||
viewMembers: false,
|
||||
viewGroups: false,
|
||||
auditOrganization: true,
|
||||
activeOrganizationName: MockOrganization.name,
|
||||
permissions: {
|
||||
...MockPermissions,
|
||||
createOrganization: false,
|
||||
},
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: false,
|
||||
editGroups: false,
|
||||
auditOrganization: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedOrgNoPerms: Story = {
|
||||
export const SelectedOrgUserAdmin: Story = {
|
||||
args: {
|
||||
activeOrganization: MockOrganization,
|
||||
activeOrgPermissions: {
|
||||
editOrganization: false,
|
||||
viewMembers: false,
|
||||
viewGroups: false,
|
||||
auditOrganization: false,
|
||||
activeOrganizationName: MockOrganization.name,
|
||||
permissions: {
|
||||
...MockPermissions,
|
||||
createOrganization: false,
|
||||
},
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiOrgAdminAndUserAdmin: Story = {
|
||||
args: {
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: false,
|
||||
editGroups: false,
|
||||
auditOrganization: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockOrganization2,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedMultiOrgAdminAndUserAdmin: Story = {
|
||||
args: {
|
||||
activeOrganizationName: MockOrganization2.name,
|
||||
organizations: [
|
||||
{
|
||||
...MockOrganization,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: false,
|
||||
editGroups: false,
|
||||
auditOrganization: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
...MockOrganization2,
|
||||
permissions: {
|
||||
editOrganization: false,
|
||||
editMembers: true,
|
||||
editGroups: true,
|
||||
auditOrganization: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,19 +13,17 @@ import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { linkToAuditing, linkToUsers, withFilter } from "modules/navigation";
|
||||
|
||||
export interface OrganizationWithPermissions extends Organization {
|
||||
permissions: AuthorizationResponse;
|
||||
}
|
||||
|
||||
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;
|
||||
/** True if a settings page is being viewed. */
|
||||
activeSettings: boolean;
|
||||
/** The active org name, if any. Overrides activeSettings. */
|
||||
activeOrganizationName: string | undefined;
|
||||
/** Organizations and their permissions or undefined if still fetching. */
|
||||
organizations: OrganizationWithPermissions[] | undefined;
|
||||
/** Site-wide permissions. */
|
||||
permissions: AuthorizationResponse;
|
||||
}
|
||||
@@ -33,44 +31,25 @@ interface SidebarProps {
|
||||
/**
|
||||
* A combined deployment settings and organization menu.
|
||||
*/
|
||||
export const SidebarView: FC<SidebarProps> = (props) => {
|
||||
export const SidebarView: FC<SidebarProps> = ({
|
||||
activeSettings,
|
||||
activeOrganizationName,
|
||||
organizations,
|
||||
permissions,
|
||||
}) => {
|
||||
// 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}
|
||||
active={!activeOrganizationName && activeSettings}
|
||||
permissions={permissions}
|
||||
/>
|
||||
<OrganizationsSettingsNavigation
|
||||
activeOrganizationName={activeOrganizationName}
|
||||
organizations={organizations}
|
||||
permissions={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>
|
||||
);
|
||||
};
|
||||
@@ -89,15 +68,16 @@ interface DeploymentSettingsNavigationProps {
|
||||
* Menu items are shown based on the permissions. If organizations can be
|
||||
* viewed, groups are skipped since they will show under each org instead.
|
||||
*/
|
||||
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = (
|
||||
props,
|
||||
) => {
|
||||
const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
active,
|
||||
permissions,
|
||||
}) => {
|
||||
return (
|
||||
<div css={{ paddingBottom: 12 }}>
|
||||
<SidebarNavItem
|
||||
active={props.active}
|
||||
active={active}
|
||||
href={
|
||||
props.permissions.viewDeploymentValues
|
||||
permissions.viewDeploymentValues
|
||||
? "/deployment/general"
|
||||
: "/deployment/workspace-proxies"
|
||||
}
|
||||
@@ -108,23 +88,23 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = (
|
||||
>
|
||||
Deployment
|
||||
</SidebarNavItem>
|
||||
{props.active && (
|
||||
{active && (
|
||||
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="general">General</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewAllLicenses && (
|
||||
{permissions.viewAllLicenses && (
|
||||
<SidebarNavSubItem href="licenses">Licenses</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.editDeploymentValues && (
|
||||
{permissions.editDeploymentValues && (
|
||||
<SidebarNavSubItem href="appearance">Appearance</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="userauth">
|
||||
User Authentication
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="external-auth">
|
||||
External Authentication
|
||||
</SidebarNavSubItem>
|
||||
@@ -133,27 +113,27 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = (
|
||||
<SidebarNavSubItem href="oauth2-provider/ap>
|
||||
OAuth2 Applications
|
||||
</SidebarNavSubItem>*/}
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="network">Network</SidebarNavSubItem>
|
||||
)}
|
||||
{/* All users can view workspace regions. */}
|
||||
<SidebarNavSubItem href="workspace-proxies">
|
||||
Workspace Proxies
|
||||
</SidebarNavSubItem>
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="security">Security</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewDeploymentValues && (
|
||||
{permissions.viewDeploymentValues && (
|
||||
<SidebarNavSubItem href="observability">
|
||||
Observability
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewAllUsers && (
|
||||
{permissions.viewAllUsers && (
|
||||
<SidebarNavSubItem href={linkToUsers.slice(1)}>
|
||||
Users
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewAnyAuditLog && (
|
||||
{permissions.viewAnyAuditLog && (
|
||||
<SidebarNavSubItem href={linkToAuditing.slice(1)}>
|
||||
Auditing
|
||||
</SidebarNavSubItem>
|
||||
@@ -168,70 +148,118 @@ function urlForSubpage(organizationName: string, subpage: string = ""): string {
|
||||
return `/organizations/${organizationName}/${subpage}`;
|
||||
}
|
||||
|
||||
interface OrganizationSettingsNavigationProps {
|
||||
active: boolean;
|
||||
organization: Organization;
|
||||
permissions: AuthorizationResponse | undefined;
|
||||
interface OrganizationsSettingsNavigationProps {
|
||||
/** The active org name if an org is being viewed. */
|
||||
activeOrganizationName: string | undefined;
|
||||
/** Organizations and their permissions or undefined if still fetching. */
|
||||
organizations: OrganizationWithPermissions[] | undefined;
|
||||
/** Site-wide permissions. */
|
||||
permissions: AuthorizationResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays navigation for an organization.
|
||||
* Displays navigation for all organizations and a create organization link.
|
||||
*
|
||||
* If organizations or their permissions are still loading, show a loader.
|
||||
*
|
||||
* If there are no organizations and the user does not have the create org
|
||||
* permission, nothing is displayed.
|
||||
*/
|
||||
const OrganizationsSettingsNavigation: FC<
|
||||
OrganizationsSettingsNavigationProps
|
||||
> = ({ activeOrganizationName, organizations, permissions }) => {
|
||||
// Wait for organizations and their permissions to load in.
|
||||
if (!organizations) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (organizations.length <= 0 && !permissions.createOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header css={styles.sidebarHeader}>Organizations</header>
|
||||
{permissions.createOrganization && (
|
||||
<SidebarNavItem
|
||||
active="auto"
|
||||
href="/organizations/new"
|
||||
icon={<AddIcon />}
|
||||
>
|
||||
New organization
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{organizations.map((org) => (
|
||||
<OrganizationSettingsNavigation
|
||||
key={org.id}
|
||||
organization={org}
|
||||
active={org.name === activeOrganizationName}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface OrganizationSettingsNavigationProps {
|
||||
/** Whether this organization is currently selected. */
|
||||
active: boolean;
|
||||
/** The organization to display in the navigation. */
|
||||
organization: OrganizationWithPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays navigation for a single 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.
|
||||
* If active, it will show sub-menu items based on the permissions.
|
||||
*/
|
||||
const OrganizationSettingsNavigation: FC<
|
||||
OrganizationSettingsNavigationProps
|
||||
> = (props) => {
|
||||
> = ({ active, organization }) => {
|
||||
const { experiments } = useDashboard();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarNavItem
|
||||
active={props.active}
|
||||
href={urlForSubpage(props.organization.name)}
|
||||
active={active}
|
||||
href={urlForSubpage(organization.name)}
|
||||
icon={
|
||||
<UserAvatar
|
||||
key={props.organization.id}
|
||||
key={organization.id}
|
||||
size="sm"
|
||||
username={props.organization.display_name}
|
||||
avatarURL={props.organization.icon}
|
||||
username={organization.display_name}
|
||||
avatarURL={organization.icon}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.organization.display_name}
|
||||
{organization.display_name}
|
||||
</SidebarNavItem>
|
||||
{props.active && !props.permissions && <Loader />}
|
||||
{props.active && props.permissions && (
|
||||
{active && (
|
||||
<Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}>
|
||||
{props.permissions.editOrganization && (
|
||||
<SidebarNavSubItem
|
||||
end
|
||||
href={urlForSubpage(props.organization.name)}
|
||||
>
|
||||
{organization.permissions.editOrganization && (
|
||||
<SidebarNavSubItem end href={urlForSubpage(organization.name)}>
|
||||
Organization settings
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewMembers && (
|
||||
{organization.permissions.editMembers && (
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(props.organization.name, "members")}
|
||||
href={urlForSubpage(organization.name, "members")}
|
||||
>
|
||||
Members
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.viewGroups && (
|
||||
{organization.permissions.editGroups && (
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(props.organization.name, "groups")}
|
||||
href={urlForSubpage(organization.name, "groups")}
|
||||
>
|
||||
Groups
|
||||
</SidebarNavSubItem>
|
||||
)}
|
||||
{props.permissions.assignOrgRole &&
|
||||
{organization.permissions.assignOrgRole &&
|
||||
experiments.includes("custom-roles") && (
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(props.organization.name, "roles")}
|
||||
href={urlForSubpage(organization.name, "roles")}
|
||||
>
|
||||
Roles
|
||||
</SidebarNavSubItem>
|
||||
@@ -239,11 +267,11 @@ const OrganizationSettingsNavigation: FC<
|
||||
{/* 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 && (
|
||||
{organization.permissions.auditOrganization && (
|
||||
<SidebarNavSubItem
|
||||
href={`/deployment${withFilter(
|
||||
linkToAuditing,
|
||||
`organization:${props.organization.name}`,
|
||||
`organization:${organization.name}`,
|
||||
)}`}
|
||||
>
|
||||
Auditing
|
||||
|
||||
Reference in New Issue
Block a user