Skip to content

fix: honor inviteId on existing-user sign-in paths (#385)#386

Merged
lindesvard merged 2 commits into
Openpanel-dev:mainfrom
one-raven:fix/invites-existing-users-385
Jun 4, 2026
Merged

fix: honor inviteId on existing-user sign-in paths (#385)#386
lindesvard merged 2 commits into
Openpanel-dev:mainfrom
one-raven:fix/invites-existing-users-385

Conversation

@jonstuebe

@jonstuebe jonstuebe commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Invites were only consumed during new-account creation, so an invited person who authenticated as an existing user (OAuth sign-in or email sign-in) had their inviteId silently dropped. With zero org memberships they were redirected into the Create Project onboarding wizard instead of joining the inviting organization.

  • connectUserToOrganization is now idempotent: reuse an existing membership instead of creating a duplicate (no unique constraint on members.organizationId+userId), then still consume the invite.
  • OAuth handleExistingUser now accepts inviteId and connects the org (best-effort, logged) for both GitHub and Google callbacks.
  • signInEmail accepts an optional inviteId and consumes it on success; for 2FA users it is carried through the challenge via a short-lived cookie and consumed in signInTotp.
  • Frontend forwards inviteId on the sign-in path (email + OAuth), the login route reads it from search params, and the onboarding "Sign in" link preserves ?inviteId so returning users don't lose it.

closes #385

Summary by CodeRabbit

  • New Features

    • Users can accept organization invites during sign-in for email, Google, and GitHub.
    • Invite context is preserved across login, sign-up, and onboarding flows (including links to the login page).
    • Pending invites are applied after completing 2FA so invites complete post-authentication.
  • Bug Fixes

    • Prevents creating duplicate organization memberships when an invited user is already a member.

Invites were only consumed during new-account creation, so an invited
person who authenticated as an existing user (OAuth sign-in or email
sign-in) had their inviteId silently dropped. With zero org memberships
they were redirected into the Create Project onboarding wizard instead
of joining the inviting organization.

- connectUserToOrganization is now idempotent: reuse an existing
  membership instead of creating a duplicate (no unique constraint on
  members.organizationId+userId), then still consume the invite.
- OAuth handleExistingUser now accepts inviteId and connects the org
  (best-effort, logged) for both GitHub and Google callbacks.
- signInEmail accepts an optional inviteId and consumes it on success;
  for 2FA users it is carried through the challenge via a short-lived
  cookie and consumed in signInTotp.
- Frontend forwards inviteId on the sign-in path (email + OAuth), the
  login route reads it from search params, and the onboarding
  "Sign in" link preserves ?inviteId so returning users don't lose it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jun 3, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR carries an optional inviteId through sign-in (email, OAuth, 2FA) and consumes invites to connect authenticated users to organizations; it prevents duplicate memberships and adds a DB migration enforcing (organizationId, userId) uniqueness.

Changes

Invite-based Organization Onboarding Through Auth

Layer / File(s) Summary
Input Validation Schema
packages/validation/src/index.ts
Zod schema for email sign-in extended with optional inviteId field.
Auth Service Invite Consumption Infrastructure
packages/trpc/src/routers/auth.ts
Auth router introduces INVITE_COOKIE constant and consumeInviteForUser helper; signInEmail and signInTotp integrate invite handling, persisting inviteId in a cookie when 2FA is required and consuming it after session creation.
Organization Membership Deduplication & Migration
packages/db/src/services/organization.service.ts, packages/db/prisma/migrations/*, packages/db/prisma/schema.prisma
connectUserToOrganization now upserts membership by (organizationId, userId) to avoid duplicates; migration cleans duplicates and adds a unique index; Prisma schema adds @@unique([organizationId, userId]).
Frontend Sign-In Components with Invite
apps/start/src/components/auth/sign-in-email-form.tsx, apps/start/src/components/auth/sign-in-github.tsx, apps/start/src/components/auth/sign-in-google.tsx
All three sign-in components accept optional inviteId prop and pass it through to their respective auth mutations.
Login Route Integration
apps/start/src/routes/_login.login.tsx
Login route's search schema includes optional inviteId; LoginPage reads it and forwards it to SignInGoogle, SignInGithub, and SignInEmailForm.
Onboarding to Login Link Preservation
apps/start/src/routes/_public.onboarding.tsx
Onboarding route's sign-in link now URL-encodes and forwards inviteId when present, preserving invite context across onboarding-to-login.
OAuth Callback Invite Handling
apps/api/src/controllers/oauth-callback.controller.tsx
OAuth callbacks now pass inviteId from cookies into handleExistingUser, which attempts to connect existing users to the organization and logs failures without blocking sign-in.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly Related Issues

Poem

🐰 A rabbit found an invite bright,

it hopped through sign-ins, day and night,
2FA cookies kept the trail,
org doors opened without fail,
duplicates gone — a tidy bite! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix: honor inviteId on existing-user sign-in paths' accurately describes the main change: handling inviteId for existing users during sign-in instead of only for new account creation.
Linked Issues check ✅ Passed All objectives from issue #385 are implemented: inviteId is honored on existing-user paths (OAuth handleExistingUser, signInEmail), preserved through frontend flows (login route search params, onboarding sign-in link), and connectUserToOrganization prevents duplicates via upsert.
Out of Scope Changes check ✅ Passed All changes are directly scoped to resolving issue #385: database schema/migration add unique constraint, backend auth flow passes/consumes inviteId on existing-user paths, frontend routes/components propagate inviteId through sign-in flows.

✏️ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (3)
apps/start/src/routes/_login.login.tsx (1)

39-45: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve inviteId in the onboarding link.

This page now carries invite context, but the "Create one today" link drops it. Invited users who switch from sign-in to sign-up will lose the invite before reaching the signUpEmail path.

Suggested fix
-          <a
-            className="font-medium text-foreground underline"
-            href="/onboarding"
-          >
+          <a
+            className="font-medium text-foreground underline"
+            href={
+              inviteId
+                ? `/onboarding?inviteId=${encodeURIComponent(inviteId)}`
+                : '/onboarding'
+            }
+          >
             Create one today
           </a>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/routes/_login.login.tsx` around lines 39 - 45, The "Create one
today" anchor in _login.login.tsx drops the invite context; read the current
inviteId (e.g., via useSearchParams or location.search) and append/preserve it
when building the onboarding link for the anchor that renders "Create one today"
so the inviteId query param is carried through to the signUpEmail flow (ensure
you merge with existing query params rather than overwriting them).
apps/api/src/controllers/oauth-callback.controller.tsx (1)

302-324: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consume and clear the invite cookie in the callback.

Both callbacks read req.cookies.inviteId but never clear it. That leaves the old invite attached to the browser for the next OAuth attempt, so a later login can reuse the previous invite and join the wrong account to the organization.

Suggested fix
 export async function githubCallback(req: FastifyRequest, reply: FastifyReply) {
+  const inviteId = req.cookies.inviteId;
+  reply.clearCookie('inviteId');
   try {
     const { code } = await validateOAuthCallback(req, 'github');
-    const inviteId = req.cookies.inviteId;
     const tokens = await github.validateAuthorizationCode(code);
 export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
+  const inviteId = req.cookies.inviteId;
+  reply.clearCookie('inviteId');
   try {
     const { code } = await validateOAuthCallback(req, 'google');
-    const inviteId = req.cookies.inviteId;
     const codeVerifier = req.cookies.google_code_verifier!;

Also applies to: 344-368

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/controllers/oauth-callback.controller.tsx` around lines 302 -
324, Read and then clear the invite cookie so it cannot persist across OAuth
attempts: after capturing req.cookies.inviteId into the inviteId variable in
oauth-callback.controller.tsx, call reply.clearCookie with the same cookie name
(e.g., 'inviteId') so the cookie is removed; do this in both places where
inviteId is read (the block around handleExistingUser where inviteId is set and
the other callback block around lines 344-368) so the invite cannot be reused on
subsequent logins.
packages/trpc/src/routers/auth.ts (1)

130-133: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale INVITE_COOKIE values on non-invite auth attempts.

This cookie is only written when input.inviteId exists, but in this file it is only cleared after a successful TOTP consumption. If a user starts an invite flow, abandons it, and then signs in again without an invite before the TTL expires, the old inviteId can still be consumed during signInTotp and attach the wrong account to the organization.

Suggested fix
       if (input.inviteId) {
-        ctx.setCookie('inviteId', input.inviteId, {
-          maxAge: 60 * 10,
-        });
+        ctx.setCookie(INVITE_COOKIE, input.inviteId, {
+          maxAge: 60 * 10,
+          ...COOKIE_OPTIONS,
+        });
+      } else {
+        ctx.setCookie(INVITE_COOKIE, '', {
+          maxAge: 0,
+          ...COOKIE_OPTIONS,
+        });
       }
         if (input.inviteId) {
           ctx.setCookie(INVITE_COOKIE, input.inviteId, {
             maxAge: TWO_FACTOR_CHALLENGE_TTL_SECONDS,
+            ...COOKIE_OPTIONS,
           });
+        } else {
+          ctx.setCookie(INVITE_COOKIE, '', {
+            maxAge: 0,
+            ...COOKIE_OPTIONS,
+          });
         }
       const inviteId = ctx.cookies[INVITE_COOKIE];
+      ctx.setCookie(INVITE_COOKIE, '', {
+        maxAge: 0,
+        ...COOKIE_OPTIONS,
+      });
       if (inviteId) {
         await consumeInviteForUser(challenge.userId, inviteId, ctx.req.log);
-        ctx.setCookie(INVITE_COOKIE, '', { maxAge: 0 });
       }

Also applies to: 280-286, 371-375

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/routers/auth.ts` around lines 130 - 133, The issue: the
inviteId cookie (INVITE_COOKIE) is only written when input.inviteId exists but
not cleared when a non-invite auth attempt occurs, allowing stale inviteIds to
be consumed later (e.g., in signInTotp). Fix: in each auth entry-point that
currently sets ctx.setCookie('inviteId', ...) (referenced here as input.inviteId
and ctx.setCookie) add logic to explicitly clear the invite cookie when
input.inviteId is falsy — i.e., call the cookie clear/remove API on ctx (the
same mechanism used in signInTotp to clear post-success) whenever no inviteId is
provided. Apply this change to the three locations noted (the current diff and
the other blocks around lines ~280-286 and ~371-375) so stale INVITE_COOKIE
values are removed on non-invite sign-ins.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/services/organization.service.ts`:
- Around line 164-184: The current findFirst + create sequence
(db.member.findFirst and db.member.create) is racy and can still produce
duplicate Member rows; add a unique constraint on Member for (organizationId,
userId) in the Prisma schema and run a migration, then replace the non-atomic
logic in the invite consumption (the existingMember / member assignment that
reads invite and user) with a database atomic operation: use db.member.upsert
with a composite unique where (organizationId_userId) or perform the create
inside a transaction and handle unique-constraint failures by re-querying the
existing member; ensure the code references the same invite.role,
invite.createdById, invite.organizationId and user.id/email when
creating/upserting so the operation is idempotent.

---

Outside diff comments:
In `@apps/api/src/controllers/oauth-callback.controller.tsx`:
- Around line 302-324: Read and then clear the invite cookie so it cannot
persist across OAuth attempts: after capturing req.cookies.inviteId into the
inviteId variable in oauth-callback.controller.tsx, call reply.clearCookie with
the same cookie name (e.g., 'inviteId') so the cookie is removed; do this in
both places where inviteId is read (the block around handleExistingUser where
inviteId is set and the other callback block around lines 344-368) so the invite
cannot be reused on subsequent logins.

In `@apps/start/src/routes/_login.login.tsx`:
- Around line 39-45: The "Create one today" anchor in _login.login.tsx drops the
invite context; read the current inviteId (e.g., via useSearchParams or
location.search) and append/preserve it when building the onboarding link for
the anchor that renders "Create one today" so the inviteId query param is
carried through to the signUpEmail flow (ensure you merge with existing query
params rather than overwriting them).

In `@packages/trpc/src/routers/auth.ts`:
- Around line 130-133: The issue: the inviteId cookie (INVITE_COOKIE) is only
written when input.inviteId exists but not cleared when a non-invite auth
attempt occurs, allowing stale inviteIds to be consumed later (e.g., in
signInTotp). Fix: in each auth entry-point that currently sets
ctx.setCookie('inviteId', ...) (referenced here as input.inviteId and
ctx.setCookie) add logic to explicitly clear the invite cookie when
input.inviteId is falsy — i.e., call the cookie clear/remove API on ctx (the
same mechanism used in signInTotp to clear post-success) whenever no inviteId is
provided. Apply this change to the three locations noted (the current diff and
the other blocks around lines ~280-286 and ~371-375) so stale INVITE_COOKIE
values are removed on non-invite sign-ins.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ade9aa0d-55aa-4859-915c-018220dea6ee

📥 Commits

Reviewing files that changed from the base of the PR and between 4d44802 and c0dda41.

📒 Files selected for processing (9)
  • apps/api/src/controllers/oauth-callback.controller.tsx
  • apps/start/src/components/auth/sign-in-email-form.tsx
  • apps/start/src/components/auth/sign-in-github.tsx
  • apps/start/src/components/auth/sign-in-google.tsx
  • apps/start/src/routes/_login.login.tsx
  • apps/start/src/routes/_public.onboarding.tsx
  • packages/db/src/services/organization.service.ts
  • packages/trpc/src/routers/auth.ts
  • packages/validation/src/index.ts

Comment thread packages/db/src/services/organization.service.ts Outdated
The findFirst + create sequence in connectUserToOrganization was
non-atomic and could produce duplicate Member rows when an invite was
consumed concurrently. Enforce uniqueness at the database level and make
the membership creation atomic.

- Add @@unique([organizationId, userId]) to the Member model. NULLs are
  distinct in Postgres, so pending email-only invite rows are unaffected.
- Migration de-duplicates existing memberships (keeping the earliest)
  before creating the unique index so it is deploy-safe.
- Replace the racy findFirst ?? create with an atomic upsert keyed on the
  composite unique, reusing an existing membership unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/db/prisma/migrations/20260603111500_add_member_org_user_unique/migration.sql (2)

5-13: 💤 Low value

Dedup DELETE logic is correct.

The self-join keeps the earliest createdAt per (organizationId, userId) with id as a deterministic tie-breaker, and the m."userId" IS NOT NULL guard correctly excludes pending email-only invites. Note this is a destructive, irreversible delete with no down migration; ensure a backup/snapshot exists before applying in production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/db/prisma/migrations/20260603111500_add_member_org_user_unique/migration.sql`
around lines 5 - 13, The migration's destructive DELETE on "public"."members"
removes duplicate rows and has no down migration; before running it create a
reversible backup and a restore path: copy affected rows to a backup table (or
archive table) that captures full rows matching the duplicate criteria used by
the DELETE (referencing "organizationId", "userId", "createdAt", "id"), then
perform the DELETE; also add a down migration that restores rows from that
backup table (or provide explicit restore instructions) so the change is
reversible and safe to apply in production.

16-16: ⚖️ Poor tradeoff

Non-concurrent unique index build takes an ACCESS EXCLUSIVE-blocking lock.

CREATE UNIQUE INDEX (without CONCURRENTLY) holds a SHARE lock that blocks writes to members for the duration of the build. On a large table this can cause a noticeable write stall during deploy. Prisma wraps migrations in a transaction, so CONCURRENTLY cannot be used here directly; if members is large, consider building the index out-of-band (concurrently, outside the migration transaction) and then marking this migration as applied. For small tables this is a non-issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/db/prisma/migrations/20260603111500_add_member_org_user_unique/migration.sql`
at line 16, The migration currently creates a non-concurrent unique index
"members_organizationId_userId_key" on table "members" which will take an ACCESS
EXCLUSIVE-blocking lock for the duration of the build; to avoid downtime for
large tables, build the index out-of-band using CREATE UNIQUE INDEX CONCURRENTLY
"members_organizationId_userId_key" ON "public"."members"(
"organizationId","userId") in a separate non-transactional session, then update
this Prisma migration to not run the blocking CREATE UNIQUE INDEX (remove or
replace it with a no-op/comment) and mark the migration as applied (e.g., prisma
migrate resolve or record the migration as succeeded) so the migration state
matches the database.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/db/prisma/migrations/20260603111500_add_member_org_user_unique/migration.sql`:
- Around line 5-13: The migration's destructive DELETE on "public"."members"
removes duplicate rows and has no down migration; before running it create a
reversible backup and a restore path: copy affected rows to a backup table (or
archive table) that captures full rows matching the duplicate criteria used by
the DELETE (referencing "organizationId", "userId", "createdAt", "id"), then
perform the DELETE; also add a down migration that restores rows from that
backup table (or provide explicit restore instructions) so the change is
reversible and safe to apply in production.
- Line 16: The migration currently creates a non-concurrent unique index
"members_organizationId_userId_key" on table "members" which will take an ACCESS
EXCLUSIVE-blocking lock for the duration of the build; to avoid downtime for
large tables, build the index out-of-band using CREATE UNIQUE INDEX CONCURRENTLY
"members_organizationId_userId_key" ON "public"."members"(
"organizationId","userId") in a separate non-transactional session, then update
this Prisma migration to not run the blocking CREATE UNIQUE INDEX (remove or
replace it with a no-op/comment) and mark the migration as applied (e.g., prisma
migrate resolve or record the migration as succeeded) so the migration state
matches the database.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a0ff8b8-a9ca-45c9-b0df-fa59512689ba

📥 Commits

Reviewing files that changed from the base of the PR and between c0dda41 and 8f84be2.

📒 Files selected for processing (3)
  • packages/db/prisma/migrations/20260603111500_add_member_org_user_unique/migration.sql
  • packages/db/prisma/schema.prisma
  • packages/db/src/services/organization.service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/db/src/services/organization.service.ts

@lindesvard lindesvard merged commit 6d9445c into Openpanel-dev:main Jun 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invites are silently dropped for existing users (sign-in path), forcing them into the "Create project" onboarding wizard

3 participants