feat: add organization.passwordReset PBAC permission (#27377)

* feat: add organization.passwordReset PBAC permission

Allow org admins/owners to reset passwords for members of their
organization via a new PBAC permission. Previously this was only
available to system-level admins.

- Add PasswordReset to CustomAction enum and PERMISSION_REGISTRY
- Create migration to grant permission to admin_role (owner has wildcard)
- Add org-scoped tRPC endpoint using createOrgPbacProcedure
- Handler validates org membership, prevents self-targeting, and blocks
  resetting owner passwords
- Wire permission through MemberPermissions, getOrgMembersPageData, and
  the org members table UI dropdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use targeted select in org password reset to avoid over-fetching

Replace findById with findForPasswordReset repository method that only
selects email, name, and locale instead of the full userSelect which
includes sensitive fields like twoFactorSecret and backupCodes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add unit tests for sendPasswordReset handler

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
sean-brydon
2026-01-29 11:18:02 +00:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 1f8f134854
commit 08a2b0bfb5
12 changed files with 410 additions and 132 deletions
@@ -95,10 +95,7 @@ const initalColumnVisibility = {
actions: true,
};
function reducer(
state: UserTableState,
action: UserTableAction
): UserTableState {
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
@@ -146,11 +143,7 @@ function UserListTable(props: UserListTableProps): JSX.Element | null {
const pathname = usePathname();
if (!pathname) return null;
return (
<DataTableProvider
tableIdentifier={pathname}
useSegments={useSegments}
defaultPageSize={25}
>
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
<UserListTableContent {...props} />
</DataTableProvider>
);
@@ -203,6 +196,7 @@ function UserListTableContent({
canRemove: permissions?.canRemove ?? adminOrOwner,
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
canResetPassword: permissions?.canResetPassword ?? adminOrOwner,
};
const generateAttributeColumns = (): ColumnDef<UserTableUser>[] => {
if (!attributes?.length) {
@@ -326,14 +320,12 @@ function UserListTableContent({
<div className="">
<div
data-testid={`member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none"
>
className="text-emphasis text-sm font-medium leading-none">
{displayName}
</div>
<div
data-testid={`member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none"
>
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
@@ -362,8 +354,7 @@ function UserListTableContent({
variant={roleVariant}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
}}
>
}}>
{roleName}
</Badge>
);
@@ -390,8 +381,7 @@ function UserListTableContent({
data-testid={`email-${email.replace("@", "")}-pending`}
onClick={() => {
table.getColumn("role")?.setFilterValue(["PENDING"]);
}}
>
}}>
{t("pending")}
</Badge>
)}
@@ -502,8 +492,7 @@ function UserListTableContent({
const permissionsForUser = {
canEdit:
((permissionsRaw.canEdit ?? false) ||
(permissions?.canEditAttributesForUser ?? false)) &&
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
user.accepted &&
!isSelf,
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
@@ -514,8 +503,8 @@ function UserListTableContent({
!!org?.canAdminImpersonate &&
(permissionsRaw.canImpersonate ?? false),
canLeave: user.accepted && isSelf,
canResendInvitation:
(permissionsRaw.canResendInvitation ?? false) && !user.accepted,
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
canResetPassword: (permissionsRaw.canResetPassword ?? false) && user.accepted && !isSelf,
};
return (
@@ -573,9 +562,7 @@ function UserListTableContent({
}))
);
default: {
const attribute = facetedTeamValues.attributes.find(
(attr) => attr.id === columnId
);
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
if (attribute) {
return convertFacetedValuesToMap(
attribute?.options.map(({ value }) => ({
@@ -635,19 +622,12 @@ function UserListTableContent({
}
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
const csvRaw = generateCsvRawForMembersTable(
headers,
allRows,
ATTRIBUTE_IDS,
domain
);
const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain);
if (!csvRaw) {
throw new Error("Generating CSV file failed.");
}
const filename = `${org.name}_${
new Date().toISOString().split("T")[0]
}.csv`;
const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`;
downloadAsCsv(csvRaw, filename);
} catch (error) {
showToast(`Error: ${error}`, "error");
@@ -677,8 +657,7 @@ function UserListTableContent({
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
>
}>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root className="bottom-[7.3rem]! md:bottom-32!">
<DynamicLink table={table} domain={domain} />
@@ -691,23 +670,17 @@ function UserListTableContent({
</p>
{!isPlatformUser && (
<>
{permissions?.canChangeMemberRole && (
<TeamListBulkAction table={table} />
)}
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
{numberOfSelectedRows >= 2 && (
<DataTableSelectionBar.Button
color="secondary"
onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)}
icon="handshake"
>
icon="handshake">
{t("group_meeting")}
</DataTableSelectionBar.Button>
)}
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
<MassAssignAttributesBulkAction
table={table}
filters={columnFilters}
/>
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
<EventTypesList table={table} orgTeams={teams} />
@@ -716,9 +689,7 @@ function UserListTableContent({
)}
{(permissions?.canRemove ?? adminOrOwner) && (
<DeleteBulkUsers
users={table
.getSelectedRowModel()
.flatRows.map((row) => row.original)}
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
)}
@@ -726,18 +697,10 @@ function UserListTableContent({
)}
</DataTableWrapper>
{state.deleteMember.showModal && (
<DeleteMemberModal state={state} dispatch={dispatch} />
)}
{state.inviteMember.showModal && (
<InviteMemberModal dispatch={dispatch} />
)}
{state.impersonateMember.showModal && (
<ImpersonationMemberModal dispatch={dispatch} state={state} />
)}
{state.changeMemberRole.showModal && (
<ChangeUserRoleModal dispatch={dispatch} state={state} />
)}
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{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}
@@ -757,8 +720,7 @@ function UserListTableContent({
StartIcon="file-down"
loading={isDownloading}
onClick={() => handleDownload()}
data-testid="export-members-button"
>
data-testid="export-members-button">
{t("download")}
</DataTableToolbar.CTA>
{(permissions?.canInvite ?? adminOrOwner) && (
@@ -775,8 +737,7 @@ function UserListTableContent({
});
posthog.capture("add_organization_member_clicked");
}}
data-testid="new-organization-member-button"
>
data-testid="new-organization-member-button">
{t("add")}
</DataTableToolbar.CTA>
)}
@@ -1,5 +1,3 @@
import { useSession } from "next-auth/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
@@ -7,17 +5,17 @@ import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import {
Dropdown,
DropdownMenuTrigger,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { UserTableUser, UserTableAction } from "./types";
import { useSession } from "next-auth/react";
import type { UserTableAction, UserTableUser } from "./types";
export function TableActions({
user,
@@ -33,6 +31,7 @@ export function TableActions({
canRemove: boolean;
canImpersonate: boolean;
canResendInvitation: boolean;
canResetPassword: boolean;
};
}) {
const { t, i18n } = useLocale();
@@ -45,6 +44,14 @@ export function TableActions({
showToast(error.message, "error");
},
});
const sendPasswordResetMutation = trpc.viewer.organizations.sendPasswordReset.useMutation({
onSuccess: () => {
showToast(t("password_reset_email_sent"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const usersProfileUrl = `${domain}/${user.username}`;
@@ -96,26 +103,37 @@ export function TableActions({
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResetPassword && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
sendPasswordResetMutation.mutate({ userId: user.id });
}}
StartIcon="key">
{t("reset_password")}
</DropdownItem>
</DropdownMenuItem>
)}
{(permissionsForUser.canImpersonate || permissionsForUser.canResetPassword) &&
permissionsForUser.canRemove && <DropdownMenuSeparator />}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
@@ -170,24 +188,34 @@ export function TableActions({
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResetPassword && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
sendPasswordResetMutation.mutate({ userId: user.id });
}}
StartIcon="key">
{t("reset_password")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>