From be86add1b0de8d2436913d5dcd0d75113043829b Mon Sep 17 00:00:00 2001 From: tejasM17 Date: Wed, 15 Jul 2026 13:06:39 +0530 Subject: [PATCH 1/2] implemented project creation, access, permission, redirection. --- app/editor/[roomId]/page.tsx | 66 +++ components/editor/access-denied.tsx | 33 ++ components/editor/editor-shell.tsx | 3 + components/editor/project-sidebar.tsx | 37 +- components/editor/workspace-shell.tsx | 413 ++++++++++++++++++ .../08-editor-workspace-shell.md | 50 +++ context/progress-tracker.md | 6 +- lib/project-access.ts | 84 ++++ 8 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 app/editor/[roomId]/page.tsx create mode 100644 components/editor/access-denied.tsx create mode 100644 components/editor/workspace-shell.tsx create mode 100644 context/feature-specs/08-editor-workspace-shell.md create mode 100644 lib/project-access.ts diff --git a/app/editor/[roomId]/page.tsx b/app/editor/[roomId]/page.tsx new file mode 100644 index 0000000..d630c0f --- /dev/null +++ b/app/editor/[roomId]/page.tsx @@ -0,0 +1,66 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUserId } 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 userId = await getCurrentUserId(); + if (!userId) { + 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 ( + + ); + } + + // Fetch all projects for the sidebar + const { owned, shared } = await getAllProjects(userId); + + // 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 ( + + ); +} \ No newline at end of file diff --git a/components/editor/access-denied.tsx b/components/editor/access-denied.tsx new file mode 100644 index 0000000..726d0fb --- /dev/null +++ b/components/editor/access-denied.tsx @@ -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 ( +
+
+
+ +
+

+ Access Denied +

+

+ You don't have access to this project. It may not exist or you + may not be invited as a collaborator. +

+ + ← Back to Editor + +
+
+ ); +} \ No newline at end of file diff --git a/components/editor/editor-shell.tsx b/components/editor/editor-shell.tsx index 262f9bc..4329d97 100644 --- a/components/editor/editor-shell.tsx +++ b/components/editor/editor-shell.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { EditorHome } from "@/components/editor/editor-home"; import { EditorNavbar } from "@/components/editor/editor-navbar"; @@ -16,6 +17,7 @@ interface EditorShellProps { } export function EditorShell({ initialOwned, initialShared }: EditorShellProps) { + const router = useRouter(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const { ownedProjects, @@ -55,6 +57,7 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) { onCreateProject={openCreate} onRenameProject={openRename} onDeleteProject={openDelete} + onOpenProject={(projectId) => router.push(`/editor/${projectId}`)} /> diff --git a/components/editor/project-sidebar.tsx b/components/editor/project-sidebar.tsx index e41ea6e..4772b97 100644 --- a/components/editor/project-sidebar.tsx +++ b/components/editor/project-sidebar.tsx @@ -17,6 +17,7 @@ interface ProjectSidebarProps { onCreateProject: () => void; onRenameProject: (projectId: string) => void; onDeleteProject: (projectId: string) => void; + onOpenProject?: (projectId: string) => void; } export function ProjectSidebar({ @@ -27,6 +28,7 @@ export function ProjectSidebar({ onCreateProject, onRenameProject, onDeleteProject, + onOpenProject, }: ProjectSidebarProps) { return ( <> @@ -92,6 +94,7 @@ export function ProjectSidebar({ projects={ownedProjects} onRename={onRenameProject} onDelete={onDeleteProject} + onOpenProject={onOpenProject} /> )} @@ -106,6 +109,7 @@ export function ProjectSidebar({ projects={sharedProjects} onRename={onRenameProject} onDelete={onDeleteProject} + onOpenProject={onOpenProject} /> )} @@ -135,9 +139,15 @@ interface ProjectListProps { projects: ProjectData[]; onRename: (projectId: string) => void; onDelete: (projectId: string) => void; + onOpenProject?: (projectId: string) => void; } -function ProjectList({ projects, onRename, onDelete }: ProjectListProps) { +function ProjectList({ + projects, + onRename, + onDelete, + onOpenProject, +}: ProjectListProps) { return (
    {projects.map((project) => ( @@ -146,6 +156,7 @@ function ProjectList({ projects, onRename, onDelete }: ProjectListProps) { project={project} onRename={onRename} onDelete={onDelete} + onOpenProject={onOpenProject} /> ))} @@ -157,13 +168,25 @@ interface ProjectRowProps { project: ProjectData; onRename: (projectId: string) => void; onDelete: (projectId: string) => void; + onOpenProject?: (projectId: string) => void; } -function ProjectRow({ project, onRename, onDelete }: ProjectRowProps) { +function ProjectRow({ + project, + onRename, + onDelete, + onOpenProject, +}: ProjectRowProps) { const isOwned = project.role === "owner"; const [menuOpen, setMenuOpen] = React.useState(false); const menuRef = React.useRef(null); + function handleClick() { + if (onOpenProject) { + onOpenProject(project.id); + } + } + React.useEffect(() => { if (!menuOpen) return; function handlePointerDown(event: MouseEvent) { @@ -185,8 +208,16 @@ function ProjectRow({ project, onRename, onDelete }: ProjectRowProps) { return (
    { + if (e.key === "Enter" || e.key === " ") { + handleClick(); + } + }} className={cn( - "group flex items-center gap-2 rounded-xl border border-transparent px-3 py-2 transition-colors", + "group flex cursor-pointer items-center gap-2 rounded-xl border border-transparent px-3 py-2 transition-colors", "hover:border-border-subtle hover:bg-bg-elevated", )} > diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx new file mode 100644 index 0000000..65b00a4 --- /dev/null +++ b/components/editor/workspace-shell.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { UserButton } from "@clerk/nextjs"; +import { PanelLeftClose, PanelLeftOpen, Share2, MessageSquare, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import type { ProjectData } from "@/hooks/use-project-actions"; +import { AccessDenied } from "@/components/editor/access-denied"; + +interface WorkspaceShellProps { + project: { id: string; name: string } | null; + currentRoomId: string; + ownedProjects: ProjectData[]; + sharedProjects: ProjectData[]; +} + +/** + * Workspace shell for the editor. Contains the full-viewport layout with + * navbar, left sidebar, canvas area, and right AI chat sidebar placeholder. + */ +export function WorkspaceShell({ + project, + currentRoomId, + ownedProjects, + sharedProjects, +}: WorkspaceShellProps) { + const router = useRouter(); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(false); + + const handleOpenProject = (projectId: string) => { + router.push(`/editor/${projectId}`); + }; + + // If no project access, show access denied + if (!project) { + return ( +
    + setIsSidebarOpen((open) => !open)} + onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)} + aiSidebarOpen={isAiSidebarOpen} + /> +
    + setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + currentRoomId={currentRoomId} + /> + + setIsAiSidebarOpen(false)} + /> +
    +
    + ); + } + + return ( +
    + setIsSidebarOpen((open) => !open)} + onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)} + aiSidebarOpen={isAiSidebarOpen} + /> +
    + setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + currentRoomId={currentRoomId} + /> + + setIsAiSidebarOpen(false)} + /> +
    +
    + ); +} + +interface EditorNavbarProps { + projectName: string; + isSidebarOpen: boolean; + aiSidebarOpen: boolean; + onToggleSidebar: () => void; + onToggleAiSidebar: () => void; +} + +function EditorNavbar({ + projectName, + isSidebarOpen, + aiSidebarOpen, + onToggleSidebar, + onToggleAiSidebar, +}: EditorNavbarProps) { + return ( +
    +
    + + {projectName ? ( + + {projectName} + + ) : ( + Editor + )} +
    +
    + + +
    +
    + +
    +
    + ); +} + +interface ProjectSidebarProps { + isOpen: boolean; + onClose: () => void; + ownedProjects: ProjectData[]; + sharedProjects: ProjectData[]; + currentRoomId: string; + onOpenProject?: (projectId: string) => void; +} + +function ProjectSidebar({ + isOpen, + onClose, + ownedProjects, + sharedProjects, + currentRoomId, + onOpenProject, +}: ProjectSidebarProps) { + return ( + <> + {/* Mobile backdrop */} + +
    + + +
    + + + My Projects + + + Shared + + +
    + + + + + + + + + +
    + +
    + +
    + + + ); +} + +interface ProjectListProps { + projects: ProjectData[]; + currentRoomId: string; + onOpenProject?: (projectId: string) => void; +} + +function ProjectList({ + projects, + currentRoomId, + onOpenProject, +}: ProjectListProps) { + if (projects.length === 0) { + return ( +
    + No projects +
    + ); + } + + return ( +
      + {projects.map((project) => ( +
    • + +
    • + ))} +
    + ); +} + +interface ProjectRowProps { + project: ProjectData; + isActive: boolean; +} + +function ProjectRow({ project, isActive }: ProjectRowProps) { + return ( + +
    + + {project.name} + + + /{project.slug} + +
    +
    + ); +} + +/** + * Canvas placeholder - dark background with centered message. + * Real canvas logic will be added later. + */ +function CanvasPlaceholder() { + return ( +
    +
    +
    + Canvas placeholder — canvas logic coming soon +
    +
    +
    + ); +} + +interface AiSidebarProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * Right sidebar placeholder for future AI chat. + */ +function AiSidebar({ isOpen, onClose }: AiSidebarProps) { + return ( + <> + {/* Mobile backdrop for AI sidebar */} + + +
    +

    AI chat coming soon

    +
    + + + ); +} \ No newline at end of file diff --git a/context/feature-specs/08-editor-workspace-shell.md b/context/feature-specs/08-editor-workspace-shell.md new file mode 100644 index 0000000..2d5b648 --- /dev/null +++ b/context/feature-specs/08-editor-workspace-shell.md @@ -0,0 +1,50 @@ +Build the `/editor/[roomId]` workspace shell with server-side access checks. No canvas logic yet. + +## Access + +`/editor/[roomId]` must be a server component. + +Before rendering: + +- unauthenticated users redirect to `/sign-in` +- users without project access see `AccessDenied` +- non-existent projects also show `AccessDenied` + +Create `components/editor/access-denied.tsx` with: + +- centered layout +- lock icon +- short message +- link back to `/editor` + +## Access Helpers + +Create `lib/project-access.ts` with helpers for: + +- getting current Clerk identity: `userId` + primary email +- checking project access by owner or collaborator + +## Layout + +Build a full-viewport workspace layout with: + +- top navbar showing the project name +- navbar actions: share button and AI sidebar toggle +- existing `ProjectSidebar` on the left +- current room highlighted in the sidebar +- central canvas placeholder with dark background and centered message +- right sidebar placeholder for future AI chat + +The canvas area should fill the remaining space. + +## Scope + +Do not add real canvas logic, Liveblocks, AI chat, or sharing behavior yet. + +## Check When Done + +- `/editor/[roomId]` builds successfully +- access helper exists outside the page component +- `AccessDenied` is used for missing or unauthorized projects +- workspace layout renders with current project context +- no TypeScript errors diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 9310838..464c22d 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -4,7 +4,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Phase -- Feature 07 (Wire editor home to project API) — completed +- Feature 08 (Editor workspace shell) — completed ## Current Goal @@ -19,6 +19,7 @@ Update this file whenever the current phase, active feature, or implementation s - feature 05: Prisma schema & data layer — schema split: `prisma/schema.prisma` keeps the datasource + generator blocks, `prisma/models/project.prisma` defines the models. `Project` carries `ownerId`, `name`, optional `description`, `ProjectStatus` enum (`DRAFT` / `ARCHIVED`) defaulting to `DRAFT`, optional `canvasJsonPath` blob reference, and `createdAt` / `updatedAt` timestamps; indexes on `ownerId` and `createdAt`. `ProjectCollaborator` carries `projectId` (FK to `Project` with `onDelete: Cascade`), `email`, and `createdAt`; unique constraint on `(projectId, email)`; indexes on `email` and `(projectId, createdAt)`. `lib/prisma.ts` is a cached singleton — in dev, stashed on `globalThis` to survive HMR — that branches on `DATABASE_URL`: `prisma+postgres://` uses Prisma Accelerate (`accelerateUrl` + `withAccelerate()` extension), anything else uses `PrismaPg` driver adapter. Generated client lives in `app/generated/prisma` and is gitignored. Initial migration `20260715063438_init_project_models` applied. `npm run build` passes; no new lint errors (the two pre-existing shadcn `interface` warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` are unchanged). - feature 06: Project API routes — `lib/auth.ts` exposes `getCurrentUserId()` which returns the Clerk `userId` from `auth()` (or `null` if unauthenticated). `app/api/projects/route.ts` handles `GET` (lists the current user's projects, ordered by `createdAt` desc, uses the existing `ownerId` index) and `POST` (creates a project owned by the current user, defaults missing/blank name to `Untitled Project`, id stays on Prisma's existing `@default(cuid())` — no sequential IDs added). `app/api/projects/[projectId]/route.ts` handles `PATCH` (renames, requires a non-empty trimmed name) and `DELETE`. Both dynamic-route handlers resolve `params` as a `Promise` per Next.js 16, look up the project with `findUnique({ select: { id, ownerId }})`, return 404 when missing and 403 when the current user is not the owner, and return 401 for unauthenticated requests. `DELETE` returns 204 with an empty body; `PATCH` returns the updated project. Body parsing is wrapped in `try`/`catch` so a missing or malformed body resolves to a 400 on `PATCH` and to the default name on `POST`. `npm run build` passes; no new lint errors (the two pre-existing shadcn warnings are unchanged). - feature 07: Wire editor home to project API — Editor page converted to server component that fetches owned and shared projects server-side via `getAllProjects()` from `lib/projects.ts`. Client-side state and mutations managed by new `useProjectActions` hook in `hooks/use-project-actions.ts` which handles create (generates room ID with slug + suffix, POSTs to API, navigates to new workspace), rename (PATCH to API, updates local state on success), and delete (DELETE to API, refreshes router on success). `CreateProjectDialog` now shows "Room ID" preview instead of "Slug". Sidebar updated to accept `ProjectData` type from the hook instead of `MockProject`. `app/editor/page.tsx` now redirects unauthenticated users to `/sign-in`. Old `useProjectDialogs` hook removed. `npm run build` passes; no new lint errors. +- feature 08: Editor workspace shell — Built `/editor/[roomId]` workspace shell with server-side access checks. Created `lib/project-access.ts` with `getCurrentUser()` (returns `userId` + primary email from Clerk) and `getProjectWithAccess()` (checks owner or collaborator access). Created `components/editor/access-denied.tsx` with centered layout, lock icon, message, and link back to `/editor`. Created `app/editor/[roomId]/page.tsx` as a server component that checks authentication (redirects to `/sign-in`), validates project access (shows `AccessDenied` for missing/unauthorized projects), and passes data to the client shell. Created `components/editor/workspace-shell.tsx` with full-viewport layout: top navbar showing project name, share button, AI chat toggle, left sidebar (with current room highlighted), central canvas placeholder (dark background + centered message), and right sidebar placeholder for future AI chat. `npm run build` passes; no new lint errors (pre-existing shadcn warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` unchanged). ## In Progress @@ -26,7 +27,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Next Up -- feature 08: TBD +- feature 09: TBD ## Open Questions @@ -47,3 +48,4 @@ Update this file whenever the current phase, active feature, or implementation s - 2026-07-15: feature 04 implemented exactly per spec. Dialog state is centralized in `useProjectDialogs` (no `useReducer`; small surface, `useState` + `useTransition` is enough). The dialog form pattern (controlled name value, ref-based auto-focus on open) means the hook owns the field state, so the dialog components stay presentational. Slug preview uses the same `slugify` helper that mutation uses, so the preview matches the stored value. Mobile backdrop is a sibling ` + + + + {/* Invite section - only for owners */} + {isOwner && ( +
    + +
    { + e.preventDefault(); + onInvite(); + }} + > + onInviteEmailChange(e.target.value)} + disabled={isLoading} + className="flex-1" + /> + +
    + {error && ( +

    + {error} +

    + )} +
    + )} + + {/* Collaborators list */} +
    + + {collaborators.length === 0 ? ( +

    + No collaborators yet. Invite someone to collaborate. +

    + ) : ( +
      + {collaborators.map((collaborator) => ( +
    • +
      + {collaborator.avatarUrl ? ( + + ) : ( +
      + {collaborator.email[0].toUpperCase()} +
      + )} +
      +

      + {collaborator.name ?? collaborator.email} +

      + {!collaborator.name && ( +

      + {collaborator.email} +

      + )} +
      +
      + {isOwner && ( + + )} +
    • + ))} +
    + )} +
    + + {/* Read-only notice for collaborators */} + {!isOwner && collaborators.length > 0 && ( +

    + You have view-only access. Contact the project owner to manage + collaborators. +

    + )} + + + ); +} \ No newline at end of file diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx index 65b00a4..20215d0 100644 --- a/components/editor/workspace-shell.tsx +++ b/components/editor/workspace-shell.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { UserButton } from "@clerk/nextjs"; import { PanelLeftClose, PanelLeftOpen, Share2, MessageSquare, X } from "lucide-react"; @@ -11,9 +10,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { ProjectData } from "@/hooks/use-project-actions"; import { AccessDenied } from "@/components/editor/access-denied"; +import { ShareDialog } from "@/components/editor/share-dialog"; +import { useShareDialog } from "@/hooks/use-share-dialog"; interface WorkspaceShellProps { - project: { id: string; name: string } | null; + project: { id: string; name: string; ownerId: string } | null; currentRoomId: string; ownedProjects: ProjectData[]; sharedProjects: ProjectData[]; @@ -29,13 +30,19 @@ export function WorkspaceShell({ ownedProjects, sharedProjects, }: WorkspaceShellProps) { - const router = useRouter(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(false); - const handleOpenProject = (projectId: string) => { - router.push(`/editor/${projectId}`); - }; + // Determine if current user is owner by checking against the projects list + const isOwner = project + ? ownedProjects.some((p) => p.id === project.id && p.role === "owner") + : false; + + // Share dialog state + const shareDialog = useShareDialog( + project?.id ?? "", + isOwner, + ); // If no project access, show access denied if (!project) { @@ -47,6 +54,7 @@ export function WorkspaceShell({ onToggleSidebar={() => setIsSidebarOpen((open) => !open)} onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)} aiSidebarOpen={isAiSidebarOpen} + onShareClick={() => {}} />
    setIsSidebarOpen((open) => !open)} onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)} aiSidebarOpen={isAiSidebarOpen} + onShareClick={shareDialog.onOpenChange} />
    setIsAiSidebarOpen(false)} />
    + + {/* Share Dialog */} + ); } @@ -99,6 +125,7 @@ interface EditorNavbarProps { aiSidebarOpen: boolean; onToggleSidebar: () => void; onToggleAiSidebar: () => void; + onShareClick: (open: boolean) => void; } function EditorNavbar({ @@ -107,6 +134,7 @@ function EditorNavbar({ aiSidebarOpen, onToggleSidebar, onToggleAiSidebar, + onShareClick, }: EditorNavbarProps) { return (
    @@ -138,8 +166,7 @@ function EditorNavbar({ variant="ghost" size="sm" className="gap-2" - // Sharing not implemented yet - onClick={() => {}} + onClick={() => onShareClick(true)} > Share @@ -180,7 +207,6 @@ interface ProjectSidebarProps { ownedProjects: ProjectData[]; sharedProjects: ProjectData[]; currentRoomId: string; - onOpenProject?: (projectId: string) => void; } function ProjectSidebar({ @@ -189,7 +215,6 @@ function ProjectSidebar({ ownedProjects, sharedProjects, currentRoomId, - onOpenProject, }: ProjectSidebarProps) { return ( <> @@ -252,7 +277,6 @@ function ProjectSidebar({ @@ -288,13 +311,11 @@ function ProjectSidebar({ interface ProjectListProps { projects: ProjectData[]; currentRoomId: string; - onOpenProject?: (projectId: string) => void; } function ProjectList({ projects, currentRoomId, - onOpenProject, }: ProjectListProps) { if (projects.length === 0) { return ( diff --git a/context/feature-specs/09-share-dialog.md b/context/feature-specs/09-share-dialog.md new file mode 100644 index 0000000..a2e3151 --- /dev/null +++ b/context/feature-specs/09-share-dialog.md @@ -0,0 +1,48 @@ +Add sharing to the workspace so project owners can invite collaborators by email. + +## Share Dialog + +Add a `Share` button to the editor navbar that opens the share dialog. + +Owners can: + +- invite collaborators by email +- view current collaborators +- remove collaborators +- copy the project link with temporary `Copied!` feedback + +Collaborators can: + +- view the collaborator list only +- not invite, remove, or manage access + +## Clerk User Data + +Collaborators are stored by email in the database. + +Use Clerk Backend API to enrich collaborator emails with: + +- display name +- avatar image + +If a Clerk user is not found for an email, fall back to showing the email only. + +## Implementation + +Add the required API logic for: + +- listing collaborators +- inviting collaborators +- removing collaborators + +Enforce ownership server-side for invite and remove actions. + +Do not add a local user table. + +## Check When Done + +- share dialog opens from the workspace +- owners can invite and remove collaborators +- collaborators see read-only access +- collaborator names/avatars load from Clerk when available +- `npm run build` passes diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 464c22d..689e940 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -4,7 +4,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Phase -- Feature 08 (Editor workspace shell) — completed +- Feature 09 (Share dialog) — completed ## Current Goal @@ -20,6 +20,7 @@ Update this file whenever the current phase, active feature, or implementation s - feature 06: Project API routes — `lib/auth.ts` exposes `getCurrentUserId()` which returns the Clerk `userId` from `auth()` (or `null` if unauthenticated). `app/api/projects/route.ts` handles `GET` (lists the current user's projects, ordered by `createdAt` desc, uses the existing `ownerId` index) and `POST` (creates a project owned by the current user, defaults missing/blank name to `Untitled Project`, id stays on Prisma's existing `@default(cuid())` — no sequential IDs added). `app/api/projects/[projectId]/route.ts` handles `PATCH` (renames, requires a non-empty trimmed name) and `DELETE`. Both dynamic-route handlers resolve `params` as a `Promise` per Next.js 16, look up the project with `findUnique({ select: { id, ownerId }})`, return 404 when missing and 403 when the current user is not the owner, and return 401 for unauthenticated requests. `DELETE` returns 204 with an empty body; `PATCH` returns the updated project. Body parsing is wrapped in `try`/`catch` so a missing or malformed body resolves to a 400 on `PATCH` and to the default name on `POST`. `npm run build` passes; no new lint errors (the two pre-existing shadcn warnings are unchanged). - feature 07: Wire editor home to project API — Editor page converted to server component that fetches owned and shared projects server-side via `getAllProjects()` from `lib/projects.ts`. Client-side state and mutations managed by new `useProjectActions` hook in `hooks/use-project-actions.ts` which handles create (generates room ID with slug + suffix, POSTs to API, navigates to new workspace), rename (PATCH to API, updates local state on success), and delete (DELETE to API, refreshes router on success). `CreateProjectDialog` now shows "Room ID" preview instead of "Slug". Sidebar updated to accept `ProjectData` type from the hook instead of `MockProject`. `app/editor/page.tsx` now redirects unauthenticated users to `/sign-in`. Old `useProjectDialogs` hook removed. `npm run build` passes; no new lint errors. - feature 08: Editor workspace shell — Built `/editor/[roomId]` workspace shell with server-side access checks. Created `lib/project-access.ts` with `getCurrentUser()` (returns `userId` + primary email from Clerk) and `getProjectWithAccess()` (checks owner or collaborator access). Created `components/editor/access-denied.tsx` with centered layout, lock icon, message, and link back to `/editor`. Created `app/editor/[roomId]/page.tsx` as a server component that checks authentication (redirects to `/sign-in`), validates project access (shows `AccessDenied` for missing/unauthorized projects), and passes data to the client shell. Created `components/editor/workspace-shell.tsx` with full-viewport layout: top navbar showing project name, share button, AI chat toggle, left sidebar (with current room highlighted), central canvas placeholder (dark background + centered message), and right sidebar placeholder for future AI chat. `npm run build` passes; no new lint errors (pre-existing shadcn warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` unchanged). +- feature 09: Share dialog — Added Share button to the editor navbar that opens the share dialog. Created `app/api/projects/[projectId]/collaborators/route.ts` for listing and inviting collaborators, and `app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts` for removing collaborators. Both enforce ownership server-side for invite and remove actions. Created `lib/auth.ts` helpers `getClerkUserByEmail()` and `getClerkUsersByEmails()` to enrich collaborator emails with display name and avatar from Clerk (falls back to showing email only when user not found). Created `components/editor/share-dialog.tsx` with the share dialog UI that shows project link (copyable with "Copied!" feedback), invite form (owners only), collaborator list (shows avatar + name when available from Clerk, email otherwise), and remove button (owners only). Created `hooks/use-share-dialog.ts` to manage share dialog state and API interactions. Updated `components/editor/workspace-shell.tsx` to include the share dialog and wire the Share button. Updated `app/editor/[roomId]/page.tsx` to pass `ownerId` to determine owner vs collaborator view. `npm run build` passes; no new lint errors. ## In Progress @@ -27,7 +28,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Next Up -- feature 09: TBD +- feature 10: TBD ## Open Questions diff --git a/hooks/use-share-dialog.ts b/hooks/use-share-dialog.ts new file mode 100644 index 0000000..84e2eba --- /dev/null +++ b/hooks/use-share-dialog.ts @@ -0,0 +1,147 @@ +"use client"; + +import { useCallback, useState, useTransition } from "react"; + +import type { CollaboratorData } from "@/components/editor/share-dialog"; + +/** + * State and handlers for the share dialog. + */ +export function useShareDialog(projectId: string, isOwner: boolean) { + const [open, setOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [collaborators, setCollaborators] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [isPending, startTransition] = useTransition(); + + const openDialog = useCallback(async () => { + setOpen(true); + setInviteEmail(""); + setError(null); + setCopied(false); + setIsLoading(true); + + try { + const response = await fetch(`/api/projects/${projectId}/collaborators`); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error ?? "Failed to load collaborators"); + } + + const { collaborators: collabs } = await response.json(); + setCollaborators(collabs); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load collaborators"); + } finally { + setIsLoading(false); + } + }, [projectId]); + + const closeDialog = useCallback(() => { + setOpen(false); + setInviteEmail(""); + setError(null); + }, []); + + const submitInvite = useCallback(async () => { + const trimmed = inviteEmail.trim(); + if (!trimmed) { + setError("Please enter an email address"); + return; + } + + setError(null); + setIsLoading(true); + + try { + const response = await fetch(`/api/projects/${projectId}/collaborators`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: trimmed }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error ?? "Failed to invite collaborator"); + } + + const { collaborator } = await response.json(); + + startTransition(() => { + setCollaborators((current) => [ + ...current, + { ...collaborator, createdAt: collaborator.createdAt.toISOString() }, + ]); + setInviteEmail(""); + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to invite collaborator"); + } finally { + setIsLoading(false); + } + }, [projectId, inviteEmail]); + + const removeCollaborator = useCallback( + async (collaboratorId: string) => { + setError(null); + + try { + const response = await fetch( + `/api/projects/${projectId}/collaborators/${collaboratorId}`, + { + method: "DELETE", + }, + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error ?? "Failed to remove collaborator"); + } + + startTransition(() => { + setCollaborators((current) => + current.filter((c) => c.id !== collaboratorId), + ); + }); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to remove collaborator", + ); + } + }, + [projectId], + ); + + const copyLink = useCallback(() => { + const link = `${window.location.origin}/editor/${projectId}`; + navigator.clipboard.writeText(link).then(() => { + setCopied(true); + // Reset after 2 seconds + setTimeout(() => setCopied(false), 2000); + }); + }, [projectId]); + + return { + open, + isOwner, + collaborators, + isLoading: isLoading || isPending, + error, + inviteEmail, + onInviteEmailChange: setInviteEmail, + onOpenChange: (o: boolean) => { + if (o) { + openDialog(); + } else { + closeDialog(); + } + }, + onInvite: submitInvite, + onRemove: removeCollaborator, + onCopyLink: copyLink, + copied, + }; +} \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts index 8014972..70542ca 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,4 +1,4 @@ -import { auth } from "@clerk/nextjs/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; /** * Resolve the current Clerk user ID from the active session, or `null` if no @@ -9,3 +9,195 @@ export async function getCurrentUserId(): Promise { const { userId } = await auth(); return userId ?? null; } + +/** + * Current user identity from Clerk session. + */ +export interface CurrentUser { + userId: string; + email: string; +} + +/** + * Resolve the current user identity (userId + primary email) from the active + * Clerk session. Returns null if no user is signed in. + */ +export async function getCurrentUser(): Promise { + const { userId } = await auth(); + if (!userId) { + return null; + } + + // Get the user's primary email from Clerk + const clerk = await clerkClient(); + const user = await clerk.users.getUser(userId); + + // Find the primary email address + const primaryEmail = user.emailAddresses.find( + (email) => email.id === user.primaryEmailAddressId + ); + + const email = primaryEmail?.emailAddress ?? user.emailAddresses[0]?.emailAddress ?? ""; + + return { + userId, + email, + }; +} + +/** + * Check if the current user has access to a project (as owner or collaborator). + * Returns the project if access is granted, null otherwise. + */ +export async function getProjectWithAccess(projectId: string): Promise<{ + id: string; + name: string; + ownerId: string; +} | null> { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return null; + } + + const { prisma } = await import("@/lib/prisma"); + + // First, check if the project exists and user is the owner + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, name: true, ownerId: true }, + }); + + if (!project) { + return null; + } + + // Owner has access + if (project.ownerId === currentUser.userId) { + return project; + } + + // Check if user is a collaborator (by email) + const collaborator = await prisma.projectCollaborator.findUnique({ + where: { + projectId_email: { + projectId: project.id, + email: currentUser.email, + }, + }, + }); + + if (collaborator) { + return project; + } + + // No access + return null; +} + +/** + * Clerk user data for a collaborator. + */ +export interface ClerkUserData { + email: string; + name: string | null; + avatarUrl: string | null; +} + +/** + * Look up a Clerk user by email address and return their name and avatar. + * Returns null if no user is found for the email. + */ +export async function getClerkUserByEmail( + email: string, +): Promise { + const clerk = await clerkClient(); + + // Search for user by email using Clerk's getUserList + const response = await clerk.users.getUserList({ + emailAddress: [email.toLowerCase()], + limit: 1, + }); + + // Access the data array directly from the paginated response + const data = (response as unknown as { data: unknown[] }).data; + if (!data || data.length === 0) { + return null; + } + + const user = data[0] as { + fullName: string | null; + firstName: string | null; + imageUrl: string | null; + }; + + return { + email: email.toLowerCase(), + name: user.fullName ?? user.firstName ?? null, + avatarUrl: user.imageUrl ?? null, + }; +} + +/** + * Look up multiple Clerk users by email addresses in batch. + * Returns a map of email -> ClerkUserData (null for emails not found). + */ +export async function getClerkUsersByEmails( + emails: string[], +): Promise> { + const clerk = await clerkClient(); + const result = new Map(); + + if (emails.length === 0) { + return result; + } + + // Clerk allows up to 100 users per request + const response = await clerk.users.getUserList({ + emailAddress: emails.map((e) => e.toLowerCase()), + limit: 100, + }); + + // Access the data array directly from the paginated response + const data = (response as unknown as { data: unknown[] }).data; + if (!data) { + // Return nulls for all requested emails if no data + for (const email of emails) { + result.set(email, null); + } + return result; + } + + // Build a map of email -> user + const userMap = new Map(); + + for (const user of data as { + emailAddresses: { emailAddress: string }[]; + fullName: string | null; + firstName: string | null; + imageUrl: string | null; + }[]) { + for (const emailEntry of user.emailAddresses) { + userMap.set(emailEntry.emailAddress.toLowerCase(), user); + } + } + + // Map each requested email to the result + for (const email of emails) { + const user = userMap.get(email.toLowerCase()); + if (user) { + result.set(email, { + email: email.toLowerCase(), + name: user.fullName ?? user.firstName ?? null, + avatarUrl: user.imageUrl ?? null, + }); + } else { + result.set(email, null); + } + } + + return result; +} \ No newline at end of file diff --git a/lib/projects.ts b/lib/projects.ts index 1302bca..fee1aaa 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -26,10 +26,11 @@ export async function getOwnedProjects(userId: string): Promise { /** * Fetch all projects the given user has been invited to collaborate on. * Returns the project data along with the collaborator email. + * @param userEmail - The user's email address to match against collaborators */ -export async function getSharedProjects(userId: string): Promise { +export async function getSharedProjects(userEmail: string): Promise { const collaborators = await prisma.projectCollaborator.findMany({ - where: { email: userId }, + where: { email: userEmail.toLowerCase() }, include: { project: true }, orderBy: { createdAt: "desc" }, }); @@ -39,14 +40,16 @@ export async function getSharedProjects(userId: string): Promise { /** * Fetch both owned and shared projects for a user. + * @param userId - The Clerk user ID for owned projects + * @param userEmail - The user's email address for shared projects */ -export async function getAllProjects(userId: string): Promise<{ +export async function getAllProjects(userId: string, userEmail: string): Promise<{ owned: Project[]; shared: Project[]; }> { const [owned, shared] = await Promise.all([ getOwnedProjects(userId), - getSharedProjects(userId), + getSharedProjects(userEmail), ]); return { owned, shared };