Skip to content

Conversation

perkinsjr
Copy link
Collaborator

@perkinsjr perkinsjr commented Sep 9, 2025

What does this PR do?

  • Add dedicated invitation acceptance API endpoint (/api/auth/accept-invitation)
  • Create invitation validation route (/api/auth/invitation)
  • Implement PostAuthInvitationHandler component for post-login invitation processing
  • Add join success page with proper invitation handling
  • Refactor auth actions to support invitation acceptance and workspace switching
  • Move join route to app-level routing with enhanced invitation logic
  • Update middleware to handle invitation-related redirects
  • Improve WorkOS auth integration for invitation flow
  • Remove legacy join route in favor of new implementation

Resolves ENG-2062: Invitations not functioning in enterprise workspace

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)
  • Enhancement (small improvements)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How should this be tested?

IF TESTING LOCALLY

** New User Scenario**

  1. Invite user to an organization.
  2. Click invite
  3. User will be asked to "verify their email"
  4. User will verify via OTP
  5. User redirected to new success page to make sure org is selected correctly and redirected to /apis

** Existing User Scenarios**

Logged In User

  1. Invite user to an organization.
  2. Login to the user account
  3. Click invite in email.
  4. User redirected to new success page to make sure org is selected correctly and redirected to /apis and logged in.

Logged out User

  1. Invite user to an organization.
  2. Click invite
  3. User will be asked to "verify their email"
  4. User will verify via OTP
  5. User redirected to new success page to make sure org is selected correctly and redirected to /apis

IF TESTING PREVIEW

You have to copy the link out of the email and change the URL to match the preview.

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Contributing Guide
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand areas
  • Ran pnpm build
  • Ran pnpm fmt
  • Checked for warnings, there are none
  • Removed all console.logs
  • Merged the latest changes from main onto my branch with git pull origin main
  • My changes don't cause any responsiveness issues

Appreciated

  • If a UI change was made: Added a screen recording or screenshots to this PR
  • Updated the Unkey Docs if changes were necessary

Summary by CodeRabbit

  • New Features

    • Streamlined invitation flow: automatically processes invitation tokens after sign-in and switches to the invited workspace.
    • New Join Success page with dynamic messaging, countdown, and a manual continue option.
    • Dedicated /join route handles invite acceptance, redirects, and preserves tokens across auth states.
  • Improvements

    • Dashboard now initializes invite handling automatically on load for smoother onboarding.
    • Middleware redirects /auth/join to /join and allows public access to /join and /join/success for a cleaner experience.
    • More resilient redirects and clearer user messaging during invite acceptance.

- Add dedicated invitation acceptance API endpoint
(/api/auth/accept-invitation)
- Create invitation validation route (/api/auth/invitation)
- Implement PostAuthInvitationHandler component for post-login
invitation processing
- Add join success page with proper invitation handling
- Refactor auth actions to support invitation acceptance and workspace
switching
- Move join route to app-level routing with enhanced invitation logic
- Update middleware to handle invitation-related redirects
- Improve WorkOS auth integration for enterprise invitation flow
- Remove legacy join route in favor of new implementation

Resolves ENG-2062: Invitations not functioning in enterprise workspace
Copy link

linear bot commented Sep 9, 2025

Copy link

changeset-bot bot commented Sep 9, 2025

⚠️ No Changeset found

Latest commit: fcf5a3a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link

vercel bot commented Sep 9, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
dashboard Ready Ready Preview Comment Sep 9, 2025 8:04pm
1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
engineering Ignored Ignored Preview Sep 9, 2025 8:04pm

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

📝 Walkthrough

Walkthrough

Adds end-to-end invitation handling: new API endpoints and helpers to process/accept invitations, a client PostAuth handler injected into the APIs page, updated join route and success page, middleware redirect for /auth/join → /join, and auth action/hook changes to integrate invitation flows.

Changes

