Skip to content

Commit 6019d0b

Browse files
authored
fix: only show editable orgs on deployment page (coder#14193)
Also make sure the redirect from /organizations goes to an org that the user can edit, rather than always the default org.
1 parent d6c4d47 commit 6019d0b

9 files changed

+536
-178
lines changed

site/src/api/queries/organizations.ts

+98-28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { QueryClient } from "react-query";
22
import { API } from "api/api";
33
import type {
4+
AuthorizationResponse,
45
CreateOrganizationRequest,
56
UpdateOrganizationRequest,
67
} from "api/typesGenerated";
@@ -133,15 +134,15 @@ export const organizationPermissions = (organizationId: string | undefined) => {
133134
return {
134135
queryKey: ["organization", organizationId, "permissions"],
135136
queryFn: () =>
137+
// Only request what we use on individual org settings, members, and group
138+
// pages, which at the moment is whether you can edit the members or roles
139+
// on the members page and whether you can see the create group button on
140+
// the groups page. The edit organization check for the settings page is
141+
// covered by the multi-org query at the moment, and the edit group check
142+
// on the group page is done on the group itself, not the org, so neither
143+
// show up here.
136144
API.checkAuthorization({
137145
checks: {
138-
viewMembers: {
139-
object: {
140-
resource_type: "organization_member",
141-
organization_id: organizationId,
142-
},
143-
action: "read",
144-
},
145146
editMembers: {
146147
object: {
147148
resource_type: "organization_member",
@@ -156,27 +157,6 @@ export const organizationPermissions = (organizationId: string | undefined) => {
156157
},
157158
action: "create",
158159
},
159-
viewGroups: {
160-
object: {
161-
resource_type: "group",
162-
organization_id: organizationId,
163-
},
164-
action: "read",
165-
},
166-
editOrganization: {
167-
object: {
168-
resource_type: "organization",
169-
organization_id: organizationId,
170-
},
171-
action: "update",
172-
},
173-
auditOrganization: {
174-
object: {
175-
resource_type: "audit_log",
176-
organization_id: organizationId,
177-
},
178-
action: "read",
179-
},
180160
assignOrgRole: {
181161
object: {
182162
resource_type: "assign_org_role",
@@ -188,3 +168,93 @@ export const organizationPermissions = (organizationId: string | undefined) => {
188168
}),
189169
};
190170
};
171+
172+
/**
173+
* Fetch permissions for all provided organizations.
174+
*
175+
* If organizations are undefined, return a disabled query.
176+
*/
177+
export const organizationsPermissions = (
178+
organizationIds: string[] | undefined,
179+
) => {
180+
if (!organizationIds) {
181+
return { enabled: false };
182+
}
183+
184+
return {
185+
queryKey: ["organizations", organizationIds.sort(), "permissions"],
186+
queryFn: async () => {
187+
// Only request what we need for the sidebar, which is one edit permission
188+
// per sub-link (audit, settings, groups, roles, and members pages) that
189+
// tells us whether to show that page, since we only show them if you can
190+
// edit (and not, at the moment if you can only view).
191+
const checks = (organizationId: string) => ({
192+
editMembers: {
193+
object: {
194+
resource_type: "organization_member",
195+
organization_id: organizationId,
196+
},
197+
action: "update",
198+
},
199+
editGroups: {
200+
object: {
201+
resource_type: "group",
202+
organization_id: organizationId,
203+
},
204+
action: "update",
205+
},
206+
editOrganization: {
207+
object: {
208+
resource_type: "organization",
209+
organization_id: organizationId,
210+
},
211+
action: "update",
212+
},
213+
auditOrganization: {
214+
object: {
215+
resource_type: "audit_log",
216+
organization_id: organizationId,
217+
},
218+
action: "read",
219+
},
220+
assignOrgRole: {
221+
object: {
222+
resource_type: "assign_org_role",
223+
organization_id: organizationId,
224+
},
225+
action: "create",
226+
},
227+
});
228+
229+
// The endpoint takes a flat array, so to avoid collisions prepend each
230+
// check with the org ID (the key can be anything we want).
231+
const prefixedChecks = organizationIds
232+
.map((orgId) =>
233+
Object.entries(checks(orgId)).map(([key, val]) => [
234+
`${orgId}.${key}`,
235+
val,
236+
]),
237+
)
238+
.flat();
239+
240+
const response = await API.checkAuthorization({
241+
checks: Object.fromEntries(prefixedChecks),
242+
});
243+
244+
// Now we can unflatten by parsing out the org ID from each check.
245+
return Object.entries(response).reduce(
246+
(acc, [key, value]) => {
247+
const index = key.indexOf(".");
248+
const orgId = key.substring(0, index);
249+
const perm = key.substring(index + 1);
250+
if (!acc[orgId]) {
251+
acc[orgId] = {};
252+
}
253+
acc[orgId][perm] = value;
254+
return acc;
255+
},
256+
{} as Record<string, AuthorizationResponse>,
257+
);
258+
},
259+
};
260+
};

site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx

+15-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type FC, Suspense } from "react";
22
import { useQuery } from "react-query";
33
import { Outlet } from "react-router-dom";
44
import { deploymentConfig } from "api/queries/deployment";
5-
import type { Organization } from "api/typesGenerated";
5+
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
66
import { Loader } from "components/Loader/Loader";
77
import { Margins } from "components/Margins/Margins";
88
import { Stack } from "components/Stack/Stack";
@@ -21,6 +21,20 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => {
2121
return { organizations };
2222
};
2323

24+
/**
25+
* Return true if the user can edit the organization settings or its members.
26+
*/
27+
export const canEditOrganization = (
28+
permissions: AuthorizationResponse | undefined,
29+
) => {
30+
return (
31+
permissions !== undefined &&
32+
(permissions.editOrganization ||
33+
permissions.editMembers ||
34+
permissions.editGroups)
35+
);
36+
};
37+
2438
/**
2539
* A multi-org capable settings page layout.
2640
*

site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ beforeEach(() => {
2828
http.post("/api/v2/authcheck", async () => {
2929
return HttpResponse.json({
3030
editMembers: true,
31-
viewMembers: true,
3231
viewDeploymentValues: true,
3332
});
3433
}),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { screen, within } from "@testing-library/react";
2+
import { HttpResponse, http } from "msw";
3+
import {
4+
MockDefaultOrganization,
5+
MockOrganization2,
6+
} from "testHelpers/entities";
7+
import {
8+
renderWithManagementSettingsLayout,
9+
waitForLoaderToBeRemoved,
10+
} from "testHelpers/renderHelpers";
11+
import { server } from "testHelpers/server";
12+
import OrganizationSettingsPage from "./OrganizationSettingsPage";
13+
14+
jest.spyOn(console, "error").mockImplementation(() => {});
15+
16+
const renderRootPage = async () => {
17+
renderWithManagementSettingsLayout(<OrganizationSettingsPage />, {
18+
route: "/organizations",
19+
path: "/organizations/:organization?",
20+
});
21+
await waitForLoaderToBeRemoved();
22+
};
23+
24+
const renderPage = async (orgName: string) => {
25+
renderWithManagementSettingsLayout(<OrganizationSettingsPage />, {
26+
route: `/organizations/${orgName}`,
27+
path: "/organizations/:organization",
28+
});
29+
await waitForLoaderToBeRemoved();
30+
};
31+
32+
describe("OrganizationSettingsPage", () => {
33+
it("has no organizations", async () => {
34+
server.use(
35+
http.get("/api/v2/organizations", () => {
36+
return HttpResponse.json([]);
37+
}),
38+
http.post("/api/v2/authcheck", async () => {
39+
return HttpResponse.json({
40+
[`${MockDefaultOrganization.id}.editOrganization`]: true,
41+
viewDeploymentValues: true,
42+
});
43+
}),
44+
);
45+
await renderRootPage();
46+
await screen.findByText("No organizations found");
47+
});
48+
49+
it("has no editable organizations", async () => {
50+
server.use(
51+
http.get("/api/v2/organizations", () => {
52+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
53+
}),
54+
http.post("/api/v2/authcheck", async () => {
55+
return HttpResponse.json({
56+
viewDeploymentValues: true,
57+
});
58+
}),
59+
);
60+
await renderRootPage();
61+
await screen.findByText("No organizations found");
62+
});
63+
64+
it("redirects to default organization", async () => {
65+
server.use(
66+
http.get("/api/v2/organizations", () => {
67+
// Default always preferred regardless of order.
68+
return HttpResponse.json([MockOrganization2, MockDefaultOrganization]);
69+
}),
70+
http.post("/api/v2/authcheck", async () => {
71+
return HttpResponse.json({
72+
[`${MockDefaultOrganization.id}.editOrganization`]: true,
73+
[`${MockOrganization2.id}.editOrganization`]: true,
74+
viewDeploymentValues: true,
75+
});
76+
}),
77+
);
78+
await renderRootPage();
79+
const form = screen.getByTestId("org-settings-form");
80+
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
81+
MockDefaultOrganization.name,
82+
);
83+
});
84+
85+
it("redirects to non-default organization", async () => {
86+
server.use(
87+
http.get("/api/v2/organizations", () => {
88+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
89+
}),
90+
http.post("/api/v2/authcheck", async () => {
91+
return HttpResponse.json({
92+
[`${MockOrganization2.id}.editOrganization`]: true,
93+
viewDeploymentValues: true,
94+
});
95+
}),
96+
);
97+
await renderRootPage();
98+
const form = screen.getByTestId("org-settings-form");
99+
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
100+
MockOrganization2.name,
101+
);
102+
});
103+
104+
it("cannot find organization", async () => {
105+
server.use(
106+
http.get("/api/v2/organizations", () => {
107+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
108+
}),
109+
http.post("/api/v2/authcheck", async () => {
110+
return HttpResponse.json({
111+
[`${MockOrganization2.id}.editOrganization`]: true,
112+
viewDeploymentValues: true,
113+
});
114+
}),
115+
);
116+
await renderPage("the-endless-void");
117+
await screen.findByText("Organization not found");
118+
});
119+
});

site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx

+24-19
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { Navigate, useNavigate, useParams } from "react-router-dom";
44
import {
55
updateOrganization,
66
deleteOrganization,
7-
organizationPermissions,
7+
organizationsPermissions,
88
} from "api/queries/organizations";
99
import type { Organization } from "api/typesGenerated";
1010
import { EmptyState } from "components/EmptyState/EmptyState";
1111
import { displaySuccess } from "components/GlobalSnackbar/utils";
1212
import { Loader } from "components/Loader/Loader";
13-
import { useOrganizationSettings } from "./ManagementSettingsLayout";
13+
import {
14+
canEditOrganization,
15+
useOrganizationSettings,
16+
} from "./ManagementSettingsLayout";
1417
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
1518

1619
const OrganizationSettingsPage: FC = () => {
@@ -32,37 +35,42 @@ const OrganizationSettingsPage: FC = () => {
3235
organizations && organizationName
3336
? getOrganizationByName(organizations, organizationName)
3437
: undefined;
35-
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
38+
const permissionsQuery = useQuery(
39+
organizationsPermissions(organizations?.map((o) => o.id)),
40+
);
3641

37-
if (!organizations) {
42+
const permissions = permissionsQuery.data;
43+
if (!organizations || !permissions) {
3844
return <Loader />;
3945
}
4046

41-
// Redirect /organizations => /organizations/default-org
47+
// Redirect /organizations => /organizations/default-org, or if they cannot edit
48+
// the default org, then the first org they can edit, if any.
4249
if (!organizationName) {
43-
const defaultOrg = getOrganizationByDefault(organizations);
44-
if (defaultOrg) {
45-
return <Navigate to={`/organizations/${defaultOrg.name}`} replace />;
50+
const editableOrg = organizations
51+
.sort((a, b) => {
52+
// Prefer default org (it may not be first).
53+
// JavaScript will happily subtract booleans, but use numbers to keep
54+
// the compiler happy.
55+
return (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0);
56+
})
57+
.find((org) => canEditOrganization(permissions[org.id]));
58+
if (editableOrg) {
59+
return <Navigate to={`/organizations/${editableOrg.name}`} replace />;
4660
}
47-
// We expect there to always be a default organization.
48-
throw new Error("No default organization found");
61+
return <EmptyState message="No organizations found" />;
4962
}
5063

5164
if (!organization) {
5265
return <EmptyState message="Organization not found" />;
5366
}
5467

55-
const permissions = permissionsQuery.data;
56-
if (!permissions) {
57-
return <Loader />;
58-
}
59-
6068
const error =
6169
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
6270

6371
return (
6472
<OrganizationSettingsPageView
65-
canEdit={permissions.editOrganization}
73+
canEdit={permissions[organization.id]?.editOrganization ?? false}
6674
organization={organization}
6775
error={error}
6876
onSubmit={async (values) => {
@@ -85,8 +93,5 @@ const OrganizationSettingsPage: FC = () => {
8593

8694
export default OrganizationSettingsPage;
8795

88-
const getOrganizationByDefault = (organizations: Organization[]) =>
89-
organizations.find((org) => org.is_default);
90-
9196
const getOrganizationByName = (organizations: Organization[], name: string) =>
9297
organizations.find((org) => org.name === name);

0 commit comments

Comments
 (0)