From 973d83aa5765d5fb7e0e57067b123122a475de47 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 22 Jan 2026 23:43:34 +0100 Subject: [PATCH] refactor: move organization members page from /settings/organizations/members to /members (#27053) * refactor: move organization members page from /settings/organizations/members to /members - Create new /members route under main-nav with same functionality - Add permanent redirect from /settings/organizations/members to /members - Include loading skeleton and server actions for the new route Co-Authored-By: peer@cal.com * refactor: Deduplicate organization members page logic (#27168) * refactor(web): extract shared org members data fetching logic - Extract data fetching, coaching, and permission logic into [getOrgMembersPageData.ts](cci:7://file:///Users/dhairyashilshinde/work/cal.com/apps/web/modules/members/getOrgMembersPageData.ts:0:0-0:0) - Deduplicate logic between [/members](cci:7://file:///Users/dhairyashilshinde/work/cal.com/apps/web/app/%28use-page-wrapper%29/%28main-nav%29/members:0:0-0:0) and pages - Reduce page component size and improve maintainability * safe guard for org id null --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> --- .../(main-nav)/members/actions.ts | 7 + .../(main-nav)/members/loading.tsx | 5 + .../(main-nav)/members/page.tsx | 45 ++++++ .../(org-user-only)/members/page.tsx | 127 +---------------- .../modules/members/getOrgMembersPageData.ts | 133 ++++++++++++++++++ apps/web/next.config.ts | 5 + 6 files changed, 202 insertions(+), 120 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/members/actions.ts create mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/members/loading.tsx create mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/members/page.tsx create mode 100644 apps/web/modules/members/getOrgMembersPageData.ts diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/members/actions.ts b/apps/web/app/(use-page-wrapper)/(main-nav)/members/actions.ts new file mode 100644 index 0000000000..1263028097 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/members/actions.ts @@ -0,0 +1,7 @@ +"use server"; + +import { revalidateTag } from "next/cache"; + +export async function revalidateAttributesList() { + revalidateTag("viewer.attributes.list", "max"); +} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/members/loading.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/members/loading.tsx new file mode 100644 index 0000000000..58084f89a3 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/members/loading.tsx @@ -0,0 +1,5 @@ +import { UserListTableSkeleton } from "@calcom/web/modules/users/components/UserTable/UserListTableSkeleton"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/members/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/members/page.tsx new file mode 100644 index 0000000000..ecc8bddcda --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/members/page.tsx @@ -0,0 +1,45 @@ +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { _generateMetadata, getTranslate } from "app/_utils"; +import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getOrgMembersPageData } from "~/members/getOrgMembersPageData"; +import { MembersView } from "~/members/members-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("organization_members"), + (t) => t("organization_description"), + undefined, + undefined, + "/members" + ); + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/my-account/profile"); + } + + const { org, teams, facetedTeamValues, attributes, permissions } = await getOrgMembersPageData(session); + const t = await getTranslate(); + + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx index 98147f9277..3e3e6b745b 100644 --- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx @@ -1,20 +1,12 @@ -import { createRouterCaller } from "app/_trpc/context"; -import { _generateMetadata } from "app/_utils"; -import { unstable_cache } from "next/cache"; -import { headers, cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { Resource, CustomAction, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry"; -import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-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 { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getOrgMembersPageData } from "~/members/getOrgMembersPageData"; import { MembersView } from "~/members/members-view"; export const generateMetadata = async () => @@ -26,25 +18,6 @@ export const generateMetadata = async () => "/settings/organizations/members" ); -const getCachedAttributes = unstable_cache( - async (orgId: number) => { - const attributeRepo = new PrismaAttributeRepository(prisma); - - return await attributeRepo.findAllByOrgIdWithOptions({ orgId }); - }, - undefined, - { revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour -); - -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"] } // Cache for 1 hour -); - const Page = async () => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); @@ -52,93 +25,7 @@ const Page = async () => { return redirect("/settings/profile"); } - const orgCaller = await createRouterCaller(viewerOrganizationsRouter); - const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]); - 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); - } - - // Get specific PBAC permissions for organization member actions - 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, - ], - 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], - }, - }, - }), - 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], - }, - }, - }), - ]); - - // Map specific permissions to member actions - const 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], - 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, - })), - })), - }; + const { org, teams, facetedTeamValues, attributes, permissions } = await getOrgMembersPageData(session); return ( { teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} - permissions={memberPermissions} + permissions={permissions} /> ); }; diff --git a/apps/web/modules/members/getOrgMembersPageData.ts b/apps/web/modules/members/getOrgMembersPageData.ts new file mode 100644 index 0000000000..96df71a0ae --- /dev/null +++ b/apps/web/modules/members/getOrgMembersPageData.ts @@ -0,0 +1,133 @@ +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 { 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"; + +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, + ], + 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], + }, + }, + }), + 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], + 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, + }; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index b02ebd2916..1cf874c6a4 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -619,6 +619,11 @@ const nextConfig = (phase: string): NextConfig => { destination: "/settings/platform", permanent: true, }, + { + source: "/settings/organizations/members", + destination: "/members", + permanent: true, + }, { source: "/settings/admin/apps", destination: "/settings/admin/apps/calendar",