Cohort / File(s) Summary
Post-auth handler injection
apps/dashboard/app/(app)/apis/page.tsx
Imports and renders PostAuthInvitationHandler at the top of the Apis page DOM.
Client handler component
apps/dashboard/components/auth/post-auth-invitation-handler.tsx
New client component that detects invitation_token in URL, POSTs to /api/auth/invitation, retries/backoffs, removes token on success, and optionally reloads; exposes onComplete.
Invitation API routes
apps/dashboard/app/api/auth/invitation/route.ts, apps/dashboard/app/api/auth/accept-invitation/route.ts
New POST routes: one to process post-auth invitation tokens (calls processPostAuthInvitation), and one to accept an invitation and switch org (validates session, accepts, updates session cookies).
Join flow routes/pages
apps/dashboard/app/join/route.ts, apps/dashboard/app/join/success/page.tsx, apps/dashboard/app/auth/join/route.ts
Added /join GET route (dynamic) handling invite token for auth/non-auth users, accepting invitation and switching org for signed-in users; added /join/success client page with timed redirect; removed legacy /auth/join route.
Auth actions and hooks
apps/dashboard/app/auth/actions.ts, apps/dashboard/app/auth/hooks/useSignIn.ts
Enhanced verifyAuthCode to optionally auto-handle invitations, added acceptInvitationAndJoin, updated redirect logic in sign-in hook to respect invitationToken, and improved logging and error handling.
Auth helpers
apps/dashboard/lib/auth.ts, apps/dashboard/lib/auth/workos.ts
Added acceptInvitationAndSwitchOrg and processPostAuthInvitation; made verifyAuthCode accept optional invitationToken; replaced token-inclusive logs with structured, non-sensitive logging.
Middleware updates
apps/dashboard/middleware.ts
Redirects /auth/join/join (preserving query) and marks /join and /join/success as public (unauthenticated) routes.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant Browser
  participant Middleware
  participant JoinRoute as /join (GET)
  participant Auth as Auth Service
  participant Org as Org Service
  participant Success as /join/success

  User->>Browser: Open /auth/join?invitation_token=...
  Browser->>Middleware: Request /auth/join?...
  Middleware-->>Browser: 307 Redirect to /join?...

  Browser->>JoinRoute: GET /join?invitation_token=...
  JoinRoute->>Auth: updateSession + getCurrentUser
  alt Unauthenticated
    JoinRoute->>Auth: findUser(by invitation email)
    alt User exists
      JoinRoute-->>Browser: Redirect to /auth/sign-in?invitation_token&email
    else No user
      JoinRoute-->>Browser: Redirect to /auth/sign-up?invitation_token&email
    end
  else Authenticated
    JoinRoute->>Auth: getInvitation(token)
    alt Invalid/missing/non-pending
      JoinRoute-->>Browser: Redirect /apis?invitation_token=...
    else Valid
      JoinRoute->>Auth: acceptInvitation
      JoinRoute->>Org: switchOrg(orgId) and set session cookie
      JoinRoute->>Auth: getOrg(orgId)
      JoinRoute-->>Browser: Redirect to /join/success?from_invite=true&org_name=...
    end
  end
Loading
sequenceDiagram
  autonumber
  actor User
  participant Browser
  participant ApisPage as /apis page
  participant Handler as PostAuthInvitationHandler
  participant API as /api/auth/invitation
  participant Lib as processPostAuthInvitation
  participant Auth as Auth Service

  User->>Browser: Land on /apis?invitation_token=...
  Browser->>ApisPage: Render
  ApisPage->>Handler: Mount handler
  Handler->>API: POST { invitationToken } (with cookies)
  API->>Lib: processPostAuthInvitation(token, userId)
  Lib->>Auth: getInvitation(token), acceptInvitation, accept+switch org
  Lib-->>API: { success, organizationId }
  API-->>Handler: { success }
  alt success
    Handler->>Browser: Remove token from URL, router.replace, reload
  else failure
    Handler->>Handler: Retry (limited) or call onComplete(false)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • mcstepp
  • ogzhanolguncu
  • chronark

Pre-merge checks (2 passed, 1 warning)

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly summarizes the primary purpose of the changeset—fixing the invitation flow for enterprise workspaces—while indicating the scope (auth) and avoiding extraneous detail. It accurately reflects the main change and is concise enough for a teammate to understand the intent at a glance.
Description Check ✅ Passed The description follows the repository’s template by providing a clear summary of changes, referencing the issue (ENG-2062), specifying the type of change, detailed testing instructions for both local and preview environments, and a comprehensive checklist with all required items marked complete. All critical sections are present and sufficiently detailed to guide reviewers and testers.

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch eng-2062-bug-invitations-are-not-functioning-in-enterprise-workspace

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.

@perkinsjr
Copy link
Collaborator Author

@CodeRabbit review

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@@ -13,6 +13,14 @@ export default async function middleware(req: NextRequest, _evt: NextFetchEvent)
return NextResponse.redirect("https://app.unkey.com/gateway-new");
}

// Redirect /auth/join to /join to bypass auth layout
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a temp redirect... because I was a bit worried that updating the workOS redirects would cause some users to have a bad time if they had an outstanding invite.

Copy link
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.

Actionable comments posted: 28

Caution

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

⚠️ Outside diff range comments (3)
apps/dashboard/middleware.ts (1)

28-36: Tighten publicPaths: remove redundant /auth/join entry

Because the early redirect always intercepts /auth/join, keeping it in publicPaths is unnecessary and can mask regressions if the redirect is reordered later.

Apply:

   const publicPaths = [
     "/auth/sign-in",
     "/auth/sign-up",
     "/auth/sso-callback",
     "/auth/oauth-sign-in",
-    "/auth/join",
     "/join",
     "/join/success",
apps/dashboard/lib/auth/workos.ts (1)

507-514: Don’t silently swallow provider errors in getInvitationList.

Returning an empty list hides operational issues and breaks alerting.

-    } catch (error) {
-      console.error("Failed to get organization invitations list:", {
-        error: error instanceof Error ? error.message : "Unknown error",
-      });
-      return {
-        data: [],
-        metadata: {},
-      };
-    }
+    } catch (error) {
+      throw this.handleError(error);
+    }
apps/dashboard/app/auth/actions.ts (1)

