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:
co-authored by
Claude Opus 4.5
parent
1f8f134854
commit
08a2b0bfb5
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
@@ -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;
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
@@ -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<TrpcSessionUser> = {
|
||||
id: 1,
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
} as NonNullable<TrpcSessionUser>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TrpcSessionUser>;
|
||||
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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrgPasswordResetSchema = z.object({
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type TOrgPasswordResetSchema = z.infer<typeof ZOrgPasswordResetSchema>;
|
||||
Reference in New Issue
Block a user