## What does this PR do? Similar to #25721, adds uuid in session so that BookingAudit has it readily available Adds the user's UUID to the booking metadata by: 1. Extending the NextAuth `User` interface to include an optional `uuid` property from PrismaUser 2. Making `uuid` required on `Session.user` via intersection type (`User & { uuid: PrismaUser["uuid"] }`) 3. Adding `uuid` to the session user object in `getServerSession.ts` 4. Adding `uuid` to the `AdapterUser` transformation in `next-auth-custom-adapter.ts` 5. Passing `userUuid` from the session to the booking creation flow 6. Updating the API key verification flow to include `uuid` in the user data (repository, service, and type definitions) 7. Adding `req.userUuid` as a required field on the request object (like `req.userId`) 8. Adding `uuid` to mock session objects in web app routes and test context 9. Adding `uuid` to the `findByEmailAndIncludeProfilesAndPassword` query in UserRepository Also removes commented-out code that was placeholder for future work and fixes lint warnings for unused variables. ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - no documentation changes needed. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Verify that the NextAuth session callback is already populating the `uuid` field on the user object 2. Create a booking and confirm `userUuid` is included in the booking metadata 3. Test API v1 endpoints (`/api/invites` POST and `/api/teams/[teamId]/publish`) to verify they receive the uuid from the authenticated user 4. Check that the booking flow works correctly with the new parameter 5. Test SAML login flow to verify session still works correctly (uuid is resolved from database after authentication) ## Human Review Checklist - [ ] Verify the NextAuth session callback populates `uuid` - if not, this change will pass `undefined` at runtime - [ ] Confirm `userUuid` is consumed downstream in the booking service - [ ] Verify that `PrismaApiKeyRepository.findByHashedKey()` correctly fetches the user's uuid from the database - [ ] Verify the discriminated union type in `ApiKeyService.ts` ensures `result.user` is always defined when `result.valid` is true - [ ] Confirm all API v1 endpoints using `req.userUuid` go through the `verifyApiKey` middleware - [ ] Verify `UserRepository.findByEmailAndIncludeProfilesAndPassword()` includes `uuid` in the select clause - [ ] Verify SAML login still works correctly - uuid is now optional on User interface so SAML providers don't need to supply it at profile stage ## Updates since last revision - **Made uuid optional on User, required on Session**: Changed the type strategy so `uuid` is optional on the NextAuth `User` interface but required on `Session.user` via intersection type. This allows SAML providers to not supply uuid at the profile stage while ensuring uuid is always present on the session after the user is resolved from the database. - **Removed uuid from SAML functions**: Since uuid is now optional on User, the SAML profile and authorize functions no longer need to include it. --- Link to Devin run: https://app.devin.ai/sessions/97e5603b719a420b9b35041252c9db26 Requested by: hariom@cal.com (@hariombalhara)
171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
import type { Adapter, AdapterUser, AdapterAccount } from "next-auth/adapters";
|
|
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import type { Account, IdentityProvider, User } from "@calcom/prisma/client";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
|
|
const parseIntSafe = (id: string | number): number => {
|
|
if (typeof id === "number") return id;
|
|
const parsed = parseInt(id, 10);
|
|
if (isNaN(parsed)) throw new Error(`Invalid ID format: ${id}`);
|
|
return parsed;
|
|
};
|
|
|
|
// Simple utility functions for transformations
|
|
const toAdapterUser = (user: User): AdapterUser => ({
|
|
id: user.id.toString(),
|
|
uuid: user.uuid,
|
|
name: user.name,
|
|
email: user.email,
|
|
emailVerified: user.emailVerified,
|
|
image: user.avatarUrl,
|
|
});
|
|
|
|
const toAdapterAccount = (account: Account): AdapterAccount => ({
|
|
userId: account.userId.toString(),
|
|
type: account.type as AdapterAccount["type"],
|
|
provider: account.provider,
|
|
providerAccountId: account.providerAccountId,
|
|
refresh_token: account.refresh_token ?? undefined,
|
|
access_token: account.access_token ?? undefined,
|
|
expires_at: account.expires_at ?? undefined,
|
|
token_type: account.token_type ?? undefined,
|
|
scope: account.scope ?? undefined,
|
|
id_token: account.id_token ?? undefined,
|
|
session_state: account.session_state ?? undefined,
|
|
});
|
|
|
|
const createUserData = (data: Omit<AdapterUser, "id">): Prisma.UserCreateInput => ({
|
|
name: data.name || null,
|
|
email: data.email,
|
|
emailVerified: data.emailVerified || null,
|
|
avatarUrl: data.image || null,
|
|
});
|
|
|
|
const createAccountData = (account: AdapterAccount): Prisma.AccountCreateInput => ({
|
|
provider: account.provider,
|
|
providerAccountId: account.providerAccountId,
|
|
type: account.type,
|
|
user: { connect: { id: parseIntSafe(account.userId) } },
|
|
access_token: account.access_token,
|
|
refresh_token: account.refresh_token,
|
|
expires_at: account.expires_at,
|
|
token_type: account.token_type,
|
|
scope: account.scope,
|
|
id_token: account.id_token,
|
|
session_state: account.session_state,
|
|
});
|
|
|
|
const getAccountWhere = (provider: string, providerAccountId: string) => ({
|
|
provider_providerAccountId: { provider, providerAccountId },
|
|
});
|
|
|
|
export default function CalComAdapter(prismaClient: PrismaClient): Adapter {
|
|
return {
|
|
createUser: async (data: Omit<AdapterUser, "id">) => {
|
|
const user = await prismaClient.user.create({ data: createUserData(data) });
|
|
return toAdapterUser(user);
|
|
},
|
|
|
|
getUser: async (id) => {
|
|
const user = await prismaClient.user.findUnique({ where: { id: parseIntSafe(id) } });
|
|
return user ? toAdapterUser(user) : null;
|
|
},
|
|
|
|
getUserByEmail: async (email) => {
|
|
const user = await prismaClient.user.findUnique({ where: { email } });
|
|
return user ? toAdapterUser(user) : null;
|
|
},
|
|
|
|
async getUserByAccount(providerAccountId) {
|
|
const account = await prismaClient.account.findUnique({
|
|
where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId),
|
|
select: { user: true },
|
|
});
|
|
|
|
if (account?.user) {
|
|
return toAdapterUser(account.user);
|
|
}
|
|
|
|
// Fallback for legacy users without Account entries
|
|
const provider = providerAccountId.provider.toUpperCase();
|
|
|
|
const isGoogleOrSaml = (p: string): p is Extract<IdentityProvider, "GOOGLE" | "SAML"> =>
|
|
["GOOGLE", "SAML"].includes(p);
|
|
if (!isGoogleOrSaml(provider)) return null;
|
|
|
|
const user = await prismaClient.user.findFirst({
|
|
where: {
|
|
identityProviderId: providerAccountId.providerAccountId,
|
|
identityProvider: provider,
|
|
},
|
|
});
|
|
|
|
return user ? toAdapterUser(user) : null;
|
|
},
|
|
|
|
updateUser: async (userData) => {
|
|
const { id, ...data } = userData;
|
|
const user = await prismaClient.user.update({
|
|
where: { id: parseIntSafe(id) },
|
|
data: {
|
|
name: data.name,
|
|
email: data.email,
|
|
emailVerified: data.emailVerified,
|
|
avatarUrl: data.image,
|
|
},
|
|
});
|
|
return toAdapterUser(user);
|
|
},
|
|
|
|
deleteUser: async (userId) => {
|
|
const user = await prismaClient.user.delete({ where: { id: parseIntSafe(userId) } });
|
|
return toAdapterUser(user);
|
|
},
|
|
|
|
createVerificationToken: async (data) => {
|
|
const token = await prismaClient.verificationToken.create({ data });
|
|
const { id: _id, ...verificationToken } = token;
|
|
return verificationToken;
|
|
},
|
|
|
|
useVerificationToken: async (identifier_token) => {
|
|
try {
|
|
const token = await prismaClient.verificationToken.delete({ where: { identifier_token } });
|
|
const { id: _id, ...verificationToken } = token;
|
|
return verificationToken;
|
|
} catch (error) {
|
|
// If token already used/deleted, just return null
|
|
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (error.code === "P2025") return null;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
linkAccount: async (account: AdapterAccount) => {
|
|
const createdAccount = await prismaClient.account.create({ data: createAccountData(account) });
|
|
return toAdapterAccount(createdAccount);
|
|
},
|
|
|
|
unlinkAccount: async (providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">) => {
|
|
const deletedAccount = await prismaClient.account.delete({
|
|
where: getAccountWhere(providerAccountId.provider, providerAccountId.providerAccountId),
|
|
});
|
|
return toAdapterAccount(deletedAccount);
|
|
},
|
|
|
|
createSession: async (session) => session,
|
|
getSessionAndUser: async () => null,
|
|
updateSession: async (session) => ({
|
|
sessionToken: session.sessionToken || "",
|
|
userId: session.userId || "",
|
|
expires: session.expires || new Date(),
|
|
}),
|
|
deleteSession: async () => {
|
|
// No-op implementation for minimal session support
|
|
},
|
|
} satisfies Adapter;
|
|
}
|