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>
This commit is contained in:
sean-brydon
2025-09-09 22:27:00 +09:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent 1137047606
commit a379bd2b52
21 changed files with 804 additions and 70 deletions
@@ -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 <OrgAttributesEditPage />;
};
@@ -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 <OrgAttributesCreatePage />;
};
@@ -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,
@@ -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 (
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={true}>
<BillingView />
</SettingsHeader>
);
};
export default Page;
@@ -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 (
<SettingsHeader
borderInShellHeader
@@ -1,15 +1,12 @@
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 DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-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,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,
@@ -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");
@@ -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");
@@ -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<typeof getServerSession>;
const mockedRedirect = vi.mocked(redirect);
describe("validateUserHasOrg", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockSession = (overrides: Partial<Session> = {}): 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");
});
});
});
@@ -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<Session> & {
user: NonNullable<Session["user"]> & {
id: number;
org: NonNullable<NonNullable<Session["user"]>["org"]>;
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
organizationId: number;
};
};
};
export async function validateUserHasOrg(): Promise<ValidatedOrgSession> {
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;
}
@@ -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<typeof getServerSession>;
const mockedCheckAdminOrOwner = vi.mocked(checkAdminOrOwner);
const mockedRedirect = vi.mocked(redirect);
describe("validateUserHasOrgAdmin", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockSession = (overrides: Partial<Session> = {}): 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");
});
});
});
@@ -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<Session> & {
user: NonNullable<Session["user"]> & {
id: number;
org: NonNullable<NonNullable<Session["user"]>["org"]> & {
role: MembershipRole;
};
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
organizationId: number;
};
};
};
export async function validateUserHasOrgAdmin(): Promise<ValidatedOrgAdminSession> {
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;
}
@@ -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 (
<SettingsHeader
title={`${t("admin")} ${t("api_reference")}`}
@@ -1,15 +1,12 @@
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 LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
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 { validateUserHasOrg } from "../actions/validateUserHasOrg";
export const generateMetadata = async () =>
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,
@@ -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;
};
@@ -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: {
@@ -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<string, string | string[] | undefined> }) => {
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();
@@ -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");
@@ -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 (
<SettingsHeader
title={t("booking_appearance")}
@@ -5,6 +5,8 @@ import LegacyPage, {
} from "@calcom/features/ee/organizations/pages/settings/other-team-members-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("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 (
<SettingsHeader
title={t("team_members")}
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-profile-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("profile"),
@@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader title={t("profile")} description={t("profile_team_description")}>
<LegacyPage />