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

fix: only show editable orgs on deployment page#14193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
code-asher merged 14 commits intomainfromasher/show-editable-orgs
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
14 commits
Select commitHold shift + click to select a range
713b954
fix: only show editable orgs on deployment page
code-asherAug 6, 2024
9f39257
canEditOrganization was lying
code-asherAug 6, 2024
475f6e2
Limit the number of org permission checks
code-asherAug 7, 2024
83bf388
fixup! fix: only show editable orgs on deployment page
code-asherAug 7, 2024
e4f5bf7
Pass in ids instead of entire orgs
code-asherAug 8, 2024
9e76ebc
Assign permission value in one spot
code-asherAug 8, 2024
bfa0000
Improved sort
code-asherAug 8, 2024
b86684d
Tuples-b-gone
code-asherAug 8, 2024
6b42506
Destruct props
code-asherAug 8, 2024
02e7e47
No need for extra routes with a bit of ?
code-asherAug 8, 2024
ddd2ea5
Add org IDs to permissions query
code-asherAug 8, 2024
64631e1
Two is now one
code-asherAug 8, 2024
9aca231
Merge remote-tracking branch 'github/main' into asher/show-editable-orgs
code-asherAug 9, 2024
ba22bc9
fixup! Merge remote-tracking branch 'github/main' into asher/show-edi…
code-asherAug 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 98 additions & 28 deletionssite/src/api/queries/organizations.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import type { QueryClient } from "react-query";
import { API } from "api/api";
import type {
AuthorizationResponse,
CreateOrganizationRequest,
UpdateOrganizationRequest,
} from "api/typesGenerated";
Expand DownExpand Up@@ -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",
Expand All@@ -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",
Expand All@@ -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>,
);
},
};
};
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand All@@ -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.
*
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,6 @@ beforeEach(() => {
http.post("/api/v2/authcheck",async()=>{
returnHttpResponse.json({
editMembers:true,
viewMembers:true,
viewDeploymentValues:true,
});
}),
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -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");
});
});
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = () => {
Expand All@@ -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) => {
Expand All@@ -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);
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp