From a379bd2b52e48e635edd913000c25940a18dc8fa Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:27:00 +0100 Subject: [PATCH] fix: org layout authentication (#23707) * refactor layout to not check session * add actions for orgs + org admins * update types on actions to be correctly non nullable * Add tests for utils * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * restore lock file * rename _actions to actions * update tests to be mocked * restore lock --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../attributes/[id]/edit/page.tsx | 4 + .../attributes/create/page.tsx | 4 + .../(org-admin-only)/attributes/page.tsx | 10 +- .../(org-admin-only)/billing/page.tsx | 36 +- .../delegation-credential/page.tsx | 4 + .../(org-admin-only)/dsync/page.tsx | 11 +- .../(org-admin-only)/privacy/page.tsx | 8 +- .../(org-admin-only)/sso/page.tsx | 6 +- .../actions/validateUserHasOrg.test.ts | 229 ++++++++++ .../actions/validateUserHasOrg.tsx | 30 ++ .../actions/validateUserHasOrgAdmin.test.ts | 418 ++++++++++++++++++ .../actions/validateUserHasOrgAdmin.tsx | 45 ++ .../organizations/admin-api/page.tsx | 4 + .../organizations/general/page.tsx | 11 +- .../organizations/layout.tsx | 15 - .../organizations/profile/page.tsx | 14 +- .../organizations/roles/page.tsx | 7 +- .../teams/other/(main-page)/page.tsx | 6 +- .../teams/other/[id]/appearance/page.tsx | 4 + .../teams/other/[id]/members/page.tsx | 4 + .../teams/other/[id]/profile/page.tsx | 4 + 21 files changed, 804 insertions(+), 70 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.tsx create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.tsx diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/[id]/edit/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/[id]/edit/page.tsx index b4562aebe0..c60161bb78 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/[id]/edit/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/[id]/edit/page.tsx @@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils"; import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view"; +import { validateUserHasOrgAdmin } from "../../../../actions/validateUserHasOrgAdmin"; + export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => await _generateMetadata( (t) => t("attribute"), @@ -12,6 +14,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin ); const OrgAttributesEditPageWrapper = async () => { + await validateUserHasOrgAdmin(); + return ; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/create/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/create/page.tsx index 4040958f0d..6a8b0690b3 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/create/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/create/page.tsx @@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils"; import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view"; +import { validateUserHasOrgAdmin } from "../../../actions/validateUserHasOrgAdmin"; + export const generateMetadata = async () => await _generateMetadata( (t) => t("attribute"), @@ -12,6 +14,8 @@ export const generateMetadata = async () => ); const OrgAttributesCreatePageWrapper = async () => { + await validateUserHasOrgAdmin(); + return ; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx index cfa0900043..343fbc45e1 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx @@ -1,15 +1,13 @@ import { _generateMetadata, getTranslate } from "app/_utils"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { MembershipRole } from "@calcom/prisma/enums"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin"; export const generateMetadata = async () => await _generateMetadata( @@ -22,11 +20,7 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { - return redirect("/settings/profile"); - } + const session = await validateUserHasOrgAdmin(); const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({ userId: session.user.id, diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx index 268371cb2d..4b4f2b3930 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx @@ -1,5 +1,35 @@ -import BillingPage, { generateMetadata } from "../../../billing/page"; +import { _generateMetadata } from "app/_utils"; +import { getTranslate } from "app/_utils"; -export { generateMetadata }; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -export default BillingPage; +import BillingView from "~/settings/billing/billing-view"; + +import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("billing"), + (t) => t("manage_billing_description"), + undefined, + undefined, + "/settings/organizations/billing" + ); + +const Page = async () => { + const t = await getTranslate(); + await validateUserHasOrgAdmin(); + + // TODO(SEAN): Add PBAC to this page in the next PR + + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/delegation-credential/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/delegation-credential/page.tsx index 3faca06291..20e5d6af4f 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/delegation-credential/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/delegation-credential/page.tsx @@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import DelegationCredentialList from "@calcom/features/ee/organizations/pages/settings/delegationCredential"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin"; + export const generateMetadata = async () => await _generateMetadata( (t) => t("delegation_credential"), @@ -15,6 +17,8 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + await validateUserHasOrgAdmin(); + return ( await _generateMetadata( @@ -22,11 +19,7 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { - return redirect("/settings/organizations/general"); - } + const session = await validateUserHasOrgAdmin(); const { canEdit } = await getResourcePermissions({ userId: session.user.id, diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx index 5423446d54..1d786ea76f 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx @@ -1,15 +1,13 @@ import { _generateMetadata, getTranslate } from "app/_utils"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy"; import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { MembershipRole } from "@calcom/prisma/enums"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin"; export const generateMetadata = async () => await _generateMetadata( @@ -21,9 +19,9 @@ export const generateMetadata = async () => ); const Page = async () => { - const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]); + const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) }); + const session = await validateUserHasOrgAdmin(); if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { return redirect("/settings/profile"); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx index a3fcf936b1..12b8c76905 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx @@ -1,15 +1,13 @@ import { _generateMetadata, getTranslate } from "app/_utils"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view"; import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { MembershipRole } from "@calcom/prisma/enums"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin"; export const generateMetadata = async () => await _generateMetadata( @@ -22,7 +20,7 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const session = await validateUserHasOrgAdmin(); if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { return redirect("/settings/organizations/general"); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts new file mode 100644 index 0000000000..ecc2f9eece --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.test.ts @@ -0,0 +1,229 @@ +import type { Session } from "next-auth"; +import { redirect } from "next/navigation"; +import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { validateUserHasOrg, type ValidatedOrgSession } from "./validateUserHasOrg"; + +// Mock the dependencies +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(() => Promise.resolve({})), + headers: vi.fn(() => Promise.resolve({})), +})); + +vi.mock("@calcom/features/auth/lib/getServerSession", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@lib/buildLegacyCtx", () => ({ + buildLegacyRequest: vi.fn(() => ({})), +})); + +const mockedGetServerSession = getServerSession as MockedFunction; +const mockedRedirect = vi.mocked(redirect); + +describe("validateUserHasOrg", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockSession = (overrides: Partial = {}): Session => ({ + expires: "2025-01-01", + hasValidLicense: true, + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.ADMIN, + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + profileId: 789, + upId: "usr-123", + ...overrides, + }); + + describe("when user has valid organization", () => { + it("should return session when user has org in user.org", async () => { + const mockSession = createMockSession(); + mockedGetServerSession.mockResolvedValue(mockSession); + + const result = await validateUserHasOrg(); + + expect(result).toEqual(mockSession); + expect(mockedRedirect).not.toHaveBeenCalled(); + expect(result.user.id).toBe(123); + expect(result.user.org?.id).toBe(456); + expect(result.user.profile?.organizationId).toBe(456); + }); + + it("should return session when user has organizationId but no org", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + + const result = await validateUserHasOrg(); + + expect(result).toEqual(mockSession); + expect(mockedRedirect).not.toHaveBeenCalled(); + }); + }); + + describe("when user does not have valid organization", () => { + it("should redirect when session is null", async () => { + mockedGetServerSession.mockResolvedValue(null); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile"); + }); + + it("should redirect when user has no organization data", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: null, + organization: null, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile"); + }); + + it("should redirect when user has no user id", async () => { + const mockSession = createMockSession({ + user: { + id: undefined as unknown as number, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.ADMIN, + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile"); + }); + }); + + describe("return type validation", () => { + it("should return ValidatedOrgSession type with non-nullable properties", async () => { + const mockSession = createMockSession(); + mockedGetServerSession.mockResolvedValue(mockSession); + + const result: ValidatedOrgSession = await validateUserHasOrg(); + + // Type assertions to ensure proper typing + expect(typeof result.user.id).toBe("number"); + expect(result.user.org).toBeTruthy(); + expect(result.user.profile).toBeTruthy(); + expect(typeof result.user.profile.organizationId).toBe("number"); + + // Runtime checks that the types guarantee these properties exist + expect(result.user.id).toBe(123); + expect(result.user.org.id).toBe(456); + expect(result.user.profile.organizationId).toBe(456); + }); + }); + + describe("error handling", () => { + it("should propagate errors from getServerSession", async () => { + const error = new Error("Session error"); + mockedGetServerSession.mockRejectedValue(error); + + await expect(validateUserHasOrg()).rejects.toThrow("Session error"); + }); + }); +}); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.tsx new file mode 100644 index 0000000000..a571e0b91b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrg.tsx @@ -0,0 +1,30 @@ +import type { Session } from "next-auth"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +export type ValidatedOrgSession = NonNullable & { + user: NonNullable & { + id: number; + org: NonNullable["org"]>; + profile: NonNullable["profile"]> & { + organizationId: number; + }; + }; +}; + +export async function validateUserHasOrg(): Promise { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + const orgExists = + session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization; + + if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) { + redirect("/settings/my-account/profile"); + } + + return session as ValidatedOrgSession; +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts new file mode 100644 index 0000000000..664fc02fac --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.test.ts @@ -0,0 +1,418 @@ +import type { Session } from "next-auth"; +import { redirect } from "next/navigation"; +import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest"; + +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { validateUserHasOrgAdmin, type ValidatedOrgAdminSession } from "./validateUserHasOrgAdmin"; + +// Mock the dependencies +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(() => Promise.resolve({})), + headers: vi.fn(() => Promise.resolve({})), +})); + +vi.mock("@calcom/features/auth/lib/getServerSession", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@calcom/features/auth/lib/checkAdminOrOwner", () => ({ + checkAdminOrOwner: vi.fn(), +})); + +vi.mock("@lib/buildLegacyCtx", () => ({ + buildLegacyRequest: vi.fn(() => ({})), +})); + +const mockedGetServerSession = getServerSession as MockedFunction; +const mockedCheckAdminOrOwner = vi.mocked(checkAdminOrOwner); +const mockedRedirect = vi.mocked(redirect); + +describe("validateUserHasOrgAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockSession = (overrides: Partial = {}): Session => ({ + expires: "2025-01-01", + hasValidLicense: true, + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.ADMIN, + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + profileId: 789, + upId: "usr-123", + ...overrides, + }); + + describe("when user has valid organization and admin role", () => { + it("should return session when user is admin via user.org.role", async () => { + const mockSession = createMockSession(); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + const result = await validateUserHasOrgAdmin(); + + expect(result).toEqual(mockSession); + expect(mockedRedirect).not.toHaveBeenCalled(); + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN); + expect(result.user.id).toBe(123); + expect(result.user.org?.id).toBe(456); + expect(result.user.profile?.organizationId).toBe(456); + }); + + it("should return session when user is owner", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.OWNER, + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + const result = await validateUserHasOrgAdmin(); + + expect(result).toEqual(mockSession); + expect(mockedRedirect).not.toHaveBeenCalled(); + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER); + }); + + it("should return session when role comes from organization members", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + members: [ + { userId: 123, role: MembershipRole.ADMIN }, + { userId: 124, role: MembershipRole.MEMBER }, + ], + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + const result = await validateUserHasOrgAdmin(); + + expect(result).toEqual(mockSession); + expect(mockedRedirect).not.toHaveBeenCalled(); + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN); + }); + }); + + describe("when user does not have valid organization", () => { + it("should redirect when session is null", async () => { + mockedGetServerSession.mockResolvedValue(null); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile"); + }); + + it("should redirect when user has no organization data", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: null, + organization: null, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile"); + }); + }); + + describe("when user has organization but is not admin", () => { + it("should redirect when user is only a member", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.MEMBER, + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(false); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile"); + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.MEMBER); + }); + + it("should redirect when role is undefined", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + members: [ + { userId: 999, role: MembershipRole.ADMIN }, // Different user + ], + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(false); + + const redirectError = new Error("NEXT_REDIRECT"); + mockedRedirect.mockImplementation(() => { + throw redirectError; + }); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT"); + expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile"); + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(undefined); + }); + }); + + describe("return type validation", () => { + it("should return ValidatedOrgAdminSession type with non-nullable properties", async () => { + const mockSession = createMockSession(); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + const result: ValidatedOrgAdminSession = await validateUserHasOrgAdmin(); + + // Type assertions to ensure proper typing + expect(typeof result.user.id).toBe("number"); + expect(result.user.org).toBeTruthy(); + expect(result.user.profile).toBeTruthy(); + expect(typeof result.user.profile.organizationId).toBe("number"); + expect(result.user.org.role).toBeTruthy(); + + // Runtime checks that the types guarantee these properties exist + expect(result.user.id).toBe(123); + expect(result.user.org.id).toBe(456); + expect(result.user.org.role).toBe(MembershipRole.ADMIN); + expect(result.user.profile.organizationId).toBe(456); + }); + }); + + describe("role resolution logic", () => { + it("should prefer org role over organization members role", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: { + id: 456, + name: "Test Org", + slug: "test-org", + logoUrl: null, + fullDomain: "test-org.cal.com", + domainSuffix: "cal.com", + role: MembershipRole.OWNER, // This should be preferred + }, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + members: [ + { userId: 123, role: MembershipRole.MEMBER }, // Different role + ], + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + await validateUserHasOrgAdmin(); + + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER); + }); + + it("should fall back to organization members role when org role is not available", async () => { + const mockSession = createMockSession({ + user: { + id: 123, + email: "test@example.com", + name: "Test User", + org: undefined, + profile: { + id: 789, + upId: "usr-123", + username: "testuser", + organizationId: 456, + organization: { + id: 456, + name: "Test Org", + slug: "test-org", + calVideoLogo: null, + bannerUrl: null, + requestedSlug: null, + members: [{ userId: 123, role: MembershipRole.ADMIN }], + }, + }, + }, + }); + mockedGetServerSession.mockResolvedValue(mockSession); + mockedCheckAdminOrOwner.mockReturnValue(true); + + await validateUserHasOrgAdmin(); + + expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN); + }); + }); + + describe("error handling", () => { + it("should propagate errors from getServerSession", async () => { + const error = new Error("Session error"); + mockedGetServerSession.mockRejectedValue(error); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("Session error"); + }); + + it("should propagate errors from checkAdminOrOwner", async () => { + const mockSession = createMockSession(); + mockedGetServerSession.mockResolvedValue(mockSession); + + const error = new Error("Permission check error"); + mockedCheckAdminOrOwner.mockImplementation(() => { + throw error; + }); + + await expect(validateUserHasOrgAdmin()).rejects.toThrow("Permission check error"); + }); + }); +}); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.tsx new file mode 100644 index 0000000000..5491f4ef7b --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgAdmin.tsx @@ -0,0 +1,45 @@ +import type { Session } from "next-auth"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +export type ValidatedOrgAdminSession = NonNullable & { + user: NonNullable & { + id: number; + org: NonNullable["org"]> & { + role: MembershipRole; + }; + profile: NonNullable["profile"]> & { + organizationId: number; + }; + }; +}; + +export async function validateUserHasOrgAdmin(): Promise { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + const orgExists = + session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization; + + if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) { + redirect("/settings/my-account/profile"); + } + + const userProfile = session?.user?.profile; + const userId = session?.user?.id; + const orgRole = + session?.user?.org?.role ?? + userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role; + const isOrgAdminOrOwner = checkAdminOrOwner(orgRole); + + if (!isOrgAdminOrOwner) { + redirect("/settings/organizations/profile"); + } + + return session as ValidatedOrgAdminSession; +} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/admin-api/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/admin-api/page.tsx index 4847949e49..d430d57432 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/admin-api/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/admin-api/page.tsx @@ -3,6 +3,8 @@ import { getTranslate, _generateMetadata } from "app/_utils"; import { AdminAPIView } from "@calcom/features/ee/organizations/pages/settings/admin-api"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { validateUserHasOrg } from "../actions/validateUserHasOrg"; + export const generateMetadata = async () => await _generateMetadata( (t) => `${t("admin")} ${t("api_reference")}`, @@ -15,6 +17,8 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + await validateUserHasOrg(); + return ( await _generateMetadata( @@ -23,11 +20,7 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { - return redirect("/settings/profile"); - } + const session = await validateUserHasOrg(); const { canRead, canEdit } = await getResourcePermissions({ userId: session.user.id, diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/layout.tsx index fb858f6132..0ed221f7c7 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/layout.tsx @@ -1,19 +1,4 @@ -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - const SettingsOrganizationsLayout = async ({ children }: { children: React.ReactNode }) => { - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - - const orgExists = - session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization; - if (!orgExists) { - return redirect("/settings/my-account/profile"); - } - return children; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx index 4b58650b45..56a8914f99 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx @@ -1,8 +1,6 @@ import { _generateMetadata, getTranslate } from "app/_utils"; -import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile"; import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; @@ -10,7 +8,7 @@ import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import type { Membership } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { validateUserHasOrg } from "../actions/validateUserHasOrg"; export const generateMetadata = async () => await _generateMetadata( @@ -22,20 +20,20 @@ export const generateMetadata = async () => ); const Page = async () => { - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const session = await validateUserHasOrg(); const t = await getTranslate(); - const orgRole = session?.user.profile?.organization.members?.find( - (member: Membership) => member.userId === session?.user.id + const orgRole = session.user.profile.organization.members?.find( + (member: Membership) => member.userId === session.user.id )?.role; - if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) { + if (!orgRole) { return redirect("/settings/profile"); } const { canRead, canEdit, canDelete } = await getResourcePermissions({ userId: session.user.id, - teamId: session?.user.profile?.organizationId, + teamId: session.user.profile.organizationId, resource: Resource.Organization, userRole: orgRole, fallbackRoles: { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx index 3b5466f46b..676a11bb02 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx @@ -1,9 +1,7 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import { unstable_cache } from "next/cache"; -import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import type { AppFlags } from "@calcom/features/flags/config"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper"; @@ -13,8 +11,7 @@ import { RoleService } from "@calcom/features/pbac/services/role.service"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { prisma } from "@calcom/prisma"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - +import { validateUserHasOrg } from "../actions/validateUserHasOrg"; import { CreateRoleCTA } from "./_components/CreateRoleCta"; import { RolesList } from "./_components/RolesList"; import { roleSearchParamsCache } from "./_components/searchParams"; @@ -58,7 +55,7 @@ export const generateMetadata = async () => const Page = async ({ searchParams }: { searchParams: Record }) => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const session = await validateUserHasOrg(); if (!session?.user?.org?.id || !session.user.id) { return notFound(); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx index f0e0154dd5..d8cee42e1e 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/(main-page)/page.tsx @@ -1,13 +1,11 @@ import { _generateMetadata, getTranslate } from "app/_utils"; -import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { OrganizationRepository } from "@calcom/lib/server/repository/organization"; -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { validateUserHasOrg } from "../../../actions/validateUserHasOrg"; export const generateMetadata = async () => await _generateMetadata( @@ -20,7 +18,7 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const session = await validateUserHasOrg(); if (!session?.user?.id) { redirect("/auth/login"); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/[id]/appearance/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/[id]/appearance/page.tsx index f89432f785..e2a460cea6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/[id]/appearance/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/teams/other/[id]/appearance/page.tsx @@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import LegacyPage from "@calcom/features/ee/teams/pages/team-appearance-view"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg"; + export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => await _generateMetadata( (t) => t("booking_appearance"), @@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin const Page = async () => { const t = await getTranslate(); + await validateUserHasOrg(); + return ( }) => await _generateMetadata( (t) => t("team_members"), @@ -17,6 +19,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin const Page = async () => { const t = await getTranslate(); + await validateUserHasOrg(); + return ( }) => await _generateMetadata( (t) => t("profile"), @@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin const Page = async () => { const t = await getTranslate(); + await validateUserHasOrg(); + return (