* 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>
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
|
import { CrudAction, CustomAction, Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
|
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) => {
|
|
const attributeRepo = new PrismaAttributeRepository(prisma);
|
|
return await attributeRepo.findAllByOrgIdWithOptions({ orgId });
|
|
},
|
|
undefined,
|
|
{ revalidate: 3600, tags: ["viewer.attributes.list"] }
|
|
);
|
|
|
|
const getCachedRoles = unstable_cache(
|
|
async (orgId: number) => {
|
|
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(orgId);
|
|
return await roleManager.getAllRoles(orgId);
|
|
},
|
|
undefined,
|
|
{ revalidate: 3600, tags: ["pbac.roles.list"] }
|
|
);
|
|
|
|
export async function getOrgMembersPageData(session: Session) {
|
|
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
|
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
|
|
|
if (!org) {
|
|
redirect("/settings/my-account/profile");
|
|
}
|
|
|
|
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
|
|
|
|
const fallbackRolesThatCanSeeMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
|
|
|
if (!org?.isPrivate) {
|
|
fallbackRolesThatCanSeeMembers.push(MembershipRole.MEMBER);
|
|
}
|
|
|
|
const [orgPermissions, attributesPermissions] = await Promise.all([
|
|
getSpecificPermissions({
|
|
userId: session.user.id,
|
|
teamId: session.user.profile!.organizationId!,
|
|
resource: Resource.Organization,
|
|
userRole: session.user.org!.role,
|
|
actions: [
|
|
CustomAction.ListMembers,
|
|
CustomAction.ListMembersPrivate,
|
|
CustomAction.Invite,
|
|
CustomAction.ChangeMemberRole,
|
|
CustomAction.Remove,
|
|
CustomAction.Impersonate,
|
|
CustomAction.PasswordReset,
|
|
],
|
|
fallbackRoles: {
|
|
[CustomAction.ListMembers]: {
|
|
roles: fallbackRolesThatCanSeeMembers,
|
|
},
|
|
[CustomAction.ListMembersPrivate]: {
|
|
roles: fallbackRolesThatCanSeeMembers,
|
|
},
|
|
[CustomAction.Invite]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
[CustomAction.ChangeMemberRole]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
[CustomAction.Remove]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
[CustomAction.Impersonate]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
[CustomAction.PasswordReset]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
},
|
|
}),
|
|
getSpecificPermissions({
|
|
userId: session.user.id,
|
|
teamId: session.user.profile!.organizationId!,
|
|
resource: Resource.Attributes,
|
|
userRole: session.user.org!.role,
|
|
actions: [CrudAction.Read, CustomAction.EditUsers],
|
|
fallbackRoles: {
|
|
[CrudAction.Read]: {
|
|
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
[CustomAction.EditUsers]: {
|
|
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const permissions: MemberPermissions = {
|
|
canListMembers: org.isPrivate
|
|
? orgPermissions[CustomAction.ListMembersPrivate]
|
|
: orgPermissions[CustomAction.ListMembers],
|
|
canInvite: orgPermissions[CustomAction.Invite],
|
|
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
|
|
canRemove: orgPermissions[CustomAction.Remove],
|
|
canImpersonate: orgPermissions[CustomAction.Impersonate],
|
|
canResetPassword: orgPermissions[CustomAction.PasswordReset],
|
|
canViewAttributes: attributesPermissions[CrudAction.Read],
|
|
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
|
|
};
|
|
|
|
const facetedTeamValues = {
|
|
roles,
|
|
teams,
|
|
attributes: attributes.map((attribute) => ({
|
|
id: attribute.id,
|
|
name: attribute.name,
|
|
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
|
|
value,
|
|
})),
|
|
})),
|
|
};
|
|
|
|
return {
|
|
org,
|
|
teams,
|
|
facetedTeamValues,
|
|
attributes,
|
|
permissions,
|
|
};
|
|
}
|