* feat: add user-specific email verification setting Add requiresBookerEmailVerification boolean field to User model that allows users to protect their email from impersonation during bookings. When enabled, anyone attempting to book using the protected user's email address (as booker or guest) must complete email verification and be logged in as that email owner. Key changes: - Add requiresBookerEmailVerification field to User schema - Create settings toggle in /settings/my-account/general - Update checkIfBookerEmailIsBlocked to check booker's account setting - Update guest filtering in handleNewBooking and addGuests handlers - Add i18n translations for new setting - Check both primary and verified secondary emails Additional fixes: - Replace 'any' types with proper Prisma and zod types in user.ts - Fix member role type in sessionMiddleware.ts - Fix avatar URL generation bug in sessionMiddleware.ts These type fixes were necessary to resolve pre-commit lint warnings that were blocking the commit. Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: address PR review comments - Remove unrelated Watchlist index drops from migration - Add missing Watchlist indexes to schema.prisma to fix drift - Refactor checkIfBookerEmailIsBlocked to throw ErrorWithCode - Move HttpError handling to handleNewBooking caller layer Addresses review comments on PR #24298 Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * refactor: move Prisma queries to UserRepository and remove unrelated Watchlist changes - Add findByEmailWithEmailVerificationSetting method to UserRepository - Add findManyByEmailsWithEmailVerificationSettings method to UserRepository - Refactor checkIfUserEmailVerificationRequired handler to use UserRepository - Refactor addGuests handler to use UserRepository - Remove unrelated Watchlist schema indices (organizationId/isGlobal, source) - Remove unrelated WatchlistAudit unique constraint on id Addresses review comments on PR #24298 Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: better error codes + use repo * Updated db query with manully written one using UNION (#24430) * fix: resolve usage of deprecated secondary email in return value * fix: type errors from refactors * fix: address CodeRabbit PR review comments - Add NOT NULL constraint to requiresBookerEmailVerification migration - Dedupe guest input by base email to handle plus-addressing correctly - Compare attendees by base email instead of raw strings - Send emails only to filtered uniqueGuests (not all guests) - Improve error logging with actual error details Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: indices added by mistake Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * chore: update label of setting * fix: return matched email for guests * chore: remove whitespace * test: add comprehensive email verification tests - Add 9 test scenarios covering user email verification setting - Test main booker verification (logged in/out, with/without code) - Test secondary email verification as main booker and guest - Test guest filtering when verification is required - Test plus-addressed email handling - Test multiple guests with mixed verification requirements - Test invalid verification code error handling - Update bookingScenario helper to support requiresBookerEmailVerification and secondaryEmails Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: correct guest placement in test mock data Move guests array from top-level booking data into responses object to match expected structure in getBookingData.ts which looks for responses.guests (line 74). Fixes three failing tests: - should filter out guest that requires verification - should filter out secondary email with verification when added as guest - should filter only guests requiring verification from multiple guests Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Rodrigo Ehlers <rodrigoehlers@outlook.com> Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Co-authored-by: Rodrigo Ehlers <rodrigo@chatbyte.ai> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
import { setUser as SentrySetUser } from "@sentry/nextjs";
|
|
import type { Session } from "next-auth";
|
|
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import prisma from "@calcom/prisma";
|
|
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import type { TRPCContextInner } from "../createContext";
|
|
import { middleware } from "../trpc";
|
|
|
|
type Maybe<T> = T | null | undefined;
|
|
|
|
export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<Session>) {
|
|
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 } = 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,
|
|
email,
|
|
username,
|
|
locale,
|
|
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
|
|
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
|
|
};
|
|
}
|
|
|
|
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
|
|
|
|
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();
|
|
|
|
const { user, session } = await getUserSession(ctx);
|
|
|
|
const middlewareEnd = performance.now();
|
|
logger.debug("Perf:t.isAuthed", middlewareEnd - middlewareStart);
|
|
|
|
if (!user || !session) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
SentrySetUser({ id: user.id });
|
|
|
|
return next({
|
|
ctx: { user, session },
|
|
});
|
|
});
|
|
|
|
export const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
|
|
const { user } = ctx;
|
|
if (user?.role !== "ADMIN") {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
return next({ ctx: { user: user } });
|
|
});
|
|
|
|
// Org admins can be admins or owners
|
|
export const isOrgAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
|
|
const { user } = ctx;
|
|
if (!user?.organization?.isOrgAdmin) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
return next({ ctx: { user: user } });
|
|
});
|