361-394: Security: validate invitation–organization consistency and make accept step idempotent

 export async function acceptInvitationAndJoin(
   invitationId: string,
   organizationId: string,
 ): Promise<{ success: boolean; error?: string }> {
   try {
+    // Retrieve and validate invitation belongs to target org
+    const invitation = await auth.getInvitation(invitationId);
+    if (invitation?.organizationId !== organizationId) {
+      throw new Error("Invitation does not belong to the specified organization");
+    }
+
+    // Accept invitation idempotently
+    try {
+      await auth.acceptInvitation(invitationId);
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      if (!/already\s+accepted/i.test(msg)) throw e;
+    }
 
     // Switch organization and get the new session token
     const { newToken, expiresAt } = await auth.switchOrg(organizationId);
@@
     console.error("Failed to accept invitation and join organization:", {
       error: error instanceof Error ? error.message : "Unknown error",
+      invitationId,
+      organizationId,
     });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0086690 and da1c9ff.

📒 Files selected for processing (12)
  • apps/dashboard/app/(app)/apis/page.tsx (2 hunks)
  • apps/dashboard/app/api/auth/accept-invitation/route.ts (1 hunks)
  • apps/dashboard/app/api/auth/invitation/route.ts (1 hunks)
  • apps/dashboard/app/auth/actions.ts (4 hunks)
  • apps/dashboard/app/auth/hooks/useSignIn.ts (1 hunks)
  • apps/dashboard/app/auth/join/route.ts (0 hunks)
  • apps/dashboard/app/join/route.ts (1 hunks)
  • apps/dashboard/app/join/success/page.tsx (1 hunks)
  • apps/dashboard/components/auth/post-auth-invitation-handler.tsx (1 hunks)
  • apps/dashboard/lib/auth.ts (1 hunks)
  • apps/dashboard/lib/auth/workos.ts (6 hunks)
  • apps/dashboard/middleware.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • apps/dashboard/app/auth/join/route.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-18T10:28:47.391Z
Learnt from: ogzhanolguncu
PR: unkeyed/unkey#3797
File: apps/dashboard/app/(app)/projects/[projectId]/deployments/components/control-cloud/index.tsx:1-4
Timestamp: 2025-08-18T10:28:47.391Z
Learning: In Next.js App Router, components that use React hooks don't need their own "use client" directive if they are rendered within a client component that already has the directive. The client boundary propagates to child components.

Applied to files:

  • apps/dashboard/app/(app)/apis/page.tsx
📚 Learning: 2025-05-15T16:26:08.666Z
Learnt from: ogzhanolguncu
PR: unkeyed/unkey#3242
File: apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/override-indicator.tsx:50-65
Timestamp: 2025-05-15T16:26:08.666Z
Learning: In the Unkey dashboard, Next.js router (router.push) should be used for client-side navigation instead of window.location.href to preserve client state and enable smoother transitions between pages.

Applied to files:

  • apps/dashboard/middleware.ts
  • apps/dashboard/app/join/success/page.tsx
🔇 Additional comments (7)
apps/dashboard/middleware.ts (1)

16-23: Good redirect; preserves invite params and bypasses auth layout

The 307 from /auth/join → /join with full query preservation is correct and happens before public-path checks.

apps/dashboard/lib/auth/workos.ts (3)

616-621: Invitation-aware verification: LGTM.

Passing invitationToken through to WorkOS during magic-auth verification is appropriate and keeps tokens out of logs.


188-191: Structured, token-free logging: good.

Logs now avoid leaking tokens/PII and standardize messages.

Also applies to: 210-213, 721-723


948-965: No change needed—invitation tokens aren’t exposed to clients. transformInvitationData’s token field is only used internally in the WorkOS service; no API route serializes the full Invitation object to the client.

Likely an incorrect or invalid review comment.

apps/dashboard/app/(app)/apis/page.tsx (1)

16-16: LGTM: Safe, idempotent post-auth hook placement.

Rendering PostAuthInvitationHandler first is appropriate; assuming it no-ops without invitation_token, this won’t affect normal visits.

If the handler performs network calls on mount, consider guarding behind a cheap has("invitation_token") check to avoid unnecessary work.

apps/dashboard/app/auth/actions.ts (2)

166-168: LGTM on structured error logging.

Consistent key’ed logging improves observability.


228-230: LGTM on rate-limit error handling.

Logs the message and returns a permissive fallback, matching prior behavior.

@perkinsjr
Copy link
Collaborator Author

@CodeRabbit resolve

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

✅ Actions performed

Comments resolved.

@perkinsjr perkinsjr marked this pull request as ready for review September 9, 2025 20:37
Copy link
Contributor

github-actions bot commented Sep 9, 2025

Thank you for following the naming conventions for pull request titles! 🙏

Copy link
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.

Actionable comments posted: 13

♻️ Duplicate comments (11)
apps/dashboard/lib/auth.ts (2)

75-85: Avoid redirecting auth in library helper (use non-redirecting get-auth).

getCurrentUser() can redirect, which is unsafe in library/API contexts. Use the non-redirecting base auth to assert session presence.

   try {
-    // Verify we have a valid session before proceeding
-    const currentUser = await getCurrentUser();
-    if (!currentUser) {
-      throw new Error("User not authenticated - cannot accept invitation");
-    }
+    // Verify session without redirect side-effects
+    const { userId } = await getBaseAuth();
+    if (!userId) {
+      throw new Error("User not authenticated");
+    }

123-125: Harden validation: generic client messages and case-insensitive email match.

Prevents token enumeration and false negatives due to case/whitespace.

-    if (!invitation) {
-      return { success: false, error: "Invitation not found" };
-    }
+    if (!invitation) {
+      return { success: false, error: "Invalid or expired invitation" };
+    }
@@
-    if (state !== "pending") {
-      return { success: false, error: `Invitation is ${state}` };
-    }
+    if (state !== "pending") {
+      return { success: false, error: "Invalid or expired invitation" };
+    }
@@
-    if (!organizationId) {
-      return { success: false, error: "No organization ID in invitation" };
-    }
+    if (!organizationId) {
+      return { success: false, error: "Invalid or expired invitation" };
+    }
@@
-    if (user.email !== invitationEmail) {
-      return { success: false, error: "Email mismatch" };
-    }
+    const normalize = (e?: string | null) => (e ?? "").trim().toLowerCase();
+    if (normalize(user.email) !== normalize(invitationEmail)) {
+      return { success: false, error: "Invalid or expired invitation" };
+    }

Also applies to: 129-135, 143-145

apps/dashboard/app/api/auth/accept-invitation/route.ts (1)

1-6: Don’t import/use getCurrentUser() in API route; use non-redirecting auth.

Avoid HTML redirects on XHR; check auth via base get-auth.

-import { acceptInvitationAndSwitchOrg, getCurrentUser } from "@/lib/auth";
+import { acceptInvitationAndSwitchOrg } from "@/lib/auth";
+import { getAuth as getBaseAuth } from "@/lib/auth/get-auth";
apps/dashboard/app/auth/actions.ts (3)

117-125: Only swallow “already accepted”; rethrow other accept failures.

Don’t mask real errors.

-            } catch (acceptError) {
-              // Log but don't fail - invitation might already be accepted
-              console.warn("Could not accept invitation (might already be accepted):", {
-                invitationId: invitation.id,
-                error: acceptError instanceof Error ? acceptError.message : "Unknown error",
-              });
-            }
+            } catch (acceptError) {
+              const msg = acceptError instanceof Error ? acceptError.message : String(acceptError);
+              if (!/already\s+accepted/i.test(msg)) {
+                throw acceptError;
+              }
+              console.warn("Invitation already accepted; continuing:", { invitationId: invitation.id });
+            }

355-361: Add orgId context to switchOrg error log.

Improves operability and supportability.

-    console.error("Organization switch failed:", {
-      error: error instanceof Error ? error.message : "Unknown error",
-    });
+    console.error("Organization switch failed:", {
+      error: error instanceof Error ? error.message : "Unknown error",
+      orgId,
+    });

71-90: redirectUrl never updated; success page URL is dropped.

Assign the built URL and avoid returning empty cookies.

-              const redirectUrl = "/apis";
+              let redirectUrl = "/apis";
               try {
                 const org = await auth.getOrg(invitation.organizationId);
                 if (org?.name) {
                   const params = new URLSearchParams({
                     from_invite: "true",
                     org_name: org.name,
                   });
-                  `/join/success?${params.toString()}`;
+                  redirectUrl = `/join/success?${params.toString()}`;
                 }
               } catch (error) {
                 // Don't fail the redirect if we can't get org name
                 console.warn("Could not fetch organization name for success page:", error);
               }
 
               return {
                 success: true,
                 redirectTo: redirectUrl,
-                cookies: [],
               };
apps/dashboard/app/join/route.ts (5)

8-8: Good: caching disabled for a side-effectful route.
Correctly uses export const dynamic = "force-dynamic".


81-99: Bug: session cookie likely dropped on redirect; attach it to the redirect response.
Set the cookie on the NextResponse returned to the browser (or pass the response into your cookie helper).

Apply this diff here:

-      // Set the session cookie securely on the server side
-      await setSessionCookie({ token: newToken, expiresAt });
-
-      // Redirect to success page with organization context
-      const JOIN_SUCCESS_URL = new URL("/join/success", request.url);
+      // Redirect to success page with organization context
+      const JOIN_SUCCESS_URL = new URL("/join/success", request.url);
@@
-      return NextResponse.redirect(JOIN_SUCCESS_URL);
+      const res = NextResponse.redirect(JOIN_SUCCESS_URL);
+      await setSessionCookie({ token: newToken, expiresAt, response: res });
+      return res;

And update cookies helper to accept an optional response:

+import type { NextResponse } from "next/server";
 
 export async function setSessionCookie(params: {
   token: string;
   expiresAt: Date;
+  response?: NextResponse;
 }): Promise<void> {
-  const { token, expiresAt } = params;
-
-  await setCookie({
-    name: UNKEY_SESSION_COOKIE,
-    value: token,
-    options: {
-      httpOnly: true,
-      secure: true,
-      sameSite: "strict",
-      path: "/",
-      maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
-    },
-  });
+  const { token, expiresAt, response } = params;
+  const options = {
+    httpOnly: true,
+    secure: true,
+    sameSite: "strict" as const,
+    path: "/",
+    maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000),
+  };
+  if (response) {
+    response.cookies.set(UNKEY_SESSION_COOKIE, token, options);
+    return;
+  }
+  await setCookie({ name: UNKEY_SESSION_COOKIE, value: token, options });
 }

