-
Notifications
You must be signed in to change notification settings - Fork 0
Development #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Development #7
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { getCurrentUserId } from "@/lib/auth"; | ||
| import { prisma } from "@/lib/prisma"; | ||
|
|
||
| /** | ||
| * Remove a collaborator. Only the owner may remove. | ||
| */ | ||
| export async function DELETE( | ||
| request: Request, | ||
| { | ||
| params, | ||
| }: { | ||
| params: Promise<{ projectId: string; collaboratorId: string }>; | ||
| }, | ||
| ) { | ||
| const userId = await getCurrentUserId(); | ||
| if (!userId) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const { projectId, collaboratorId } = await params; | ||
|
|
||
| const project = await prisma.project.findUnique({ | ||
| where: { id: projectId }, | ||
| select: { id: true, ownerId: true }, | ||
| }); | ||
|
|
||
| if (!project) { | ||
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| // Only owner can remove collaborators | ||
| if (project.ownerId !== userId) { | ||
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | ||
| } | ||
|
|
||
| // Find the collaborator | ||
| const collaborator = await prisma.projectCollaborator.findUnique({ | ||
| where: { id: collaboratorId }, | ||
| select: { id: true, projectId: true }, | ||
| }); | ||
|
|
||
| if (!collaborator || collaborator.projectId !== projectId) { | ||
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| await prisma.projectCollaborator.delete({ | ||
| where: { id: collaboratorId }, | ||
| }); | ||
|
|
||
| return new NextResponse(null, { status: 204 }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,187 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import { getCurrentUserId, getClerkUsersByEmails } from "@/lib/auth"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { prisma } from "@/lib/prisma"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| interface InviteBody { | ||||||||||||||||||||||||||||||||||||||||||||||||
| email?: unknown; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| function readEmail(body: unknown): string | null { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (body === null || typeof body !== "object") return null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = (body as InviteBody).email; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof raw !== "string") return null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const trimmed = raw.trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| // Basic email validation | ||||||||||||||||||||||||||||||||||||||||||||||||
| const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | ||||||||||||||||||||||||||||||||||||||||||||||||
| return emailRegex.test(trimmed) ? trimmed : null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+10
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Duplicate-collaborator check is case-sensitive while stored emails are lowercased.
🐛 Proposed fix function readEmail(body: unknown): string | null {
if (body === null || typeof body !== "object") return null;
const raw = (body as InviteBody).email;
if (typeof raw !== "string") return null;
- const trimmed = raw.trim();
+ const trimmed = raw.trim().toLowerCase();
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(trimmed) ? trimmed : null;
}Also applies to: 134-149 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||
| * List collaborators for a project. Owner and collaborators can view. | ||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||
| export async function GET( | ||||||||||||||||||||||||||||||||||||||||||||||||
| request: Request, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { params }: { params: Promise<{ projectId: string }> }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const userId = await getCurrentUserId(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const { projectId } = await params; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const project = await prisma.project.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| where: { id: projectId }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| select: { id: true, ownerId: true }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!project) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Check access: owner or collaborator | ||||||||||||||||||||||||||||||||||||||||||||||||
| const isOwner = project.ownerId === userId; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Get user's email from Clerk | ||||||||||||||||||||||||||||||||||||||||||||||||
| const { clerkClient } = await import("@clerk/nextjs/server"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const clerk = await clerkClient(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const user = await clerk.users.getUser(userId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const primaryEmail = user.emailAddresses.find( | ||||||||||||||||||||||||||||||||||||||||||||||||
| (e) => e.id === user.primaryEmailAddressId | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const userEmail = primaryEmail?.emailAddress ?? ""; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const actualCollaborator = await prisma.projectCollaborator.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| projectId_email: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| projectId: project.id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| email: userEmail, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const hasAccess = isOwner || !!actualCollaborator; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!hasAccess) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const collaborators = await prisma.projectCollaborator.findMany({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| where: { projectId }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| orderBy: { createdAt: "asc" }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Enrich with Clerk user data | ||||||||||||||||||||||||||||||||||||||||||||||||
| const emails = collaborators.map((c) => c.email); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const clerkUsers = await getClerkUsersByEmails(emails); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const enrichedCollaborators = collaborators.map((c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const clerkUser = clerkUsers.get(c.email); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||
| ...c, | ||||||||||||||||||||||||||||||||||||||||||||||||
| createdAt: c.createdAt.toISOString(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: clerkUser?.name ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||
| avatarUrl: clerkUser?.avatarUrl ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ collaborators: enrichedCollaborators }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||
| * Invite a collaborator. Only the owner may invite. | ||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||
| export async function POST( | ||||||||||||||||||||||||||||||||||||||||||||||||
| request: Request, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { params }: { params: Promise<{ projectId: string }> }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const userId = await getCurrentUserId(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const { projectId } = await params; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const project = await prisma.project.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| where: { id: projectId }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| select: { id: true, ownerId: true }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!project) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Only owner can invite | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (project.ownerId !== userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| let body: unknown = null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||
| body = await request.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||
| body = null; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const email = readEmail(body); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!email) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||||||||
| { error: "Valid email is required" }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { status: 400 }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Check if already a collaborator | ||||||||||||||||||||||||||||||||||||||||||||||||
| const existing = await prisma.projectCollaborator.findUnique({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| projectId_email: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| projectId: project.id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (existing) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||||||||
| { error: "User is already a collaborator" }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { status: 409 }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Don't allow inviting the owner | ||||||||||||||||||||||||||||||||||||||||||||||||
| const { clerkClient } = await import("@clerk/nextjs/server"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const clerk = await clerkClient(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const owner = await clerk.users.getUser(project.ownerId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const ownerEmail = owner.emailAddresses.find( | ||||||||||||||||||||||||||||||||||||||||||||||||
| (e) => e.id === owner.primaryEmailAddressId | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (ownerEmail?.emailAddress.toLowerCase() === email.toLowerCase()) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||||||||
| { error: "Cannot invite the project owner" }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { status: 400 }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const collaborator = await prisma.projectCollaborator.create({ | ||||||||||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| projectId: project.id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| email: email.toLowerCase(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+165
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Concurrent invites for the same email can throw an unhandled Prisma unique-constraint error instead of a clean 409. The existing-collaborator check (134-149) and this 🛡️ Proposed fix- const collaborator = await prisma.projectCollaborator.create({
- data: {
- projectId: project.id,
- email: email.toLowerCase(),
- },
- });
+ let collaborator;
+ try {
+ collaborator = await prisma.projectCollaborator.create({
+ data: { projectId: project.id, email: email.toLowerCase() },
+ });
+ } catch (err) {
+ if (
+ err instanceof Prisma.PrismaClientKnownRequestError &&
+ err.code === "P2002"
+ ) {
+ return NextResponse.json(
+ { error: "User is already a collaborator" },
+ { status: 409 },
+ );
+ }
+ throw err;
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Try to get Clerk user data for the response | ||||||||||||||||||||||||||||||||||||||||||||||||
| const { getClerkUserByEmail } = await import("@/lib/auth"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const clerkUser = await getClerkUserByEmail(email.toLowerCase()); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||
| collaborator: { | ||||||||||||||||||||||||||||||||||||||||||||||||
| ...collaborator, | ||||||||||||||||||||||||||||||||||||||||||||||||
| createdAt: collaborator.createdAt.toISOString(), | ||||||||||||||||||||||||||||||||||||||||||||||||
| name: clerkUser?.name ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||
| avatarUrl: clerkUser?.avatarUrl ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| { status: 201 }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { redirect } from "next/navigation"; | ||
|
|
||
| import { getCurrentUser } from "@/lib/auth"; | ||
| import { getProjectWithAccess } from "@/lib/project-access"; | ||
| import { getAllProjects } from "@/lib/projects"; | ||
| import { slugify } from "@/lib/mock-projects"; | ||
| import { WorkspaceShell } from "@/components/editor/workspace-shell"; | ||
|
|
||
| interface EditorRoomPageProps { | ||
| params: Promise<{ roomId: string }>; | ||
| } | ||
|
|
||
| /** | ||
| * Editor workspace page for a specific project. Server component that checks | ||
| * access permissions and renders the workspace shell. | ||
| */ | ||
| export default async function EditorRoomPage({ params }: EditorRoomPageProps) { | ||
| const { roomId } = await params; | ||
|
|
||
| // Check authentication | ||
| const currentUser = await getCurrentUser(); | ||
| if (!currentUser) { | ||
| redirect("/sign-in"); | ||
| } | ||
|
|
||
| // Check project access | ||
| const project = await getProjectWithAccess(roomId); | ||
| if (!project) { | ||
| // Return the shell with AccessDenied - we'll render it client-side | ||
| return ( | ||
| <WorkspaceShell | ||
| project={null} | ||
| currentRoomId={roomId} | ||
| ownedProjects={[]} | ||
| sharedProjects={[]} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| // Fetch all projects for the sidebar (owned by userId, shared by email) | ||
| const { owned, shared } = await getAllProjects(currentUser.userId, currentUser.email); | ||
|
|
||
| // Transform to UI shape | ||
| const ownedProjects = owned.map((p) => ({ | ||
| id: p.id, | ||
| name: p.name, | ||
| slug: slugify(p.name), | ||
| role: "owner" as const, | ||
| })); | ||
|
|
||
| const sharedProjects = shared.map((p) => ({ | ||
| id: p.id, | ||
| name: p.name, | ||
| slug: slugify(p.name), | ||
| role: "collaborator" as const, | ||
| })); | ||
|
|
||
| return ( | ||
| <WorkspaceShell | ||
| project={{ id: project.id, name: project.name, ownerId: project.ownerId }} | ||
| currentRoomId={roomId} | ||
| ownedProjects={ownedProjects} | ||
| sharedProjects={sharedProjects} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| "use client"; | ||
|
|
||
| import { Lock } from "lucide-react"; | ||
| import Link from "next/link"; | ||
|
|
||
| /** | ||
| * AccessDenied component shown when a user doesn't have access to a project. | ||
| * Displays a centered layout with a lock icon, message, and link back to editor. | ||
| */ | ||
| export function AccessDenied() { | ||
| return ( | ||
| <div className="flex h-full w-full items-center justify-center px-6"> | ||
| <div className="flex max-w-md flex-col items-center gap-4 text-center"> | ||
| <div className="flex h-16 w-16 items-center justify-center rounded-full bg-bg-elevated"> | ||
| <Lock className="h-8 w-8 text-text-muted" /> | ||
| </div> | ||
| <h1 className="text-xl font-semibold tracking-tight text-text-primary"> | ||
| Access Denied | ||
| </h1> | ||
| <p className="text-sm text-text-muted"> | ||
| You don't have access to this project. It may not exist or you | ||
| may not be invited as a collaborator. | ||
| </p> | ||
| <Link | ||
| href="/editor" | ||
| className="mt-2 text-sm font-medium text-accent-primary hover:underline" | ||
| > | ||
| ← Back to Editor | ||
| </Link> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Non-atomic find-then-delete can throw on a concurrent duplicate request.
Between the existence check (39-42) and the
deletecall (48-50), another request removing the same collaborator (e.g., a double-click before the UI disables the button) can delete the row first; the seconddeletethen throws an uncaught Prisma "record not found" error instead of returning a clean 404.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents