feat: add permission for editUsers + implement UI (#25402)
## What does this PR do? This PR implements attributes PBAC - router checks + UI ## Visual Demo (For contributors especially) A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**. #### Video Demo (if applicable): - Show screen recordings of the issue or feature. - Demonstrate how to reproduce the issue, the behavior before and after the change. #### Image Demo (if applicable): - Add side-by-side screenshots of the original and updated change. - Highlight any significant change(s). ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? Enable PBAC on an org Create a custom role -> advanced -> organizations -> "editUser" Assign it to a user impersonate user test they have access to all things attributes remove permissions check they dont have permissions. ## Checklist <!-- Remove bullet points below that don't apply to you --> - I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code doesn't follow the style guidelines of this project - I haven't commented my code, particularly in hard-to-understand areas - I haven't checked if my changes generate no new warnings <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a new PBAC “editUsers” permission and read gating for Attributes, updating the UI and backend so users can view and edit attributes only when allowed. - **New Features** - Added CustomAction.EditUsers to the permission registry for organization-scoped attribute editing. - Settings computes canViewAttributes and shows the Attributes tab only when allowed. - Members page fetches Attributes permissions and exposes canViewAttributes and canEditAttributesForUser to the UI. - User Edit Sheet hides attributes without read permission and shows attribute editing and the bulk “Mass Assign Attributes” action only with editUsers; other user edits depend on changeMemberRole. - Attributes TRPC router gates create/edit/delete/toggle via PBAC and requires organization.attributes.editUsers for assign/bulk-assign; added a helper to create PBAC-aware procedures. - **Migration** - Run the new Prisma migration to seed the admin role with editUsers. - If using custom roles, grant Edit Users under Attributes as needed. <sup>Written for commit 856aa2e8e1521fc22cd71cb0fa6d720036efe8a2. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+13
-12
@@ -181,6 +181,7 @@ export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
canUpdateOrganization?: boolean;
|
||||
canViewAttributes?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
@@ -211,7 +212,7 @@ const useTabs = ({
|
||||
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
if (permissions?.canViewAttributes) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
@@ -452,7 +453,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
@@ -460,7 +461,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
||||
@@ -481,7 +482,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
@@ -491,7 +492,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -500,7 +501,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("settings")}
|
||||
href={`/settings/teams/${team.id}/settings`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -578,7 +579,7 @@ const SettingsSidebarContainer = ({
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
className={classNames(
|
||||
"no-scrollbar bg-cal-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col stack-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
"no-scrollbar bg-cal-muted stack-y-1 fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
|
||||
className,
|
||||
navigationIsOpenedOnMobile
|
||||
? "translate-x-0 opacity-100"
|
||||
@@ -626,7 +627,7 @@ const SettingsSidebarContainer = ({
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`h-7 w-fit px-2! ${
|
||||
className={`px-2! h-7 w-fit ${
|
||||
tab.children && index === tab.children?.length - 1 && "mb-3!"
|
||||
}`}
|
||||
disableChevron
|
||||
@@ -668,7 +669,7 @@ const SettingsSidebarContainer = ({
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
@@ -772,14 +773,14 @@ const SettingsSidebarContainer = ({
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto px-2!"
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
<>
|
||||
@@ -881,7 +882,7 @@ export default function SettingsLayoutAppDirClient({
|
||||
TopNavContainer={
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 *:flex-1">
|
||||
<div className="*:flex-1 flex flex-1">
|
||||
<div
|
||||
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
|
||||
@@ -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) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization, canViewAttributes }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+58
-39
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -71,11 +71,13 @@ export function EditForm({
|
||||
avatarUrl,
|
||||
domainUrl,
|
||||
dispatch,
|
||||
canEditAttributesForUser,
|
||||
}: {
|
||||
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
|
||||
avatarUrl: string;
|
||||
domainUrl: string;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
canEditAttributesForUser?: boolean;
|
||||
}) {
|
||||
const setEditMode = useEditMode((state) => state.setEditMode);
|
||||
const [mutationLoading, setMutationLoading] = useState(false);
|
||||
@@ -249,8 +251,12 @@ export function EditForm({
|
||||
<Label>{t("timezone")}</Label>
|
||||
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
|
||||
</div>
|
||||
<Divider />
|
||||
<AttributesList selectedUserId={selectedUser?.id} />
|
||||
{canEditAttributesForUser && (
|
||||
<>
|
||||
<Divider />
|
||||
<AttributesList selectedUserId={selectedUser?.id} />
|
||||
</>
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
|
||||
@@ -23,9 +23,15 @@ function removeProtocol(url: string) {
|
||||
export function EditUserSheet({
|
||||
state,
|
||||
dispatch,
|
||||
canViewAttributes,
|
||||
canEditAttributesForUser,
|
||||
canChangeMemberRole,
|
||||
}: {
|
||||
state: UserTableState;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
canViewAttributes?: boolean;
|
||||
canEditAttributesForUser?: boolean;
|
||||
canChangeMemberRole?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { user: selectedUser } = state.editSheet;
|
||||
@@ -47,7 +53,7 @@ export function EditUserSheet({
|
||||
userId: selectedUser?.id,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedUser?.id,
|
||||
enabled: !!selectedUser?.id && !!canViewAttributes,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -83,8 +89,8 @@ export function EditUserSheet({
|
||||
</p>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody className="flex flex-col stack-y-4 p-4">
|
||||
<div className="mb-4 flex flex-col stack-y-4">
|
||||
<SheetBody className="stack-y-4 flex flex-col p-4">
|
||||
<div className="stack-y-4 mb-4 flex flex-col">
|
||||
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
|
||||
<DisplayInfo
|
||||
label="Cal"
|
||||
@@ -108,10 +114,10 @@ export function EditUserSheet({
|
||||
icon="calendar"
|
||||
/>
|
||||
</div>
|
||||
{usersAttributes && usersAttributes?.length > 0 && (
|
||||
{canViewAttributes && usersAttributes && usersAttributes?.length > 0 && (
|
||||
<div className="mt-4 flex flex-col">
|
||||
<h3 className="text-emphasis mb-5 text-base font-semibold">{t("attributes")}</h3>
|
||||
<div className="flex flex-col stack-y-4">
|
||||
<div className="stack-y-4 flex flex-col">
|
||||
{usersAttributes.map((attribute, index) => (
|
||||
<>
|
||||
<DisplayInfo
|
||||
@@ -130,7 +136,10 @@ export function EditUserSheet({
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetFooterControls />
|
||||
<SheetFooterControls
|
||||
canEditAttributesForUser={canEditAttributesForUser}
|
||||
canChangeMemberRole={canChangeMemberRole}
|
||||
/>
|
||||
</SheetFooter>
|
||||
</>
|
||||
) : (
|
||||
@@ -140,6 +149,7 @@ export function EditUserSheet({
|
||||
avatarUrl={loadedUser.avatarUrl ?? avatarURL}
|
||||
domainUrl={orgBranding?.fullDomain ?? WEBAPP_URL}
|
||||
dispatch={dispatch}
|
||||
canEditAttributesForUser={canEditAttributesForUser}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -28,10 +28,19 @@ function EditModeFooter() {
|
||||
);
|
||||
}
|
||||
|
||||
function MoreInfoFooter() {
|
||||
function MoreInfoFooter({
|
||||
canEditAttributesForUser,
|
||||
canChangeMemberRole,
|
||||
}: {
|
||||
canEditAttributesForUser?: boolean;
|
||||
canChangeMemberRole?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const setEditMode = useEditMode((state) => state.setEditMode);
|
||||
|
||||
// Show edit button if user can change member role (edit user info) or edit attributes
|
||||
const canEdit = canChangeMemberRole || canEditAttributesForUser;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetClose asChild>
|
||||
@@ -39,21 +48,40 @@ function MoreInfoFooter() {
|
||||
{t("close")}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
key="EDIT_BUTTON"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
key="EDIT_BUTTON"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetFooterControls() {
|
||||
export function SheetFooterControls({
|
||||
canEditAttributesForUser,
|
||||
canChangeMemberRole,
|
||||
}: {
|
||||
canEditAttributesForUser?: boolean;
|
||||
canChangeMemberRole?: boolean;
|
||||
}) {
|
||||
const editMode = useEditMode((state) => state.editMode);
|
||||
return <>{editMode ? <EditModeFooter /> : <MoreInfoFooter />}</>;
|
||||
return (
|
||||
<>
|
||||
{editMode ? (
|
||||
<EditModeFooter />
|
||||
) : (
|
||||
<MoreInfoFooter
|
||||
canEditAttributesForUser={canEditAttributesForUser}
|
||||
canChangeMemberRole={canChangeMemberRole}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,16 +449,19 @@ function UserListTableContent({
|
||||
const isSelf = user.id === session?.user.id;
|
||||
|
||||
const permissionsForUser = {
|
||||
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
|
||||
canRemove: permissionsRaw.canRemove && !isSelf,
|
||||
canEdit:
|
||||
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
|
||||
user.accepted &&
|
||||
!isSelf,
|
||||
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
|
||||
canImpersonate:
|
||||
user.accepted &&
|
||||
!user.disableImpersonation &&
|
||||
!isSelf &&
|
||||
!!org?.canAdminImpersonate &&
|
||||
permissionsRaw.canImpersonate,
|
||||
(permissionsRaw.canImpersonate ?? false),
|
||||
canLeave: user.accepted && isSelf,
|
||||
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
|
||||
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -633,7 +636,7 @@ function UserListTableContent({
|
||||
{t("group_meeting")}
|
||||
</DataTableSelectionBar.Button>
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
@@ -655,7 +658,15 @@ function UserListTableContent({
|
||||
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
|
||||
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
|
||||
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
|
||||
{state.editSheet.showModal && <EditUserSheet dispatch={dispatch} state={state} />}
|
||||
{state.editSheet.showModal && (
|
||||
<EditUserSheet
|
||||
dispatch={dispatch}
|
||||
state={state}
|
||||
canViewAttributes={permissions?.canViewAttributes}
|
||||
canEditAttributesForUser={permissions?.canEditAttributesForUser}
|
||||
canChangeMemberRole={permissions?.canChangeMemberRole ?? adminOrOwner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ctaContainerRef.current &&
|
||||
createPortal(
|
||||
|
||||
@@ -59,4 +59,6 @@ export interface MemberPermissions {
|
||||
canChangeMemberRole: boolean;
|
||||
canRemove: boolean;
|
||||
canImpersonate: boolean;
|
||||
canEditAttributesForUser?: boolean;
|
||||
canViewAttributes?: boolean;
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- Insert editUsers permission for admin role
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
-- Attribute editUsers permission
|
||||
('organization.attributes', 'editUsers')
|
||||
) AS permissions(resource, action)
|
||||
ON CONFLICT ("roleId", resource, action) DO NOTHING;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure";
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema";
|
||||
import { bulkAssignAttributesSchema } from "./bulkAssignAttributes.schema";
|
||||
@@ -11,6 +11,7 @@ import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMem
|
||||
import { getAttributeSchema } from "./get.schema";
|
||||
import { getByUserIdSchema } from "./getByUserId.schema";
|
||||
import { toggleActiveSchema } from "./toggleActive.schema";
|
||||
import { createAttributePbacProcedure } from "./util";
|
||||
|
||||
export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer<
|
||||
typeof ZFindTeamMembersMatchingAttributeLogicInputSchema
|
||||
@@ -30,31 +31,39 @@ export const attributesRouter = router({
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
// Mutations
|
||||
create: authedOrgAdminProcedure.input(createAttributeSchema).mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./create.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
edit: authedOrgAdminProcedure.input(editAttributeSchema).mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./edit.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
delete: authedOrgAdminProcedure.input(deleteAttributeSchema).mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./delete.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
toggleActive: authedOrgAdminProcedure.input(toggleActiveSchema).mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./toggleActive.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
create: createAttributePbacProcedure("organization.attributes.create")
|
||||
.input(createAttributeSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./create.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
edit: createAttributePbacProcedure("organization.attributes.update")
|
||||
.input(editAttributeSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./edit.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
delete: createAttributePbacProcedure("organization.attributes.delete")
|
||||
.input(deleteAttributeSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./delete.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
toggleActive: createAttributePbacProcedure("organization.attributes.update")
|
||||
.input(toggleActiveSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./toggleActive.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
assignUserToAttribute: authedOrgAdminProcedure
|
||||
assignUserToAttribute: createAttributePbacProcedure("organization.attributes.editUsers")
|
||||
.input(assignUserToAttributeSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./assignUserToAttribute.handler");
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
bulkAssignAttributes: authedOrgAdminProcedure
|
||||
bulkAssignAttributes: createAttributePbacProcedure("organization.attributes.editUsers")
|
||||
.input(bulkAssignAttributesSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { default: handler } = await import("./bulkAssignAttributes.handler");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
|
||||
/**
|
||||
* Creates an attribute procedure with configurable PBAC permissions for organization-scoped operations
|
||||
* @param permission - The specific permission required (e.g., "organization.attributes.editUsers", "organization.attributes.create")
|
||||
* @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"])
|
||||
* @returns A procedure that checks the specified permission
|
||||
*/
|
||||
export const createAttributePbacProcedure = (
|
||||
permission: PermissionString,
|
||||
fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
|
||||
) => {
|
||||
return authedProcedure.use(async ({ ctx, next }) => {
|
||||
const org = ctx.user.organization;
|
||||
|
||||
if (!org?.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to be part of an organization to use this feature",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: org.id,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user