56-58: Avoid mutating the shared DASHBOARD_URL; clone before setting search params.
Prevents state bleed across branches and repeated mutations.

-    DASHBOARD_URL.searchParams.set("invitation_token", invitationToken);
-    return NextResponse.redirect(DASHBOARD_URL);
+    const dash = new URL(DASHBOARD_URL);
+    dash.searchParams.set("invitation_token", invitationToken);
+    return NextResponse.redirect(dash);
@@
-      DASHBOARD_URL.searchParams.set("invitation_token", invitationToken);
-      return NextResponse.redirect(DASHBOARD_URL);
+      const dash = new URL(DASHBOARD_URL);
+      dash.searchParams.set("invitation_token", invitationToken);
+      return NextResponse.redirect(dash);

Also applies to: 105-107


62-64: Normalize email compare and preserve token on mismatch/missing org.
Case-insensitive email checks; keep invitation_token so the post-auth handler can recover.

-    if (user.email !== invitationEmail) {
-      return NextResponse.redirect(DASHBOARD_URL);
-    }
+    if (user.email?.trim().toLowerCase() !== invitationEmail.trim().toLowerCase()) {
+      const dash = new URL(DASHBOARD_URL);
+      dash.searchParams.set("invitation_token", invitationToken);
+      return NextResponse.redirect(dash);
+    }
@@
-    if (!organizationId) {
-      return NextResponse.redirect(DASHBOARD_URL);
-    }
+    if (!organizationId) {
+      const dash = new URL(DASHBOARD_URL);
+      dash.searchParams.set("invitation_token", invitationToken);
+      return NextResponse.redirect(dash);
+    }

