diff --git a/packages/features/auth/lib/userFromSessionUtils.ts b/packages/features/auth/lib/userFromSessionUtils.ts new file mode 100644 index 0000000000..b102476e0f --- /dev/null +++ b/packages/features/auth/lib/userFromSessionUtils.ts @@ -0,0 +1,140 @@ +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; +import type { GetServerSidePropsContext, NextApiRequest } from "next"; +import type { Session } from "next-auth"; + +type Maybe = T | null | undefined; + +export type SessionContext = { + req?: NextApiRequest | GetServerSidePropsContext["req"]; + locale?: string; + session?: Session | null; +}; + +async function getUserFromSession(ctx: SessionContext, session: Maybe) { + if (!session) { + return null; + } + + if (!session.user?.id) { + return null; + } + + const userRepo = new UserRepository(prisma); + const userFromDb = await userRepo.findUnlockedUserForSession({ userId: session.user.id }); + + // some hacks to make sure `username` and `email` are never inferred as `null` + if (!userFromDb) { + return null; + } + + const upId = session.upId; + + const user = await userRepo.enrichUserWithTheProfile({ + user: userFromDb, + upId, + }); + + logger.debug( + `getUserFromSession: enriched user with profile - ${ctx.req?.url}`, + safeStringify({ user, userFromDb, upId }) + ); + + const { email, username, id, uuid } = user; + if (!email || !id) { + return null; // should we return null here? + } + + const userMetaData = userMetadata.parse(user.metadata || {}); + const orgMetadata = teamMetadataSchema.parse(user.profile?.organization?.metadata || {}); + // This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url + + const locale = user?.locale ?? ctx.locale ?? "en"; + const { members = [], ..._organization } = user.profile?.organization || {}; + const isOrgAdmin = members.some((member: { role: string }) => ["OWNER", "ADMIN"].includes(member.role)); + + if (isOrgAdmin) { + logger.debug("User is an org admin", safeStringify({ userId: user.id })); + } else { + logger.debug("User is not an org admin", safeStringify({ userId: user.id })); + } + const organization = { + ..._organization, + id: user.profile?.organization?.id ?? null, + isOrgAdmin, + metadata: orgMetadata, + requestedSlug: orgMetadata?.requestedSlug ?? null, + }; + + return { + ...user, + avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`, + // TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now. + organization, + organizationId: organization.id, + id, + uuid, + email, + username, + locale, + defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null, + requiresBookerEmailVerification: user.requiresBookerEmailVerification, + }; +} + +export type UserFromSession = Awaited>; + +export const getSession = async (ctx: SessionContext) => { + const { req } = ctx; + const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession"); + return req ? await getServerSession({ req }) : null; +}; + +export const getUserSession = async (ctx: SessionContext) => { + /** + * It is possible that the session and user have already been added to the context by a previous middleware + * or when creating the context + */ + const session = ctx.session || (await getSession(ctx)); + const user = session ? await getUserFromSession(ctx, session) : null; + let foundProfile = null; + // Check authorization for profile + if (session?.profileId && user?.id) { + foundProfile = await ProfileRepository.findByUserIdAndProfileId({ + userId: user.id, + profileId: session.profileId, + }); + if (!foundProfile) { + logger.error( + "Profile not found or not authorized", + safeStringify({ profileId: session.profileId, userId: user?.id }) + ); + // TODO: Test that logout should happen automatically + throw new ErrorWithCode(ErrorCode.Unauthorized, "Profile not found or not authorized"); + } + } + + let sessionWithUpId = null; + if (session) { + let upId = session.upId; + if (!upId) { + upId = foundProfile?.upId ?? `usr-${user?.id}`; + } + + if (!upId) { + throw new ErrorWithCode(ErrorCode.InternalServerError, "No upId found for session"); + } + sessionWithUpId = { + ...session, + upId, + }; + } + return { user, session: sessionWithUpId }; +}; diff --git a/packages/features/data-table/__tests__/filterSegments/create.test.ts b/packages/features/data-table/__tests__/filterSegments/create.test.ts index 8332bf013c..401452bee6 100644 --- a/packages/features/data-table/__tests__/filterSegments/create.test.ts +++ b/packages/features/data-table/__tests__/filterSegments/create.test.ts @@ -1,11 +1,8 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; - -import { describe, expect, it } from "vitest"; - import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - +import { describe, expect, it } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { FilterSegmentRepository } from "../../repositories/filterSegment"; import type { TCreateFilterSegmentInputSchema } from "../../repositories/filterSegment.type"; @@ -16,7 +13,7 @@ describe("FilterSegmentRepository.create()", () => { const mockUser = { id: userId, name: "Test User", - } as NonNullable; + } as NonNullable; const baseInput = { tableIdentifier: "bookings", diff --git a/packages/features/data-table/__tests__/filterSegments/delete.test.ts b/packages/features/data-table/__tests__/filterSegments/delete.test.ts index fffa248534..007d7250c5 100644 --- a/packages/features/data-table/__tests__/filterSegments/delete.test.ts +++ b/packages/features/data-table/__tests__/filterSegments/delete.test.ts @@ -1,12 +1,9 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; - -import { describe, expect, it } from "vitest"; - import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - +import { describe, expect, it } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { FilterSegmentRepository } from "../../repositories/filterSegment"; -import { type TDeleteFilterSegmentInputSchema } from "../../repositories/filterSegment.type"; +import type { TDeleteFilterSegmentInputSchema } from "../../repositories/filterSegment.type"; const repository = new FilterSegmentRepository(); @@ -15,7 +12,7 @@ describe("FilterSegmentRepository.delete()", () => { const mockUser = { id: userId, name: "Test User", - } as NonNullable; + } as NonNullable; it("should delete a user-scoped filter segment", async () => { // Create a user-scoped segment first diff --git a/packages/features/data-table/__tests__/filterSegments/get.test.ts b/packages/features/data-table/__tests__/filterSegments/get.test.ts index eda9a8793a..bbb558c5db 100644 --- a/packages/features/data-table/__tests__/filterSegments/get.test.ts +++ b/packages/features/data-table/__tests__/filterSegments/get.test.ts @@ -1,12 +1,9 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; - -import { describe, expect, it } from "vitest"; - import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - +import { describe, expect, it } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { FilterSegmentRepository } from "../../repositories/filterSegment"; -import { type TListFilterSegmentsInputSchema } from "../../repositories/filterSegment.type"; +import type { TListFilterSegmentsInputSchema } from "../../repositories/filterSegment.type"; const repository = new FilterSegmentRepository(); @@ -15,7 +12,7 @@ describe("FilterSegmentRepository.get()", () => { const mockUser = { id: userId, name: "Test User", - } as NonNullable; + } as NonNullable; it("should return user-scoped filter segments", async () => { // Create user-scoped segments diff --git a/packages/features/data-table/__tests__/filterSegments/update.test.ts b/packages/features/data-table/__tests__/filterSegments/update.test.ts index 22e6d9bb62..e0bcfcc6d6 100644 --- a/packages/features/data-table/__tests__/filterSegments/update.test.ts +++ b/packages/features/data-table/__tests__/filterSegments/update.test.ts @@ -1,13 +1,10 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; - -import { describe, expect, it } from "vitest"; - import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - +import { describe, expect, it } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { FilterSegmentRepository } from "../../repositories/filterSegment"; -import { type TUpdateFilterSegmentInputSchema } from "../../repositories/filterSegment.type"; +import type { TUpdateFilterSegmentInputSchema } from "../../repositories/filterSegment.type"; const repository = new FilterSegmentRepository(); @@ -16,7 +13,7 @@ describe("FilterSegmentRepository.update()", () => { const mockUser = { id: userId, name: "Test User", - } as NonNullable; + } as NonNullable; const baseInput = { tableIdentifier: "bookings", diff --git a/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts b/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts index 656e217078..8237788bab 100644 --- a/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts +++ b/packages/features/ee/dsync/lib/server/userCanCreateTeamGroupMapping.ts @@ -1,11 +1,10 @@ import { canAccessOrganization } from "@calcom/features/ee/sso/lib/saml"; -import prisma from "@calcom/prisma"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import prisma from "@calcom/prisma"; const userCanCreateTeamGroupMapping = async ( - user: NonNullable, + user: { id: number; email: string }, organizationId: number | null, teamId?: number ) => { diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index 09ca77a8d7..9387133b06 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -1,8 +1,6 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - import { prisma } from "@calcom/prisma"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; - +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { OrganizationPaymentService } from "./OrganizationPaymentService"; import type { IOrganizationPermissionService } from "./OrganizationPermissionService"; @@ -60,7 +58,7 @@ vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({ describe("OrganizationPaymentService", () => { let service: OrganizationPaymentService; let mockPermissionService: IOrganizationPermissionService; - const mockUser: TrpcSessionUser = { + const mockUser: UserFromSession = { id: 1, email: "test@example.com", role: "USER", diff --git a/packages/features/ee/organizations/lib/OrganizationPermissionService.test.ts b/packages/features/ee/organizations/lib/OrganizationPermissionService.test.ts index 3e97b0ef04..7086e7f977 100644 --- a/packages/features/ee/organizations/lib/OrganizationPermissionService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPermissionService.test.ts @@ -1,8 +1,6 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - import { prisma } from "@calcom/prisma"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; - +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { OrganizationPermissionService } from "./OrganizationPermissionService"; vi.mock("@calcom/prisma", () => ({ @@ -21,7 +19,7 @@ vi.mock("@calcom/prisma", () => ({ describe("OrganizationPermissionService", () => { let service: OrganizationPermissionService; - const mockUser: TrpcSessionUser = { + const mockUser: UserFromSession = { id: 1, email: "test@example.com", role: "USER", diff --git a/packages/features/schedules/services/ScheduleService.ts b/packages/features/schedules/services/ScheduleService.ts index 328a9c5dac..9062a9ec92 100644 --- a/packages/features/schedules/services/ScheduleService.ts +++ b/packages/features/schedules/services/ScheduleService.ts @@ -4,8 +4,8 @@ import { hasEditPermissionForUserID } from "@calcom/lib/hasEditPermissionForUser import { HttpError } from "@calcom/lib/http-error"; import { transformScheduleToAvailabilityForAtom } from "@calcom/lib/schedules/transformers/for-atom"; import type { PrismaClient } from "@calcom/prisma"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { z } from "zod"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import { ScheduleRepository } from "../repositories/ScheduleRepository"; export const ZUpdateInputSchema = z.object({ @@ -37,7 +37,7 @@ export type TUpdateInputSchema = z.infer; interface IUpdateScheduleOptions { input: TUpdateInputSchema; - user: Pick, "id" | "defaultScheduleId" | "timeZone">; + user: Pick, "id" | "defaultScheduleId" | "timeZone">; } export type UpdateScheduleResponse = Awaited>; diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index bc917fef46..ddaa9e0e36 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -1,142 +1,9 @@ -import { setUser as SentrySetUser } from "@sentry/nextjs"; -import type { Session } from "next-auth"; - -import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; -import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getUserSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import prisma from "@calcom/prisma"; -import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; - +import { setUser as SentrySetUser } from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; - -import type { TRPCContextInner } from "../createContext"; import { middleware } from "../trpc"; -type Maybe = T | null | undefined; - -export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe) { - if (!session) { - return null; - } - - if (!session.user?.id) { - return null; - } - - const userRepo = new UserRepository(prisma); - const userFromDb = await userRepo.findUnlockedUserForSession({ userId: session.user.id }); - - // some hacks to make sure `username` and `email` are never inferred as `null` - if (!userFromDb) { - return null; - } - - const upId = session.upId; - - const user = await userRepo.enrichUserWithTheProfile({ - user: userFromDb, - upId, - }); - - logger.debug( - `getUserFromSession: enriched user with profile - ${ctx.req?.url}`, - safeStringify({ user, userFromDb, upId }) - ); - - const { email, username, id, uuid } = user; - if (!email || !id) { - return null; // should we return null here? - } - - const userMetaData = userMetadata.parse(user.metadata || {}); - const orgMetadata = teamMetadataSchema.parse(user.profile?.organization?.metadata || {}); - // This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url - - const locale = user?.locale ?? ctx.locale; - const { members = [], ..._organization } = user.profile?.organization || {}; - const isOrgAdmin = members.some((member: { role: string }) => ["OWNER", "ADMIN"].includes(member.role)); - - if (isOrgAdmin) { - logger.debug("User is an org admin", safeStringify({ userId: user.id })); - } else { - logger.debug("User is not an org admin", safeStringify({ userId: user.id })); - } - const organization = { - ..._organization, - id: user.profile?.organization?.id ?? null, - isOrgAdmin, - metadata: orgMetadata, - requestedSlug: orgMetadata?.requestedSlug ?? null, - }; - - return { - ...user, - avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`, - // TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now. - organization, - organizationId: organization.id, - id, - uuid, - email, - username, - locale, - defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null, - requiresBookerEmailVerification: user.requiresBookerEmailVerification, - }; -} - -export type UserFromSession = Awaited>; - -export const getSession = async (ctx: TRPCContextInner) => { - const { req } = ctx; - const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession"); - return req ? await getServerSession({ req }) : null; -}; - -export const getUserSession = async (ctx: TRPCContextInner) => { - /** - * It is possible that the session and user have already been added to the context by a previous middleware - * or when creating the context - */ - const session = ctx.session || (await getSession(ctx)); - const user = session ? await getUserFromSession(ctx, session) : null; - let foundProfile = null; - // Check authorization for profile - if (session?.profileId && user?.id) { - foundProfile = await ProfileRepository.findByUserIdAndProfileId({ - userId: user.id, - profileId: session.profileId, - }); - if (!foundProfile) { - logger.error( - "Profile not found or not authorized", - safeStringify({ profileId: session.profileId, userId: user?.id }) - ); - // TODO: Test that logout should happen automatically - throw new TRPCError({ code: "UNAUTHORIZED", message: "Profile not found or not authorized" }); - } - } - - let sessionWithUpId = null; - if (session) { - let upId = session.upId; - if (!upId) { - upId = foundProfile?.upId ?? `usr-${user?.id}`; - } - - if (!upId) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No upId found for session" }); - } - sessionWithUpId = { - ...session, - upId, - }; - } - return { user, session: sessionWithUpId }; -}; - export const isAuthed = middleware(async ({ ctx, next }) => { const middlewareStart = performance.now(); diff --git a/packages/trpc/server/routers/publicViewer/procedures/event.ts b/packages/trpc/server/routers/publicViewer/procedures/event.ts index b286e85be3..f67218500a 100644 --- a/packages/trpc/server/routers/publicViewer/procedures/event.ts +++ b/packages/trpc/server/routers/publicViewer/procedures/event.ts @@ -1,4 +1,4 @@ -import { getSession } from "../../../middlewares/sessionMiddleware"; +import { getSession } from "@calcom/features/auth/lib/userFromSessionUtils"; import publicProcedure from "../../../procedures/publicProcedure"; import { ZEventInputSchema } from "../event.schema"; diff --git a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/create.handler.ts b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/create.handler.ts index b1418ff76e..a190a51e65 100644 --- a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/create.handler.ts +++ b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/create.handler.ts @@ -13,7 +13,7 @@ type Options = { export const createHandler = async ({ ctx, input }: Options) => { const { organizationId } = await userCanCreateTeamGroupMapping( - ctx.user, + { id: ctx.user.id, email: ctx.user.email }, ctx.user.organizationId, input.teamId ); diff --git a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/delete.handler.ts b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/delete.handler.ts index b00157adeb..141bb6bf7f 100644 --- a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/delete.handler.ts @@ -13,7 +13,7 @@ type Options = { // Delete directory sync connection for a team export const deleteHandler = async ({ ctx, input }: Options) => { - await userCanCreateTeamGroupMapping(ctx.user, ctx.user.organizationId, input.teamId); + await userCanCreateTeamGroupMapping({ id: ctx.user.id, email: ctx.user.email }, ctx.user.organizationId, input.teamId); await prisma.dSyncTeamGroupMapping.delete({ where: { diff --git a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/get.handler.ts b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/get.handler.ts index 2b89bfab3e..d8cf597ebb 100644 --- a/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/get.handler.ts +++ b/packages/trpc/server/routers/viewer/dsync/teamGroupMapping/get.handler.ts @@ -11,7 +11,7 @@ type Options = { }; export const getHandler = async ({ ctx }: Options) => { - const { organizationId } = await userCanCreateTeamGroupMapping(ctx.user, ctx.user.organizationId); + const { organizationId } = await userCanCreateTeamGroupMapping({ id: ctx.user.id, email: ctx.user.email }, ctx.user.organizationId); // Get org teams const teamsQuery = await prisma.team.findMany({ diff --git a/packages/trpc/server/types.ts b/packages/trpc/server/types.ts index 80797eb740..9924203c0b 100644 --- a/packages/trpc/server/types.ts +++ b/packages/trpc/server/types.ts @@ -1,3 +1,3 @@ -import type { UserFromSession } from "./middlewares/sessionMiddleware"; +import type { UserFromSession } from "@calcom/features/auth/lib/userFromSessionUtils"; export type TrpcSessionUser = UserFromSession;