From 22f828575d70a684f825ed879a1216d5a78883e4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:37:53 -0500 Subject: [PATCH 01/15] feat: fix sharing --- src/app/api/invites/[token]/route.ts | 67 +++++++++ src/app/api/invites/claim/route.ts | 88 +++++++++++ .../workspaces/[id]/collaborators/route.ts | 139 ++++++++++++----- src/app/dashboard/page.tsx | 46 ++++++ src/components/email/invite-email.tsx | 2 +- src/components/ui/card.tsx | 92 ++++++++++++ .../workspace/InviteLandingPage.tsx | 141 ++++++++++++++++++ src/lib/db/schema.ts | 28 ++++ 8 files changed, 565 insertions(+), 38 deletions(-) create mode 100644 src/app/api/invites/[token]/route.ts create mode 100644 src/app/api/invites/claim/route.ts create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/workspace/InviteLandingPage.tsx diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts new file mode 100644 index 00000000..dcd9a56e --- /dev/null +++ b/src/app/api/invites/[token]/route.ts @@ -0,0 +1,67 @@ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { workspaceInvites, workspaces, user } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { withErrorHandling } from "@/lib/api/workspace-helpers"; + +async function handleGET( + request: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + if (!token) { + return NextResponse.json({ error: "Token is required" }, { status: 400 }); + } + + // Find the invite + const [invite] = await db + .select({ + email: workspaceInvites.email, + expiresAt: workspaceInvites.expiresAt, + workspaceId: workspaceInvites.workspaceId, + inviterId: workspaceInvites.inviterId, + }) + .from(workspaceInvites) + .where(eq(workspaceInvites.token, token)) + .limit(1); + + if (!invite) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + // Check expiration + if (new Date(invite.expiresAt) < new Date()) { + return NextResponse.json({ error: "Invite expired" }, { status: 410 }); + } + + // Get workspace details + const [workspace] = await db + .select({ + name: workspaces.name, + }) + .from(workspaces) + .where(eq(workspaces.id, invite.workspaceId)) + .limit(1); + + // Get inviter details + const [inviter] = await db + .select({ + name: user.name, + email: user.email, + image: user.image + }) + .from(user) + .where(eq(user.id, invite.inviterId)) + .limit(1); + + return NextResponse.json({ + email: invite.email, + workspaceName: workspace?.name || "Workspace", + inviterName: inviter?.name || inviter?.email || "Someone", + inviterImage: inviter?.image + }); +} + +export const GET = withErrorHandling(handleGET, "GET /api/invites/[token]"); diff --git a/src/app/api/invites/claim/route.ts b/src/app/api/invites/claim/route.ts new file mode 100644 index 00000000..427af032 --- /dev/null +++ b/src/app/api/invites/claim/route.ts @@ -0,0 +1,88 @@ + +import { NextRequest, NextResponse } from "next/server"; +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"; +import { auth } from "@/lib/auth"; // Need to get full session to check email + +async function handlePOST(request: NextRequest) { + // We need the email from the session, simpler requireAuth only returns userId. + // So we use auth.api.getSession directly + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { token } = await request.json(); + + if (!token) { + return NextResponse.json({ error: "Token is required" }, { status: 400 }); + } + + // Find the invite + const [invite] = await db + .select() + .from(workspaceInvites) + .where(eq(workspaceInvites.token, token)) + .limit(1); + + if (!invite) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + // Check expiration + if (new Date(invite.expiresAt) < new Date()) { + return NextResponse.json({ error: "Invite expired" }, { status: 410 }); + } + + // Verify email matches current user + if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) { + return NextResponse.json({ + error: "Email mismatch", + message: `This invite is for ${invite.email}, but you are logged in as ${session.user.email}. Please log out and sign up/in with the invited email.` + }, { status: 403 }); + } + + // 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)); + + // Get workspace slug for redirection + const [workspace] = await db + .select({ slug: workspaces.slug, id: workspaces.id }) + .from(workspaces) + .where(eq(workspaces.id, invite.workspaceId)) + .limit(1); + + return NextResponse.json({ + success: true, + workspaceId: workspace?.id, + workspaceSlug: workspace?.slug + }); +} + +export const POST = withErrorHandling(handlePOST, "POST /api/invites/claim"); diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts index 05bcfe30..3395ecb2 100644 --- a/src/app/api/workspaces/[id]/collaborators/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -14,7 +14,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db/client"; -import { workspaceCollaborators, workspaces, user } from "@/lib/db/schema"; +import { workspaceCollaborators, workspaces, user, workspaceInvites } from "@/lib/db/schema"; import { eq, and } from "drizzle-orm"; import { verifyWorkspaceAccess, @@ -130,7 +130,7 @@ async function handlePOST( .where( and( eq(workspaceCollaborators.workspaceId, workspaceId), - eq(workspaceCollaborators.userId, invitedUser.id) + eq(workspaceCollaborators.userId, invitedUser?.id || "non-existent") // non-existent won't match ) ) .limit(1); @@ -142,15 +142,7 @@ async function handlePOST( ); } - // Can't invite yourself - if (invitedUser.id === currentUser.userId) { - return NextResponse.json( - { message: "You can't invite yourself" }, - { status: 400 } - ); - } - - // Get workspace to check if invitee is the owner and get slug for url + // Get workspace details for email and token generation const [ws] = await db .select({ userId: workspaces.userId, @@ -161,31 +153,100 @@ async function handlePOST( .where(eq(workspaces.id, workspaceId)) .limit(1); - if (ws && invitedUser.id === ws.userId) { - return NextResponse.json( - { message: "Cannot invite workspace owner as collaborator" }, - { status: 400 } - ); + // If user exists, add as collaborator directly + if (invitedUser) { + // Can't invite yourself + if (invitedUser.id === currentUser.userId) { + return NextResponse.json( + { message: "You can't invite yourself" }, + { status: 400 } + ); + } + + if (ws && invitedUser.id === ws.userId) { + return NextResponse.json( + { message: "Cannot invite workspace owner as collaborator" }, + { status: 400 } + ); + } + + // Add collaborator + const [newCollaborator] = await db + .insert(workspaceCollaborators) + .values({ + workspaceId, + userId: invitedUser.id, + permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", + }) + .returning(); + + // 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 ', + 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); + } + + return NextResponse.json({ collaborator: newCollaborator }, { status: 201 }); } - // Add collaborator - const [newCollaborator] = await db - .insert(workspaceCollaborators) - .values({ - workspaceId, - userId: invitedUser.id, - permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", - }) - .returning(); + // User does NOT exist - Create Pending Invite + + // Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only + // Simple secure random token generator + const generateToken = () => { + const array = new Uint8Array(24); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); + }; + + const token = generateToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiry + - // Send invitation email + // Implementation for Pending Invite + // 1. Delete existing invite to replace it + await db + .delete(workspaceInvites) + .where( + and( + eq(workspaceInvites.workspaceId, workspaceId), + eq(workspaceInvites.email, email.trim().toLowerCase()) + ) + ); + + // 2. Create new invite + await db.insert(workspaceInvites).values({ + workspaceId, + email: email.trim().toLowerCase(), + token, + inviterId: currentUser.userId, + permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor", + expiresAt: expiresAt.toISOString(), + }); + + // 3. Send Invite Email try { - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://thinkex.app'; - // Use slug if available, otherwise fallback to id (though slug should always be present for access) const identifier = ws.slug || workspaceId; - const workspaceUrl = `https://thinkex.app/workspace/${identifier}`; - const { data, error } = await resend.emails.send({ - from: 'ThinkEx ', // Update this with your verified domain if available + // Append ?invite=token to the URL + const workspaceUrl = `https://thinkex.app/workspace/${identifier}?invite=${token}`; + + await resend.emails.send({ + from: 'ThinkEx ', to: [email], subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`, react: InviteEmailTemplate({ @@ -194,15 +255,19 @@ async function handlePOST( workspaceUrl, }), }); - - if (error) { - console.error("Failed to send invitation email:", error); - } } catch (emailError) { - console.error("Error sending invitation email:", emailError); + console.error("Error sending pending invitation email:", emailError); + // We still return success as the invite was created } - return NextResponse.json({ collaborator: newCollaborator }, { status: 201 }); + return NextResponse.json( + { + message: "Invitation sent to new user", + pending: true, + email + }, + { status: 201 } + ); } export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/collaborators"); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 7cb4e24e..cadc3626 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -36,6 +36,9 @@ import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper"; import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; import { RealtimeProvider } from "@/contexts/RealtimeContext"; +import { toast } from "sonner"; +import { InviteLandingPage } from "@/components/workspace/InviteLandingPage"; + // Main dashboard content component interface DashboardContentProps { currentWorkspace: WorkspaceWithState | null; @@ -487,6 +490,44 @@ function DashboardContent({ // Main page component export function DashboardPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const inviteToken = searchParams.get('invite'); + const { data: session, isPending: isSessionLoading } = useSession(); + + // Handle invitation auto-claiming + 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]); + // Get workspace context - currentWorkspace is loaded directly by slug (fast path) const { currentSlug, @@ -536,6 +577,11 @@ export function DashboardPage() { clearPlayingYouTubeCards(); }, [currentWorkspaceId, clearPlayingYouTubeCards]); + // Show InviteLandingPage if we have a token and NO session (and not loading) + if (inviteToken && !isSessionLoading && !session) { + return ; + } + return (

or copy and paste this link into your browser:
- + {workspaceUrl}

diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 00000000..681ad980 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/workspace/InviteLandingPage.tsx b/src/components/workspace/InviteLandingPage.tsx new file mode 100644 index 00000000..e26918f1 --- /dev/null +++ b/src/components/workspace/InviteLandingPage.tsx @@ -0,0 +1,141 @@ + +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Loader2, ArrowRight, UserPlus, LogIn } from "lucide-react"; + +interface InviteLandingPageProps { + token: string; +} + +interface InviteDetails { + email: string; + workspaceName: string; + inviterName: string; + inviterImage?: string; +} + +export function InviteLandingPage({ token }: InviteLandingPageProps) { + const router = useRouter(); + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchInvite() { + if (!token) return; + + try { + const res = await fetch(`/api/invites/${token}`); + if (res.ok) { + const data = await res.json(); + setDetails(data); + } else { + const err = await res.json(); + setError(err.error || "Failed to load invite"); + } + } catch (e) { + setError("Something went wrong"); + } finally { + setLoading(false); + } + } + fetchInvite(); + }, [token]); + + const handleAction = (action: 'signin' | 'signup') => { + // Redirect to auth page with pre-filled email if available + // We pass the callback URL back to the current page (which has the invite param) + // So after auth, they return here and the "auto-claim" logic kicks in + const currentUrl = window.location.href; + const authPath = action === 'signin' ? '/auth/sign-in' : '/auth/sign-up'; + const emailParam = details?.email ? `&email=${encodeURIComponent(details.email)}` : ''; + + router.push(`${authPath}?callbackUrl=${encodeURIComponent(currentUrl)}${emailParam}`); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + + Invalid Invite + {error} + + + + + +
+ ); + } + + return ( +
+ {/* Abstract Background */} +
+
+ + + +
+ + + + {details?.inviterName?.slice(0, 2)} + + +
+ + + You've been invited! + + + {details?.inviterName} has invited you to join the {details?.workspaceName} workspace on ThinkEx. + +
+ + +
+
+ +
+
+

{details?.email}

+

Your invite is linked to this email.

+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0364cd6e..ee8f3f36 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -210,3 +210,31 @@ export const workspaceCollaborators = pgTable("workspace_collaborators", { WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))` }), pgPolicy("Collaborators can view their access", { as: "permissive", for: "select", to: ["authenticated"], using: sql`(user_id = (auth.jwt() ->> 'sub'::text))` }), ]); + +export const workspaceInvites = pgTable("workspace_invites", { + id: uuid().defaultRandom().primaryKey().notNull(), + workspaceId: uuid("workspace_id").notNull(), + email: text("email").notNull(), + token: text("token").notNull(), + inviterId: text("inviter_id").notNull(), + permissionLevel: text("permission_level").default('editor').notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow(), +}, (table) => [ + index("idx_workspace_invites_token").using("btree", table.token.asc().nullsLast().op("text_ops")), + index("idx_workspace_invites_email").using("btree", table.email.asc().nullsLast().op("text_ops")), + index("idx_workspace_invites_workspace").using("btree", table.workspaceId.asc().nullsLast().op("uuid_ops")), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_invites_workspace_id_fkey" + }).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 + WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))` }), +]); From 3edd8e4d86730136cc4059322b73969ca0c4c9be Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:43:57 -0500 Subject: [PATCH 02/15] fix: address code review feedback (null checks, retry loop, dead code) --- src/app/api/invites/claim/route.ts | 2 +- .../workspaces/[id]/collaborators/route.ts | 21 +++++++++++-------- src/app/dashboard/page.tsx | 16 +++++++++++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/api/invites/claim/route.ts b/src/app/api/invites/claim/route.ts index 427af032..f3be0568 100644 --- a/src/app/api/invites/claim/route.ts +++ b/src/app/api/invites/claim/route.ts @@ -40,7 +40,7 @@ async function handlePOST(request: NextRequest) { } // Verify email matches current user - if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) { + if (!session.user.email || invite.email.toLowerCase() !== session.user.email.toLowerCase()) { return NextResponse.json({ error: "Email mismatch", message: `This invite is for ${invite.email}, but you are logged in as ${session.user.email}. Please log out and sign up/in with the invited email.` diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts index 3395ecb2..313fca60 100644 --- a/src/app/api/workspaces/[id]/collaborators/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -116,12 +116,8 @@ async function handlePOST( .where(eq(user.email, email.trim().toLowerCase())) .limit(1); - if (!invitedUser) { - return NextResponse.json( - { message: "User not found. They need to sign up first." }, - { status: 404 } - ); - } + // If user not found, we will create a pending invite later in the code + // The previous 404 block here was preventing that flow // Check if already a collaborator const [existing] = await db @@ -208,9 +204,16 @@ async function handlePOST( // Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only // Simple secure random token generator const generateToken = () => { - 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') { + 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 token = generateToken(); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index cadc3626..885c3925 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -513,15 +513,29 @@ export function DashboardPage() { // Remove query param const newParams = new URLSearchParams(searchParams.toString()); newParams.delete('invite'); - router.replace(`/workspace/${data.workspaceSlug || ''}?${newParams.toString()}`); + + // Construct proper URL + const targetPath = `/workspace/${data.workspaceSlug || data.workspaceId}`; + const queryString = newParams.toString(); + const targetUrl = queryString ? `${targetPath}?${queryString}` : targetPath; + + router.replace(targetUrl); // Force reload to get permission updates window.location.reload(); } else { + // Error case: show error and remove param to prevent infinite loop toast.error(data.message || data.error || 'Failed to accept invitation'); + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete('invite'); + router.replace(`/workspace?${newParams.toString()}`); } } catch (e) { console.error(e); toast.error('Failed to accept invitation'); + // Error case: remove param to prevent infinite loop + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete('invite'); + router.replace(`/workspace?${newParams.toString()}`); } } From 104b1b8729a1ee2c0c77600ec5cfcbe210d645a1 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:52:20 -0500 Subject: [PATCH 03/15] fix: prevent anonymous users from auto-claiming invites --- src/app/dashboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 885c3925..862f5c57 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -497,7 +497,7 @@ export function DashboardPage() { // Handle invitation auto-claiming useEffect(() => { async function claimInvite() { - if (!inviteToken || !session?.user || isSessionLoading) return; + if (!inviteToken || !session?.user || session.user.isAnonymous || isSessionLoading) return; try { const res = await fetch('/api/invites/claim', { @@ -592,7 +592,7 @@ export function DashboardPage() { }, [currentWorkspaceId, clearPlayingYouTubeCards]); // Show InviteLandingPage if we have a token and NO session (and not loading) - if (inviteToken && !isSessionLoading && !session) { + if (inviteToken && !isSessionLoading && (!session || session.user?.isAnonymous)) { return ; } From 5322da9909376415a635408689a406801e97255c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:58:51 -0500 Subject: [PATCH 04/15] fix: persistence of invite flow through onboarding and signup --- src/app/auth/[path]/page.tsx | 17 +++++++++++------ .../workspace/InviteLandingPage.tsx | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/app/auth/[path]/page.tsx b/src/app/auth/[path]/page.tsx index 4cbfd11b..d8762d87 100644 --- a/src/app/auth/[path]/page.tsx +++ b/src/app/auth/[path]/page.tsx @@ -17,18 +17,23 @@ export default async function AuthPage({ searchParams }: { params: Promise<{ path: string }>; - searchParams: Promise<{ redirect_url?: string }>; + searchParams: Promise<{ redirect_url?: string; callbackUrl?: string }>; }) { const { path } = await params; - const { redirect_url } = await searchParams; + const { redirect_url, callbackUrl } = await searchParams; - let redirectTo = redirect_url; - 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 + 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"; } let title = "Welcome to ThinkEx"; diff --git a/src/components/workspace/InviteLandingPage.tsx b/src/components/workspace/InviteLandingPage.tsx index e26918f1..1f3d3e66 100644 --- a/src/components/workspace/InviteLandingPage.tsx +++ b/src/components/workspace/InviteLandingPage.tsx @@ -55,7 +55,8 @@ export function InviteLandingPage({ token }: InviteLandingPageProps) { const authPath = action === 'signin' ? '/auth/sign-in' : '/auth/sign-up'; const emailParam = details?.email ? `&email=${encodeURIComponent(details.email)}` : ''; - router.push(`${authPath}?callbackUrl=${encodeURIComponent(currentUrl)}${emailParam}`); + // Use redirect_url to match what AuthPage expects + router.push(`${authPath}?redirect_url=${encodeURIComponent(currentUrl)}${emailParam}`); }; if (loading) { @@ -127,13 +128,15 @@ export function InviteLandingPage({ token }: InviteLandingPageProps) { Create Account - +
+ Already have an account? + +
From ff95472a859b7ca759d1879bedefe495757817b0 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:01:50 -0500 Subject: [PATCH 05/15] feat: add delete account option --- src/app/api/user/delete/route.ts | 35 +++++++++++ src/components/home/UserProfileDropdown.tsx | 70 ++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/app/api/user/delete/route.ts diff --git a/src/app/api/user/delete/route.ts b/src/app/api/user/delete/route.ts new file mode 100644 index 00000000..f800082f --- /dev/null +++ b/src/app/api/user/delete/route.ts @@ -0,0 +1,35 @@ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { user } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; + +export async function DELETE(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + // Delete the user + // Drizzle schema should have cascade delete configured for related tables (sessions, accounts, workspaces etc) + // If not, we might need to manually delete related records, but typically cascade handles it. + // Checking schema.ts from earlier view_file, constraints like onDelete: "cascade" are present on session/account tables. + + await db.delete(user).where(eq(user.id, userId)); + + return NextResponse.json({ success: true, message: "Account deleted successfully" }); + } catch (error: any) { + console.error("Error deleting account:", error); + return NextResponse.json( + { error: "Failed to delete account", details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/components/home/UserProfileDropdown.tsx b/src/components/home/UserProfileDropdown.tsx index 10f25826..30f1db45 100644 --- a/src/components/home/UserProfileDropdown.tsx +++ b/src/components/home/UserProfileDropdown.tsx @@ -17,11 +17,24 @@ import { import { AccountModal } from "@/components/auth/AccountModal"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; + export function UserProfileDropdown() { const { data: session } = useSession(); const router = useRouter(); const [showAccountModal, setShowAccountModal] = useState(false); - + const [showDeleteAlert, setShowDeleteAlert] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const userName = session?.user?.name || session?.user?.email || "User"; const userImage = session?.user?.image || undefined; @@ -41,6 +54,28 @@ export function UserProfileDropdown() { router.push("/"); }, [router]); + const handleDeleteAccount = async () => { + setIsDeleting(true); + try { + const res = await fetch("/api/user/delete", { + method: "DELETE", + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete account"); + } + + toast.success("Account deleted successfully"); + await signOut(); + router.push("/"); + } catch (error: any) { + toast.error(error.message); + setIsDeleting(false); + setShowDeleteAlert(false); + } + }; + // Anonymous user: show sign in/up buttons if (session?.user?.isAnonymous) { return ( @@ -81,6 +116,14 @@ export function UserProfileDropdown() { Account + setShowDeleteAlert(true)} + className="cursor-pointer text-red-500 focus:text-red-500 focus:bg-red-500/10" + > + {/* Re-using icon, maybe Trash is better but keeping simple */} + Delete Account + + Sign out @@ -89,6 +132,31 @@ export function UserProfileDropdown() { + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + { + e.preventDefault(); + handleDeleteAccount(); + }} + className="bg-red-500 hover:bg-red-600 focus:ring-red-500 text-white" + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete Account"} + + + + ); } From d5bd81076fb548f201b10e065b3832361b1fdf25 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:06:44 -0500 Subject: [PATCH 06/15] refactor: moving delete account to settings modal --- src/components/auth/AccountModal.tsx | 150 +++++++++++++++----- src/components/home/UserProfileDropdown.tsx | 68 --------- 2 files changed, 114 insertions(+), 104 deletions(-) diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 3f0b3fda..ca728368 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -117,11 +117,27 @@ function ProfileForm({ user }: { user: any }) { ); } + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useRouter } from "next/navigation"; + function SecurityForm() { const [isLoading, setIsLoading] = useState(false); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [showDeleteAlert, setShowDeleteAlert] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const router = useRouter(); const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); @@ -148,48 +164,110 @@ function SecurityForm() { } }; + const handleDeleteAccount = async () => { + setIsDeleting(true); + try { + const res = await fetch("/api/user/delete", { + method: "DELETE", + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete account"); + } + + toast.success("Account deleted successfully"); + // Force hard reload/redirect to ensure session is cleared + window.location.href = "/"; + } catch (error: any) { + toast.error(error.message); + setIsDeleting(false); + setShowDeleteAlert(false); + } + }; + return ( -
-
- - setCurrentPassword(e.target.value)} - required - /> -
+
+ +
+ + setCurrentPassword(e.target.value)} + required + /> +
-
- - setNewPassword(e.target.value)} - required - /> -
+
+ + setNewPassword(e.target.value)} + required + /> +
-
- - setConfirmPassword(e.target.value)} - required - /> -
+
+ + setConfirmPassword(e.target.value)} + required + /> +
-
- +
+ + +
+

