Skip to content

Commit f01285d

Browse files
committed
fix: only show editable orgs on deployment page
Also make sure the redirect from /organizations goes to an org that the user can edit, rather than always the default org.
1 parent 3b53f5a commit f01285d

8 files changed

+494
-154
lines changed

site/src/api/queries/organizations.ts

+99-45
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { QueryClient } from "react-query";
22
import { API } from "api/api";
33
import type {
4+
AuthorizationCheck,
5+
AuthorizationResponse,
46
CreateOrganizationRequest,
7+
Organization,
58
UpdateOrganizationRequest,
69
} from "api/typesGenerated";
710
import { meKey } from "./users";
@@ -121,6 +124,53 @@ export const provisionerDaemons = (organization: string) => {
121124
};
122125
};
123126

127+
const orgChecks = (
128+
organizationId: string,
129+
): Record<string, AuthorizationCheck> => ({
130+
viewMembers: {
131+
object: {
132+
resource_type: "organization_member",
133+
organization_id: organizationId,
134+
},
135+
action: "read",
136+
},
137+
editMembers: {
138+
object: {
139+
resource_type: "organization_member",
140+
organization_id: organizationId,
141+
},
142+
action: "update",
143+
},
144+
createGroup: {
145+
object: {
146+
resource_type: "group",
147+
organization_id: organizationId,
148+
},
149+
action: "create",
150+
},
151+
viewGroups: {
152+
object: {
153+
resource_type: "group",
154+
organization_id: organizationId,
155+
},
156+
action: "read",
157+
},
158+
editOrganization: {
159+
object: {
160+
resource_type: "organization",
161+
organization_id: organizationId,
162+
},
163+
action: "update",
164+
},
165+
auditOrganization: {
166+
object: {
167+
resource_type: "audit_log",
168+
organization_id: organizationId,
169+
},
170+
action: "read",
171+
},
172+
});
173+
124174
/**
125175
* Fetch permissions for a single organization.
126176
*
@@ -133,51 +183,55 @@ export const organizationPermissions = (organizationId: string | undefined) => {
133183
return {
134184
queryKey: ["organization", organizationId, "permissions"],
135185
queryFn: () =>
136-
API.checkAuthorization({
137-
checks: {
138-
viewMembers: {
139-
object: {
140-
resource_type: "organization_member",
141-
organization_id: organizationId,
142-
},
143-
action: "read",
144-
},
145-
editMembers: {
146-
object: {
147-
resource_type: "organization_member",
148-
organization_id: organizationId,
149-
},
150-
action: "update",
151-
},
152-
createGroup: {
153-
object: {
154-
resource_type: "group",
155-
organization_id: organizationId,
156-
},
157-
action: "create",
158-
},
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-
},
186+
API.checkAuthorization({ checks: orgChecks(organizationId) }),
187+
};
188+
};
189+
190+
/**
191+
* Fetch permissions for all provided organizations.
192+
*
193+
* If organizations are undefined, return a disabled query.
194+
*/
195+
export const organizationsPermissions = (
196+
organizations: Organization[] | undefined,
197+
) => {
198+
if (!organizations) {
199+
return { enabled: false };
200+
}
201+
202+
return {
203+
queryKey: ["organizations", "permissions"],
204+
queryFn: async () => {
205+
// The endpoint takes a flat array, so to avoid collisions prepend each
206+
// check with the org ID (the key can be anything we want).
207+
const checks = organizations
208+
.map((org) =>
209+
Object.entries(orgChecks(org.id)).map(([key, val]) => [
210+
`${org.id}.${key}`,
211+
val,
212+
]),
213+
)
214+
.flat();
215+
216+
const response = await API.checkAuthorization({
217+
checks: Object.fromEntries(checks),
218+
});
219+
220+
// Now we can unflatten by parsing out the org ID from each check.
221+
return Object.entries(response).reduce(
222+
(acc, [key, value]) => {
223+
const index = key.indexOf(".");
224+
const orgId = key.substring(0, index);
225+
const perm = key.substring(index + 1);
226+
if (!acc[orgId]) {
227+
acc[orgId] = { [perm]: value };
228+
} else {
229+
acc[orgId][perm] = value;
230+
}
231+
return acc;
180232
},
181-
}),
233+
{} as Record<string, AuthorizationResponse>,
234+
);
235+
},
182236
};
183237
};

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 &&
32+
(permissions.editOrganization ||
33+
permissions.viewMembers ||
34+
permissions.viewGroups)
35+
);
36+
};
37+
2438
/**
2539
* A multi-org capable settings page layout.
2640
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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",
20+
extraRoutes: [
21+
{
22+
path: "/organizations/:organization",
23+
element: <OrganizationSettingsPage />,
24+
},
25+
],
26+
});
27+
await waitForLoaderToBeRemoved();
28+
};
29+
30+
const renderPage = async (orgName: string) => {
31+
renderWithManagementSettingsLayout(<OrganizationSettingsPage />, {
32+
route: `/organizations/${orgName}`,
33+
path: "/organizations/:organization",
34+
});
35+
await waitForLoaderToBeRemoved();
36+
};
37+
38+
describe("OrganizationSettingsPage", () => {
39+
it("has no organizations", async () => {
40+
server.use(
41+
http.get("/api/v2/organizations", () => {
42+
return HttpResponse.json([]);
43+
}),
44+
http.post("/api/v2/authcheck", async () => {
45+
return HttpResponse.json({
46+
[`${MockDefaultOrganization.id}.editOrganization`]: true,
47+
viewDeploymentValues: true,
48+
});
49+
}),
50+
);
51+
await renderRootPage();
52+
await screen.findByText("No organizations found");
53+
});
54+
55+
it("has no editable organizations", async () => {
56+
server.use(
57+
http.get("/api/v2/organizations", () => {
58+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
59+
}),
60+
http.post("/api/v2/authcheck", async () => {
61+
return HttpResponse.json({
62+
viewDeploymentValues: true,
63+
});
64+
}),
65+
);
66+
await renderRootPage();
67+
await screen.findByText("No organizations found");
68+
});
69+
70+
it("redirects to default organization", async () => {
71+
server.use(
72+
http.get("/api/v2/organizations", () => {
73+
// Default always preferred regardless of order.
74+
return HttpResponse.json([MockOrganization2, MockDefaultOrganization]);
75+
}),
76+
http.post("/api/v2/authcheck", async () => {
77+
return HttpResponse.json({
78+
[`${MockDefaultOrganization.id}.editOrganization`]: true,
79+
[`${MockOrganization2.id}.editOrganization`]: true,
80+
viewDeploymentValues: true,
81+
});
82+
}),
83+
);
84+
await renderRootPage();
85+
const form = screen.getByTestId("org-settings-form");
86+
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
87+
MockDefaultOrganization.name,
88+
);
89+
});
90+
91+
it("redirects to non-default organization", async () => {
92+
server.use(
93+
http.get("/api/v2/organizations", () => {
94+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
95+
}),
96+
http.post("/api/v2/authcheck", async () => {
97+
return HttpResponse.json({
98+
[`${MockOrganization2.id}.editOrganization`]: true,
99+
viewDeploymentValues: true,
100+
});
101+
}),
102+
);
103+
await renderRootPage();
104+
const form = screen.getByTestId("org-settings-form");
105+
expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue(
106+
MockOrganization2.name,
107+
);
108+
});
109+
110+
it("cannot find organization", async () => {
111+
server.use(
112+
http.get("/api/v2/organizations", () => {
113+
return HttpResponse.json([MockDefaultOrganization, MockOrganization2]);
114+
}),
115+
http.post("/api/v2/authcheck", async () => {
116+
return HttpResponse.json({
117+
[`${MockOrganization2.id}.editOrganization`]: true,
118+
viewDeploymentValues: true,
119+
});
120+
}),
121+
);
122+
await renderPage("the-endless-void");
123+
await screen.findByText("Organization not found");
124+
});
125+
});

0 commit comments

Comments
 (0)