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;