{children}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
index b106939634..e3e71e95a0 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx
@@ -48,6 +48,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let canViewRoles = false;
let canViewOrganizationBilling = false;
let canUpdateOrganization = false;
+ let canViewAttributes = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
@@ -66,9 +67,10 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
- const [rolePermissions, organizationPermissions] = await Promise.all([
+ const [rolePermissions, organizationPermissions, attributesPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
+ getCachedResourcePermissions(userId, orgId, Resource.Attributes),
]);
// Check if user has permission to read roles
@@ -77,11 +79,14 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
+ const attributesActions = PermissionMapper.toActionMap(attributesPermissions, Resource.Attributes);
+ canViewAttributes = attributesActions[CrudAction.Read] ?? isOrgAdminOrOwner;
} else {
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
+ canViewAttributes = isOrgAdminOrOwner;
}
}
@@ -90,7 +95,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
>
);
diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
index c8b55bd3d1..97ded33368 100644
--- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
@@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
-import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
+import { Resource, CustomAction, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
@@ -63,50 +63,69 @@ const Page = async () => {
}
// Get specific PBAC permissions for organization member actions
- const permissions = await getSpecificPermissions({
- userId: session.user.id,
- teamId: session.user.profile.organizationId,
- resource: Resource.Organization,
- userRole: session.user.org.role,
- actions: [
- CustomAction.ListMembers,
- CustomAction.ListMembersPrivate,
- CustomAction.Invite,
- CustomAction.ChangeMemberRole,
- CustomAction.Remove,
- CustomAction.Impersonate,
- ],
- fallbackRoles: {
- [CustomAction.ListMembers]: {
- roles: fallbackRolesThatCanSeeMembers,
+ const [orgPermissions, attributesPermissions] = await Promise.all([
+ getSpecificPermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ actions: [
+ CustomAction.ListMembers,
+ CustomAction.ListMembersPrivate,
+ CustomAction.Invite,
+ CustomAction.ChangeMemberRole,
+ CustomAction.Remove,
+ CustomAction.Impersonate,
+ ],
+ fallbackRoles: {
+ [CustomAction.ListMembers]: {
+ roles: fallbackRolesThatCanSeeMembers,
+ },
+ [CustomAction.ListMembersPrivate]: {
+ roles: fallbackRolesThatCanSeeMembers,
+ },
+ [CustomAction.Invite]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.ChangeMemberRole]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.Remove]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.Impersonate]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
},
- [CustomAction.ListMembersPrivate]: {
- roles: fallbackRolesThatCanSeeMembers,
+ }),
+ getSpecificPermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Attributes,
+ userRole: session.user.org.role,
+ actions: [CrudAction.Read, CustomAction.EditUsers],
+ fallbackRoles: {
+ [CrudAction.Read]: {
+ roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ [CustomAction.EditUsers]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
},
- [CustomAction.Invite]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.ChangeMemberRole]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.Remove]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- [CustomAction.Impersonate]: {
- roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- },
- });
+ }),
+ ]);
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: org.isPrivate
- ? permissions[CustomAction.ListMembersPrivate]
- : permissions[CustomAction.ListMembers],
- canInvite: permissions[CustomAction.Invite],
- canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
- canRemove: permissions[CustomAction.Remove],
- canImpersonate: permissions[CustomAction.Impersonate],
+ ? orgPermissions[CustomAction.ListMembersPrivate]
+ : orgPermissions[CustomAction.ListMembers],
+ canInvite: orgPermissions[CustomAction.Invite],
+ canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
+ canRemove: orgPermissions[CustomAction.Remove],
+ canImpersonate: orgPermissions[CustomAction.Impersonate],
+ canViewAttributes: attributesPermissions[CrudAction.Read],
+ canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
const facetedTeamValues = {
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 5d7c54f44a..fddd3c90f8 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3599,6 +3599,7 @@
"pbac_action_read_org_bookings": "View Organization Bookings",
"pbac_action_read_recordings": "View Recordings",
"pbac_action_impersonate": "Impersonate",
+ "pbac_action_edit_users": "Edit Users",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3612,6 +3613,7 @@
"pbac_desc_update_organization_attributes": "Update organization attributes",
"pbac_desc_delete_organization_attributes": "Delete organization attributes",
"pbac_desc_create_organization_attributes": "Create organization attributes",
+ "pbac_desc_edit_user_attributes": "Edit user attributes",
"pbac_desc_view_roles": "View roles",
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts
index 90efdbd3ce..0dd2a7b93d 100644
--- a/packages/features/pbac/domain/types/permission-registry.ts
+++ b/packages/features/pbac/domain/types/permission-registry.ts
@@ -34,6 +34,7 @@ export enum CustomAction {
ReadOrgBookings = "readOrgBookings",
ReadRecordings = "readRecordings",
Impersonate = "impersonate",
+ EditUsers = "editUsers",
}
export enum Scope {
@@ -316,6 +317,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team",
i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_members",
+ dependsOn: ["team.read"],
visibleWhen: {
teamPrivacy: "public", // Only show for public teams
},
@@ -559,6 +561,22 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
scope: [Scope.Organization],
dependsOn: ["organization.attributes.read"],
},
+ [CustomAction.EditUsers]: {
+ description: "Edit user attributes",
+ category: "attributes",
+ i18nKey: "pbac_action_edit_users",
+ descriptionI18nKey: "pbac_desc_edit_user_attributes",
+ scope: [Scope.Organization],
+ dependsOn: [
+ "organization.read",
+ "organization.listMembers",
+ "organization.attributes.read",
+ "organization.attributes.update",
+ "organization.attributes.delete",
+ "organization.attributes.create",
+ "organization.changeMemberRole",
+ ],
+ },
},
[Resource.RoutingForm]: {
_resource: {
diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
index 0be79ca710..5b6611a222 100644
--- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
+++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
@@ -71,11 +71,13 @@ export function EditForm({
avatarUrl,
domainUrl,
dispatch,
+ canEditAttributesForUser,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch;
+ canEditAttributesForUser?: boolean;
}) {
const setEditMode = useEditMode((state) => state.setEditMode);
const [mutationLoading, setMutationLoading] = useState(false);
@@ -249,8 +251,12 @@ export function EditForm({
-
-
+ {canEditAttributesForUser && (
+ <>
+
+
+ >
+ )}