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..f3be0568 --- /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 (!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.` + }, { 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..a6276e71 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, @@ -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 @@ -116,12 +129,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 @@ -130,7 +139,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 +151,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 +162,107 @@ 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 = () => { + // 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(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiry + + + // 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()) + ) + ); - // Send invitation email + // 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 +271,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/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/auth/[path]/page.tsx b/src/app/auth/[path]/page.tsx index 4cbfd11b..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,23 +20,68 @@ export default async function AuthPage({ searchParams }: { params: Promise<{ path: string }>; - searchParams: Promise<{ redirect_url?: string }>; + searchParams: Promise<{ redirect_url?: string; callbackUrl?: string; invite?: string }>; }) { const { path } = await params; - const { redirect_url } = await searchParams; + const { redirect_url, callbackUrl, invite: inviteToken } = 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"; - 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 (
@@ -48,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/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 7cb4e24e..9a34f18e 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -36,6 +36,10 @@ 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 { InviteGuard } from "@/components/workspace/InviteGuard"; + // Main dashboard content component interface DashboardContentProps { currentWorkspace: WorkspaceWithState | null; @@ -485,8 +489,18 @@ function DashboardContent({ // Main page component // Main page component +// Main page component (wrapper) export function DashboardPage() { - const router = useRouter(); + 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, diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 513c3fad..8311063d 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -58,7 +58,6 @@ export default function OnboardingPage() { // prioritize that over the onboarding redirect if ( redirectUrl && - !redirectUrl.startsWith("/workspace") && !redirectUrl.startsWith("/dashboard") && !redirectUrl.startsWith("/onboarding") && !redirectUrl.startsWith("/home") diff --git a/src/app/workspace/[slug]/page.tsx b/src/app/workspace/[slug]/page.tsx index aa0a9012..3797849e 100644 --- a/src/app/workspace/[slug]/page.tsx +++ b/src/app/workspace/[slug]/page.tsx @@ -6,11 +6,16 @@ * Client component to enable ssr: false for faster compilation. */ import { DashboardShell } from "../../dashboard/page"; +import { InviteGuard } from "@/components/workspace/InviteGuard"; interface WorkspacePageProps { params: { slug: string }; } export default function WorkspacePage({ params }: WorkspacePageProps) { - return ; + return ( + + + + ); } diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 3f0b3fda..418cd9e4 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,107 @@ function SecurityForm() { } }; + const handleDeleteAccount = async () => { + setIsDeleting(true); + try { + const res = await authClient.deleteUser(); + + if (res.error) { + throw new Error(res.error.message || "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/auth/AuthForms.tsx b/src/components/auth/AuthForms.tsx index 8d05c04f..3c5d5dcd 100644 --- a/src/components/auth/AuthForms.tsx +++ b/src/components/auth/AuthForms.tsx @@ -11,12 +11,26 @@ 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; + 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,16 +71,14 @@ 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"}

@@ -81,7 +93,7 @@ export function SignInForm({ className, redirectTo, ...props }: AuthFormProps) { setEmail(e.target.value)} @@ -97,6 +109,7 @@ export function SignInForm({ className, redirectTo, ...props }: AuthFormProps) { required value={password} onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••" />
@@ -178,23 +188,13 @@ export function SignUpForm({ className, redirectTo, ...props }: AuthFormProps) {
-
- - setName(e.target.value)} - /> -
+
setEmail(e.target.value)} @@ -208,6 +208,7 @@ export function SignUpForm({ className, redirectTo, ...props }: AuthFormProps) { required value={password} onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••" />

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

diff --git a/src/components/home/UserProfileDropdown.tsx b/src/components/home/UserProfileDropdown.tsx index 10f25826..7a57b6bd 100644 --- a/src/components/home/UserProfileDropdown.tsx +++ b/src/components/home/UserProfileDropdown.tsx @@ -16,13 +16,13 @@ import { } from "@/components/ui/dropdown-menu"; import { AccountModal } from "@/components/auth/AccountModal"; +import { toast } from "sonner"; export function UserProfileDropdown() { const { data: session } = useSession(); const router = useRouter(); const [showAccountModal, setShowAccountModal] = useState(false); - const userName = session?.user?.name || session?.user?.email || "User"; const userImage = session?.user?.image || undefined; 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-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 432c52e9..9d792dde 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"; @@ -391,6 +392,10 @@ export function WorkspaceSection({ }; + // Get search params for invite check + const searchParams = useSearchParams(); + const hasInviteParam = searchParams.get('invite'); + return (
+ ) : 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 new file mode 100644 index 00000000..7c0f1bf9 --- /dev/null +++ b/src/components/workspace/InviteGuard.tsx @@ -0,0 +1,114 @@ +"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 { AuthPageBackground } from "@/components/auth/AuthPageBackground"; + +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 + // 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 : ''; + router.replace(`/auth/sign-up?invite=${inviteToken}&redirect_url=${encodeURIComponent(currentUrl)}`); + } + }, [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. + // The useEffect above will handle the network request and redirect. + return ( +
+
+ + +
+
+ +
+

+ Joining workspace... +

+

+ This will only take a moment +

+
+
+ ); + } + + // 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..9439c163 100644 --- a/src/components/workspace/ShareWorkspaceDialog.tsx +++ b/src/components/workspace/ShareWorkspaceDialog.tsx @@ -9,9 +9,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -19,6 +16,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { toast } from "sonner"; @@ -37,6 +37,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; @@ -71,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); @@ -79,6 +89,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] : []); @@ -103,6 +117,7 @@ export default function ShareWorkspaceDialog({ loadCollaborators(); } if (open) { + setActiveTab("invite"); loadFrequentCollaborators(); } }, [workspace, open, isBulk]); @@ -116,6 +131,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 +291,52 @@ export default function ShareWorkspaceDialog({ } }; + const handleRevokeInvite = async (inviteId: string) => { + if (!workspace) return; + setIsRevoking(inviteId); + try { + const response = await fetch(`/api/workspaces/${workspace.id}/invites/${inviteId}`, { + method: 'DELETE' + }); + + if (response.ok) { + toast.success("Invite revoked"); + setInvites(prev => prev.filter(i => i.id !== inviteId)); + } else { + toast.error("Failed to revoke invite"); + } + } catch (e) { + toast.error("Failed to revoke invite"); + } finally { + setIsRevoking(null); + } + }; + 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 }), + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ permissionLevel: newPermission }) }); if (response.ok) { toast.success("Permission updated"); - loadCollaborators(); } else { - toast.error("Failed to update permission"); + 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(); } }; @@ -310,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."} - - - - - - - - Collaborate - - {!isBulk && ( - - - Share Copy + + + + + + Collaborate - )} - {!isBulk && showHistoryTab && ( - - - History - - )} - + {!isBulk && ( + + + Share Copy + + )} + {!isBulk && showHistoryTab && ( + + + History + + )} + + - +
+
+ {currentHeader.description} +
- {/* Frequent Collaborators Section */} - {frequentCollaborators.length > 0 && ( -
-
- - Frequent Collaborators -
- {isLoadingFrequent ? ( -
- -
- ) : ( -
- {frequentCollaborators.slice(0, 6).map((collab) => ( -
handleQuickAddCollaborator(collab)} - > -
- - - - {getInitials(collab.name, collab.email)} - - -
-

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

-
-
- -
- ))} + + + {/* Invite Form */} +
+ +
+ setInviteEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleInvite()} + className="flex-1" + disabled={!canInvite} + /> +
+
+ +
+ {!canInvite && ( +

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

)} -
- )} - {/* Invite Form */} -
- -
- setInviteEmail(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleInvite()} - className="flex-1" - disabled={!canInvite} - /> -
- {!canInvite && ( -

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

- )} -
+ {/* Collaborators List - Only show for single workspace */} + {!isBulk && ( +
- {/* Collaborators List - Only show for single workspace */} - {!isBulk && ( -
-
- - People with access ({collaborators.length}) -
+ {/* 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} -

- )} + {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 && ( - ) - )} -
+ )} +
+ ))}
- ))} +
+ )} +
+ )} + + {/* Frequent Collaborators Section */} + {frequentCollaborators.length > 0 && ( +
+
+ + Frequent Collaborators
+ {isLoadingFrequent ? ( +
+ +
+ ) : ( +
+ {frequentCollaborators.slice(0, 6).map((collab) => ( +
handleQuickAddCollaborator(collab)} + > +
+ + + + {getInitials(collab.name, collab.email)} + + +
+

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

+
+
+ +
+ ))} +
+ )} +
+ )} + + + + +
+ +
+ + +
+ {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} + /> +
+
+ )} +
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 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"], 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 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))))` }), +]);