Danger Zone

+

+ Permanently delete your account and all of your content. This action cannot be undone. +

+
- + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account + and remove your data from our servers. + + + + Cancel + { + e.preventDefault(); + handleDeleteAccount(); + }} + className="bg-red-500 hover:bg-red-600 focus:ring-red-500 text-white" + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete Account"} + + + + +
); } diff --git a/src/components/home/UserProfileDropdown.tsx b/src/components/home/UserProfileDropdown.tsx index 30f1db45..7a57b6bd 100644 --- a/src/components/home/UserProfileDropdown.tsx +++ b/src/components/home/UserProfileDropdown.tsx @@ -16,25 +16,12 @@ import { } from "@/components/ui/dropdown-menu"; import { AccountModal } from "@/components/auth/AccountModal"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { toast } from "sonner"; export function UserProfileDropdown() { const { data: session } = useSession(); const router = useRouter(); const [showAccountModal, setShowAccountModal] = useState(false); - const [showDeleteAlert, setShowDeleteAlert] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); const userName = session?.user?.name || session?.user?.email || "User"; const userImage = session?.user?.image || undefined; @@ -54,28 +41,6 @@ export function UserProfileDropdown() { router.push("/"); }, [router]); - const handleDeleteAccount = async () => { - setIsDeleting(true); - try { - const res = await fetch("/api/user/delete", { - method: "DELETE", - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Failed to delete account"); - } - - toast.success("Account deleted successfully"); - await signOut(); - router.push("/"); - } catch (error: any) { - toast.error(error.message); - setIsDeleting(false); - setShowDeleteAlert(false); - } - }; - // Anonymous user: show sign in/up buttons if (session?.user?.isAnonymous) { return ( @@ -116,14 +81,6 @@ export function UserProfileDropdown() { Account
- setShowDeleteAlert(true)} - className="cursor-pointer text-red-500 focus:text-red-500 focus:bg-red-500/10" - > - {/* Re-using icon, maybe Trash is better but keeping simple */} - Delete Account - - Sign out @@ -132,31 +89,6 @@ export function UserProfileDropdown() { - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your account - and remove your data from our servers. - - - - Cancel - { - e.preventDefault(); - handleDeleteAccount(); - }} - className="bg-red-500 hover:bg-red-600 focus:ring-red-500 text-white" - disabled={isDeleting} - > - {isDeleting ? "Deleting..." : "Delete Account"} - - - - ); } From 66d8ba6adb682ede521238014dd2684aa8adec40 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:09:39 -0500 Subject: [PATCH 07/15] fix: use better-auth deleteUser with workspace cleanup --- src/components/auth/AccountModal.tsx | 9 +++------ src/lib/auth.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index ca728368..418cd9e4 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -167,13 +167,10 @@ function SecurityForm() { const handleDeleteAccount = async () => { setIsDeleting(true); try { - const res = await fetch("/api/user/delete", { - method: "DELETE", - }); + const res = await authClient.deleteUser(); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Failed to delete account"); + if (res.error) { + throw new Error(res.error.message || "Failed to delete account"); } toast.success("Account deleted successfully"); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index da217297..9830acab 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -63,6 +63,19 @@ export const auth = betterAuth({ maxAge: 5 * 60, // Cache duration in seconds (5 minutes) }, }, + 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); + } + }, + }, + }, // Advanced cookie security configuration advanced: { // Force secure cookies (HTTPS only) - critical for preventing session hijacking From 1d5cbd8fb991c11fc8b5cbfb4938affcd28292eb Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:13:01 -0500 Subject: [PATCH 08/15] fix: sharing --- src/app/api/user/delete/route.ts | 35 ------------- src/app/dashboard/page.tsx | 49 +++++++++---------- .../workspace-canvas/WorkspaceSection.tsx | 5 ++ src/contexts/WorkspaceContext.tsx | 7 ++- 4 files changed, 34 insertions(+), 62 deletions(-) delete mode 100644 src/app/api/user/delete/route.ts diff --git a/src/app/api/user/delete/route.ts b/src/app/api/user/delete/route.ts deleted file mode 100644 index f800082f..00000000 --- a/src/app/api/user/delete/route.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db/client"; -import { user } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; - -export async function DELETE(request: NextRequest) { - try { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session || !session.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userId = session.user.id; - - // Delete the user - // Drizzle schema should have cascade delete configured for related tables (sessions, accounts, workspaces etc) - // If not, we might need to manually delete related records, but typically cascade handles it. - // Checking schema.ts from earlier view_file, constraints like onDelete: "cascade" are present on session/account tables. - - await db.delete(user).where(eq(user.id, userId)); - - return NextResponse.json({ success: true, message: "Account deleted successfully" }); - } catch (error: any) { - console.error("Error deleting account:", error); - return NextResponse.json( - { error: "Failed to delete account", details: error.message }, - { status: 500 } - ); - } -} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 862f5c57..013c9b50 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -510,37 +510,35 @@ export function DashboardPage() { if (res.ok) { toast.success('Invitation accepted!'); - // Remove query param - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete('invite'); - - // Construct proper URL - const targetPath = `/workspace/${data.workspaceSlug || data.workspaceId}`; - const queryString = newParams.toString(); - const targetUrl = queryString ? `${targetPath}?${queryString}` : targetPath; - - router.replace(targetUrl); - // Force reload to get permission updates - window.location.reload(); + + // 1. Remove invite param using router to trigger re-render in WorkspaceContext + // This removes the 'pause' on the workspace query + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); } else { - // Error case: show error and remove param to prevent infinite loop - toast.error(data.message || data.error || 'Failed to accept invitation'); - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete('invite'); - router.replace(`/workspace?${newParams.toString()}`); + // Error case (e.g. 404 Not Found - likely because already claimed or double-fired) + // Don't toast error immediately for 404/410 to avoid confusing users if they actually ARE added + if (res.status !== 404 && res.status !== 410) { + toast.error(data.message || data.error || 'Failed to accept invitation'); + } + + // Just remove the param and stay on the page. + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); } } catch (e) { console.error(e); - toast.error('Failed to accept invitation'); - // Error case: remove param to prevent infinite loop - const newParams = new URLSearchParams(searchParams.toString()); - newParams.delete('invite'); - router.replace(`/workspace?${newParams.toString()}`); + // On network error etc, just clean URL and let it fail gracefully + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); } } claimInvite(); - }, [inviteToken, session, isSessionLoading, router, searchParams]); + }, [inviteToken, session, isSessionLoading]); // Get workspace context - currentWorkspace is loaded directly by slug (fast path) const { @@ -591,8 +589,9 @@ export function DashboardPage() { clearPlayingYouTubeCards(); }, [currentWorkspaceId, clearPlayingYouTubeCards]); - // Show InviteLandingPage if we have a token and NO session (and not loading) - if (inviteToken && !isSessionLoading && (!session || session.user?.isAnonymous)) { + const showInviteLanding = inviteToken && !isSessionLoading && (!session || session.user?.isAnonymous); + + if (showInviteLanding) { return ; } diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 432c52e9..e6364d65 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -1,4 +1,5 @@ import React, { RefObject, useState, useMemo, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import type { AgentState, Item, CardType, PdfData } from "@/lib/workspace-state/types"; @@ -432,6 +433,10 @@ export function WorkspaceSection({ !isLoadingWorkspace && !loadingWorkspaces && !currentWorkspaceId ? ( session?.user?.isAnonymous ? ( + ) : 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 + ) : ( ) diff --git a/src/contexts/WorkspaceContext.tsx b/src/contexts/WorkspaceContext.tsx index 99bf5219..e931906b 100644 --- a/src/contexts/WorkspaceContext.tsx +++ b/src/contexts/WorkspaceContext.tsx @@ -1,7 +1,7 @@ "use client"; import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react"; -import { useRouter, usePathname } from "next/navigation"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { WorkspaceWithState } from "@/lib/workspace-state/types"; @@ -83,6 +83,9 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { return workspacesData.find((w: WorkspaceWithState) => w.slug === currentSlug); }, [currentSlug, workspacesData]); + const searchParams = useSearchParams(); + const inviteToken = searchParams.get("invite"); + // Fetch current workspace by slug (fast path for direct workspace access) // This loads only the workspace metadata needed, not the entire list or state // State is loaded separately by useWorkspaceState hook @@ -99,7 +102,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const data = await response.json(); return data.workspace || null; }, - enabled: !!currentSlug, // Only fetch when we have a slug + enabled: !!currentSlug && !inviteToken, // Only fetch when we have a slug AND no pending invite (pause query while claiming) initialData: cachedWorkspace, // Use cached data from list if available initialDataUpdatedAt: cachedWorkspace ? Date.now() : undefined, // Treat as fresh staleTime: 5 * 60 * 1000, // 5 minutes From 1f7dbec55bc08028022122d0083f8ad094c419e6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:59:21 -0500 Subject: [PATCH 09/15] fix: sharing --- .../workspaces/[id]/collaborators/route.ts | 15 +- .../[id]/invites/[inviteId]/route.ts | 41 ++++ src/app/dashboard/page.tsx | 69 ++----- src/app/onboarding/page.tsx | 1 - src/app/workspace/[slug]/page.tsx | 7 +- src/components/workspace/InviteGuard.tsx | 94 +++++++++ .../workspace/ShareWorkspaceDialog.tsx | 191 +++++++++++------- 7 files changed, 288 insertions(+), 130 deletions(-) create mode 100644 src/app/api/workspaces/[id]/invites/[inviteId]/route.ts create mode 100644 src/components/workspace/InviteGuard.tsx diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts index 313fca60..a6276e71 100644 --- a/src/app/api/workspaces/[id]/collaborators/route.ts +++ b/src/app/api/workspaces/[id]/collaborators/route.ts @@ -69,6 +69,19 @@ async function handleGET( .leftJoin(user, eq(workspaceCollaborators.userId, user.id)) .where(eq(workspaceCollaborators.workspaceId, workspaceId)); + // 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)); + const ownerAsCollaborator = workspaceOwner ? { id: `owner-${workspaceOwner.userId}`, userId: workspaceOwner.userId, @@ -83,7 +96,7 @@ async function handleGET( ? [ownerAsCollaborator, ...collaborators] : collaborators; - return NextResponse.json({ collaborators: allCollaborators }); + return NextResponse.json({ collaborators: allCollaborators, invites }); } // POST /api/workspaces/[id]/collaborators diff --git a/src/app/api/workspaces/[id]/invites/[inviteId]/route.ts b/src/app/api/workspaces/[id]/invites/[inviteId]/route.ts new file mode 100644 index 00000000..fefe5f6a --- /dev/null +++ b/src/app/api/workspaces/[id]/invites/[inviteId]/route.ts @@ -0,0 +1,41 @@ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { workspaceInvites } from "@/lib/db/schema"; +import { eq, and } from "drizzle-orm"; +import { withErrorHandling, requireAuth, verifyWorkspaceAccess } from "@/lib/api/workspace-helpers"; + +// DELETE /api/workspaces/[id]/invites/[inviteId] +async function handleDELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; inviteId: string }> } +) { + const { id: workspaceId, inviteId } = await params; + const userId = await requireAuth(); + + // Verify access (only editors/owners can revoke) + await verifyWorkspaceAccess(workspaceId, userId, "editor"); + + // Check if invite exists and belongs to workspace + const [invite] = await db + .select() + .from(workspaceInvites) + .where(and( + eq(workspaceInvites.id, inviteId), + eq(workspaceInvites.workspaceId, workspaceId) + )) + .limit(1); + + if (!invite) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + // Delete the invite + await db + .delete(workspaceInvites) + .where(eq(workspaceInvites.id, inviteId)); + + return NextResponse.json({ success: true }); +} + +export const DELETE = withErrorHandling(handleDELETE, "DELETE /api/workspaces/[id]/invites/[inviteId]"); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 013c9b50..9a34f18e 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -37,7 +37,8 @@ import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModa import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; import { RealtimeProvider } from "@/contexts/RealtimeContext"; import { toast } from "sonner"; -import { InviteLandingPage } from "@/components/workspace/InviteLandingPage"; + +import { InviteGuard } from "@/components/workspace/InviteGuard"; // Main dashboard content component interface DashboardContentProps { @@ -488,58 +489,18 @@ function DashboardContent({ // Main page component // Main page component +// Main page component (wrapper) export function DashboardPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const inviteToken = searchParams.get('invite'); - const { data: session, isPending: isSessionLoading } = useSession(); - - // Handle invitation auto-claiming - useEffect(() => { - 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 }) - }); - - const data = await res.json(); - - if (res.ok) { - toast.success('Invitation accepted!'); - - // 1. Remove invite param using router to trigger re-render in WorkspaceContext - // This removes the 'pause' on the workspace query - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('invite'); - router.replace(newUrl.pathname + newUrl.search); - } else { - // Error case (e.g. 404 Not Found - likely because already claimed or double-fired) - // Don't toast error immediately for 404/410 to avoid confusing users if they actually ARE added - if (res.status !== 404 && res.status !== 410) { - toast.error(data.message || data.error || 'Failed to accept invitation'); - } - - // Just remove the param and stay on the page. - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('invite'); - router.replace(newUrl.pathname + newUrl.search); - } - } catch (e) { - console.error(e); - // On network error etc, just clean URL and let it fail gracefully - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('invite'); - router.replace(newUrl.pathname + newUrl.search); - } - } - - claimInvite(); - }, [inviteToken, session, isSessionLoading]); + return ( + + + + ); +} +// Inner component with all the dashboard hooks +// Only rendered when InviteGuard allows (authenticated + invite processed) +function DashboardView() { // Get workspace context - currentWorkspace is loaded directly by slug (fast path) const { currentSlug, @@ -589,12 +550,6 @@ export function DashboardPage() { clearPlayingYouTubeCards(); }, [currentWorkspaceId, clearPlayingYouTubeCards]); - const showInviteLanding = inviteToken && !isSessionLoading && (!session || session.user?.isAnonymous); - - if (showInviteLanding) { - return ; - } - return ( ; + return ( + + + + ); } diff --git a/src/components/workspace/InviteGuard.tsx b/src/components/workspace/InviteGuard.tsx new file mode 100644 index 00000000..ec34424e --- /dev/null +++ b/src/components/workspace/InviteGuard.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "@/lib/auth-client"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { InviteLandingPage } from "@/components/workspace/InviteLandingPage"; + +interface InviteGuardProps { + children: React.ReactNode; +} + +export function InviteGuard({ children }: InviteGuardProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const inviteToken = searchParams.get('invite'); + const { data: session, isPending: isSessionLoading } = useSession(); + + // Handle invitation auto-claiming + useEffect(() => { + 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 }) + }); + + const data = await res.json(); + + if (res.ok) { + toast.success('Invitation accepted!'); + + // 1. Remove invite param using router to trigger re-render in WorkspaceContext + // This removes the 'pause' on the workspace query + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); + } else { + // Error case (e.g. 404 Not Found - likely because already claimed or double-fired) + // Don't toast error immediately for 404/410 to avoid confusing users if they actually ARE added + if (res.status !== 404 && res.status !== 410) { + toast.error(data.message || data.error || 'Failed to accept invitation'); + } + + // Just remove the param and stay on the page. + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); + } + } catch (e) { + console.error(e); + // On network error etc, just clean URL and let it fail gracefully + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete('invite'); + router.replace(newUrl.pathname + newUrl.search); + } + } + + claimInvite(); + }, [inviteToken, session, isSessionLoading, router]); + + // 1. Loading Session: Show Loader + if (isSessionLoading) { + return ( +
+ +
+ ); + } + + // 2. Invite Token Present: Handle Invite Flow + if (inviteToken) { + // If not logged in (or anonymous), show landing page + if (!session || session.user?.isAnonymous) { + return ; + } + + // If logged in, we are "claiming" the invite. Show loading state. + // The useEffect above will handle the network request and redirect. + return ( +
+ +

Joining workspace...

+
+ ); + } + + // 3. No Invite Token: Render Dashboard (Safe) + return <>{children}; +} diff --git a/src/components/workspace/ShareWorkspaceDialog.tsx b/src/components/workspace/ShareWorkspaceDialog.tsx index 95285789..4481cf97 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -12,13 +12,6 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { toast } from "sonner"; @@ -37,6 +30,15 @@ interface Collaborator { createdAt: string; } +interface PendingInvite { + id: string; + email: string; + permissionLevel: string; + createdAt: string; + expiresAt: string; + inviterId: string; +} + interface FrequentCollaborator { userId: string; name?: string; @@ -79,6 +81,10 @@ export default function ShareWorkspaceDialog({ const [frequentCollaborators, setFrequentCollaborators] = useState([]); const [isLoadingFrequent, setIsLoadingFrequent] = useState(false); + // Pending Invites State + const [invites, setInvites] = useState([]); + const [isRevoking, setIsRevoking] = useState(null); + // Bulk mode check const isBulk = !!workspaceIds && workspaceIds.length > 1; const targetIds = isBulk ? workspaceIds! : (workspace ? [workspace.id] : []); @@ -116,6 +122,8 @@ export default function ShareWorkspaceDialog({ if (response.ok) { const data = await response.json(); setCollaborators(data.collaborators || []); + // Set pending invites + setInvites(data.invites || []); } } catch (error) { console.error("Failed to load collaborators:", error); @@ -274,25 +282,24 @@ export default function ShareWorkspaceDialog({ } }; - const handleUpdatePermission = async (collaboratorId: string, newPermission: "viewer" | "editor") => { + const handleRevokeInvite = async (inviteId: string) => { if (!workspace) return; - + setIsRevoking(inviteId); try { - const response = await fetch(`/api/workspaces/${workspace.id}/collaborators/${collaboratorId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ permissionLevel: newPermission }), + const response = await fetch(`/api/workspaces/${workspace.id}/invites/${inviteId}`, { + method: 'DELETE' }); if (response.ok) { - toast.success("Permission updated"); - loadCollaborators(); + toast.success("Invite revoked"); + setInvites(prev => prev.filter(i => i.id !== inviteId)); } else { - toast.error("Failed to update permission"); + toast.error("Failed to revoke invite"); } - } catch (error) { - console.error("Failed to update permission:", error); - toast.error("Failed to update permission"); + } catch (e) { + toast.error("Failed to revoke invite"); + } finally { + setIsRevoking(null); } }; @@ -429,65 +436,109 @@ export default function ShareWorkspaceDialog({ {/* Collaborators List - Only show for single workspace */} {!isBulk && ( -
-
- - People with access ({collaborators.length}) -
+
- {isLoadingCollaborators ? ( -
- + {/* Active Collaborators */} +
+
+ + People with access ({collaborators.length})
- ) : collaborators.length === 0 ? ( -

- No collaborators yet. Invite someone above! -

- ) : ( -
- {collaborators.map((collab) => ( -
-
- - - - {getInitials(collab.name, collab.email)} - - -
-

- {collab.name || collab.email || "Unknown"} -

- {collab.name && collab.email && ( -

- {collab.email} + + {isLoadingCollaborators ? ( +

+ +
+ ) : collaborators.length === 0 ? ( +

+ No collaborators yet. Invite someone above! +

+ ) : ( +
+ {collaborators.map((collab) => ( +
+
+ + + + {getInitials(collab.name, collab.email)} + + +
+

+ {collab.name || collab.email || "Unknown"}

+ {collab.name && collab.email && ( +

+ {collab.email} +

+ )} +
+
+
+ {collab.permissionLevel === "owner" ? ( + + Owner + + ) : ( + canManage && ( + + ) )}
-
- {collab.permissionLevel === "owner" ? ( - - Owner - - ) : ( - canManage && ( - - ) + ))} +
+ )} +
+ + {/* Pending Invites */} + {!isLoadingCollaborators && invites.length > 0 && ( +
+
+ + Pending Invites ({invites.length}) +
+
+ {invites.map((invite) => ( +
+
+
+ +
+
+

{invite.email}

+

Invited as {invite.permissionLevel}

+
+
+ {canManage && ( + )}
-
- ))} + ))} +
)}
From 6cd672c1b0573e6eeb0ab462c56abc9f40f70dad Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:18:52 -0500 Subject: [PATCH 10/15] fix: joining workspace background --- src/components/workspace/InviteGuard.tsx | 21 +++++++++++++++---- .../workspace/InviteLandingPage.tsx | 6 +++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/workspace/InviteGuard.tsx b/src/components/workspace/InviteGuard.tsx index ec34424e..821e5b22 100644 --- a/src/components/workspace/InviteGuard.tsx +++ b/src/components/workspace/InviteGuard.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useSession } from "@/lib/auth-client"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; +import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; import { InviteLandingPage } from "@/components/workspace/InviteLandingPage"; interface InviteGuardProps { @@ -82,10 +83,22 @@ export function InviteGuard({ children }: InviteGuardProps) { // If logged in, we are "claiming" the invite. Show loading state. // The useEffect above will handle the network request and redirect. return ( -
- -

Joining workspace...

-
+
+
+ + +
+
+ +
+

+ Joining workspace... +

+

+ This will only take a moment +

+
+
); } diff --git a/src/components/workspace/InviteLandingPage.tsx b/src/components/workspace/InviteLandingPage.tsx index 1f3d3e66..1c6b5d04 100644 --- a/src/components/workspace/InviteLandingPage.tsx +++ b/src/components/workspace/InviteLandingPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; +import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Loader2, ArrowRight, UserPlus, LogIn } from "lucide-react"; @@ -85,9 +86,8 @@ export function InviteLandingPage({ token }: InviteLandingPageProps) { return (
- {/* Abstract Background */} -
-
+ {/* Background with grid and cards - same as auth page */} + From 125373cc48441fcb811d3f2dd9dee85da983c049 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:42:19 -0500 Subject: [PATCH 11/15] fix: sharing --- src/app/auth/[path]/page.tsx | 57 ++++++- src/components/auth/AuthForms.tsx | 14 +- src/components/workspace/InviteGuard.tsx | 10 +- .../workspace/InviteLandingPage.tsx | 144 ------------------ .../workspace/ShareWorkspaceDialog.tsx | 122 ++++++++++++--- src/hooks/workspace/use-workspace-mutation.ts | 12 ++ 6 files changed, 180 insertions(+), 179 deletions(-) delete mode 100644 src/components/workspace/InviteLandingPage.tsx diff --git a/src/app/auth/[path]/page.tsx b/src/app/auth/[path]/page.tsx index d8762d87..c4980ece 100644 --- a/src/app/auth/[path]/page.tsx +++ b/src/app/auth/[path]/page.tsx @@ -1,6 +1,9 @@ import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; import { SignInForm, SignUpForm, ForgotPasswordForm } from "@/components/auth/AuthForms"; import Link from "next/link"; +import { db } from "@/lib/db/client"; +import { workspaceInvites, workspaces, user } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; export const dynamicParams = false; @@ -17,10 +20,10 @@ export default async function AuthPage({ searchParams }: { params: Promise<{ path: string }>; - searchParams: Promise<{ redirect_url?: string; callbackUrl?: string }>; + searchParams: Promise<{ redirect_url?: string; callbackUrl?: string; invite?: string }>; }) { const { path } = await params; - const { redirect_url, callbackUrl } = await searchParams; + const { redirect_url, callbackUrl, invite: inviteToken } = await searchParams; let redirectTo = redirect_url || callbackUrl; @@ -36,9 +39,49 @@ export default async function AuthPage({ redirectTo = "/home"; } - let title = "Welcome to ThinkEx"; - if (path === "sign-up") title = "Create an account"; - if (path === "forgot-password") title = "Reset Password"; + let title = path === "sign-in" ? "Welcome back" : + path === "sign-up" ? "Create an account" : + "Reset Password"; + + let description = path === "sign-in" ? "Enter your email below to login to your account" : + path === "sign-up" ? "Enter your information to get started" : + "Enter your email to reset your password"; + + // Handle Invite Context + if (inviteToken) { + try { + 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 workspaceName = workspace?.name || "a workspace"; + const inviterName = inviter?.name || inviter?.email || "someone"; + + title = `You've been invited!`; + description = `${inviterName} has invited you to join ${workspaceName}. ${path === "sign-in" ? "Login" : "Create an account"} to accept.`; + } + } catch (e) { + console.error("Failed to fetch invite details for auth page:", e); + } + } return (
@@ -53,8 +96,8 @@ export default async function AuthPage({
- {path === "sign-in" && } - {path === "sign-up" && } + {path === "sign-in" && } + {path === "sign-up" && } {path === "forgot-password" && }
diff --git a/src/components/auth/AuthForms.tsx b/src/components/auth/AuthForms.tsx index 8d05c04f..90cceb8a 100644 --- a/src/components/auth/AuthForms.tsx +++ b/src/components/auth/AuthForms.tsx @@ -14,9 +14,11 @@ import { toast } from "sonner"; interface AuthFormProps extends React.HTMLAttributes { redirectTo?: string; onSuccess?: () => void; + title?: string; + description?: string; } -export function SignInForm({ className, redirectTo, ...props }: AuthFormProps) { +export function SignInForm({ className, redirectTo, title, description, ...props }: AuthFormProps) { const [isLoading, setIsLoading] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -57,9 +59,9 @@ export function SignInForm({ className, redirectTo, ...props }: AuthFormProps) { return (
-

Welcome back

+

{title || "Welcome back"}

- Enter your email below to login to your account + {description || "Enter your email below to login to your account"}

@@ -116,7 +118,7 @@ export function SignInForm({ className, redirectTo, ...props }: AuthFormProps) { ); } -export function SignUpForm({ className, redirectTo, ...props }: AuthFormProps) { +export function SignUpForm({ className, redirectTo, title, description, ...props }: AuthFormProps) { const [isLoading, setIsLoading] = useState(false); const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -159,9 +161,9 @@ export function SignUpForm({ className, redirectTo, ...props }: AuthFormProps) { return (
-

Create an account

+

{title || "Create an account"}

- Enter your information to get started + {description || "Enter your information to get started"}

diff --git a/src/components/workspace/InviteGuard.tsx b/src/components/workspace/InviteGuard.tsx index 821e5b22..3116644d 100644 --- a/src/components/workspace/InviteGuard.tsx +++ b/src/components/workspace/InviteGuard.tsx @@ -6,7 +6,6 @@ import { useSession } from "@/lib/auth-client"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; -import { InviteLandingPage } from "@/components/workspace/InviteLandingPage"; interface InviteGuardProps { children: React.ReactNode; @@ -75,9 +74,14 @@ export function InviteGuard({ children }: InviteGuardProps) { // 2. Invite Token Present: Handle Invite Flow if (inviteToken) { - // If not logged in (or anonymous), show landing page + // If not logged in (or anonymous), redirect to auth with invite context if (!session || session.user?.isAnonymous) { - return ; + const currentUrl = typeof window !== 'undefined' ? window.location.href : ''; + // 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 } // If logged in, we are "claiming" the invite. Show loading state. diff --git a/src/components/workspace/InviteLandingPage.tsx b/src/components/workspace/InviteLandingPage.tsx deleted file mode 100644 index 1c6b5d04..00000000 --- a/src/components/workspace/InviteLandingPage.tsx +++ /dev/null @@ -1,144 +0,0 @@ - -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Loader2, ArrowRight, UserPlus, LogIn } from "lucide-react"; - -interface InviteLandingPageProps { - token: string; -} - -interface InviteDetails { - email: string; - workspaceName: string; - inviterName: string; - inviterImage?: string; -} - -export function InviteLandingPage({ token }: InviteLandingPageProps) { - const router = useRouter(); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - async function fetchInvite() { - if (!token) return; - - try { - const res = await fetch(`/api/invites/${token}`); - if (res.ok) { - const data = await res.json(); - setDetails(data); - } else { - const err = await res.json(); - setError(err.error || "Failed to load invite"); - } - } catch (e) { - setError("Something went wrong"); - } finally { - setLoading(false); - } - } - fetchInvite(); - }, [token]); - - const handleAction = (action: 'signin' | 'signup') => { - // Redirect to auth page with pre-filled email if available - // We pass the callback URL back to the current page (which has the invite param) - // So after auth, they return here and the "auto-claim" logic kicks in - const currentUrl = window.location.href; - const authPath = action === 'signin' ? '/auth/sign-in' : '/auth/sign-up'; - const emailParam = details?.email ? `&email=${encodeURIComponent(details.email)}` : ''; - - // Use redirect_url to match what AuthPage expects - router.push(`${authPath}?redirect_url=${encodeURIComponent(currentUrl)}${emailParam}`); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- - - Invalid Invite - {error} - - - - - -
- ); - } - - return ( -
- {/* Background with grid and cards - same as auth page */} - - - - -
- - - - {details?.inviterName?.slice(0, 2)} - - -
- - - You've been invited! - - - {details?.inviterName} has invited you to join the {details?.workspaceName} workspace on ThinkEx. - -
- - -
-
- -
-
-

{details?.email}

-

Your invite is linked to this email.

-
-
-
- - - -
- Already have an account? - -
-
-
-
- ); -} diff --git a/src/components/workspace/ShareWorkspaceDialog.tsx b/src/components/workspace/ShareWorkspaceDialog.tsx index 4481cf97..1335d934 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -9,6 +9,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -73,6 +80,7 @@ export default function ShareWorkspaceDialog({ const { data: session } = useSession(); const [copied, setCopied] = useState(false); const [shareUrl, setShareUrl] = useState(""); + const [activeTab, setActiveTab] = useState("invite"); const [inviteEmail, setInviteEmail] = useState(""); const [invitePermission, setInvitePermission] = useState<"viewer" | "editor">("editor"); const [isInviting, setIsInviting] = useState(false); @@ -109,6 +117,7 @@ export default function ShareWorkspaceDialog({ loadCollaborators(); } if (open) { + setActiveTab("invite"); loadFrequentCollaborators(); } }, [workspace, open, isBulk]); @@ -303,6 +312,34 @@ export default function ShareWorkspaceDialog({ } }; + const handleUpdatePermission = async (collaboratorId: string, newPermission: "viewer" | "editor") => { + if (!workspace) return; + + // Optimistic update + setCollaborators(prev => prev.map(c => + c.id === collaboratorId ? { ...c, permissionLevel: newPermission } : c + )); + + try { + const response = await fetch(`/api/workspaces/${workspace.id}/collaborators/${collaboratorId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ permissionLevel: newPermission }) + }); + + if (response.ok) { + toast.success("Permission updated"); + } else { + throw new Error("Failed to update"); + } + } catch (error) { + console.error("Failed to update permission:", error); + toast.error("Failed to update permission"); + // Revert on failure + loadCollaborators(); + } + }; + const getInitials = (name?: string, email?: string) => { if (name) { const parts = name.trim().split(/\s+/); @@ -317,6 +354,25 @@ export default function ShareWorkspaceDialog({ return "??"; }; + const headerContent = { + invite: { + title: "Share Workspace", + description: "Invite collaborators to work together in real-time." + }, + link: { + title: "Share Copy", + description: "Share a link for others to fork this workspace." + }, + history: { + title: "Version History", + description: "View and revert to previous versions." + } + }; + + const currentHeader = isBulk + ? { title: `Share ${workspaceIds?.length} Workspaces`, description: "Invite collaborators to all selected workspaces at once." } + : headerContent[activeTab as keyof typeof headerContent] || headerContent.invite; + return ( - {isBulk ? `Share ${workspaceIds?.length} Workspaces` : "Share Workspace"} - - {isBulk - ? "Invite collaborators to all selected workspaces at once." - : "Invite collaborators to work together in real-time or share a link for others to fork."} - + {currentHeader.title} + {currentHeader.description} - + @@ -410,7 +462,7 @@ export default function ShareWorkspaceDialog({ {/* Invite Form */}
- +
{isInviting ? : "Invite"} +
+ +
{!canInvite && (

@@ -480,20 +547,37 @@ export default function ShareWorkspaceDialog({

{collab.permissionLevel === "owner" ? ( - + Owner ) : ( - canManage && ( - - ) + <> +
+ +
+ {canManage && ( + + )} + )}
diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts index 74fbc134..5b414d49 100644 --- a/src/hooks/workspace/use-workspace-mutation.ts +++ b/src/hooks/workspace/use-workspace-mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; import { logger } from "@/lib/utils/logger"; import { useRef } from "react"; +import { toast } from "sonner"; /** * Maximum number of automatic retries for version conflicts @@ -40,6 +41,9 @@ async function appendWorkspaceEvent( }); if (!response.ok) { + if (response.status === 403) { + throw new Error("PERMISSION_DENIED"); + } const errorText = await response.text(); logger.error("❌ [API] Response error:", response.status, errorText); throw new Error(`Failed to append event: ${response.statusText} - ${errorText}`); @@ -176,6 +180,14 @@ export function useWorkspaceMutation(workspaceId: string | null, options: Worksp 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"); + } + // Rollback to previous state queryClient.setQueryData( ["workspace", workspaceId, "events"], From 7655c58dd0b4096f71891c343a1c02279e06c3a6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:47:57 -0500 Subject: [PATCH 12/15] fixes --- .../workspace-canvas/WorkspaceSection.tsx | 6 +++++- src/components/workspace/InviteGuard.tsx | 17 ++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index e6364d65..9d792dde 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -392,6 +392,10 @@ export function WorkspaceSection({ }; + // Get search params for invite check + const searchParams = useSearchParams(); + const hasInviteParam = searchParams.get('invite'); + return (
- ) : useSearchParams().get('invite') ? ( + ) : hasInviteParam ? ( // 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 diff --git a/src/components/workspace/InviteGuard.tsx b/src/components/workspace/InviteGuard.tsx index 3116644d..7c0f1bf9 100644 --- a/src/components/workspace/InviteGuard.tsx +++ b/src/components/workspace/InviteGuard.tsx @@ -73,15 +73,18 @@ export function InviteGuard({ children }: InviteGuardProps) { } // 2. Invite Token Present: Handle Invite Flow - if (inviteToken) { - // If not logged in (or anonymous), redirect to auth with invite context - if (!session || session.user?.isAnonymous) { + // Handle redirect for unauthenticated users in an effect to avoid "Cannot update a component while rendering" + useEffect(() => { + if (inviteToken && (!session || session.user?.isAnonymous) && !isSessionLoading) { const currentUrl = typeof window !== 'undefined' ? window.location.href : ''; - // 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 + } + }, [inviteToken, session, isSessionLoading, router]); + + if (inviteToken) { + // If not logged in (or anonymous), show loader (or null) while redirecting + if (!session || session.user?.isAnonymous) { + return null; } // If logged in, we are "claiming" the invite. Show loading state. From d23ad578708b5460a1ce5ad184eaee1b4d1c1fbd Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:49:28 -0500 Subject: [PATCH 13/15] Update ShareWorkspaceDialog.tsx --- src/components/workspace/ShareWorkspaceDialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/workspace/ShareWorkspaceDialog.tsx b/src/components/workspace/ShareWorkspaceDialog.tsx index 1335d934..6b7774ef 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -474,10 +474,7 @@ export default function ShareWorkspaceDialog({ className="flex-1" disabled={!canInvite} /> - -
+
+
{!canInvite && (

From d8cedd2263f4a098fff5606d57ec958ab7bfa29f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:54:24 -0500 Subject: [PATCH 14/15] Update AuthForms.tsx --- src/components/auth/AuthForms.tsx | 43 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/components/auth/AuthForms.tsx b/src/components/auth/AuthForms.tsx index 90cceb8a..3c5d5dcd 100644 --- a/src/components/auth/AuthForms.tsx +++ b/src/components/auth/AuthForms.tsx @@ -11,6 +11,18 @@ import { Loader2, ArrowLeft } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; +function GoogleIcon(props: React.SVGProps) { + return ( + + + + ); +} + interface AuthFormProps extends React.HTMLAttributes { redirectTo?: string; onSuccess?: () => void; @@ -66,9 +78,7 @@ export function SignInForm({ className, redirectTo, title, description, ...props

@@ -83,7 +93,7 @@ export function SignInForm({ className, redirectTo, title, description, ...props setEmail(e.target.value)} @@ -99,6 +109,7 @@ export function SignInForm({ className, redirectTo, title, description, ...props required value={password} onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••" />
@@ -180,23 +188,13 @@ export function SignUpForm({ className, redirectTo, title, description, ...props
-
- - setName(e.target.value)} - /> -
+
setEmail(e.target.value)} @@ -210,6 +208,7 @@ export function SignUpForm({ className, redirectTo, title, description, ...props required value={password} onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••" />
+
+ {!canInvite && ( +

+ You must be an editor or owner to invite others. +

+ )} + +
+ + {/* Collaborators List - Only show for single workspace */} + {!isBulk && ( +
+ + {/* Active Collaborators */} +
+
+ + People with access ({collaborators.length}) +
+ + {isLoadingCollaborators ? ( +
+ +
+ ) : collaborators.length === 0 ? ( +

+ No collaborators yet. Invite someone above! +

+ ) : ( +
+ {collaborators.map((collab) => ( +
+
+ + + + {getInitials(collab.name, collab.email)} + + +
+

+ {collab.name || collab.email || "Unknown"} +

+ {collab.name && collab.email && ( +

+ {collab.email} +

+ )} +
+
+
+ {collab.permissionLevel === "owner" ? ( + + Owner + + ) : ( + <> +
+ +
+ {canManage && ( + + )} + + )} +
-
- + ))}
- ))} + )}
- )} -
- )} - {/* Invite Form */} -
- -
- setInviteEmail(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleInvite()} - className="flex-1" - disabled={!canInvite} - /> -
- + {/* Pending Invites */} + {!isLoadingCollaborators && invites.length > 0 && ( +
+
+ + Pending Invites ({invites.length}) +
+
+ {invites.map((invite) => ( +
+
+
+ +
+
+

{invite.email}

+

Invited as {invite.permissionLevel}

+
+
+ {canManage && ( + + )} +
+ ))} +
+
+ )}
- -
- {!canInvite && ( -

- You must be an editor or owner to invite others. -

)} -
- - {/* Collaborators List - Only show for single workspace */} - {!isBulk && ( -
- - {/* Active Collaborators */} + {/* Frequent Collaborators Section */} + {frequentCollaborators.length > 0 && (
- - People with access ({collaborators.length}) + + Frequent Collaborators
- - {isLoadingCollaborators ? ( -
- + {isLoadingFrequent ? ( +
+
- ) : collaborators.length === 0 ? ( -

- No collaborators yet. Invite someone above! -

) : ( -
- {collaborators.map((collab) => ( +
+ {frequentCollaborators.slice(0, 6).map((collab) => (
handleQuickAddCollaborator(collab)} > -
- +
+ - + {getInitials(collab.name, collab.email)}
-

- {collab.name || collab.email || "Unknown"} +

+ {collab.name || collab.email}

- {collab.name && collab.email && ( -

- {collab.email} -

- )}
-
- {collab.permissionLevel === "owner" ? ( - - Owner - - ) : ( - <> -
- -
- {canManage && ( - - )} - - )} -
+
))}
)}
+ )} + - {/* Pending Invites */} - {!isLoadingCollaborators && invites.length > 0 && ( -
-
- - Pending Invites ({invites.length}) -
-
- {invites.map((invite) => ( -
-
-
- -
-
-

{invite.email}

-

Invited as {invite.permissionLevel}

-
-
- {canManage && ( - - )} -
- ))} -
-
+ + +
+ +
+ + +
+ {copied && ( +

Copied to clipboard!

)}
- )} -
- - - -
- -
- + +
- {copied && ( -

Copied to clipboard!

- )} -
- -
- -
- - - {/* History Tab - only shown on workspace routes */} - {!isBulk && showHistoryTab && ( - - - - {/* Embed the version history content inline */} -
- { })} - items={workspace?.state?.items || []} - workspaceId={workspace?.id || null} - isOpen={open} - /> -
- )} + + {/* History Tab - only shown on workspace routes */} + {!isBulk && showHistoryTab && ( + + + + {/* Embed the version history content inline */} +
+ { })} + items={workspace?.state?.items || []} + workspaceId={workspace?.id || null} + isOpen={open} + /> +
+
+ )} +