Skip to content

feat: fix sharing#169

Merged
urjitc merged 17 commits into
mainfrom
fix/fix-sharing
Feb 5, 2026
Merged

feat: fix sharing#169
urjitc merged 17 commits into
mainfrom
fix/fix-sharing

Conversation

@urjitc

@urjitc urjitc commented Feb 5, 2026

Copy link
Copy Markdown
Member

Important

This pull request adds comprehensive workspace invitation management, including sending, claiming, and revoking invites, with integrated UI and API support.

  • New Features:
    • Add ability to invite users to workspaces via email, supporting both existing users and new signups (route.ts, ShareWorkspaceDialog.tsx).
    • Implement invite claiming and revocation (route.ts, InviteGuard.tsx).
    • Allow users to delete their accounts from settings (AccountModal.tsx).
  • API Endpoints:
    • GET /api/invites/[token]: Retrieve invite details.
    • POST /api/invites/claim: Claim an invite.
    • DELETE /api/workspaces/[id]/invites/[inviteId]: Revoke an invite.
    • GET /api/workspaces/[id]/collaborators: List collaborators and pending invites.
    • POST /api/workspaces/[id]/collaborators: Invite a new collaborator.
  • UI Components:
    • InviteGuard: Handles invite token processing and redirects.
    • ShareWorkspaceDialog: Manages inviting collaborators and viewing pending invites.
    • AuthForms: Updated to handle invite context during sign-up.
  • Database:
    • Add workspaceInvites table to manage invite tokens and expiration.
  • Misc:
    • Improved email formatting for invitation emails (invite-email.tsx).
    • Adjusted onboarding and authentication flows to incorporate invite handling (page.tsx).

This description was created by Ellipsis for 3161d49. You can customize this summary. It will automatically update as commits are pushed.


Summary by CodeRabbit

  • New Features

    • Invite others to workspaces via email (existing users or pending invites for new signups) and claim invites after sign-in
    • Manage, revoke, and view pending invites from the Share dialog
    • Delete your account from Settings (Danger Zone)
  • Refactor

    • Invite guard added across dashboard/workspace flows to streamline invite handling and onboarding redirects
    • Auth forms accept dynamic titles/descriptions and updated sign-in/sign-up UX
  • Style

    • Improved invite email link wrapping for long URLs

@vercel

vercel Bot commented Feb 5, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
thinkex Ready Ready Preview, Comment Feb 5, 2026 11:20pm

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed everything up to 22f8285 in 9 seconds. Click for details.
  • Reviewed 718 lines of code in 8 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_xhQobriXfA5iOfoQ

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@coderabbitai

coderabbitai Bot commented Feb 5, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a workspace invite system: DB schema for invites, API endpoints to view/claim/revoke invites, UI components and guards to handle invite flows, auth redirect adjustments, and workspace/collaborator updates to support pending invites and claiming.

Changes

Cohort / File(s) Summary
Database Schema
src/lib/db/schema.ts
Adds workspace_invites table, indexes (token/email/workspace), unique token constraint, FK to workspaces (cascade delete), and access policies for select/insert.
Invite API Routes
src/app/api/invites/[token]/route.ts, src/app/api/invites/claim/route.ts, src/app/api/workspaces/[id]/invites/[inviteId]/route.ts
New GET to fetch invite by token, POST to claim invite (session check, email match, add collaborator, delete invite), and DELETE to revoke a pending invite with access checks.
Collaborators API
src/app/api/workspaces/[id]/collaborators/route.ts
GET now includes pending invites; POST handles existing users (add collaborator + email) and non-existent users (create tokenized pending invite, 7-day expiry, email with ?invite=token).
Invite UX / Guard
src/components/workspace/InviteGuard.tsx, src/app/dashboard/page.tsx, src/app/workspace/[slug]/page.tsx
New InviteGuard component that reads invite param, redirects unauthenticated users to auth with invite, claims invites for logged-in users, and is applied around dashboard/workspace pages.
Share & Management UI
src/components/workspace/ShareWorkspaceDialog.tsx
Manage collaborators + pending invites UI; add revoke action, optimistic permission updates, tabbed UI for invites/link/history, and invite creation flows.
Auth & Forms
src/app/auth/[path]/page.tsx, src/components/auth/AuthForms.tsx
Auth page accepts invite param and adjusts title/description; SignIn/SignUp forms accept dynamic title/description props; sign-up redirect flows updated to use onboarding.
Account Deletion Hook & UI
src/lib/auth.ts, src/components/auth/AccountModal.tsx
Adds user.deleteUser.afterDelete hook to clean up user workspaces; AccountModal adds Danger Zone delete-account flow with confirmation and redirect.
Workspace Loading/Context
src/contexts/WorkspaceContext.tsx, src/components/workspace-canvas/WorkspaceSection.tsx
Pause workspace fetch when invite token present; render skeleton (not AccessDenied) during invite-based loading races.
UI Components & Email
src/components/ui/card.tsx, src/components/email/invite-email.tsx
New Card component family; invite email link styling updated to allow long URL wrapping.
Misc / Error Handling
src/hooks/workspace/use-workspace-mutation.ts, src/components/home/UserProfileDropdown.tsx
Permission-aware error handling (403 → PERMISSION_DENIED + toast), small import/cleanup tweaks.

Sequence Diagram

sequenceDiagram
    participant User as User (Guest / Auth)
    participant Browser as Client
    participant Auth as Auth Service
    participant API as API Server
    participant DB as Database
    participant Email as Email Service

    Note over User,Browser: User clicks token link from email
    Browser->>API: GET /api/invites/[token]
    API->>DB: select invite by token
    DB-->>API: invite details
    API-->>Browser: invite info (workspace, inviter, email, expiresAt)

    alt User not signed in
        Browser->>Auth: redirect /auth/sign-up?invite=token&redirect=currentURL
        Auth-->>Browser: show sign-up (invite context)
        User->>Auth: submit signup
        Auth-->>Browser: create session, redirect back
    end

    Browser->>API: POST /api/invites/claim { token } (with session)
    API->>Auth: verify session
    Auth-->>API: user info
    API->>DB: validate invite (token, expiry, email match)
    DB-->>API: invite valid
    API->>DB: insert collaborator (if not exists)
    DB-->>API: collaborator added
    API->>DB: delete workspace_invite
    DB-->>API: deleted
    API-->>Browser: { success, workspaceId, workspaceSlug }
    Browser->>Browser: navigate to workspace
    Browser-->>User: joined workspace
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

"🐰
Hops with glee, a token in paw,
I plant an invite, open the door.
Click, claim, and together we build,
Workspaces flourish, collaboration fulfilled.
Thump! — celebrate with a carrot cake!"

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat: fix sharing' is vague and uses non-descriptive terms that do not convey meaningful information about the substantial changes in this PR. Replace with a more descriptive title that captures the main change, such as 'feat: add workspace invite system' or 'feat: implement workspace invitations with email notifications'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/fix-sharing

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.

import { db } from "@/lib/db/client";
import { workspaceInvites, workspaceCollaborators, workspaces } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { withErrorHandling, requireAuth } from "@/lib/api/workspace-helpers";
Comment thread src/components/workspace/InviteLandingPage.tsx Fixed
@greptile-apps

greptile-apps Bot commented Feb 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Overview

Greptile Summary

This PR introduces a workspace invitation system: API routes to create/revoke/claim email-based invites, a landing page + client-side guard for invite links, and UI updates in the share dialog and account management.

Key flow: POST /api/workspaces/[id]/collaborators now creates workspace_invites for unknown emails and sends invite links; InviteGuard detects ?invite= on workspace routes and either shows InviteLandingPage (anonymous) or auto-claims via POST /api/invites/claim (authenticated), then removes the query param so workspace fetching resumes. Schema changes add a workspace_invites table and related policies/indexes.

Confidence Score: 2/5

  • This PR is not safe to merge as-is due to a guaranteed runtime crash in a key UI component and a security-sensitive invite-token generation fallback.
  • Confidence is reduced primarily by (1) a Rules-of-Hooks violation that will throw when the affected render path is hit, and (2) a non-cryptographic Math.random() fallback for invite tokens, which undermines the security of invite links in runtimes without global Web Crypto. The rest of the changes look coherent but should be re-validated after these are fixed.
  • src/components/workspace-canvas/WorkspaceSection.tsx, src/app/api/workspaces/[id]/collaborators/route.ts

Important Files Changed

Filename Overview
src/app/api/invites/[token]/route.ts Adds GET invite-by-token endpoint returning invite, workspace, and inviter display info.
src/app/api/invites/claim/route.ts Adds POST endpoint to claim invite token and create collaborator record; relies on session email for matching.
src/app/api/workspaces/[id]/collaborators/route.ts Extends collaborators API to return pending invites and create email-based invites for non-existent users; token generation uses crypto/Math.random fallback.
src/app/api/workspaces/[id]/invites/[inviteId]/route.ts Adds DELETE endpoint to revoke a pending workspace invite with access checks.
src/app/auth/[path]/page.tsx Adjusts auth page redirect handling to support callbackUrl and route sign-up through onboarding.
src/app/dashboard/page.tsx Wires dashboard shell for /workspace routing and imports InviteGuard; invitation handling logic moved to workspace route/guard.
src/app/onboarding/page.tsx Preserves redirect_url across onboarding and prioritizes non-dashboard redirects after onboarding completes.
src/app/workspace/[slug]/page.tsx Wraps workspace dashboard shell with InviteGuard for invite-token flows.
src/components/auth/AccountModal.tsx Adds account deletion UI via authClient.deleteUser and hard-redirect to clear session.
src/components/email/invite-email.tsx Minor tweak to email template to wrap long invite URLs with break-all styling.
src/components/home/UserProfileDropdown.tsx Adds Account modal entry to user dropdown; keeps anonymous sign-in/up buttons.
src/components/ui/card.tsx Adds reusable Card UI primitives (Card, CardHeader, CardContent, etc.).
src/components/workspace-canvas/WorkspaceSection.tsx Adds invite-aware loading branch but introduces hook usage inside conditional render via useSearchParams().
src/components/workspace/InviteGuard.tsx Implements invite token guard that shows landing page for anonymous users and auto-claims for authenticated users.
src/components/workspace/InviteLandingPage.tsx New invite landing page that fetches invite details and routes users to sign-in/sign-up with redirect_url.
src/components/workspace/ShareWorkspaceDialog.tsx Extends sharing dialog to invite by email, display pending invites, and allow revocation.
src/contexts/WorkspaceContext.tsx Pauses workspace-by-slug query when invite token is present to avoid 404 before claim completes.
src/lib/auth.ts Enables deleteUser with afterDelete cleanup of owned workspaces; keeps anonymous linking migration logic.
src/lib/db/schema.ts Adds workspaceInvites table and policies plus indexes for invitation lookup and insertion.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant UI as Workspace route (/workspace/[slug])
  participant IG as InviteGuard
  participant ILP as InviteLandingPage
  participant API1 as GET /api/invites/{token}
  participant API2 as POST /api/invites/claim
  participant WC as WorkspaceContext
  participant WS as Workspaces API (/api/workspaces/slug/{slug})
  participant DB as DB (workspace_invites/collaborators)

  U->>UI: Open /workspace/{slug}?invite={token}
  UI->>IG: Render InviteGuard
  IG->>IG: Read inviteToken from search params

  alt session loading
    IG-->>U: Show spinner
  else inviteToken present AND anonymous/not logged in
    IG->>ILP: Render landing page
    ILP->>API1: Fetch invite details
    API1->>DB: Select invite, workspace, inviter
    DB-->>API1: Invite details
    API1-->>ILP: JSON details
    ILP-->>U: Show invite + Sign in/Sign up
    U->>ILP: Click Sign in/Sign up
    ILP->>UI: Navigate to /auth/... with redirect_url back
  else inviteToken present AND logged in
    IG->>API2: Claim invite token
    API2->>DB: Validate invite + email match
    API2->>DB: Insert workspace_collaborators (if missing)
    API2->>DB: Delete workspace_invites row
    API2-->>IG: success
    IG->>UI: router.replace(remove invite param)
    UI->>WC: workspace-by-slug query enabled
    WC->>WS: Fetch workspace metadata
    WS-->>WC: Workspace metadata
    WC-->>UI: Render workspace dashboard
  else no inviteToken
    IG-->>UI: Render children (dashboard)
  end

Loading

@greptile-apps greptile-apps 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.

4 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/app/api/invites/claim/route.ts Outdated
}

// Verify email matches current user
if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) {

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.

Missing null check - session.user.email could be null or undefined, which would cause a runtime error when calling .toLowerCase().

Suggested change
if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
if (!session.user.email || invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/invites/claim/route.ts
Line: 43:43

Comment:
Missing null check - `session.user.email` could be null or undefined, which would cause a runtime error when calling `.toLowerCase()`.

```suggestion
    if (!session.user.email || invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/app/dashboard/page.tsx Outdated
// Remove query param
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete('invite');
router.replace(`/workspace/${data.workspaceSlug || ''}?${newParams.toString()}`);

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.

Malformed URL - workspaceSlug could be empty string, resulting in URL like /workspace/?..., and query string isn't properly appended (would result in ?invite=...?other=...).

Suggested change
router.replace(`/workspace/${data.workspaceSlug || ''}?${newParams.toString()}`);
router.replace(`/workspace/${data.workspaceSlug || data.workspaceId}`);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/dashboard/page.tsx
Line: 516:516

Comment:
Malformed URL - `workspaceSlug` could be empty string, resulting in URL like `/workspace/?...`, and query string isn't properly appended (would result in `?invite=...?other=...`).

```suggestion
          router.replace(`/workspace/${data.workspaceSlug || data.workspaceId}`);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/app/dashboard/page.tsx Outdated
Comment on lines +498 to +529
useEffect(() => {
async function claimInvite() {
if (!inviteToken || !session?.user || isSessionLoading) return;

try {
const res = await fetch('/api/invites/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken })
});

const data = await res.json();

if (res.ok) {
toast.success('Invitation accepted!');
// Remove query param
const newParams = new URLSearchParams(searchParams.toString());
newParams.delete('invite');
router.replace(`/workspace/${data.workspaceSlug || ''}?${newParams.toString()}`);
// Force reload to get permission updates
window.location.reload();
} else {
toast.error(data.message || data.error || 'Failed to accept invitation');
}
} catch (e) {
console.error(e);
toast.error('Failed to accept invitation');
}
}

claimInvite();
}, [inviteToken, session, isSessionLoading, router, searchParams]);

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.

Missing cleanup or error handling - if claiming the invite fails, the invite query param persists in the URL, causing the claim attempt to repeat on every re-render or navigation. This creates an infinite retry loop for failed invites.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/dashboard/page.tsx
Line: 498:529

Comment:
Missing cleanup or error handling - if claiming the invite fails, the `invite` query param persists in the URL, causing the claim attempt to repeat on every re-render or navigation. This creates an infinite retry loop for failed invites.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +210 to +214
const generateToken = () => {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};

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.

Using crypto.getRandomValues() in Node.js requires importing from node:crypto or using the global crypto object which may not be available in all environments. Verify this works in your deployment environment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/route.ts
Line: 210:214

Comment:
Using `crypto.getRandomValues()` in Node.js requires importing from `node:crypto` or using the global `crypto` object which may not be available in all environments. Verify this works in your deployment environment.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps

greptile-apps Bot commented Feb 5, 2026

Copy link
Copy Markdown
Contributor
Additional Comments (1)

src/app/api/workspaces/[id]/collaborators/route.ts
Dead code path - this check now happens after user existence check, but this early return prevents pending invite flow for non-existent users. The logic at line 119-124 will never execute for new users since invitedUser is checked at line 157, making this block unreachable for the intended pending invite flow.

    // Find the user by email
    const [invitedUser] = await db
        .select({ id: user.id })
        .from(user)
        .where(eq(user.email, email.trim().toLowerCase()))
        .limit(1);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/route.ts
Line: 119:124

Comment:
Dead code path - this check now happens after user existence check, but this early return prevents pending invite flow for non-existent users. The logic at line 119-124 will never execute for new users since `invitedUser` is checked at line 157, making this block unreachable for the intended pending invite flow.

```suggestion
    // Find the user by email
    const [invitedUser] = await db
        .select({ id: user.id })
        .from(user)
        .where(eq(user.email, email.trim().toLowerCase()))
        .limit(1);
```

How can I resolve this? If you propose a fix, please make it concise.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
src/app/api/workspaces/[id]/collaborators/route.ts (2)

119-124: ⚠️ Potential issue | 🔴 Critical

Dead code: Pending invite logic is unreachable.

This early return when the user doesn't exist prevents the pending invite flow (lines 206-270) from ever executing. According to the PR objectives, non-existing users should receive a pending invite with a token, but this code path makes that impossible.

Remove this early return to allow the pending invite logic to execute for non-existing users.

🐛 Proposed fix
-    if (!invitedUser) {
-        return NextResponse.json(
-            { message: "User not found. They need to sign up first." },
-            { status: 404 }
-        );
-    }

243-257: ⚠️ Potential issue | 🟡 Minor

Apply consistent null check for ws.

Same issue as line 186 - add optional chaining for consistency.

🛡️ Proposed fix
     try {
-        const identifier = ws.slug || workspaceId;
+        const identifier = ws?.slug || workspaceId;
🤖 Fix all issues with AI agents
In `@src/app/api/invites/claim/route.ts`:
- Around line 50-73: Wrap the existence check, potential insert into
workspaceCollaborators, and delete from workspaceInvites in a single
db.transaction() so the claim is atomic; inside the transaction attempt the
insert of {workspaceId: invite.workspaceId, userId: session.user.id,
permissionLevel: invite.permissionLevel} but use onConflictDoNothing()
(targeting the composite key workspaceId+userId / the
workspace_collaborators_workspace_user_unique constraint) to avoid
unique-constraint errors from concurrent claims, then delete the invite
(workspaceInvites where id = invite.id) within the same transaction; keep the
prior existence check if desired but ensure the insert+delete run inside the
transaction obtained from db.transaction().

In `@src/app/api/workspaces/`[id]/collaborators/route.ts:
- Around line 183-201: The code accesses ws.slug and ws.name without the
null-check used earlier, which can cause a runtime error if ws is null; update
the invitation-email block (the identifier/workspaceUrl construction and the
InviteEmailTemplate props passed to resend.emails.send) to safely use optional
chaining or the same guard as before (e.g., use ws?.slug ?? workspaceId and
ws?.name ?? 'Workspace' and ws?.name ?? 'a workspace'), and ensure all uses of
ws in that try block are null-safe (including building workspaceUrl and subject)
so the send call never dereferences a potentially null ws.

In `@src/app/dashboard/page.tsx`:
- Around line 497-529: The current claimInvite flow races router.replace() with
an immediate window.location.reload(), which can reload the old URL (with the
invite param) before the SPA navigation commits; replace that sequence by
building the target URL (use the same logic that deletes the 'invite' key from
searchParams and uses data.workspaceSlug || '') and call
window.location.assign(targetUrl) instead of router.replace(...) followed by
window.location.reload(); update the claimInvite function to remove
router.replace(...) and window.location.reload() and use
window.location.assign(targetUrl) to force a full-page navigation to the correct
URL.

In `@src/lib/db/schema.ts`:
- Around line 233-234: Remove the permissive public SELECT policy named "Public
can view invite by token" on workspace_invites (the pgPolicy call with using:
sql`true` and to: ["public"]) because it exposes emails and tokens; instead
restrict SELECT to authenticated users (e.g., change to to: ["authenticated"] or
add a using condition that checks auth.uid matches workspace membership) and
ensure any token validation/lookup is performed server-side (RPC or Edge
Function) using the service role key rather than allowing public reads.
🧹 Nitpick comments (1)
src/app/api/workspaces/[id]/collaborators/route.ts (1)

1-14: Remove duplicated documentation block.

The file header comment (lines 1-6) is duplicated again at lines 8-13.

🧹 Proposed fix
 /**
  * Collaborators API - List and invite collaborators
  * 
  * GET /api/workspaces/[id]/collaborators - List collaborators
  * POST /api/workspaces/[id]/collaborators - Invite a new collaborator
  */
 
-/**
- * Collaborators API - List and invite collaborators
- * 
- * GET /api/workspaces/[id]/collaborators - List collaborators
- * POST /api/workspaces/[id]/collaborators - Invite a new collaborator
- */
-
 import { NextRequest, NextResponse } from "next/server";

Comment on lines +50 to +73
// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);

if (!existing) {
// Add to collaborators
await db.insert(workspaceCollaborators).values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
});
}

// Clean up used invite
await db
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and verify its existence
find . -name "route.ts" -path "*/api/invites/claim*" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 99


🏁 Script executed:

# Search for the file in the repository
git ls-files | grep -i "invite" | grep -i "route"

Repository: ThinkEx-OSS/thinkex

Length of output: 134


🏁 Script executed:

# Get the repository structure to understand the project
ls -la | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 1235


🏁 Script executed:

# Read the file to see the code at lines 50-73
cat -n src/app/api/invites/claim/route.ts | sed -n '45,80p'

Repository: ThinkEx-OSS/thinkex

Length of output: 1523


🏁 Script executed:

# Find the database schema files to understand constraints
find . -type f -name "*.ts" -o -name "*.sql" | xargs grep -l "workspaceCollaborators\|workspaceInvites" | head -10

Repository: ThinkEx-OSS/thinkex

Length of output: 513


🏁 Script executed:

# Look for schema definitions
fd -t f "schema\|db\|types" --path "*db*" --path "*schema*" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 296


🏁 Script executed:

# Search for schema/database definitions
find . -type f \( -name "*.ts" -o -name "*.sql" \) | xargs grep -l "workspaceCollaborators" | head -10

Repository: ThinkEx-OSS/thinkex

Length of output: 497


🏁 Script executed:

# Look for Drizzle schema files
find . -path "*/schema*" -name "*.ts" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 962


🏁 Script executed:

# Search more broadly for db schema
rg "workspaceCollaborators" -t ts | grep -E "(table|primaryKey|unique)" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Read the database schema file
cat -n src/lib/db/schema.ts | head -200

Repository: ThinkEx-OSS/thinkex

Length of output: 12031


🏁 Script executed:

# Search specifically for workspaceCollaborators definition
rg "workspaceCollaborators" src/lib/db/schema.ts -A 20

Repository: ThinkEx-OSS/thinkex

Length of output: 1485


🏁 Script executed:

# Check if the codebase uses db.transaction anywhere
rg "db\.transaction|\.transaction\(" src/ -A 5 | head -50

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Look for onConflictDoNothing usage in the codebase
rg "onConflictDoNothing|onConflict" src/ -A 3 -B 1

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Check package.json for Drizzle version
cat package.json | grep -A 5 -B 5 "drizzle"

Repository: ThinkEx-OSS/thinkex

Length of output: 1069


🌐 Web query:

Drizzle ORM 0.44.7 transaction support PostgreSQL

💡 Result:

Drizzle ORM v0.44.7 already has full PostgreSQL transaction support via db.transaction(...) (commit/rollback handled automatically). You can also pass PostgreSQL-specific transaction options like isolationLevel, accessMode, and deferrable. [1]

What changed in 0.44.7? Nothing PostgreSQL-specific—v0.44.7 is a small patch release that fixes a durable-sqlite transaction return value issue, not Postgres transactions. [2]

PostgreSQL transaction usage (v0.44.7):

await db.transaction(
  async (tx) => {
    await tx.update(accounts).set({ /* ... */ });
    await tx.insert(logs).values({ /* ... */ });
  },
  {
    isolationLevel: "read committed",
    accessMode: "read write",
    deferrable: true,
  }
);

[1]

Nested transactions: tx.transaction(...) works on PostgreSQL by using SAVEPOINTs under the hood (so nested blocks can roll back independently). [1]

Sources
[1] Drizzle ORM docs – Transactions https://orm.drizzle.team/docs/transactions
[2] Drizzle ORM site – v0.44.7 release note (durable sqlite fix) https://orm.drizzle.team/


🌐 Web query:

Drizzle ORM onConflictDoNothing composite unique key

💡 Result:

To use onConflictDoNothing with a composite unique key in Drizzle, pass an array of columns as the target, and make sure your schema actually creates a UNIQUE constraint (or composite PRIMARY KEY) on the same columns.

1) Define a composite UNIQUE constraint in the schema

import { pgTable, text, unique } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  firstName: text("first_name").notNull(),
  lastName: text("last_name").notNull(),
}, (t) => [
  unique().on(t.firstName, t.lastName),
]);

Drizzle supports composite unique constraints via unique().on(col1, col2). [1]

2) Insert with onConflictDoNothing targeting that composite key

await db.insert(users).values({ firstName: "John", lastName: "Doe" })
  .onConflictDoNothing({ target: [users.firstName, users.lastName] });

onConflictDoNothing supports an explicit target, and conflict targets are columns (or an array of columns for composite). [2]

SQLite note (common pitfall)

If you get ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint, it usually means your table has a unique index but not a UNIQUE constraint / PK matching the target (this has come up with SQLite in Drizzle). In that case either:

  • ensure the migration produces a real UNIQUE(col1, col2) table constraint (like the unique().on(...) example), or
  • use .onConflictDoNothing() without target to ignore conflicts on any unique constraint. [2][3]

Sources: Drizzle docs on constraints [1] and insert/onConflictDoNothing [2], plus SQLite-related conflict-target issue discussion [3].


Wrap collaborator insert and invite delete in a transaction to prevent race conditions.

The current code is vulnerable to concurrent requests: two simultaneous claims can both pass the existence check and attempt to insert, causing one to fail on the unique constraint workspace_collaborators_workspace_user_unique on (workspaceId, userId). Use db.transaction() to make both the insert and delete atomic, and handle conflicts with onConflictDoNothing() targeting the composite key.

Proposed transactional fix
-    if (!existing) {
-        // Add to collaborators
-        await db.insert(workspaceCollaborators).values({
-            workspaceId: invite.workspaceId,
-            userId: session.user.id,
-            permissionLevel: invite.permissionLevel,
-        });
-    }
-
-    // Clean up used invite
-    await db
-        .delete(workspaceInvites)
-        .where(eq(workspaceInvites.id, invite.id));
+    await db.transaction(async (tx) => {
+        if (!existing) {
+            await tx
+                .insert(workspaceCollaborators)
+                .values({
+                    workspaceId: invite.workspaceId,
+                    userId: session.user.id,
+                    permissionLevel: invite.permissionLevel,
+                })
+                .onConflictDoNothing({
+                    target: [
+                        workspaceCollaborators.workspaceId,
+                        workspaceCollaborators.userId,
+                    ],
+                });
+        }
+
+        await tx
+            .delete(workspaceInvites)
+            .where(eq(workspaceInvites.id, invite.id));
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);
if (!existing) {
// Add to collaborators
await db.insert(workspaceCollaborators).values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
});
}
// Clean up used invite
await db
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));
// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);
await db.transaction(async (tx) => {
if (!existing) {
await tx
.insert(workspaceCollaborators)
.values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
})
.onConflictDoNothing({
target: [
workspaceCollaborators.workspaceId,
workspaceCollaborators.userId,
],
});
}
await tx
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));
});
🤖 Prompt for AI Agents
In `@src/app/api/invites/claim/route.ts` around lines 50 - 73, Wrap the existence
check, potential insert into workspaceCollaborators, and delete from
workspaceInvites in a single db.transaction() so the claim is atomic; inside the
transaction attempt the insert of {workspaceId: invite.workspaceId, userId:
session.user.id, permissionLevel: invite.permissionLevel} but use
onConflictDoNothing() (targeting the composite key workspaceId+userId / the
workspace_collaborators_workspace_user_unique constraint) to avoid
unique-constraint errors from concurrent claims, then delete the invite
(workspaceInvites where id = invite.id) within the same transaction; keep the
prior existence check if desired but ensure the insert+delete run inside the
transaction obtained from db.transaction().

Comment on lines +183 to +201
// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent null check for ws.

Line 166 checks if (ws && ...) before accessing ws.userId, but line 186 accesses ws.slug without a null check. While verifyWorkspaceAccess should ensure the workspace exists, it's safer to be consistent.

🛡️ Proposed fix
         // Send standard invitation email
         try {
             // Use slug if available, otherwise fallback to id
-            const identifier = ws.slug || workspaceId;
+            const identifier = ws?.slug || workspaceId;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}
// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws?.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}
🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 183 - 201,
The code accesses ws.slug and ws.name without the null-check used earlier, which
can cause a runtime error if ws is null; update the invitation-email block (the
identifier/workspaceUrl construction and the InviteEmailTemplate props passed to
resend.emails.send) to safely use optional chaining or the same guard as before
(e.g., use ws?.slug ?? workspaceId and ws?.name ?? 'Workspace' and ws?.name ??
'a workspace'), and ensure all uses of ws in that try block are null-safe
(including building workspaceUrl and subject) so the send call never
dereferences a potentially null ws.

Comment thread src/app/dashboard/page.tsx Outdated
Comment thread src/lib/db/schema.ts
Comment on lines +233 to +234
pgPolicy("Public can view invite by token", { as: "permissive", for: "select", to: ["public"], using: sql`true` }),
pgPolicy("Users can insert invites for workspaces they own/edit", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Postgres RLS policy "using true" "to public" anonymous client all rows security

💡 Result:

In PostgreSQL RLS, a policy like:

CREATE POLICY p ON my_table
  FOR ALL
  TO PUBLIC
  USING (true);

means:

  • TO PUBLIC applies to every role (it’s a special “all roles” pseudo-role), so it includes anonymous / unauthenticated DB roles too. (postgresql.org)
  • USING (true) allows every existing row for commands that use the USING clause (notably SELECT, and the row-targeting part of UPDATE/DELETE). Since the predicate is always true, RLS won’t filter anything—so the client can see “all rows” as long as they also have normal SQL privileges (e.g., GRANT SELECT) on the table. (postgresql.org)
  • RLS policies only take effect if RLS is enabled on the table (ALTER TABLE ... ENABLE ROW LEVEL SECURITY). If enabled but no applicable policies exist, Postgres is default-deny. (postgresql.org)
  • For INSERT/UPDATE of new row values, enforcement is via WITH CHECK (or USING is reused if WITH CHECK isn’t specified for policies where that applies). So “allow all writes” typically needs WITH CHECK (true) too. (postgresql.org)

If your goal is “anonymous can’t read anything”, don’t use USING (true) for PUBLIC. Use USING (false), or target only authenticated roles (not PUBLIC), and ensure you haven’t granted broad table privileges to the anonymous role. (postgresql.org)

Citations:


🌐 Web query:

Supabase RLS public SELECT policy security best practices token lookup

💡 Result:

Public SELECT policy (e.g., USING (true)) — when it’s OK, and how to do it safely

  • Assume anything in public is reachable via the Data API; if RLS is off, the table is accessible using the anon role. Enable RLS on every exposed table/view/function first. [1], [2]
  • RLS-on + no policies = no access (default deny). Only add a “public read” policy if you truly want public reads. [2]
  • If you intentionally allow public reads, prefer least data:
    • Put only non-sensitive fields in that table, or expose a view that contains only safe columns/rows, and put the public SELECT policy on the view/table that’s safe to publish. (Supabase emphasizes RLS as the control point for what rows are accessible.) [2]
  • Also lock down table privileges (RLS and grants work together): for public-read tables, grant SELECT only; revoke write privileges from anon. [3]
  • Never expose the service role key in any client (it bypasses RLS). Use the anon key in the browser. [4]
  • In policies that require a logged-in user, explicitly check auth is present (because auth.uid() is NULL when unauthenticated). [2]

Token lookup best practices (API keys, invite tokens, magic links, “shared secret” rows)

  • Do not make the token table publicly selectable. A “public SELECT” policy on a table that stores tokens (or token hashes) enables enumeration/harvesting and turns your DB into an oracle. Keep token material out of anything you expose broadly. (General rule derived from Supabase’s “public schema is reachable” + “policies act like a WHERE clause” model.) [1], [2]
  • Prefer server-side validation paths:
    • Validate tokens in a backend/Edge Function using the service role key (server-only) and return only the minimal result to the client. [4]
    • Or use a database function (RPC) to validate the token and return a minimal payload—this lets you keep the underlying token table non-readable while still supporting the workflow. (Supabase’s guidance: secure data via RLS; don’t rely on clients having broad table access.) [1], [2]
  • Store tokens as hashes, not plaintext, and compare hashes inside the trusted path (RPC/backend). (This isn’t Supabase-specific, but it’s the standard mitigation once you accept tokens may leak via logs/backups.)
  • If you’re using JWT claims for authorization logic, be careful about what claims you trust; Supabase JWTs carry claims and role used for RLS evaluation. [5]

Minimal “public read” pattern (safe-only table)

  • RLS enabled + SELECT policy USING (true) on a sanitized table/view. [2]
  • REVOKE ALL then GRANT SELECT to anon for that object. [3]

If you share your exact “token lookup” flow (what the token represents, who presents it, and what data you need to return), I can suggest a concrete table + RLS/RPC pattern that avoids public token reads.

Sources: Supabase docs on securing the API/public schema exposure and RLS basics [1][2], Data API hardening and grants [3], and service-role key/RLS bypass guidance [4], plus JWT/claims overview [5].


🌐 Web query:

Postgres Row Level Security using: true select permission public role behavior

💡 Result:

  • CREATE POLICY ... FOR SELECT TO PUBLIC USING (true) means: for any role, the RLS predicate for SELECT is always true, so RLS will not filter out any rows (all rows are visible as far as RLS is concerned). RLS rows are shown when the USING expression evaluates to true; false/null rows are silently hidden. [1]

  • This does not grant SELECT privilege. PostgreSQL checks normal table privileges first; you still must GRANT SELECT ON table ... (directly or via a role the user inherits). RLS only further restricts which rows are visible once the command itself is allowed. [2]

  • PUBLIC in TO PUBLIC is the special “all roles” target (not the public schema). So the policy applies to everyone unless overridden by other policies. [1][2]

  • If you have multiple permissive SELECT policies, they are OR’ed together; a single USING (true) permissive policy effectively makes the permissive portion always pass. Restrictive policies, if any, are AND’ed and can still block rows. [1]

  • Table owners (and superusers / roles with BYPASSRLS) can bypass RLS unless you ALTER TABLE ... FORCE ROW LEVEL SECURITY. [2]


🏁 Script executed:

# Find the invites table definition and any related RLS policies
cd src/lib/db && grep -n "invites" schema.ts | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 920


🏁 Script executed:

# Look at the invites table schema and all its RLS policies
cd src/lib/db && sed -n '200,300p' schema.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 2492


🏁 Script executed:

# Check for GRANT statements related to workspace_invites
rg "GRANT.*workspace_invites|workspace_invites.*GRANT" src/

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Search for where the public SELECT policy might be used/called in the codebase
rg "workspace_invites" src/ --type ts --type tsx | grep -E "(select|query|from)" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 90


🏁 Script executed:

# Check if there's any RLS configuration or anon role setup
rg "anon|role|GRANT|permission" src/lib/db/schema.ts | head -30

Repository: ThinkEx-OSS/thinkex

Length of output: 706


Remove public SELECT policy on workspace_invites — exposes email addresses and invite tokens.

The using: sql\true`withto: ["public"]` allows any anonymous client to read all rows, including email addresses (PII) and unique invite tokens. This enables enumeration attacks and token harvesting.

Move token validation to a backend RPC or Edge Function using the service role key, or restrict the SELECT policy to authenticated users only. Supabase guidance explicitly recommends against exposing token tables publicly.

🤖 Prompt for AI Agents
In `@src/lib/db/schema.ts` around lines 233 - 234, Remove the permissive public
SELECT policy named "Public can view invite by token" on workspace_invites (the
pgPolicy call with using: sql`true` and to: ["public"]) because it exposes
emails and tokens; instead restrict SELECT to authenticated users (e.g., change
to to: ["authenticated"] or add a using condition that checks auth.uid matches
workspace membership) and ensure any token validation/lookup is performed
server-side (RPC or Edge Function) using the service role key rather than
allowing public reads.

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 8 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/app/api/workspaces/[id]/collaborators/route.ts">

<violation number="1" location="src/app/api/workspaces/[id]/collaborators/route.ts:156">
P0: Dead code: The pending invite feature will never execute. There's an early return at lines 118-122 that returns 404 when `invitedUser` is not found, but this `if (invitedUser)` check assumes the user might not exist. The entire else branch (pending invite logic) is unreachable. The early return needs to be removed for this feature to work.</violation>
</file>

<file name="src/components/workspace/InviteLandingPage.tsx">

<violation number="1" location="src/components/workspace/InviteLandingPage.tsx:58">
P1: The authentication page (`src/app/auth/[path]/page.tsx`) expects a `redirect_url` query parameter to handle post-login redirection, but this component sends `callbackUrl`. This mismatch will cause the user to be redirected to the default page (e.g., `/home`) instead of returning to this invite page, breaking the auto-claim workflow.</violation>
</file>

<file name="src/lib/db/schema.ts">

<violation number="1" location="src/lib/db/schema.ts:234">
P2: The \`INSERT\` policy checks workspace permissions but does not enforce that the \`inviter_id\` matches the authenticated user. This allows a user to create an invite while impersonating another user as the inviter.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src/app/api/workspaces/[id]/collaborators/route.ts
Comment thread src/components/workspace/InviteLandingPage.tsx Outdated
Comment thread src/lib/db/schema.ts
}).onDelete("cascade"),
unique("workspace_invites_token_key").on(table.token),
pgPolicy("Public can view invite by token", { as: "permissive", for: "select", to: ["public"], using: sql`true` }),
pgPolicy("Users can insert invites for workspaces they own/edit", {

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P2: The `INSERT` policy checks workspace permissions but does not enforce that the `inviter_id` matches the authenticated user. This allows a user to create an invite while impersonating another user as the inviter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/db/schema.ts, line 234:

<comment>The \`INSERT\` policy checks workspace permissions but does not enforce that the \`inviter_id\` matches the authenticated user. This allows a user to create an invite while impersonating another user as the inviter.</comment>

<file context>
@@ -210,3 +210,31 @@ export const workspaceCollaborators = pgTable("workspace_collaborators", {
+	}).onDelete("cascade"),
+	unique("workspace_invites_token_key").on(table.token),
+	pgPolicy("Public can view invite by token", { as: "permissive", for: "select", to: ["public"], using: sql`true` }),
+	pgPolicy("Users can insert invites for workspaces they own/edit", {
+		as: "permissive", for: "insert", to: ["authenticated"], withCheck: sql`(EXISTS ( SELECT 1
+   FROM workspaces w
</file context>
Fix with Cubic

const [confirmPassword, setConfirmPassword] = useState("");
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
} from "@/components/ui/dropdown-menu";
import { AccountModal } from "@/components/auth/AccountModal";

import { toast } from "sonner";

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 10 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/app/auth/[path]/page.tsx">

<violation number="1" location="src/app/auth/[path]/page.tsx:25">
P1: Potential Open Redirect vulnerability. The `redirect_url` and `callbackUrl` parameters are taken directly from user input without validation. If an attacker provides an external URL (e.g., `https://evil.com`), this value is passed to the authentication flow.

Specifically, in the `sign-up` path, this URL is encoded and passed as a query parameter to `/onboarding`. The `OnboardingPage` (verified via file read) redirects to this URL if it doesn't match a specific internal blocklist, which inadvertently allows external domains.

To prevent this, ensure `redirectTo` is a relative path (starts with `/` and not `//`).</violation>
</file>

<file name="src/app/api/workspaces/[id]/collaborators/route.ts">

<violation number="1" location="src/app/api/workspaces/[id]/collaborators/route.ts:207">
P1: The fallback to `Math.random()` creates predictable tokens, which is a critical security vulnerability for invitation systems. 

In Next.js 16 (Node.js 18+ or Edge Runtime), the Web Crypto API (`crypto`) is globally available. If `crypto` is missing for some reason, the application should fail securely rather than silently degrading to insecure token generation.

Remove the fallback and reliance on `Math.random`.</violation>
</file>

<file name="src/components/workspace-canvas/WorkspaceSection.tsx">

<violation number="1" location="src/components/workspace-canvas/WorkspaceSection.tsx:436">
P1: `useSearchParams` is a hook and must be called at the top level of the component. Calling it inside this conditional render branch violates the Rules of Hooks and can cause hook ordering bugs.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if (!redirectTo) {
if (path === "sign-in") {
redirectTo = "/home";
let redirectTo = redirect_url || callbackUrl;

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P1: Potential Open Redirect vulnerability. The redirect_url and callbackUrl parameters are taken directly from user input without validation. If an attacker provides an external URL (e.g., https://evil.com), this value is passed to the authentication flow.

Specifically, in the sign-up path, this URL is encoded and passed as a query parameter to /onboarding. The OnboardingPage (verified via file read) redirects to this URL if it doesn't match a specific internal blocklist, which inadvertently allows external domains.

To prevent this, ensure redirectTo is a relative path (starts with / and not //).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/auth/[path]/page.tsx, line 25:

<comment>Potential Open Redirect vulnerability. The `redirect_url` and `callbackUrl` parameters are taken directly from user input without validation. If an attacker provides an external URL (e.g., `https://evil.com`), this value is passed to the authentication flow.

Specifically, in the `sign-up` path, this URL is encoded and passed as a query parameter to `/onboarding`. The `OnboardingPage` (verified via file read) redirects to this URL if it doesn't match a specific internal blocklist, which inadvertently allows external domains.

To prevent this, ensure `redirectTo` is a relative path (starts with `/` and not `//`).</comment>

<file context>
@@ -17,18 +17,23 @@ export default async function AuthPage({
-  if (!redirectTo) {
-    if (path === "sign-in") {
-      redirectTo = "/home";
+  let redirectTo = redirect_url || callbackUrl;
+
+  // For sign-up, always route through onboarding, but preserve the final destination
</file context>
Suggested change
let redirectTo = redirect_url || callbackUrl;
let redirectTo = redirect_url || callbackUrl;
// Prevent open redirects by ensuring the path is relative and not protocol-relative (//)
if (redirectTo && (!redirectTo.startsWith("/") || redirectTo.startsWith("//"))) {
redirectTo = undefined;
}
Fix with Cubic

Comment on lines +207 to +216
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P1: The fallback to Math.random() creates predictable tokens, which is a critical security vulnerability for invitation systems.

In Next.js 16 (Node.js 18+ or Edge Runtime), the Web Crypto API (crypto) is globally available. If crypto is missing for some reason, the application should fail securely rather than silently degrading to insecure token generation.

Remove the fallback and reliance on Math.random.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/workspaces/[id]/collaborators/route.ts, line 207:

<comment>The fallback to `Math.random()` creates predictable tokens, which is a critical security vulnerability for invitation systems. 

In Next.js 16 (Node.js 18+ or Edge Runtime), the Web Crypto API (`crypto`) is globally available. If `crypto` is missing for some reason, the application should fail securely rather than silently degrading to insecure token generation.

Remove the fallback and reliance on `Math.random`.</comment>

<file context>
@@ -208,9 +204,16 @@ async function handlePOST(
-        const array = new Uint8Array(24);
-        crypto.getRandomValues(array);
-        return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
+        // Use Web Crypto API which is available in Node.js global scope in newer versions
+        // or fall back to Math.random for older environments if critical
+        if (typeof crypto !== 'undefined') {
</file context>
Suggested change
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
Fix with Cubic

Comment thread src/components/workspace-canvas/WorkspaceSection.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
src/components/workspace-canvas/WorkspaceSection.tsx (1)

430-440: ⚠️ Potential issue | 🟠 Major

Move useSearchParams() to the top level.

useSearchParams() is a hook and must be called unconditionally at the component's top level, not inside conditional render paths. Calling it inside a ternary expression violates React's hook rules and can break render ordering. Hoist it to the top of the component and derive a boolean variable from it.

Proposed fix
 export function WorkspaceSection({
   // props...
 }: WorkspaceSectionProps) {
+  const searchParams = useSearchParams();
+  const hasInviteParam = searchParams.get("invite");
   const selectedCardIdsArray = useUIStore(
     useShallow(selectSelectedCardIdsArray)
   );
   // ... other hooks
   
   return (
     // ...
-                  ) : useSearchParams().get('invite') ? (
+                  ) : hasInviteParam ? (
🤖 Fix all issues with AI agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts:
- Around line 204-216: The generateToken function currently falls back to
Math.random which is insecure; replace its implementation to use Node's
crypto.randomBytes unconditionally (e.g., call
crypto.randomBytes(24).toString('hex') or import randomBytes from 'crypto') for
invite token generation in the API route; remove the Web Crypto + Math.random
branch and ensure the code uses the Node crypto API only so tokens are
cryptographically secure.

In `@src/app/auth/`[path]/page.tsx:
- Around line 25-36: The redirect target logic currently assigns redirect_url or
callbackUrl directly to redirectTo (variables: redirectTo, redirect_url,
callbackUrl, path) which allows external or protocol-relative open redirects;
add a validation step (e.g., an isSafeRedirect helper) that ensures the chosen
redirect is an internal path only — require it to start with a single slash
("/") and explicitly reject values that start with "//" or include a protocol
("://"); use this validator before encoding/embedding into
`/onboarding?redirect_url=...` and if validation fails fall back to a safe
default like "/home" or "/" so that only validated internal routes are used for
redirectTo.

In `@src/lib/auth.ts`:
- Around line 66-75: The afterDelete hook for deleteUser currently swallows
failures when running db.delete(workspaces).where(eq(workspaces.userId,
user.id)), which can leave orphaned user data; update deleteUser.afterDelete to
make workspace cleanup actionable: either ensure DB cascade constraints on
workspaces.userId and remove this manual delete, or if keeping manual cleanup,
propagate the error (rethrow) so account deletion aborts on failure, and/or
enqueue a retry + emit an alert/metric when the delete fails; also audit and add
similar cleanup (or cascades) for other user-linked tables such as
collaborators, invites, and events to avoid orphaned records.

Comment on lines +204 to +216
// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => {
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check the runtime configuration and examine the file context
rg -n "export const runtime" src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Also check the full structure of the file around the problematic code
head -n 220 src/app/api/workspaces/[id]/collaborators/route.ts | tail -n 30

Repository: ThinkEx-OSS/thinkex

Length of output: 1325


🏁 Script executed:

# Check if there are any existing imports from crypto
rg -n "import.*crypto" src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

wc -l src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 117


🏁 Script executed:

# Read the beginning of the file to see imports and understand the context
head -n 50 src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 1628


🏁 Script executed:

# Check if next.config file has any edge runtime configuration that might affect this route
find . -name "next.config.*" -type f | head -5

Repository: ThinkEx-OSS/thinkex

Length of output: 79


🏁 Script executed:

# Read the full route file to check for runtime configuration
cat -n src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 11627


🏁 Script executed:

# Check next.config for any edge runtime settings
cat next.config.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 1208


Remove the insecure Math.random fallback from invite token generation.

The fallback to Math.random() is cryptographically insecure and should never be used for generating security-sensitive tokens like invitations. Since this is a Node.js API route (no edge runtime is configured), use crypto.randomBytes() unconditionally instead of the defensive Web Crypto API check with unsafe fallback.

🔐 Suggested fix
+import { randomBytes } from "crypto";
...
-    const generateToken = () => {
-        // Use Web Crypto API which is available in Node.js global scope in newer versions
-        // or fall back to Math.random for older environments if critical
-        if (typeof crypto !== 'undefined') {
-            const array = new Uint8Array(24);
-            crypto.getRandomValues(array);
-            return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
-        } else {
-            // Fallback for environments without crypto global
-            return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
-        }
-    };
+    const generateToken = () => randomBytes(24).toString("hex");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => {
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
import { randomBytes } from "crypto";
// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => randomBytes(24).toString("hex");
🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 204 - 216,
The generateToken function currently falls back to Math.random which is
insecure; replace its implementation to use Node's crypto.randomBytes
unconditionally (e.g., call crypto.randomBytes(24).toString('hex') or import
randomBytes from 'crypto') for invite token generation in the API route; remove
the Web Crypto + Math.random branch and ensure the code uses the Node crypto API
only so tokens are cryptographically secure.

Comment on lines +25 to +36
let redirectTo = redirect_url || callbackUrl;

// For sign-up, always route through onboarding, but preserve the final destination
if (path === "sign-up") {
if (redirectTo) {
redirectTo = `/onboarding?redirect_url=${encodeURIComponent(redirectTo)}`;
} else {
redirectTo = "/onboarding";
}
} else if (!redirectTo) {
// For sign-in without a specific target, go to home
redirectTo = "/home";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sanitize redirect targets to prevent open redirects.

Line 25 now accepts callbackUrl as a redirect target without validation. This allows external/protocol-relative redirects. Please restrict redirects to safe internal paths.

🔒 Suggested fix
-  let redirectTo = redirect_url || callbackUrl;
+  const sanitizeRedirect = (value?: string) => {
+    if (!value) return undefined;
+    // allow only internal, absolute paths
+    if (value.startsWith("/") && !value.startsWith("//")) return value;
+    return undefined;
+  };
+
+  let redirectTo = sanitizeRedirect(redirect_url || callbackUrl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let redirectTo = redirect_url || callbackUrl;
// For sign-up, always route through onboarding, but preserve the final destination
if (path === "sign-up") {
if (redirectTo) {
redirectTo = `/onboarding?redirect_url=${encodeURIComponent(redirectTo)}`;
} else {
redirectTo = "/onboarding";
}
} else if (!redirectTo) {
// For sign-in without a specific target, go to home
redirectTo = "/home";
const sanitizeRedirect = (value?: string) => {
if (!value) return undefined;
// allow only internal, absolute paths
if (value.startsWith("/") && !value.startsWith("//")) return value;
return undefined;
};
let redirectTo = sanitizeRedirect(redirect_url || callbackUrl);
// For sign-up, always route through onboarding, but preserve the final destination
if (path === "sign-up") {
if (redirectTo) {
redirectTo = `/onboarding?redirect_url=${encodeURIComponent(redirectTo)}`;
} else {
redirectTo = "/onboarding";
}
} else if (!redirectTo) {
// For sign-in without a specific target, go to home
redirectTo = "/home";
🤖 Prompt for AI Agents
In `@src/app/auth/`[path]/page.tsx around lines 25 - 36, The redirect target logic
currently assigns redirect_url or callbackUrl directly to redirectTo (variables:
redirectTo, redirect_url, callbackUrl, path) which allows external or
protocol-relative open redirects; add a validation step (e.g., an isSafeRedirect
helper) that ensures the chosen redirect is an internal path only — require it
to start with a single slash ("/") and explicitly reject values that start with
"//" or include a protocol ("://"); use this validator before encoding/embedding
into `/onboarding?redirect_url=...` and if validation fails fall back to a safe
default like "/home" or "/" so that only validated internal routes are used for
redirectTo.

Comment thread src/lib/auth.ts
Comment on lines +66 to +75
user: {
deleteUser: {
enabled: true,
afterDelete: async (user) => {
// Cleanup workspaces since there is no cascade constraint in the DB
try {
await db.delete(workspaces).where(eq(workspaces.userId, user.id));
} catch (error) {
console.error("Failed to cleanup user workspaces:", error);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Account deletion cleanup shouldn’t silently fail.

Line 71–75 logs and continues if workspace deletion fails, which can leave user-owned content behind after account deletion (data retention/compliance risk). Consider enforcing DB-level cascades or making cleanup failures actionable (e.g., throw to abort if possible, or enqueue a guaranteed retry + alert). Also verify whether other user-linked tables (collaborators/invites/events) need explicit cleanup to avoid orphaned records.

🤖 Prompt for AI Agents
In `@src/lib/auth.ts` around lines 66 - 75, The afterDelete hook for deleteUser
currently swallows failures when running
db.delete(workspaces).where(eq(workspaces.userId, user.id)), which can leave
orphaned user data; update deleteUser.afterDelete to make workspace cleanup
actionable: either ensure DB cascade constraints on workspaces.userId and remove
this manual delete, or if keeping manual cleanup, propagate the error (rethrow)
so account deletion aborts on failure, and/or enqueue a retry + emit an
alert/metric when the delete fails; also audit and add similar cleanup (or
cascades) for other user-linked tables such as collaborators, invites, and
events to avoid orphaned records.

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed 1f7dbec in 17 seconds. Click for details.
  • Reviewed 562 lines of code in 7 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_3Owm8U7mTWoc0uaN

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal";
import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog";
import { RealtimeProvider } from "@/contexts/RealtimeContext";
import { toast } from "sonner";

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/app/onboarding/page.tsx">

<violation number="1" location="src/app/onboarding/page.tsx:61">
P1: The `redirectUrl` is used in `router.replace` without validation, potentially allowing Open Redirects to external sites (e.g. `https://evil.com` which passes the negative checks).

While enabling workspace redirects is the goal, it is recommended to validate that `redirectUrl` is a relative path (starts with `/` and not `//`) to prevent open redirects. This replaces the removed line with a safety check that still allows `/workspace` paths.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

if (
redirectUrl &&
!redirectUrl.startsWith("/workspace") &&
!redirectUrl.startsWith("/dashboard") &&

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P1: The redirectUrl is used in router.replace without validation, potentially allowing Open Redirects to external sites (e.g. https://evil.com which passes the negative checks).

While enabling workspace redirects is the goal, it is recommended to validate that redirectUrl is a relative path (starts with / and not //) to prevent open redirects. This replaces the removed line with a safety check that still allows /workspace paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/onboarding/page.tsx, line 61:

<comment>The `redirectUrl` is used in `router.replace` without validation, potentially allowing Open Redirects to external sites (e.g. `https://evil.com` which passes the negative checks).

While enabling workspace redirects is the goal, it is recommended to validate that `redirectUrl` is a relative path (starts with `/` and not `//`) to prevent open redirects. This replaces the removed line with a safety check that still allows `/workspace` paths.</comment>

<file context>
@@ -58,7 +58,6 @@ export default function OnboardingPage() {
                 if (
                     redirectUrl &&
-                    !redirectUrl.startsWith("/workspace") &&
                     !redirectUrl.startsWith("/dashboard") &&
                     !redirectUrl.startsWith("/onboarding") &&
                     !redirectUrl.startsWith("/home")
</file context>
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts:
- Around line 72-83: The current code always queries and returns
workspaceInvites (the invites variable), which exposes invitee emails to
viewers; update the route handler in route.ts to first determine the requester's
permission for the workspace (e.g., call or implement a
getWorkspacePermission(userId, workspaceId) or reuse existing permission check)
and only run the workspaceInvites query (the select from workspaceInvites) when
the permissionLevel is owner/editor; for viewer-level users return an empty
invites list or omit invites from the response. Ensure you reference
workspaceId, workspaceInvites, and the invites variable when gating the database
query so emails are never returned to viewers.

In `@src/components/workspace/ShareWorkspaceDialog.tsx`:
- Around line 84-87: The current isRevoking state only holds a single id causing
races; change its type to a collection (e.g., useState<Set<string>> or
useState<string[]>) and update all usages (isRevoking, setIsRevoking) so that
starting a revoke adds the invite id to the set and finishing/removing the
revoke deletes that id (preserve immutability: create a new Set/array when
updating). Also update the UI checks (e.g., button disabled or spinner
visibility) from isRevoking === id to collection.has(id) or
collection.includes(id), and ensure the error/cleanup paths remove the id on
both success and failure; apply the same pattern wherever isRevoking is
referenced (including the other revoke locations noted).
🧹 Nitpick comments (1)
src/components/workspace/ShareWorkspaceDialog.tsx (1)

33-40: Narrow PendingInvite.permissionLevel to known values.

Using string weakens type safety; align it with the collaborator permission union.

♻️ Suggested change
 interface PendingInvite {
   id: string;
   email: string;
-  permissionLevel: string;
+  permissionLevel: "viewer" | "editor";
   createdAt: string;
   expiresAt: string;
   inviterId: string;
 }

Comment on lines +72 to +83
// Get pending invites
const invites = await db
.select({
id: workspaceInvites.id,
email: workspaceInvites.email,
permissionLevel: workspaceInvites.permissionLevel,
createdAt: workspaceInvites.createdAt,
expiresAt: workspaceInvites.expiresAt,
inviterId: workspaceInvites.inviterId,
})
.from(workspaceInvites)
.where(eq(workspaceInvites.workspaceId, workspaceId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t expose pending invite emails to viewers.

This endpoint allows “viewer” access, but returning invites leaks invitee emails to users who cannot manage access. Consider only returning invites to owners/editors.

🔐 Suggested change (gate invite visibility)
-    const invites = await db
-        .select({
-            id: workspaceInvites.id,
-            email: workspaceInvites.email,
-            permissionLevel: workspaceInvites.permissionLevel,
-            createdAt: workspaceInvites.createdAt,
-            expiresAt: workspaceInvites.expiresAt,
-            inviterId: workspaceInvites.inviterId,
-        })
-        .from(workspaceInvites)
-        .where(eq(workspaceInvites.workspaceId, workspaceId));
+    let invites: Array<{
+        id: string;
+        email: string;
+        permissionLevel: string;
+        createdAt: string;
+        expiresAt: string;
+        inviterId: string;
+    }> = [];
+    try {
+        await verifyWorkspaceAccess(workspaceId, userId, "editor");
+        invites = await db
+            .select({
+                id: workspaceInvites.id,
+                email: workspaceInvites.email,
+                permissionLevel: workspaceInvites.permissionLevel,
+                createdAt: workspaceInvites.createdAt,
+                expiresAt: workspaceInvites.expiresAt,
+                inviterId: workspaceInvites.inviterId,
+            })
+            .from(workspaceInvites)
+            .where(eq(workspaceInvites.workspaceId, workspaceId));
+    } catch {
+        invites = [];
+    }

Also applies to: 99-99

🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 72 - 83, The
current code always queries and returns workspaceInvites (the invites variable),
which exposes invitee emails to viewers; update the route handler in route.ts to
first determine the requester's permission for the workspace (e.g., call or
implement a getWorkspacePermission(userId, workspaceId) or reuse existing
permission check) and only run the workspaceInvites query (the select from
workspaceInvites) when the permissionLevel is owner/editor; for viewer-level
users return an empty invites list or omit invites from the response. Ensure you
reference workspaceId, workspaceInvites, and the invites variable when gating
the database query so emails are never returned to viewers.

Comment on lines +84 to +87
// Pending Invites State
const [invites, setInvites] = useState<PendingInvite[]>([]);
const [isRevoking, setIsRevoking] = useState<string | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Track revocations per invite to avoid spinner/enablement races.

isRevoking holds only one id, so a second revoke can clear the first one’s loading state while it’s still in-flight.

🛠️ Suggested fix
-  const [isRevoking, setIsRevoking] = useState<string | null>(null);
+  const [revokingIds, setRevokingIds] = useState<Set<string>>(new Set());
...
-    setIsRevoking(inviteId);
+    setRevokingIds(prev => new Set(prev).add(inviteId));
...
-    setIsRevoking(null);
+    setRevokingIds(prev => {
+      const next = new Set(prev);
+      next.delete(inviteId);
+      return next;
+    });
...
-  disabled={isRevoking === invite.id}
+  disabled={revokingIds.has(invite.id)}
...
-  {isRevoking === invite.id ? (
+  {revokingIds.has(invite.id) ? (

Also applies to: 285-303, 525-535

🤖 Prompt for AI Agents
In `@src/components/workspace/ShareWorkspaceDialog.tsx` around lines 84 - 87, The
current isRevoking state only holds a single id causing races; change its type
to a collection (e.g., useState<Set<string>> or useState<string[]>) and update
all usages (isRevoking, setIsRevoking) so that starting a revoke adds the invite
id to the set and finishing/removing the revoke deletes that id (preserve
immutability: create a new Set/array when updating). Also update the UI checks
(e.g., button disabled or spinner visibility) from isRevoking === id to
collection.has(id) or collection.includes(id), and ensure the error/cleanup
paths remove the id on both success and failure; apply the same pattern wherever
isRevoking is referenced (including the other revoke locations noted).

@urjitc

urjitc commented Feb 5, 2026

Copy link
Copy Markdown
Member Author

@CodeRabbit check this pr

@coderabbitai

coderabbitai Bot commented Feb 5, 2026

Copy link
Copy Markdown

@urjitc, I'll review this PR for you! This PR introduces a comprehensive workspace invitation system with:

  • New API routes for invite management (retrieve, claim, revoke)
  • Database schema for workspace invites
  • UI components for invite landing page and guards
  • Email notifications for invitations
  • Auto-claiming functionality

I'll analyze the implementation across backend, database, and frontend changes to ensure everything is working correctly and identify any potential issues or improvements.

✅ 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.

@greptile-apps greptile-apps 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.

19 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 433 to +438
!isLoadingWorkspace && !loadingWorkspaces && !currentWorkspaceId ? (
session?.user?.isAnonymous ? (
<LoginGate />
) : useSearchParams().get('invite') ? (
// If we have an invite query param, show skeleton instead of Access Denied
// This handles the race condition where workspace fetch 404s before claim completes

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.

Invalid hook call path

useSearchParams() is being called inside a conditional render branch (... ? useSearchParams().get('invite') ? ...), which violates React’s Rules of Hooks and will throw at runtime when that branch is reached. This needs to be moved to the top-level of the component (call once, then reference the value in conditionals) so hook order is consistent across renders.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/workspace-canvas/WorkspaceSection.tsx
Line: 433:438

Comment:
**Invalid hook call path**

`useSearchParams()` is being called inside a conditional render branch (`... ? useSearchParams().get('invite') ? ...`), which violates React’s Rules of Hooks and will throw at runtime when that branch is reached. This needs to be moved to the top-level of the component (call once, then reference the value in conditionals) so hook order is consistent across renders.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +219 to +229
const generateToken = () => {
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

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.

Insecure invite token fallback

generateToken() falls back to Math.random() when crypto is undefined (lines in the 219-229 range). In any runtime where the global Web Crypto API isn’t present (or is blocked), this produces guessable invite tokens, which would allow unauthorized users to brute-force invite links. Since these tokens gate workspace access, the fallback should be removed and token generation should fail hard (or use Node’s crypto module) rather than degrading to non-cryptographic randomness.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/route.ts
Line: 219:229

Comment:
**Insecure invite token fallback**

`generateToken()` falls back to `Math.random()` when `crypto` is undefined (lines in the 219-229 range). In any runtime where the global Web Crypto API isn’t present (or is blocked), this produces guessable invite tokens, which would allow unauthorized users to brute-force invite links. Since these tokens gate workspace access, the fallback should be removed and token generation should fail hard (or use Node’s `crypto` module) rather than degrading to non-cryptographic randomness.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/components/workspace/InviteLandingPage.tsx Fixed

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed 125373c in 18 seconds. Click for details.
  • Reviewed 549 lines of code in 6 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_dZjYq9xPr63BwWxp

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (2)
src/hooks/workspace/use-workspace-mutation.ts (2)

181-195: ⚠️ Potential issue | 🟡 Minor

Permission toast may be skipped due to early return.

The early return at line 181 (if (!workspaceId || !context?.previous) return;) executes before the permission error toast is shown. If workspaceId is null or context.previous is undefined, users won't see the permission denied notification.

Move the toast logic before the guard, or restructure to ensure the toast always fires for permission errors:

🐛 Proposed fix
     onError: (err, event, context) => {
       logger.error("❌ [MUTATION] Event mutation failed:", err);
       logger.error("❌ [MUTATION] Failed event:", event);
       logger.error("❌ [MUTATION] Error details:", {
         message: err instanceof Error ? err.message : String(err),
         workspaceId,
       });

-      if (!workspaceId || !context?.previous) return;
-
       // Show toast for permission errors
       if (err.message === "PERMISSION_DENIED") {
         toast.error("You don't have permission to edit this workspace");
-      } else {
-        // Only show generic error for non-permission issues to avoid noise
-        // toast.error("Failed to save changes");
       }
 
+      if (!workspaceId || !context?.previous) return;
+
       // Rollback to previous state
       queryClient.setQueryData(
         ["workspace", workspaceId, "events"],
         context.previous
       );
     },

326-351: ⚠️ Potential issue | 🟡 Minor

Retry error handler doesn't show permission toast.

When the retry at line 268 fails with a permission error, the .catch() handler logs and rolls back but doesn't show a toast to the user. This creates inconsistent UX where initial permission failures show a toast but retry failures don't.

🐛 Proposed fix to add permission toast in retry error handler
               }).catch((err) => {
                 logger.error("❌ [RETRY] Retry failed with error:", err);
 
+                // Show toast for permission errors
+                if (err.message === "PERMISSION_DENIED") {
+                  toast.error("You don't have permission to edit this workspace");
+                }
+
                 // Remove optimistic event
                 queryClient.setQueryData<EventResponse>(
🤖 Fix all issues with AI agents
In `@src/app/auth/`[path]/page.tsx:
- Around line 59-60: The query that fetches invites using workspaceInvites.token
and inviteToken should also filter out expired invites by adding a condition on
workspaceInvites.expiresAt (e.g., require expiresAt to be greater than the
current time); update the query chain that currently calls
.where(eq(workspaceInvites.token, inviteToken)).limit(1) to include an
additional where/compare (using expiresAt and the appropriate GT/greater-than
comparator or timestamp check) so only non-expired invites are returned.
🧹 Nitpick comments (6)
src/hooks/workspace/use-workspace-mutation.ts (1)

44-46: Consider using a custom error class for type-safe error handling.

Using a magic string "PERMISSION_DENIED" as an error message and later comparing against it (line 184) is fragile. A custom error class would provide type safety and better extensibility.

♻️ Optional refactor using custom error class
// At top of file or in a shared errors module
class PermissionDeniedError extends Error {
  constructor() {
    super("You don't have permission to edit this workspace");
    this.name = "PermissionDeniedError";
  }
}

Then in appendWorkspaceEvent:

   if (response.status === 403) {
-    throw new Error("PERMISSION_DENIED");
+    throw new PermissionDeniedError();
   }

And in onError:

-  if (err.message === "PERMISSION_DENIED") {
+  if (err instanceof PermissionDeniedError) {
     toast.error("You don't have permission to edit this workspace");
   }
src/components/workspace/InviteGuard.tsx (1)

21-64: Consider adding an abort controller to prevent state updates after unmount.

The claimInvite async function lacks cleanup handling. If the component unmounts while the fetch is in-flight (e.g., user navigates away), router.replace could be called on an unmounted component. Additionally, if session changes rapidly, multiple claim requests could fire.

♻️ Suggested fix with AbortController
     useEffect(() => {
+        const controller = new AbortController();
+        let isMounted = true;
+
         async function claimInvite() {
             if (!inviteToken || !session?.user || session.user.isAnonymous || isSessionLoading) return;

             try {
                 const res = await fetch('/api/invites/claim', {
                     method: 'POST',
                     headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify({ token: inviteToken })
+                    body: JSON.stringify({ token: inviteToken }),
+                    signal: controller.signal
                 });

+                if (!isMounted) return;
+
                 const data = await res.json();
                 // ... rest of the handler
             } catch (e) {
+                if (e instanceof DOMException && e.name === 'AbortError') return;
                 console.error(e);
                 // ...
             }
         }

         claimInvite();
+
+        return () => {
+            isMounted = false;
+            controller.abort();
+        };
     }, [inviteToken, session, isSessionLoading, router]);
src/components/workspace/ShareWorkspaceDialog.tsx (3)

40-47: Consider using a stricter type for permissionLevel.

PendingInvite.permissionLevel is typed as string, while Collaborator.permissionLevel uses "viewer" | "editor" | "owner". For consistency and type safety, consider using the same union type.

♻️ Suggested fix
 interface PendingInvite {
   id: string;
   email: string;
-  permissionLevel: string;
+  permissionLevel: "viewer" | "editor";
   createdAt: string;
   expiresAt: string;
   inviterId: string;
 }

308-309: Add error logging in the catch block.

The caught error e is not logged, making it harder to debug failures in production.

♻️ Suggested fix
     } catch (e) {
+      console.error("Failed to revoke invite:", e);
       toast.error("Failed to revoke invite");
     } finally {

112-123: Missing function dependencies in useEffect.

The useEffect calls loadCollaborators() and loadFrequentCollaborators() but these functions aren't included in the dependency array. While this works in practice (the functions are stable), it may trigger ESLint warnings. Consider wrapping them with useCallback or disabling the lint rule with an explanation.

src/app/auth/[path]/page.tsx (1)

53-73: Consider combining sequential queries with a JOIN.

Three sequential database queries add SSR latency. These could be combined into a single query with JOINs.

♻️ Suggested optimization
-      const [invite] = await db
-        .select({
-          workspaceId: workspaceInvites.workspaceId,
-          inviterId: workspaceInvites.inviterId,
-        })
-        .from(workspaceInvites)
-        .where(eq(workspaceInvites.token, inviteToken))
-        .limit(1);
-
-      if (invite) {
-        const [workspace] = await db
-          .select({ name: workspaces.name })
-          .from(workspaces)
-          .where(eq(workspaces.id, invite.workspaceId))
-          .limit(1);
-
-        const [inviter] = await db
-          .select({ name: user.name, email: user.email })
-          .from(user)
-          .where(eq(user.id, invite.inviterId))
-          .limit(1);
+      const [result] = await db
+        .select({
+          workspaceName: workspaces.name,
+          inviterName: user.name,
+          inviterEmail: user.email,
+        })
+        .from(workspaceInvites)
+        .innerJoin(workspaces, eq(workspaces.id, workspaceInvites.workspaceId))
+        .innerJoin(user, eq(user.id, workspaceInvites.inviterId))
+        .where(eq(workspaceInvites.token, inviteToken))
+        .limit(1);
+
+      if (result) {
+        const workspaceName = result.workspaceName || "a workspace";
+        const inviterName = result.inviterName || result.inviterEmail || "someone";

Comment on lines +59 to +60
.where(eq(workspaceInvites.token, inviteToken))
.limit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider filtering out expired invites.

The query doesn't check expiresAt, so expired invites will still display personalized invite messaging. This could confuse users who see "You've been invited!" but then fail to claim.

🛡️ Suggested fix
+import { eq, and, gt } from "drizzle-orm";
...
         .from(workspaceInvites)
-        .where(eq(workspaceInvites.token, inviteToken))
+        .where(and(
+          eq(workspaceInvites.token, inviteToken),
+          gt(workspaceInvites.expiresAt, new Date())
+        ))
         .limit(1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.where(eq(workspaceInvites.token, inviteToken))
.limit(1);
.where(and(
eq(workspaceInvites.token, inviteToken),
gt(workspaceInvites.expiresAt, new Date())
))
.limit(1);
🤖 Prompt for AI Agents
In `@src/app/auth/`[path]/page.tsx around lines 59 - 60, The query that fetches
invites using workspaceInvites.token and inviteToken should also filter out
expired invites by adding a condition on workspaceInvites.expiresAt (e.g.,
require expiresAt to be greater than the current time); update the query chain
that currently calls .where(eq(workspaceInvites.token, inviteToken)).limit(1) to
include an additional where/compare (using expiresAt and the appropriate
GT/greater-than comparator or timestamp check) so only non-expired invites are
returned.

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed 7655c58 in 17 seconds. Click for details.
  • Reviewed 54 lines of code in 2 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_xx8caBZJ0xvo8Eni

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/components/workspace/InviteGuard.tsx">

<violation number="1" location="src/components/workspace/InviteGuard.tsx:83">
P2: Triggering a route change (`router.replace`) directly in the component's render body is a side effect, which violates React's purity rules and can lead to errors (e.g., "Cannot update a component while rendering a different component").

**Recommended Fix:**
Move the redirection logic into a `useEffect` hook at the top level of the component:
```typescript
useEffect(() => {
    if (!isSessionLoading && inviteToken && (!session || session.user?.isAnonymous)) {
        const currentUrl = window.location.href;
        router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`);
    }
}, [isSessionLoading, inviteToken, session, router]);

Then, update the render logic to show a loading state instead of returning null:

if (!session || session.user?.isAnonymous) {
    return (
        <div className="flex h-screen w-full items-center justify-center bg-black/95">
            <Loader2 className="h-8 w-8 animate-spin text-white/50" />
        </div>
    );
}
```</violation>
</file>

<file name="src/app/auth/[path]/page.tsx">

<violation number="1" location="src/app/auth/[path]/page.tsx:59">
P2: The invite query should filter out expired invites to prevent showing "You've been invited!" messaging for invites that will fail when claimed. Add a check for `expiresAt > new Date()` to the where clause.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// Determine if we should send to sign-in or sign-up (default to sign-up for new invites)
// We pass the invite token so the auth page can show the custom header
// We pass redirect_url so they come back here to claim the invite after auth
router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`);

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P2: Triggering a route change (router.replace) directly in the component's render body is a side effect, which violates React's purity rules and can lead to errors (e.g., "Cannot update a component while rendering a different component").

Recommended Fix:
Move the redirection logic into a useEffect hook at the top level of the component:

useEffect(() => {
    if (!isSessionLoading && inviteToken && (!session || session.user?.isAnonymous)) {
        const currentUrl = window.location.href;
        router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`);
    }
}, [isSessionLoading, inviteToken, session, router]);

Then, update the render logic to show a loading state instead of returning null:

if (!session || session.user?.isAnonymous) {
    return (
        <div className="flex h-screen w-full items-center justify-center bg-black/95">
            <Loader2 className="h-8 w-8 animate-spin text-white/50" />
        </div>
    );
}
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/workspace/InviteGuard.tsx, line 83:

<comment>Triggering a route change (`router.replace`) directly in the component's render body is a side effect, which violates React's purity rules and can lead to errors (e.g., "Cannot update a component while rendering a different component").

**Recommended Fix:**
Move the redirection logic into a `useEffect` hook at the top level of the component:
```typescript
useEffect(() => {
    if (!isSessionLoading && inviteToken && (!session || session.user?.isAnonymous)) {
        const currentUrl = window.location.href;
        router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`);
    }
}, [isSessionLoading, inviteToken, session, router]);

Then, update the render logic to show a loading state instead of returning null:

if (!session || session.user?.isAnonymous) {
    return (
        <div className="flex h-screen w-full items-center justify-center bg-black/95">
            <Loader2 className="h-8 w-8 animate-spin text-white/50" />
        </div>
    );
}
```</comment>

<file context>
@@ -75,9 +74,14 @@ export function InviteGuard({ children }: InviteGuardProps) {
+            // Determine if we should send to sign-in or sign-up (default to sign-up for new invites)
+            // We pass the invite token so the auth page can show the custom header
+            // We pass redirect_url so they come back here to claim the invite after auth
+            router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`);
+            return null; // Don't render anything while redirecting
         }
</file context>
Fix with Cubic

inviterId: workspaceInvites.inviterId,
})
.from(workspaceInvites)
.where(eq(workspaceInvites.token, inviteToken))

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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.

P2: The invite query should filter out expired invites to prevent showing "You've been invited!" messaging for invites that will fail when claimed. Add a check for expiresAt > new Date() to the where clause.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/auth/[path]/page.tsx, line 59:

<comment>The invite query should filter out expired invites to prevent showing "You've been invited!" messaging for invites that will fail when claimed. Add a check for `expiresAt > new Date()` to the where clause.</comment>

<file context>
@@ -36,9 +39,49 @@ export default async function AuthPage({
+          inviterId: workspaceInvites.inviterId,
+        })
+        .from(workspaceInvites)
+        .where(eq(workspaceInvites.token, inviteToken))
+        .limit(1);
+
</file context>
Fix with Cubic

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed d23ad57 in 18 seconds. Click for details.
  • Reviewed 26 lines of code in 1 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_gb9uPlSdLMakIIvF

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed d8cedd2 in 18 seconds. Click for details.
  • Reviewed 122 lines of code in 1 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_l5HHLsDaKk1PD1Xw

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed 3161d49 in 14 seconds. Click for details.
  • Reviewed 588 lines of code in 1 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_Hpfu2yWT8kQ1wKsU

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@sonarqubecloud

sonarqubecloud Bot commented Feb 5, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
2 Security Hotspots
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@urjitc
urjitc merged commit e5c044b into main Feb 5, 2026
4 of 7 checks passed
@urjitc
urjitc deleted the fix/fix-sharing branch February 5, 2026 23:18
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Dev Board Feb 5, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant