Skip to content

Commit 224e606

Browse files
feat: pbac org billing (calcom#23709)
* refactor layout to not check session * add actions for orgs + org admins * update types on actions to be correctly non nullable * Add tests for utils * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * restore lock file * add permission check action for org authentication managment * WIP BRANCH * Git merge fix conflicts * Fix imports * intro to pbac team billing * restore log * refactor to be a billing portal factory service * fix merge conflict * Fix merge conflicts * Passing test with non hardcoded vars * fix migration * Wip * remove layout permision checks * Fix type check * remove logs * improve error handling and logs * Fix nits * nits * Use push instead of slice for billing tab * Add permissions to memo * Fix credits handlers to use PBAC also * fix imports --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 14b3344 commit 224e606

20 files changed

Lines changed: 722 additions & 120 deletions

File tree

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
101101
name: "privacy",
102102
href: "/settings/organizations/privacy",
103103
},
104-
{
105-
name: "billing",
106-
href: "/settings/organizations/billing",
107-
},
104+
108105
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
109106
{
110107
name: "SSO",
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
173170
// The following keys are assigned to admin only
174171
const adminRequiredKeys = ["admin"];
175172
const organizationRequiredKeys = ["organization"];
176-
const organizationAdminKeys = [
177-
"privacy",
178-
"billing",
179-
"OAuth Clients",
180-
"SSO",
181-
"directory_sync",
182-
"delegation_credential",
183-
];
173+
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
184174

185175
export interface SettingsPermissions {
186176
canViewRoles?: boolean;
177+
canViewOrganizationBilling?: boolean;
187178
}
188179

189180
const useTabs = ({
@@ -232,11 +223,27 @@ const useTabs = ({
232223

233224
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
234225
// This prevents showing the menu item when user has no organization permissions
235-
if (isPbacEnabled && permissions?.canViewRoles) {
236-
newArray.push({
237-
name: "roles_and_permissions",
238-
href: "/settings/organizations/roles",
239-
});
226+
if (isPbacEnabled) {
227+
if (permissions?.canViewRoles) {
228+
newArray.push({
229+
name: "roles_and_permissions",
230+
href: "/settings/organizations/roles",
231+
});
232+
}
233+
234+
if (permissions?.canViewOrganizationBilling) {
235+
newArray.push({
236+
name: "billing",
237+
href: "/settings/organizations/billing",
238+
});
239+
}
240+
} else {
241+
if (isOrgAdminOrOwner) {
242+
newArray.push({
243+
name: "billing",
244+
href: "/settings/organizations/billing",
245+
});
246+
}
240247
}
241248

242249
return {
@@ -272,7 +279,7 @@ const useTabs = ({
272279
if (isAdmin) return true;
273280
return !adminRequiredKeys.includes(tab.name);
274281
});
275-
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
282+
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
276283

277284
return processTabsMemod;
278285
};

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
33
import { redirect } from "next/navigation";
44
import React from "react";
55

6+
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
67
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
78
import type { TeamFeatures } from "@calcom/features/flags/config";
89
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
910
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
10-
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
11+
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
1112
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
1213
import { prisma } from "@calcom/prisma";
1314

@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
4546

4647
let teamFeatures: Record<number, TeamFeatures> | null = null;
4748
let canViewRoles = false;
49+
let canViewOrganizationBilling = false;
4850
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
4951

5052
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
5153
if (orgId) {
52-
const [features, rolePermissions] = await Promise.all([
54+
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
55+
const [features, rolePermissions, organizationPermissions] = await Promise.all([
5356
getTeamFeatures(orgId),
5457
getCachedResourcePermissions(userId, orgId, Resource.Role),
58+
getCachedResourcePermissions(userId, orgId, Resource.Organization),
5559
]);
5660

5761
if (features) {
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
6266
// Check if user has permission to read roles
6367
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
6468
canViewRoles = roleActions[CrudAction.Read] ?? false;
69+
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
70+
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
6571
}
6672
}
6773

@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
7076
<SettingsLayoutAppDirClient
7177
{...props}
7278
teamFeatures={teamFeatures ?? {}}
73-
permissions={{ canViewRoles }}
79+
permissions={{ canViewRoles, canViewOrganizationBilling }}
7480
/>
7581
</>
7682
);

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
22
import { getTranslate } from "app/_utils";
33

44
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
5+
import { MembershipRole } from "@calcom/prisma/enums";
56

67
import BillingView from "~/settings/billing/billing-view";
78

8-
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
9+
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
910

1011
export const generateMetadata = async () =>
1112
await _generateMetadata(
@@ -18,9 +19,11 @@ export const generateMetadata = async () =>
1819

1920
const Page = async () => {
2021
const t = await getTranslate();
21-
await validateUserHasOrgAdmin();
2222

23-
// TODO(SEAN): Add PBAC to this page in the next PR
23+
await validateUserHasOrgPerms({
24+
permission: "organization.manageBilling",
25+
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
26+
});
2427

2528
return (
2629
<SettingsHeader

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/layout.tsx

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,4 @@
1-
import { cookies, headers } from "next/headers";
2-
import { redirect } from "next/navigation";
3-
4-
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
5-
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
6-
7-
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
8-
91
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
10-
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
11-
const userProfile = session?.user?.profile;
12-
const userId = session?.user?.id;
13-
const orgRole =
14-
session?.user?.org?.role ??
15-
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
16-
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
17-
18-
if (!isOrgAdminOrOwner) {
19-
return redirect("/settings/organizations/profile");
20-
}
21-
222
return children;
233
};
244

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { redirect } from "next/navigation";
2+
3+
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
4+
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
5+
import type { MembershipRole } from "@calcom/prisma/enums";
6+
7+
import { validateUserHasOrg } from "./validateUserHasOrg";
8+
9+
export const validateUserHasOrgPerms = async ({
10+
redirectTo,
11+
fallbackRoles,
12+
permission,
13+
}: {
14+
redirectTo?: string;
15+
permission: PermissionString;
16+
fallbackRoles: MembershipRole[];
17+
}) => {
18+
const session = await validateUserHasOrg();
19+
20+
const permissionCheckService = new PermissionCheckService();
21+
22+
const hasPermission = await permissionCheckService.checkPermission({
23+
userId: session.user.id,
24+
teamId: session.user.org.id,
25+
permission,
26+
fallbackRoles,
27+
});
28+
29+
if (!hasPermission) {
30+
redirect(redirectTo || "/settings/my-account/profile");
31+
}
32+
33+
return session;
34+
};

0 commit comments

Comments
 (0)