Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit6019d0b

Browse files
authored
fix: only show editable orgs on deployment page (#14193)
Also make sure the redirect from /organizations goes to an org that theuser can edit, rather than always the default org.
1 parentd6c4d47 commit6019d0b

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
importtype{QueryClient}from"react-query";
22
import{API}from"api/api";
33
importtype{
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+
exportconstorganizationsPermissions=(
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+
constchecks=(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+
constprefixedChecks=organizationIds
232+
.map((orgId)=>
233+
Object.entries(checks(orgId)).map(([key,val])=>[
234+
`${orgId}.${key}`,
235+
val,
236+
]),
237+
)
238+
.flat();
239+
240+
constresponse=awaitAPI.checkAuthorization({
241+
checks:Object.fromEntries(prefixedChecks),
242+
});
243+
244+
// Now we can unflatten by parsing out the org ID from each check.
245+
returnObject.entries(response).reduce(
246+
(acc,[key,value])=>{
247+
constindex=key.indexOf(".");
248+
constorgId=key.substring(0,index);
249+
constperm=key.substring(index+1);
250+
if(!acc[orgId]){
251+
acc[orgId]={};
252+
}
253+
acc[orgId][perm]=value;
254+
returnacc;
255+
},
256+
{}asRecord<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-
importtype{Organization}from"api/typesGenerated";
5+
importtype{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+
exportconstcanEditOrganization=(
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
returnHttpResponse.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+
importOrganizationSettingsPagefrom"./OrganizationSettingsPage";
13+
14+
jest.spyOn(console,"error").mockImplementation(()=>{});
15+
16+
constrenderRootPage=async()=>{
17+
renderWithManagementSettingsLayout(<OrganizationSettingsPage/>,{
18+
route:"/organizations",
19+
path:"/organizations/:organization?",
20+
});
21+
awaitwaitForLoaderToBeRemoved();
22+
};
23+
24+
constrenderPage=async(orgName:string)=>{
25+
renderWithManagementSettingsLayout(<OrganizationSettingsPage/>,{
26+
route:`/organizations/${orgName}`,
27+
path:"/organizations/:organization",
28+
});
29+
awaitwaitForLoaderToBeRemoved();
30+
};
31+
32+
describe("OrganizationSettingsPage",()=>{
33+
it("has no organizations",async()=>{
34+
server.use(
35+
http.get("/api/v2/organizations",()=>{
36+
returnHttpResponse.json([]);
37+
}),
38+
http.post("/api/v2/authcheck",async()=>{
39+
returnHttpResponse.json({
40+
[`${MockDefaultOrganization.id}.editOrganization`]:true,
41+
viewDeploymentValues:true,
42+
});
43+
}),
44+
);
45+
awaitrenderRootPage();
46+
awaitscreen.findByText("No organizations found");
47+
});
48+
49+
it("has no editable organizations",async()=>{
50+
server.use(
51+
http.get("/api/v2/organizations",()=>{
52+
returnHttpResponse.json([MockDefaultOrganization,MockOrganization2]);
53+
}),
54+
http.post("/api/v2/authcheck",async()=>{
55+
returnHttpResponse.json({
56+
viewDeploymentValues:true,
57+
});
58+
}),
59+
);
60+
awaitrenderRootPage();
61+
awaitscreen.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+
returnHttpResponse.json([MockOrganization2,MockDefaultOrganization]);
69+
}),
70+
http.post("/api/v2/authcheck",async()=>{
71+
returnHttpResponse.json({
72+
[`${MockDefaultOrganization.id}.editOrganization`]:true,
73+
[`${MockOrganization2.id}.editOrganization`]:true,
74+
viewDeploymentValues:true,
75+
});
76+
}),
77+
);
78+
awaitrenderRootPage();
79+
constform=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+
returnHttpResponse.json([MockDefaultOrganization,MockOrganization2]);
89+
}),
90+
http.post("/api/v2/authcheck",async()=>{
91+
returnHttpResponse.json({
92+
[`${MockOrganization2.id}.editOrganization`]:true,
93+
viewDeploymentValues:true,
94+
});
95+
}),
96+
);
97+
awaitrenderRootPage();
98+
constform=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+
returnHttpResponse.json([MockDefaultOrganization,MockOrganization2]);
108+
}),
109+
http.post("/api/v2/authcheck",async()=>{
110+
returnHttpResponse.json({
111+
[`${MockOrganization2.id}.editOrganization`]:true,
112+
viewDeploymentValues:true,
113+
});
114+
}),
115+
);
116+
awaitrenderPage("the-endless-void");
117+
awaitscreen.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
importtype{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
constOrganizationSettingsPage:FC=()=>{
@@ -32,37 +35,42 @@ const OrganizationSettingsPage: FC = () => {
3235
organizations&&organizationName
3336
?getOrganizationByName(organizations,organizationName)
3437
:undefined;
35-
constpermissionsQuery=useQuery(organizationPermissions(organization?.id));
38+
constpermissionsQuery=useQuery(
39+
organizationsPermissions(organizations?.map((o)=>o.id)),
40+
);
3641

37-
if(!organizations){
42+
constpermissions=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-
constdefaultOrg=getOrganizationByDefault(organizations);
44-
if(defaultOrg){
45-
return<Navigateto={`/organizations/${defaultOrg.name}`}replace/>;
50+
consteditableOrg=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<Navigateto={`/organizations/${editableOrg.name}`}replace/>;
4660
}
47-
// We expect there to always be a default organization.
48-
thrownewError("No default organization found");
61+
return<EmptyStatemessage="No organizations found"/>;
4962
}
5063

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

55-
constpermissions=permissionsQuery.data;
56-
if(!permissions){
57-
return<Loader/>;
58-
}
59-
6068
consterror=
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
exportdefaultOrganizationSettingsPage;
8795

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

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp