Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/(protected)/InvalidSessionHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client'

import { useEffect } from 'react'
import { signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'

export function InvalidSessionHandler() {
const router = useRouter()

useEffect(() => {
// Clear the invalid session and redirect to login
signOut({
redirect: false
}).then(() => {
router.push('/login?error=InvalidSession')
})
}, [router])

return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-[#191919]">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Session Invalid</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Your session is no longer valid. Redirecting to login...
</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
)
}
18 changes: 17 additions & 1 deletion app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@ import { redirect } from "next/navigation"
import { getCurrentUser } from "@/lib/auth"
import { SnapDocsSidebar } from "@/components/layout/snapdocs-sidebar"
import { ClientLayout } from "./ClientLayout"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth-config"
import { InvalidSessionHandler } from "./InvalidSessionHandler"

interface ProtectedLayoutProps {
children: React.ReactNode
}

export default async function ProtectedLayout({ children }: ProtectedLayoutProps) {
// First check if there's a valid session
const session = await getServerSession(authOptions)

if (!session) {
// No valid session, redirect to login
redirect("/login")
}

// Then get the full user data
const user = await getCurrentUser()

if (!user) {
redirect("/login")
// Session exists but user not found in database
// This means the session is invalid (user was deleted or session is corrupted)
console.error(`Session exists but user not found in database: ${session.user?.id}`)
// Return a client component that will clear the session
return <InvalidSessionHandler />
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ interface PageEditorProps {
name?: string | null
email?: string | null
}
workspaceMembers?: Array<{
id: string
name?: string | null
email?: string | null
}>
}

export default function PageEditorV2({ page, initialContent, user }: PageEditorProps) {
export default function PageEditorV2({ page, initialContent, user, workspaceMembers = [] }: PageEditorProps) {
const router = useRouter()
// const { isConnected, joinPage, leavePage } = useSocket() // Removed - using Yjs collaboration now
const [title, setTitle] = useState(page.title || '')
Expand Down Expand Up @@ -316,6 +321,7 @@ export default function PageEditorV2({ page, initialContent, user }: PageEditorP
autoSaveInterval={2000}
userId={user?.id}
user={user}
workspaceMembers={workspaceMembers}
/>
</div>
</div>
Expand Down
17 changes: 17 additions & 0 deletions app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ export default async function PageEditorPage({ params }: PageEditorPageProps) {
notFound()
}

// Get all workspace members for mentions
const workspaceMembers = await prisma.workspaceMember.findMany({
where: {
workspaceId: resolvedParams.workspaceId
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
}
}
})

// Get page content from MongoDB
const pageContent = await pageContentService.loadPageContent(resolvedParams.pageId)

Expand All @@ -94,6 +110,7 @@ export default async function PageEditorPage({ params }: PageEditorPageProps) {
page={transformedPage}
initialContent={pageContent}
user={user}
workspaceMembers={workspaceMembers.map(m => m.user)}
/>
)
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth-config"

export async function GET() {
try {
const session = await getServerSession(authOptions)

if (!session) {
return NextResponse.json(
{ error: "Unauthorized - Invalid or expired token" },
{ status: 403 }
)
}

return NextResponse.json({
authenticated: true,
user: session.user
})
} catch (error) {
console.error("Auth verification error:", error)
return NextResponse.json(
{ error: "Authentication verification failed" },
{ status: 500 }
)
}
}
9 changes: 9 additions & 0 deletions app/api/pages/[pageId]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { prisma } from '@/lib/db/prisma'
import { pageContentService } from '@/lib/services/page-content'
import { Block } from '@/types'
import { ensureMentionsPopulated } from '@/lib/utils/mentions'
import { NotificationService } from '@/lib/services/notification'

// GET /api/pages/[pageId]/content - Get page content
export async function GET(
Expand Down Expand Up @@ -136,6 +137,14 @@ export async function PUT(
// Save content immediately (optimized for real-time collaboration)
const savedContent = await pageContentService.savePageContent(pageId, blocks, user.id)

// Process mentions and create notifications
await NotificationService.processMentionsInContent(
blocks,
pageId,
page.workspaceId,
user.id
)

// Update page metadata in PostgreSQL
await prisma.page.update({
where: { id: pageId },
Expand Down
11 changes: 9 additions & 2 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useEffect } from "react"
import { signIn, getSession, useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
Expand All @@ -15,13 +15,20 @@ export default function LoginPage() {
const [password, setPassword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, status } = useSession()

useEffect(() => {
// Check for error messages in URL
const error = searchParams.get('error')
if (error === 'InvalidSession') {
toast.error('Your session has expired. Please login again.')
}

if (status === "authenticated") {
router.push("/dashboard")
}
}, [status, router])
}, [status, router, searchParams])

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
Expand Down
68 changes: 67 additions & 1 deletion components/editor/BlockNoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Block as BlockNoteBlock,
BlockNoteSchema,
defaultBlockSpecs,
defaultInlineContentSpecs,
filterSuggestionItems
} from '@blocknote/core'
import {
Expand All @@ -29,6 +30,7 @@ import {
cleanupCollaborationProvider
} from '@/lib/collaboration/yjs-provider'
import type YPartyKitProvider from 'y-partykit/provider'
import { Mention } from './Mention'

// Dynamically import DatabaseBlock to avoid SSR issues
const DatabaseBlock = dynamic(() => import('./DatabaseBlock'), {
Expand All @@ -52,6 +54,11 @@ interface BlockNoteEditorProps {
email?: string | null
}
enableCollaboration?: boolean
workspaceMembers?: Array<{
id: string
name?: string | null
email?: string | null
}>
}

type SaveStatus = 'saved' | 'saving' | 'error' | 'unsaved'
Expand All @@ -67,7 +74,8 @@ export default function BlockNoteEditorComponent({
showSaveStatus = true,
userId,
user,
enableCollaboration = true
enableCollaboration = true,
workspaceMembers = []
}: BlockNoteEditorProps) {
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved')
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
Expand Down Expand Up @@ -165,6 +173,10 @@ export default function BlockNoteEditorComponent({
const schema = useMemo(() => {
return BlockNoteSchema.create({
blockSpecs: customBlockSpecs,
inlineContentSpecs: {
...defaultInlineContentSpecs,
mention: Mention,
},
})
}, [customBlockSpecs])

Expand Down Expand Up @@ -275,6 +287,54 @@ export default function BlockNoteEditorComponent({
[insertDatabaseItem]
)

// Get mention menu items from workspace members
const getMentionMenuItems = useCallback(
(editor: any): DefaultReactSuggestionItem[] => {
// Include the current user
const allUsers = [
...(user ? [user] : []),
...workspaceMembers.filter(member => member.id !== user?.id)
]

return allUsers.map((member) => ({
title: member.name || member.email || 'Unknown User',
onItemClick: async () => {
editor.insertInlineContent([
{
type: "mention",
props: {
user: member.name || member.email || 'Unknown',
userId: member.id,
email: member.email || undefined
},
},
" ", // add a space after the mention
])

// Send notification for the mention
if (member.id !== user?.id) {
try {
await fetch('/api/notifications/mention', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: member.id,
pageId,
workspaceId,
message: `mentioned you in a document`
})
})
} catch (error) {
console.error('Failed to send mention notification:', error)
}
}
},
subtext: member.email || undefined,
}))
},
[user, workspaceMembers, pageId, workspaceId]
)

// Convert BlockNote blocks back to our storage format
const convertFromBlockNoteFormat = useCallback((blocks: BlockNoteBlock[]): AppBlockType[] => {
try {
Expand Down Expand Up @@ -441,6 +501,12 @@ export default function BlockNoteEditorComponent({
filterSuggestionItems(getCustomSlashMenuItems(editor), query)
}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={async (query) =>
filterSuggestionItems(getMentionMenuItems(editor as any), query)
}
/>
</BlockNoteView>
</div>

Expand Down
48 changes: 48 additions & 0 deletions components/editor/Mention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client'

import { createReactInlineContentSpec } from '@blocknote/react'
import { cn } from '@/lib/utils'

export const Mention = createReactInlineContentSpec(
{
type: "mention" as const,
propSchema: {
user: {
default: "Unknown" as const
},
userId: {
default: undefined,
type: "string" as const
},
email: {
default: undefined,
type: "string" as const
}
} as const,
content: "none" as const,
},
{
render: (props) => {
const { user, userId, email } = props.inlineContent.props

return (
<span
className={cn(
"inline-flex items-center px-1.5 py-0.5 mx-0.5",
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
"rounded-md text-sm font-medium",
"hover:bg-blue-200 dark:hover:bg-blue-900/50",
"cursor-pointer transition-colors duration-150",
"align-baseline"
)}
data-mention-user={user}
data-mention-userid={userId}
data-mention-email={email}
contentEditable={false}
>
@{user}
</span>
)
}
}
)
5 changes: 4 additions & 1 deletion components/layout/snapdocs-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
Hash,
Calendar,
File,
Circle
Circle,
Bell
} from "lucide-react"
import {
DropdownMenu,
Expand All @@ -39,6 +40,7 @@ import { CreateWorkspaceModal } from "@/components/workspace/create-workspace-mo
import { SearchDialog } from "@/components/search/SearchDialog"
import { SettingsModal } from "@/components/settings/SettingsModal"
import { AvatarUpload } from "@/components/ui/avatar-upload"
import { NotificationButton } from "@/components/notifications/NotificationPanel"
import { cn } from "@/lib/utils"
import toast from "react-hot-toast"

Expand Down Expand Up @@ -308,6 +310,7 @@ export function SnapDocsSidebar({ user }: SnapDocsSidebarProps) {
}
onClick={() => setShowSearchDialog(true)}
/>
<NotificationButton />
<SidebarItem
icon={<Settings />}
label="Settings & members"
Expand Down
Loading
Loading