Skip to content

Commit 6019d0b

Browse files
authored
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.
1 parent d6c4d47 commit 6019d0b

9 files changed

+536
-178
lines changed

site/src/api/queries/organizations.ts

Lines changed: 98 additions & 28 deletions
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

Lines changed: 15 additions & 1 deletion
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

Lines changed: 0 additions & 1 deletion
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
}),
Lines changed: 119 additions & 0 deletions
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

Lines changed: 24 additions & 19 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy