From 08a2b0bfb51575fb8ab684cc6ec42655cec2c988 Mon Sep 17 00:00:00 2001
From: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:18:02 +0000
Subject: [PATCH] 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
* 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
* test: add unit tests for sendPasswordReset handler
Co-Authored-By: Claude Opus 4.5
---------
Co-authored-by: Claude Opus 4.5
---
.../modules/members/getOrgMembersPageData.ts | 15 +-
.../components/UserTable/UserListTable.tsx | 87 +++------
.../components/UserTable/UserTableActions.tsx | 114 +++++++----
apps/web/public/static/locales/en/common.json | 2 +
.../pbac/domain/types/permission-registry.ts | 9 +
.../pbac/lib/team-member-permissions.ts | 6 +-
.../users/repositories/UserRepository.ts | 11 ++
.../migration.sql | 10 +
.../routers/viewer/organizations/_router.tsx | 36 ++--
.../sendPasswordReset.handler.test.ts | 183 ++++++++++++++++++
.../sendPasswordReset.handler.ts | 62 ++++++
.../organizations/sendPasswordReset.schema.ts | 7 +
12 files changed, 410 insertions(+), 132 deletions(-)
create mode 100644 packages/prisma/migrations/20260129170000_pbac_add_password_reset_permission/migration.sql
create mode 100644 packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.test.ts
create mode 100644 packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.ts
create mode 100644 packages/trpc/server/routers/viewer/organizations/sendPasswordReset.schema.ts
diff --git a/apps/web/modules/members/getOrgMembersPageData.ts b/apps/web/modules/members/getOrgMembersPageData.ts
index 96df71a0ae..b4c5ea6f6b 100644
--- a/apps/web/modules/members/getOrgMembersPageData.ts
+++ b/apps/web/modules/members/getOrgMembersPageData.ts
@@ -1,17 +1,15 @@
-import { unstable_cache } from "next/cache";
-import { redirect } from "next/navigation";
-
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
-import type { Session } from "next-auth";
import { CrudAction, CustomAction, Resource } from "@calcom/features/pbac/domain/types/permission-registry";
-import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
+import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
-
import { createRouterCaller } from "app/_trpc/context";
+import { unstable_cache } from "next/cache";
+import { redirect } from "next/navigation";
+import type { Session } from "next-auth";
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
@@ -60,6 +58,7 @@ export async function getOrgMembersPageData(session: Session) {
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
+ CustomAction.PasswordReset,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
@@ -80,6 +79,9 @@ export async function getOrgMembersPageData(session: Session) {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
+ [CustomAction.PasswordReset]: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
},
}),
getSpecificPermissions({
@@ -107,6 +109,7 @@ export async function getOrgMembersPageData(session: Session) {
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
canRemove: orgPermissions[CustomAction.Remove],
canImpersonate: orgPermissions[CustomAction.Impersonate],
+ canResetPassword: orgPermissions[CustomAction.PasswordReset],
canViewAttributes: attributesPermissions[CrudAction.Read],
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
diff --git a/apps/web/modules/users/components/UserTable/UserListTable.tsx b/apps/web/modules/users/components/UserTable/UserListTable.tsx
index 139cd04b36..af7fdd0c5c 100644
--- a/apps/web/modules/users/components/UserTable/UserListTable.tsx
+++ b/apps/web/modules/users/components/UserTable/UserListTable.tsx
@@ -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 (
-
+
);
@@ -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[] => {
if (!attributes?.length) {
@@ -326,14 +320,12 @@ function UserListTableContent({
+ className="text-emphasis text-sm font-medium leading-none">
{displayName}
+ className="text-subtle mt-1 text-sm leading-none">
{email}
@@ -362,8 +354,7 @@ function UserListTableContent({
variant={roleVariant}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
- }}
- >
+ }}>
{roleName}
);
@@ -390,8 +381,7 @@ function UserListTableContent({
data-testid={`email-${email.replace("@", "")}-pending`}
onClick={() => {
table.getColumn("role")?.setFilterValue(["PENDING"]);
- }}
- >
+ }}>
{t("pending")}
)}
@@ -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({
>
- }
- >
+ }>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
@@ -691,23 +670,17 @@ function UserListTableContent({
{!isPlatformUser && (
<>
- {permissions?.canChangeMemberRole && (
-
- )}
+ {permissions?.canChangeMemberRole && }
{numberOfSelectedRows >= 2 && (
setDynamicLinkVisible(!dynamicLinkVisible)}
- icon="handshake"
- >
+ icon="handshake">
{t("group_meeting")}
)}
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
-
+
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
@@ -716,9 +689,7 @@ function UserListTableContent({
)}
{(permissions?.canRemove ?? adminOrOwner) && (
row.original)}
+ users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
)}
@@ -726,18 +697,10 @@ function UserListTableContent({
)}
- {state.deleteMember.showModal && (
-
- )}
- {state.inviteMember.showModal && (
-
- )}
- {state.impersonateMember.showModal && (
-
- )}
- {state.changeMemberRole.showModal && (
-
- )}
+ {state.deleteMember.showModal && }
+ {state.inviteMember.showModal && }
+ {state.impersonateMember.showModal && }
+ {state.changeMemberRole.showModal && }
{state.editSheet.showModal && (
handleDownload()}
- data-testid="export-members-button"
- >
+ data-testid="export-members-button">
{t("download")}
{(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")}
)}
diff --git a/apps/web/modules/users/components/UserTable/UserTableActions.tsx b/apps/web/modules/users/components/UserTable/UserTableActions.tsx
index cab769adfe..3c3f1c94ed 100644
--- a/apps/web/modules/users/components/UserTable/UserTableActions.tsx
+++ b/apps/web/modules/users/components/UserTable/UserTableActions.tsx
@@ -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({
)}
{permissionsForUser.canImpersonate && (
- <>
-
-
- dispatch({
- type: "SET_IMPERSONATE_ID",
- payload: {
- user,
- showModal: true,
- },
- })
- }
- StartIcon="lock">
- {t("impersonate")}
-
-
-
- >
+
+
+ dispatch({
+ type: "SET_IMPERSONATE_ID",
+ payload: {
+ user,
+ showModal: true,
+ },
+ })
+ }
+ StartIcon="lock">
+ {t("impersonate")}
+
+
)}
+ {permissionsForUser.canResetPassword && (
+
+ {
+ sendPasswordResetMutation.mutate({ userId: user.id });
+ }}
+ StartIcon="key">
+ {t("reset_password")}
+
+
+ )}
+ {(permissionsForUser.canImpersonate || permissionsForUser.canResetPassword) &&
+ permissionsForUser.canRemove && }
{permissionsForUser.canRemove && (
{permissionsForUser.canEdit && (
- <>
-
-
- dispatch({
- type: "EDIT_USER_SHEET",
- payload: {
- user,
- showModal: true,
- },
- })
- }
- StartIcon="pencil">
- {t("edit")}
-
-
- >
+
+
+ dispatch({
+ type: "EDIT_USER_SHEET",
+ payload: {
+ user,
+ showModal: true,
+ },
+ })
+ }
+ StartIcon="pencil">
+ {t("edit")}
+
+
+ )}
+ {permissionsForUser.canResetPassword && (
+
+ {
+ sendPasswordResetMutation.mutate({ userId: user.id });
+ }}
+ StartIcon="key">
+ {t("reset_password")}
+
+
)}
{permissionsForUser.canRemove && (
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 4205a45954..a79898784f 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -3878,6 +3878,8 @@
"pbac_action_read_org_booking_audit_logs": "View organization booking audit logs",
"pbac_action_impersonate": "Impersonate",
"pbac_action_edit_users": "Edit users",
+ "pbac_action_password_reset": "Reset password",
+ "pbac_desc_reset_password_organization_members": "Send password reset emails to organization members",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts
index 95dd88f734..5126c8c845 100644
--- a/packages/features/pbac/domain/types/permission-registry.ts
+++ b/packages/features/pbac/domain/types/permission-registry.ts
@@ -36,6 +36,7 @@ export enum CustomAction {
ReadRecordings = "readRecordings",
Impersonate = "impersonate",
EditUsers = "editUsers",
+ PasswordReset = "passwordReset",
ReadTeamAuditLogs = "readTeamAuditLogs",
ReadOrgAuditLogs = "readOrgAuditLogs",
}
@@ -436,6 +437,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_impersonate_organization_members",
scope: [Scope.Organization],
},
+ [CustomAction.PasswordReset]: {
+ description: "Reset passwords for organization members",
+ category: "org",
+ i18nKey: "pbac_action_password_reset",
+ descriptionI18nKey: "pbac_desc_reset_password_organization_members",
+ scope: [Scope.Organization],
+ dependsOn: ["organization.listMembers"],
+ },
[CrudAction.Update]: {
description: "Edit organization settings",
category: "org",
diff --git a/packages/features/pbac/lib/team-member-permissions.ts b/packages/features/pbac/lib/team-member-permissions.ts
index 473642b9ab..b6f70cfa06 100644
--- a/packages/features/pbac/lib/team-member-permissions.ts
+++ b/packages/features/pbac/lib/team-member-permissions.ts
@@ -1,6 +1,5 @@
import { MembershipRole } from "@calcom/prisma/enums";
-
-import { Resource, CustomAction } from "../domain/types/permission-registry";
+import { CustomAction, Resource } from "../domain/types/permission-registry";
import { getSpecificPermissions } from "./resource-permissions";
export interface MemberPermissions {
@@ -9,10 +8,11 @@ export interface MemberPermissions {
canChangeMemberRole: boolean;
canRemove: boolean;
canImpersonate: boolean;
+ canResetPassword?: boolean;
canEditAttributesForUser?: boolean;
canViewAttributes?: boolean;
}
-
+
interface TeamWithMembership {
id: number;
isPrivate: boolean;
diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts
index 9c1a01e94e..252b93c809 100644
--- a/packages/features/users/repositories/UserRepository.ts
+++ b/packages/features/users/repositories/UserRepository.ts
@@ -1339,6 +1339,17 @@ export class UserRepository {
});
}
+ async findForPasswordReset({ id }: { id: number }) {
+ return this.prismaClient.user.findUnique({
+ where: { id },
+ select: {
+ email: true,
+ name: true,
+ locale: true,
+ },
+ });
+ }
+
/**
* Finds a user by ID returning only their username
* @param userId - The user ID
diff --git a/packages/prisma/migrations/20260129170000_pbac_add_password_reset_permission/migration.sql b/packages/prisma/migrations/20260129170000_pbac_add_password_reset_permission/migration.sql
new file mode 100644
index 0000000000..0497d8616c
--- /dev/null
+++ b/packages/prisma/migrations/20260129170000_pbac_add_password_reset_permission/migration.sql
@@ -0,0 +1,10 @@
+-- Add passwordReset permission to admin role for organizations
+-- Owner role already has wildcard permissions (*.*) so no migration needed for it
+INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
+SELECT
+ gen_random_uuid(), 'admin_role', resource, action, NOW()
+FROM (
+ VALUES
+ ('organization', 'passwordReset')
+) AS permissions(resource, action)
+ON CONFLICT ("roleId", resource, action) DO NOTHING;
diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx
index 7bb2d96976..7c0c51230e 100644
--- a/packages/trpc/server/routers/viewer/organizations/_router.tsx
+++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx
@@ -1,15 +1,16 @@
-import type { NextApiRequest } from "next";
import { createPhoneCallSchema } from "@calcom/features/calAIPhone/zod-utils";
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
-
+import type { NextApiRequest } from "next";
import authedProcedure, {
authedAdminProcedure,
authedOrgAdminProcedure,
} from "../../../procedures/authedProcedure";
+import { createOrgPbacProcedure } from "../../../procedures/pbacProcedures";
import { router } from "../../../trpc";
import { eventOwnerProcedure } from "../eventTypes/util";
import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema";
import { ZAddMembersToTeams } from "./addMembersToTeams.schema";
+import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
import { ZAdminDeleteInput } from "./adminDelete.schema";
import { ZAdminGet } from "./adminGet.schema";
import { ZAdminUpdate } from "./adminUpdate.schema";
@@ -22,18 +23,18 @@ import { ZCreateWatchlistEntryInputSchema } from "./createWatchlistEntry.schema"
import { ZCreateWithPaymentIntentInputSchema } from "./createWithPaymentIntent.schema";
import { ZDeleteTeamInputSchema } from "./deleteTeam.schema";
import { ZDeleteWatchlistEntryInputSchema } from "./deleteWatchlistEntry.schema";
+import { ZDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZGetUserInput } from "./getUser.schema";
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema";
-import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
-import { ZDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
import { ZListBookingReportsInputSchema } from "./listBookingReports.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
+import { ZOrgPasswordResetSchema } from "./sendPasswordReset.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateUserInputSchema } from "./updateUser.schema";
@@ -86,6 +87,12 @@ export const viewerOrganizationsRouter = router({
const { default: handler } = await import("./setPassword.handler");
return handler(opts);
}),
+ sendPasswordReset: createOrgPbacProcedure("organization.passwordReset")
+ .input(ZOrgPasswordResetSchema)
+ .mutation(async (opts) => {
+ const { default: handler } = await import("./sendPasswordReset.handler");
+ return handler(opts);
+ }),
getMembers: authedProcedure.input(ZGetMembersInput).query(async (opts) => {
const { default: handler } = await import("./getMembers.handler");
return handler(opts);
@@ -195,18 +202,14 @@ export const viewerOrganizationsRouter = router({
const { getWatchlistEntryDetailsHandler: handler } = await import("./getWatchlistEntryDetails.handler");
return handler(opts);
}),
- listBookingReports: authedOrgAdminProcedure
- .input(ZListBookingReportsInputSchema)
- .query(async (opts) => {
- const { default: handler } = await import("./listBookingReports.handler");
- return handler(opts);
- }),
- addToWatchlist: authedOrgAdminProcedure
- .input(ZAddToWatchlistInputSchema)
- .mutation(async (opts) => {
- const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
- return handler(opts);
- }),
+ listBookingReports: authedOrgAdminProcedure.input(ZListBookingReportsInputSchema).query(async (opts) => {
+ const { default: handler } = await import("./listBookingReports.handler");
+ return handler(opts);
+ }),
+ addToWatchlist: authedOrgAdminProcedure.input(ZAddToWatchlistInputSchema).mutation(async (opts) => {
+ const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
+ return handler(opts);
+ }),
dismissBookingReport: authedOrgAdminProcedure
.input(ZDismissBookingReportInputSchema)
.mutation(async (opts) => {
@@ -217,5 +220,4 @@ export const viewerOrganizationsRouter = router({
const { default: handler } = await import("./pendingReportsCount.handler");
return handler(opts);
}),
-
});
diff --git a/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.test.ts
new file mode 100644
index 0000000000..2821eb28df
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.test.ts
@@ -0,0 +1,183 @@
+import { MembershipRole } from "@calcom/prisma/enums";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { TrpcSessionUser } from "../../../types";
+import sendPasswordResetHandler from "./sendPasswordReset.handler";
+
+const { mockFindUniqueByUserIdAndTeamId, MockMembershipRepository } = vi.hoisted(() => {
+ const mockFindUniqueByUserIdAndTeamId = vi.fn();
+
+ class MockMembershipRepository {
+ findUniqueByUserIdAndTeamId = mockFindUniqueByUserIdAndTeamId;
+ }
+
+ return { mockFindUniqueByUserIdAndTeamId, MockMembershipRepository };
+});
+
+const { mockFindForPasswordReset, MockUserRepository } = vi.hoisted(() => {
+ const mockFindForPasswordReset = vi.fn();
+
+ class MockUserRepository {
+ findForPasswordReset = mockFindForPasswordReset;
+ }
+
+ return { mockFindForPasswordReset, MockUserRepository };
+});
+
+const { mockPasswordResetRequest } = vi.hoisted(() => {
+ const mockPasswordResetRequest = vi.fn();
+ return { mockPasswordResetRequest };
+});
+
+vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({
+ MembershipRepository: MockMembershipRepository,
+}));
+
+vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
+ UserRepository: MockUserRepository,
+}));
+
+vi.mock("@calcom/features/auth/lib/passwordResetRequest", () => ({
+ passwordResetRequest: mockPasswordResetRequest,
+}));
+
+vi.mock("@calcom/prisma", () => ({
+ prisma: {},
+}));
+
+describe("sendPasswordResetHandler", () => {
+ const mockUser: NonNullable = {
+ id: 1,
+ name: "Admin User",
+ email: "admin@example.com",
+ } as NonNullable;
+
+ const organizationId = 100;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("self-reset prevention", () => {
+ it("should throw BAD_REQUEST when user tries to reset their own password", async () => {
+ await expect(
+ sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: mockUser.id },
+ })
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: "You cannot reset your own password through this endpoint.",
+ });
+
+ expect(mockFindUniqueByUserIdAndTeamId).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("membership validation", () => {
+ it("should throw NOT_FOUND when target user is not a member of the organization", async () => {
+ mockFindUniqueByUserIdAndTeamId.mockResolvedValue(null);
+
+ await expect(
+ sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: 2 },
+ })
+ ).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ message: "User is not a member of this organization.",
+ });
+
+ expect(mockFindUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
+ userId: 2,
+ teamId: organizationId,
+ });
+ expect(mockFindForPasswordReset).not.toHaveBeenCalled();
+ });
+
+ it("should throw FORBIDDEN when target user is an organization OWNER", async () => {
+ mockFindUniqueByUserIdAndTeamId.mockResolvedValue({
+ userId: 2,
+ teamId: organizationId,
+ role: MembershipRole.OWNER,
+ });
+
+ await expect(
+ sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: 2 },
+ })
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Cannot reset password for an organization owner.",
+ });
+
+ expect(mockFindForPasswordReset).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("user validation", () => {
+ it("should throw NOT_FOUND when user does not exist", async () => {
+ mockFindUniqueByUserIdAndTeamId.mockResolvedValue({
+ userId: 2,
+ teamId: organizationId,
+ role: MembershipRole.MEMBER,
+ });
+ mockFindForPasswordReset.mockResolvedValue(null);
+
+ await expect(
+ sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: 2 },
+ })
+ ).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ message: "User not found.",
+ });
+
+ expect(mockFindForPasswordReset).toHaveBeenCalledWith({ id: 2 });
+ expect(mockPasswordResetRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("successful password reset", () => {
+ it("should send password reset for a MEMBER", async () => {
+ const targetUser = { id: 2, email: "member@example.com", name: "Member User" };
+
+ mockFindUniqueByUserIdAndTeamId.mockResolvedValue({
+ userId: 2,
+ teamId: organizationId,
+ role: MembershipRole.MEMBER,
+ });
+ mockFindForPasswordReset.mockResolvedValue(targetUser);
+ mockPasswordResetRequest.mockResolvedValue(undefined);
+
+ const result = await sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: 2 },
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockPasswordResetRequest).toHaveBeenCalledWith(targetUser);
+ });
+
+ it("should send password reset for an ADMIN", async () => {
+ const targetUser = { id: 3, email: "admin2@example.com", name: "Admin User 2" };
+
+ mockFindUniqueByUserIdAndTeamId.mockResolvedValue({
+ userId: 3,
+ teamId: organizationId,
+ role: MembershipRole.ADMIN,
+ });
+ mockFindForPasswordReset.mockResolvedValue(targetUser);
+ mockPasswordResetRequest.mockResolvedValue(undefined);
+
+ const result = await sendPasswordResetHandler({
+ ctx: { user: mockUser, organizationId },
+ input: { userId: 3 },
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(mockPasswordResetRequest).toHaveBeenCalledWith(targetUser);
+ });
+ });
+});
diff --git a/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.ts b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.ts
new file mode 100644
index 0000000000..126408eefd
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.handler.ts
@@ -0,0 +1,62 @@
+import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
+import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../types";
+import type { TOrgPasswordResetSchema } from "./sendPasswordReset.schema";
+
+type SendPasswordResetOptions = {
+ ctx: {
+ user: NonNullable;
+ organizationId: number;
+ };
+ input: TOrgPasswordResetSchema;
+};
+
+const sendPasswordResetHandler = async ({ ctx, input }: SendPasswordResetOptions) => {
+ const { organizationId } = ctx;
+ const { userId } = input;
+
+ if (userId === ctx.user.id) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You cannot reset your own password through this endpoint.",
+ });
+ }
+
+ const membershipRepository = new MembershipRepository();
+ const targetMembership = await membershipRepository.findUniqueByUserIdAndTeamId({
+ userId,
+ teamId: organizationId,
+ });
+
+ if (!targetMembership) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User is not a member of this organization.",
+ });
+ }
+
+ if (targetMembership.role === MembershipRole.OWNER) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot reset password for an organization owner.",
+ });
+ }
+
+ const userRepository = new UserRepository(prisma);
+ const user = await userRepository.findForPasswordReset({ id: userId });
+
+ if (!user) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found." });
+ }
+
+ await passwordResetRequest(user);
+
+ return { success: true };
+};
+
+export default sendPasswordResetHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.schema.ts b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.schema.ts
new file mode 100644
index 0000000000..b36f0b05d5
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/sendPasswordReset.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZOrgPasswordResetSchema = z.object({
+ userId: z.number(),
+});
+
+export type TOrgPasswordResetSchema = z.infer;