Skip to content

fix(auth): allow invited placeholder users to complete signup from invite links#28938

Open
Adityakk9031 wants to merge 2 commits intocalcom:mainfrom
Adityakk9031:fix/auth-invite-signup-28380
Open

fix(auth): allow invited placeholder users to complete signup from invite links#28938
Adityakk9031 wants to merge 2 commits intocalcom:mainfrom
Adityakk9031:fix/auth-invite-signup-28380

Conversation

@Adityakk9031
Copy link
Copy Markdown

Linked Issues

Closes #28380
Follow-up to #28351

Summary

This PR fixes a regression where invited users could receive invite emails but fail account creation from the invite link.

Symptoms:

  • Email/password flow returned invalid_server_response
  • Google flow could land on Internal Server Error

Root Cause

Invite-signup completion logic treated some valid invited placeholder records as conflicts:

  1. Invite-token signup rejected users when invitedTo was null, even for valid invited placeholders.
  2. OAuth invited-user merge required username to be empty, but placeholder records can already have generated usernames.

This caused signup paths to incorrectly fail instead of completing the invited account.

Changes

1) Invite token signup handlers

Updated conflict guard to reject only when invitedTo is explicitly set to a different team.

Files:

  • apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts
  • apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts

Change:

  • From: reject when existingUser.invitedTo !== foundToken.teamId
  • To: reject only when existingUser.invitedTo !== null && existingUser.invitedTo !== foundToken.teamId

2) OAuth invited account completion

Relaxed invited placeholder detection in NextAuth login callback to rely on:

  • no password hash
  • no email verification

and no longer require missing username.

File:

  • packages/features/auth/lib/next-auth-options.ts

Behavior After Fix

  • Invitee with placeholder account can complete signup via email/password.
  • Invitee can complete signup via Google OAuth even if placeholder username exists.
  • Existing safety checks still block users explicitly invited to a different team.

@github-actions
Copy link
Copy Markdown
Contributor

Welcome to Cal.diy, @Adityakk9031! Thanks for opening this pull request.

A few things to keep in mind:

  • This is Cal.diy, not Cal.com. Cal.diy is a community-driven, fully open-source fork of Cal.com licensed under MIT. Your changes here will be part of Cal.diy — they will not be deployed to the Cal.com production app.
  • Please review our Contributing Guidelines if you haven't already.
  • Make sure your PR title follows the Conventional Commits format.

A maintainer will review your PR soon. Thanks for contributing!

@github-actions github-actions Bot added the 🐛 bug Something isn't working label Apr 20, 2026
@Adityakk9031 Adityakk9031 force-pushed the fix/auth-invite-signup-28380 branch from 4ad14d1 to 842a4b8 Compare April 20, 2026 04:49
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

This change refines authentication and onboarding logic across multiple handlers. The signup handlers (calcomSignupHandler and selfHostedHandler) now only return a conflict error when an existing user has a non-null invitedTo value that differs from the token's teamId, rather than checking the difference unconditionally. The onboarding card component increases bottom padding and switches the floating footer from absolute to sticky positioning. The auth options callback narrows its check for incomplete invited placeholder user account setup by removing the username signal and retaining only password hash and email verification checks, with an added explanatory comment.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive The OnboardingCard.tsx footer styling changes appear unrelated to the auth invite signup issue; consider clarifying whether this is a necessary UI adjustment or should be a separate PR. Verify that OnboardingCard.tsx footer padding and positioning changes are necessary for the invite signup flow, or consider moving to a separate PR if unrelated.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: allowing invited placeholder users to complete signup from invite links, directly addressing the core issue.
Description check ✅ Passed The description comprehensively explains the regression, root causes, specific changes, and expected behavior, all directly related to the changeset.
Linked Issues check ✅ Passed All code changes directly address issue #28380: token signup handlers now correctly allow invited users with null invitedTo values [calcomSignupHandler.ts, selfHostedHandler.ts], and OAuth login now properly identifies placeholder users without requiring username [next-auth-options.ts].

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts (1)

97-109: ⚠️ Potential issue | 🟠 Major

Account takeover via invitation link: insufficient guard allows password reset on existing user accounts.

The new condition only rejects when existingUser.invitedTo is non-null and mismatched. A fully-registered independent user (with no pending invite) has invitedTo === null, so the request flows past this check into the prisma.user.upsert below (line 221), whose update branch rewrites password, emailVerified, identityProvider, and organizationId on the existing record.

Consequences:

  • If a team admin invites an email that already belongs to an existing cal.com user, whoever opens the invite link can reset the victim's password and reassign their organizationId without logging in.
  • Even in the benign case, a legitimate user who already has an account and clicks the invite link will have their existing password silently replaced instead of being told to log in.

Tighten the guard to additionally require the existing user to look like a true placeholder (no password hash, no emailVerified) before allowing the upsert to proceed—mirroring the signal already used in next-auth-options.ts. Otherwise return 409.

🛡️ Suggested tightening
       if (foundToken?.teamId) {
         const existingUser = await prisma.user.findUnique({
           where: { email },
-          select: { invitedTo: true },
+          select: { invitedTo: true, emailVerified: true, password: { select: { hash: true } } },
         });
-        if (
-          existingUser &&
-          existingUser.invitedTo !== null &&
-          existingUser.invitedTo !== foundToken.teamId
-        ) {
-          return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 });
-        }
+        if (existingUser) {
+          const isInvitedPlaceholder = !existingUser.password?.hash && !existingUser.emailVerified;
+          const invitedToMismatch =
+            existingUser.invitedTo !== null && existingUser.invitedTo !== foundToken.teamId;
+          if (invitedToMismatch || !isInvitedPlaceholder) {
+            return NextResponse.json(
+              { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS },
+              { status: 409 }
+            );
+          }
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts` around lines 97
- 109, The current guard around foundToken?.teamId is insufficient: locate the
block using foundToken and the prisma.user.findUnique call (and the later
prisma.user.upsert) and tighten it so you only allow the upsert/update path when
the existing user is a placeholder invitation account — i.e., invitedTo equals
foundToken.teamId AND the existing user has no password hash and emailVerified
is falsy; if any of those conditions are not true, return a 409
(SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS) and do not perform the upsert that
would overwrite password, emailVerified, identityProvider, or organizationId.
♻️ Duplicate comments (1)
apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts (1)

48-60: ⚠️ Potential issue | 🟠 Major

Same relaxed-guard concern as calcomSignupHandler.ts: existing real users can be overwritten via invite token.

The condition now allows requests where existingUser.invitedTo === null to fall through into the prisma.user.upsert at Line 123, whose update branch replaces password, emailVerified, identityProvider, and organizationId on any matching email.

This means a team admin who invites an email belonging to an already-existing self-hosted user will cause that user's password and org association to be silently overwritten when the invite link is completed. If the invite URL leaks, it becomes an account takeover path.

Recommend gating the upsert on the user actually looking like a placeholder (no password hash and no emailVerified), consistent with the signal used in next-auth-options.ts.

🛡️ Suggested tightening
     if (foundToken?.teamId) {
       const existingUser = await prisma.user.findUnique({
         where: { email: userEmail },
-        select: { invitedTo: true },
+        select: { invitedTo: true, emailVerified: true, password: { select: { hash: true } } },
       });
-      if (
-        existingUser &&
-        existingUser.invitedTo !== null &&
-        existingUser.invitedTo !== foundToken.teamId
-      ) {
-        return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 });
-      }
+      if (existingUser) {
+        const isInvitedPlaceholder = !existingUser.password?.hash && !existingUser.emailVerified;
+        const invitedToMismatch =
+          existingUser.invitedTo !== null && existingUser.invitedTo !== foundToken.teamId;
+        if (invitedToMismatch || !isInvitedPlaceholder) {
+          return NextResponse.json(
+            { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS },
+            { status: 409 }
+          );
+        }
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts` around lines 48 -
60, The current guard allows an upsert to overwrite real accounts; modify the
pre-upsert checks in selfHostedHandler.ts so we only allow the upsert for
placeholder/invite-only users. When loading existingUser (variable
existingUser), include and check the credential signals used in
next-auth-options.ts (e.g., password hash field and emailVerified) and refuse
the invite/upsert if the user has a password hash or emailVerified is true; keep
the existing invitedTo mismatch check that returns 409. Update the logic that
precedes the prisma.user.upsert call so the upsert path only runs when
existingUser is either absent or explicitly a placeholder (no password hash and
not emailVerified).
🧹 Nitpick comments (1)
apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts (1)

102-108: Nit: indentation is off for this block.

Lines 102–108 are indented two spaces deeper than the surrounding if (foundToken?.teamId) body.

✏️ Proposed formatting fix
-        if (
-          existingUser &&
-          existingUser.invitedTo !== null &&
-          existingUser.invitedTo !== foundToken.teamId
-        ) {
-          return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 });
-        }
+      if (
+        existingUser &&
+        existingUser.invitedTo !== null &&
+        existingUser.invitedTo !== foundToken.teamId
+      ) {
+        return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 });
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts` around lines
102 - 108, The if-block checking existingUser.invitedTo should be re-indented to
match the surrounding if (foundToken?.teamId) body; locate the block that
returns NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, {
status: 409 }) and align its indentation with the other statements in
calcomSignupHandler.ts so the "if (existingUser && existingUser.invitedTo !==
null && existingUser.invitedTo !== foundToken.teamId) { ... }" lines use the
same indentation level as the rest of the foundToken handling code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts`:
- Around line 97-109: The current guard around foundToken?.teamId is
insufficient: locate the block using foundToken and the prisma.user.findUnique
call (and the later prisma.user.upsert) and tighten it so you only allow the
upsert/update path when the existing user is a placeholder invitation account —
i.e., invitedTo equals foundToken.teamId AND the existing user has no password
hash and emailVerified is falsy; if any of those conditions are not true, return
a 409 (SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS) and do not perform the upsert
that would overwrite password, emailVerified, identityProvider, or
organizationId.

---

Duplicate comments:
In `@apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts`:
- Around line 48-60: The current guard allows an upsert to overwrite real
accounts; modify the pre-upsert checks in selfHostedHandler.ts so we only allow
the upsert for placeholder/invite-only users. When loading existingUser
(variable existingUser), include and check the credential signals used in
next-auth-options.ts (e.g., password hash field and emailVerified) and refuse
the invite/upsert if the user has a password hash or emailVerified is true; keep
the existing invitedTo mismatch check that returns 409. Update the logic that
precedes the prisma.user.upsert call so the upsert path only runs when
existingUser is either absent or explicitly a placeholder (no password hash and
not emailVerified).

---

Nitpick comments:
In `@apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts`:
- Around line 102-108: The if-block checking existingUser.invitedTo should be
re-indented to match the surrounding if (foundToken?.teamId) body; locate the
block that returns NextResponse.json({ message:
SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }) and align its
indentation with the other statements in calcomSignupHandler.ts so the "if
(existingUser && existingUser.invitedTo !== null && existingUser.invitedTo !==
foundToken.teamId) { ... }" lines use the same indentation level as the rest of
the foundToken handling code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a13db0c1-b44f-4f7c-ac4d-49c2ce9af9e8

📥 Commits

Reviewing files that changed from the base of the PR and between 9efd0e6 and 4ad14d1.

📒 Files selected for processing (4)
  • apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts
  • apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts
  • apps/web/modules/onboarding/components/OnboardingCard.tsx
  • packages/features/auth/lib/next-auth-options.ts

@pull-request-size pull-request-size Bot added size/M and removed size/S labels Apr 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active.

@github-actions github-actions Bot added the Stale label Apr 28, 2026
@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, In both invite-token signup handlers, an existing user is only treated as an allowed invited placeholder when existingUser.invitedTo === foundToken.teamId, so placeholder users with invitedTo = null are still rejected with 409. This contradicts the PR’s stated root-cause and will continue to break invite completion for those records.

Severity: action required | Category: correctness

How to fix: Allow null invitedTo placeholders

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

Invite-token signup currently only treats an existing account as an allowed invited-placeholder when existingUser.invitedTo === foundToken.teamId. Placeholder invite users can have invitedTo = null, so they are still rejected with 409.

Issue Context

User.invitedTo is nullable, and the PR description explicitly calls out the invitedTo = null case as a root cause.

Fix Focus Areas

  • apps/web/app/api/auth/signup/handlers/calcomSignupHandler.ts[92-120]
  • apps/web/app/api/auth/signup/handlers/selfHostedHandler.ts[24-61]

Adjust isInvitedPlaceholderAccount to accept placeholder accounts where invitedTo is either null or equals foundToken.teamId, while still rejecting real existing accounts (e.g., with password hash or verified email, or explicitly invited to a different team).

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Spotted by Qodo code review - free for open-source projects.

@Adityakk9031
Copy link
Copy Markdown
Author

still working

@github-actions github-actions Bot removed the Stale label Apr 29, 2026
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 6, 2026

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

invalid_server_response / Internal Server Error when signing up from invite link

3 participants