Also applies to: 66-68


71-76: Invite acceptance + org switch can leave a partial state; confirm idempotency or use an atomic helper.
If switchOrg fails after accept, the invite may be consumed.

Option if available on the provider:

-      // Accept invitation first
-      await auth.acceptInvitation(invitationId);
-
-      // Switch organization and get the new session token
-      const { newToken, expiresAt } = await auth.switchOrg(organizationId);
+      // Accept + switch atomically
+      const { newToken, expiresAt } = await auth.acceptInvitationAndSwitchOrg({
+        invitationId,
+        organizationId,
+      });

If you keep the current flow, please confirm:

  • acceptInvitation is idempotent, and
  • failed switchOrg does not consume the invite irreversibly.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between da1c9ff and fcf5a3a.

📒 Files selected for processing (5)
  • apps/dashboard/app/api/auth/accept-invitation/route.ts (1 hunks)
  • apps/dashboard/app/api/auth/invitation/route.ts (1 hunks)
  • apps/dashboard/app/auth/actions.ts (4 hunks)
  • apps/dashboard/app/join/route.ts (1 hunks)
  • apps/dashboard/lib/auth.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-05-05T17:55:59.607Z
Learnt from: mcstepp
PR: unkeyed/unkey#3215
File: apps/dashboard/lib/auth/sessions.ts:47-51
Timestamp: 2025-05-05T17:55:59.607Z
Learning: Local auth cookies in apps/dashboard/lib/auth/sessions.ts intentionally omit HttpOnly and Secure flags to allow easier debugging during local development. This is by design as these cookies are only used in local development environments, not production.

Applied to files:

  • apps/dashboard/app/api/auth/accept-invitation/route.ts
📚 Learning: 2025-09-09T19:25:09.941Z
Learnt from: perkinsjr
PR: unkeyed/unkey#3940
File: apps/dashboard/app/api/auth/accept-invitation/route.ts:7-16
Timestamp: 2025-09-09T19:25:09.941Z
Learning: WorkOS is configured in production to only allow URLs "https://app.unkey.com" to modify tokens, providing CSRF protection at the provider level rather than requiring application-level CSRF checks.

Applied to files:

  • apps/dashboard/app/api/auth/accept-invitation/route.ts
🧬 Code graph analysis (5)
apps/dashboard/app/join/route.ts (5)
apps/dashboard/lib/auth/types.ts (4)
  • SIGN_IN_URL (6-6)
  • SIGN_UP_URL (7-7)
  • AuthenticatedUser (23-30)
  • Invitation (149-161)
apps/dashboard/lib/auth/sessions.ts (1)
  • updateSession (22-161)
apps/dashboard/lib/auth.ts (1)
  • getCurrentUser (53-61)
apps/dashboard/lib/auth/server.ts (1)
  • auth (67-67)
apps/dashboard/lib/auth/cookies.ts (1)
  • setSessionCookie (105-122)
apps/dashboard/app/api/auth/invitation/route.ts (3)
apps/dashboard/app/api/auth/accept-invitation/route.ts (1)
  • POST (7-114)
apps/dashboard/lib/auth.ts (2)
  • getAuth (29-40)
  • processPostAuthInvitation (115-157)
apps/dashboard/lib/auth/server.ts (1)
  • auth (67-67)
apps/dashboard/app/api/auth/accept-invitation/route.ts (4)
apps/dashboard/lib/auth/sessions.ts (1)
  • updateSession (22-161)
apps/dashboard/lib/auth.ts (2)
  • getCurrentUser (53-61)
  • acceptInvitationAndSwitchOrg (75-102)
apps/dashboard/lib/auth/types.ts (1)
  • Invitation (149-161)
apps/dashboard/lib/auth/server.ts (1)
  • auth (67-67)
apps/dashboard/app/auth/actions.ts (3)
apps/dashboard/lib/auth/server.ts (1)
  • auth (67-67)
apps/dashboard/lib/auth/cookies.ts (2)
  • setCookies (42-47)
  • setSessionCookie (105-122)
apps/dashboard/lib/auth/workos.ts (1)
  • completeOrgSelection (751-789)
apps/dashboard/lib/auth.ts (4)
apps/dashboard/lib/auth/server.ts (1)
  • auth (67-67)
apps/dashboard/app/auth/actions.ts (1)
  • switchOrg (346-363)
apps/dashboard/lib/auth/workos.ts (1)
  • switchOrg (285-328)
apps/dashboard/lib/trpc/routers/user/switchOrg.ts (1)
  • switchOrg (5-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Test API / API Test Local
  • GitHub Check: Test Go API Local / Test
  • GitHub Check: Build / Build
🔇 Additional comments (2)
apps/dashboard/lib/auth.ts (1)

151-156: Good: generic error to callers while logging details.

This avoids leaking internals in API responses. LGTM.

apps/dashboard/app/auth/actions.ts (1)

106-166: Verify flow duplication and switching behavior.

This block accepts the invite but does not switch org. Given you also run post-auth processing via /api/auth/invitation, confirm users won’t double-accept or end up in the wrong org. If needed, invoke switchOrg here after acceptance.

Would you like me to wire switchOrg here and de-duplicate acceptance across the flow?

@perkinsjr
Copy link
Collaborator Author

@CodeRabbit resolve

Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

✅ Actions performed

Comments resolved.

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.

1 participant