diff --git a/app/(protected)/InvalidSessionHandler.tsx b/app/(protected)/InvalidSessionHandler.tsx
new file mode 100644
index 0000000..d0ccfe4
--- /dev/null
+++ b/app/(protected)/InvalidSessionHandler.tsx
@@ -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 (
+
+
+
Session Invalid
+
+ Your session is no longer valid. Redirecting to login...
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx
index fc0fbe0..3a52fab 100644
--- a/app/(protected)/layout.tsx
+++ b/app/(protected)/layout.tsx
@@ -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
}
return (
diff --git a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx
index c373504..7e2c96e 100644
--- a/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx
+++ b/app/(protected)/workspace/[workspaceId]/page/[pageId]/PageEditorV2.tsx
@@ -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 || '')
@@ -316,6 +321,7 @@ export default function PageEditorV2({ page, initialContent, user }: PageEditorP
autoSaveInterval={2000}
userId={user?.id}
user={user}
+ workspaceMembers={workspaceMembers}
/>
diff --git a/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx b/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx
index d31106a..9760d96 100644
--- a/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx
+++ b/app/(protected)/workspace/[workspaceId]/page/[pageId]/page.tsx
@@ -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)
@@ -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) {
diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts
new file mode 100644
index 0000000..ada1806
--- /dev/null
+++ b/app/api/auth/verify/route.ts
@@ -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 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/api/pages/[pageId]/content/route.ts b/app/api/pages/[pageId]/content/route.ts
index 00bbbb0..466d54b 100644
--- a/app/api/pages/[pageId]/content/route.ts
+++ b/app/api/pages/[pageId]/content/route.ts
@@ -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(
@@ -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 },
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 139eba1..8123811 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -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"
@@ -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()
diff --git a/components/editor/BlockNoteEditor.tsx b/components/editor/BlockNoteEditor.tsx
index 2df37ab..b995a4c 100644
--- a/components/editor/BlockNoteEditor.tsx
+++ b/components/editor/BlockNoteEditor.tsx
@@ -6,6 +6,7 @@ import {
Block as BlockNoteBlock,
BlockNoteSchema,
defaultBlockSpecs,
+ defaultInlineContentSpecs,
filterSuggestionItems
} from '@blocknote/core'
import {
@@ -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'), {
@@ -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'
@@ -67,7 +74,8 @@ export default function BlockNoteEditorComponent({
showSaveStatus = true,
userId,
user,
- enableCollaboration = true
+ enableCollaboration = true,
+ workspaceMembers = []
}: BlockNoteEditorProps) {
const [saveStatus, setSaveStatus] = useState('saved')
const autoSaveTimeoutRef = useRef(null)
@@ -165,6 +173,10 @@ export default function BlockNoteEditorComponent({
const schema = useMemo(() => {
return BlockNoteSchema.create({
blockSpecs: customBlockSpecs,
+ inlineContentSpecs: {
+ ...defaultInlineContentSpecs,
+ mention: Mention,
+ },
})
}, [customBlockSpecs])
@@ -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 {
@@ -441,6 +501,12 @@ export default function BlockNoteEditorComponent({
filterSuggestionItems(getCustomSlashMenuItems(editor), query)
}
/>
+
+ filterSuggestionItems(getMentionMenuItems(editor as any), query)
+ }
+ />
diff --git a/components/editor/Mention.tsx b/components/editor/Mention.tsx
new file mode 100644
index 0000000..a5b37b1
--- /dev/null
+++ b/components/editor/Mention.tsx
@@ -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 (
+
+ @{user}
+
+ )
+ }
+ }
+)
\ No newline at end of file
diff --git a/components/layout/snapdocs-sidebar.tsx b/components/layout/snapdocs-sidebar.tsx
index 6d1162e..dfaf7b5 100644
--- a/components/layout/snapdocs-sidebar.tsx
+++ b/components/layout/snapdocs-sidebar.tsx
@@ -24,7 +24,8 @@ import {
Hash,
Calendar,
File,
- Circle
+ Circle,
+ Bell
} from "lucide-react"
import {
DropdownMenu,
@@ -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"
@@ -308,6 +310,7 @@ export function SnapDocsSidebar({ user }: SnapDocsSidebarProps) {
}
onClick={() => setShowSearchDialog(true)}
/>
+
}
label="Settings & members"
diff --git a/components/notifications/NotificationDropdown.tsx b/components/notifications/NotificationDropdown.tsx
new file mode 100644
index 0000000..76d5dd4
--- /dev/null
+++ b/components/notifications/NotificationDropdown.tsx
@@ -0,0 +1,262 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Bell, Check, CheckCheck, X } from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { formatDistanceToNow } from 'date-fns'
+import { useRouter } from 'next/navigation'
+import toast from 'react-hot-toast'
+import { cn } from '@/lib/utils'
+
+interface Notification {
+ id: string
+ type: string
+ title: string
+ message: string
+ read: boolean
+ createdAt: string
+ mentionedBy?: {
+ id: string
+ name: string | null
+ email: string
+ avatarUrl: string | null
+ } | null
+ page?: {
+ id: string
+ title: string
+ icon: string | null
+ } | null
+ workspace?: {
+ id: string
+ name: string
+ slug: string
+ } | null
+ metadata?: any
+}
+
+export function NotificationDropdown() {
+ const [notifications, setNotifications] = useState([])
+ const [unreadCount, setUnreadCount] = useState(0)
+ const [isLoading, setIsLoading] = useState(false)
+ const [isOpen, setIsOpen] = useState(false)
+ const router = useRouter()
+
+ // Fetch notifications
+ const fetchNotifications = async () => {
+ setIsLoading(true)
+ try {
+ const response = await fetch('/api/notifications')
+ if (response.ok) {
+ const data = await response.json()
+ setNotifications(data.notifications || [])
+ setUnreadCount(data.unreadCount || 0)
+ }
+ } catch (error) {
+ console.error('Error fetching notifications:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // Mark notification as read
+ const markAsRead = async (notificationId: string) => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notificationIds: [notificationId] })
+ })
+
+ if (response.ok) {
+ setNotifications(prev =>
+ prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
+ )
+ setUnreadCount(prev => Math.max(0, prev - 1))
+ }
+ } catch (error) {
+ console.error('Error marking notification as read:', error)
+ }
+ }
+
+ // Mark all as read
+ const markAllAsRead = async () => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ markAllRead: true })
+ })
+
+ if (response.ok) {
+ setNotifications(prev => prev.map(n => ({ ...n, read: true })))
+ setUnreadCount(0)
+ toast.success('All notifications marked as read')
+ }
+ } catch (error) {
+ console.error('Error marking all as read:', error)
+ toast.error('Failed to mark notifications as read')
+ }
+ }
+
+ // Handle notification click
+ const handleNotificationClick = async (notification: Notification) => {
+ // Mark as read if unread
+ if (!notification.read) {
+ await markAsRead(notification.id)
+ }
+
+ // Navigate to the page if it exists
+ if (notification.page && notification.workspace) {
+ router.push(`/workspace/${notification.workspace.id}/page/${notification.page.id}`)
+ setIsOpen(false)
+ }
+ }
+
+ // Fetch notifications when dropdown opens
+ useEffect(() => {
+ if (isOpen) {
+ fetchNotifications()
+ }
+ }, [isOpen])
+
+ // Initial fetch
+ useEffect(() => {
+ fetchNotifications()
+ }, [])
+
+ // Poll for new notifications every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(fetchNotifications, 30000)
+ return () => clearInterval(interval)
+ }, [])
+
+ const getInitials = (name: string | null, email: string) => {
+ if (name) {
+ return name.split(' ').map(n => n[0]).join('').toUpperCase()
+ }
+ return email[0].toUpperCase()
+ }
+
+ return (
+
+
+
+
+
+
+ Notifications
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+ ) : notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+ notifications.map((notification) => (
+ handleNotificationClick(notification)}
+ >
+
+
+
+ {notification.mentionedBy
+ ? getInitials(notification.mentionedBy.name, notification.mentionedBy.email)
+ : '?'}
+
+
+
+
+
+
+
+ {notification.title}
+
+
+ {notification.message}
+
+
+ {!notification.read && (
+
+ )}
+
+
+
+ {notification.page && (
+ <>
+ {notification.page.icon && (
+ {notification.page.icon}
+ )}
+
+ {notification.page.title}
+
+ •
+ >
+ )}
+
+ {formatDistanceToNow(new Date(notification.createdAt), {
+ addSuffix: true
+ })}
+
+
+
+
+ ))
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/notifications/NotificationPanel.tsx b/components/notifications/NotificationPanel.tsx
new file mode 100644
index 0000000..d05fff2
--- /dev/null
+++ b/components/notifications/NotificationPanel.tsx
@@ -0,0 +1,326 @@
+'use client'
+
+import React, { useState, useEffect, useRef } from 'react'
+import { Bell, X, Check, CheckCheck } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { formatDistanceToNow } from 'date-fns'
+import { useRouter } from 'next/navigation'
+import toast from 'react-hot-toast'
+import { cn } from '@/lib/utils'
+
+interface Notification {
+ id: string
+ type: string
+ title: string
+ message: string
+ read: boolean
+ createdAt: string
+ mentionedBy?: {
+ id: string
+ name: string | null
+ email: string
+ avatarUrl: string | null
+ } | null
+ page?: {
+ id: string
+ title: string
+ icon: string | null
+ } | null
+ workspace?: {
+ id: string
+ name: string
+ slug: string
+ } | null
+ metadata?: any
+}
+
+interface NotificationPanelProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
+ const [notifications, setNotifications] = useState([])
+ const [unreadCount, setUnreadCount] = useState(0)
+ const [isLoading, setIsLoading] = useState(false)
+ const router = useRouter()
+
+ // Fetch notifications
+ const fetchNotifications = async () => {
+ setIsLoading(true)
+ try {
+ const response = await fetch('/api/notifications')
+ if (response.ok) {
+ const data = await response.json()
+ setNotifications(data.notifications || [])
+ setUnreadCount(data.unreadCount || 0)
+ }
+ } catch (error) {
+ console.error('Error fetching notifications:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // Mark notification as read
+ const markAsRead = async (notificationId: string) => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ notificationIds: [notificationId] })
+ })
+
+ if (response.ok) {
+ setNotifications(prev =>
+ prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
+ )
+ setUnreadCount(prev => Math.max(0, prev - 1))
+ }
+ } catch (error) {
+ console.error('Error marking notification as read:', error)
+ }
+ }
+
+ // Mark all as read
+ const markAllAsRead = async () => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ markAllRead: true })
+ })
+
+ if (response.ok) {
+ setNotifications(prev => prev.map(n => ({ ...n, read: true })))
+ setUnreadCount(0)
+ toast.success('All notifications marked as read')
+ }
+ } catch (error) {
+ console.error('Error marking all as read:', error)
+ toast.error('Failed to mark notifications as read')
+ }
+ }
+
+ // Handle notification click
+ const handleNotificationClick = async (notification: Notification) => {
+ // Mark as read if unread
+ if (!notification.read) {
+ await markAsRead(notification.id)
+ }
+
+ // Navigate to the page if it exists
+ if (notification.page && notification.workspace) {
+ router.push(`/workspace/${notification.workspace.id}/page/${notification.page.id}`)
+ onClose()
+ }
+ }
+
+ // Fetch notifications when panel opens
+ useEffect(() => {
+ if (isOpen) {
+ fetchNotifications()
+ }
+ }, [isOpen])
+
+ // Initial fetch
+ useEffect(() => {
+ fetchNotifications()
+ }, [])
+
+ // Poll for new notifications every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(fetchNotifications, 30000)
+ return () => clearInterval(interval)
+ }, [])
+
+ const getInitials = (name: string | null, email: string) => {
+ if (name) {
+ return name.split(' ').map(n => n[0]).join('').toUpperCase()
+ }
+ return email[0].toUpperCase()
+ }
+
+ if (!isOpen) return null
+
+ return (
+ <>
+ {/* Backdrop - click outside to close */}
+
+
+ {/* Dropdown Panel positioned beside sidebar */}
+
+ {/* Header */}
+
+
+
+
Notifications
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+
+ {/* Notifications List */}
+
+ {isLoading ? (
+
+ ) : notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+
+ {notifications.map((notification) => (
+
handleNotificationClick(notification)}
+ >
+
+
+
+ {notification.mentionedBy
+ ? getInitials(notification.mentionedBy.name, notification.mentionedBy.email)
+ : '?'}
+
+
+
+
+
+
+
+ {notification.title}
+
+
+ {notification.message}
+
+
+ {!notification.read && (
+
+ )}
+
+
+
+ {notification.page && (
+ <>
+ {notification.page.icon && (
+ {notification.page.icon}
+ )}
+
+ {notification.page.title}
+
+ •
+ >
+ )}
+
+ {formatDistanceToNow(new Date(notification.createdAt), {
+ addSuffix: true
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+
+ >
+ )
+}
+
+export function NotificationButton() {
+ const [isOpen, setIsOpen] = useState(false)
+ const [unreadCount, setUnreadCount] = useState(0)
+ const buttonRef = useRef(null)
+
+ // Fetch unread count
+ useEffect(() => {
+ const fetchUnreadCount = async () => {
+ try {
+ const response = await fetch('/api/notifications?unread=true')
+ if (response.ok) {
+ const data = await response.json()
+ setUnreadCount(data.unreadCount || 0)
+ }
+ } catch (error) {
+ console.error('Error fetching unread count:', error)
+ }
+ }
+
+ fetchUnreadCount()
+ // Poll every 30 seconds
+ const interval = setInterval(fetchUnreadCount, 30000)
+ return () => clearInterval(interval)
+ }, [])
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && isOpen) {
+ setIsOpen(false)
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [isOpen])
+
+ return (
+
+
+ setIsOpen(false)} />
+
+ )
+}
\ No newline at end of file
diff --git a/lib/api-auth.ts b/lib/api-auth.ts
new file mode 100644
index 0000000..7a773d1
--- /dev/null
+++ b/lib/api-auth.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/lib/auth-config"
+import { getCurrentUser } from "@/lib/auth"
+
+/**
+ * Validates authentication for API routes
+ * Returns 403 for invalid/expired tokens
+ * Returns 401 for missing authentication
+ */
+export async function validateApiAuth() {
+ try {
+ // First check if there's a session
+ const session = await getServerSession(authOptions)
+
+ if (!session) {
+ return {
+ error: NextResponse.json(
+ { error: "Unauthorized - No valid session" },
+ { status: 403 }
+ ),
+ user: null
+ }
+ }
+
+ // Then get the full user data
+ const user = await getCurrentUser()
+
+ if (!user) {
+ // Session exists but user not found
+ return {
+ error: NextResponse.json(
+ { error: "Forbidden - Invalid token or user not found" },
+ { status: 403 }
+ ),
+ user: null
+ }
+ }
+
+ return {
+ error: null,
+ user
+ }
+ } catch (error) {
+ console.error("API authentication error:", error)
+ return {
+ error: NextResponse.json(
+ { error: "Internal authentication error" },
+ { status: 500 }
+ ),
+ user: null
+ }
+ }
+}
+
+/**
+ * Wrapper for API route handlers that require authentication
+ */
+export function withApiAuth(
+ handler: (req: Request, context: any, user: any) => Promise
+) {
+ return async (req: Request, context: any) => {
+ const { error, user } = await validateApiAuth()
+
+ if (error) {
+ return error
+ }
+
+ return handler(req, context, user)
+ }
+}
\ No newline at end of file
diff --git a/lib/auth-config.ts b/lib/auth-config.ts
index 66183ac..40245ad 100644
--- a/lib/auth-config.ts
+++ b/lib/auth-config.ts
@@ -72,15 +72,39 @@ export const authOptions: NextAuthOptions = {
token.name = user.name
token.avatarUrl = user.avatarUrl || null
}
+
+ // Validate that user still exists in database on every request
+ if (token && token.id) {
+ try {
+ const existingUser = await prisma.user.findUnique({
+ where: { id: token.id as string },
+ select: { id: true }
+ })
+
+ if (!existingUser) {
+ // User no longer exists, clear the token
+ console.error(`User ${token.id} no longer exists in database, clearing session`)
+ return {} as any // Return empty token to invalidate session
+ }
+ } catch (error) {
+ console.error('Error validating user in JWT callback:', error)
+ }
+ }
+
return token
},
async session({ session, token }) {
- if (token) {
- session.user.id = token.id as string
- session.user.email = token.email as string
- session.user.name = token.name as string
- session.user.avatarUrl = token.avatarUrl as string | null
+ // Check if token is valid (has required fields)
+ if (!token || !token.id || !token.email) {
+ // Invalid token, return null to invalidate session
+ return null as any
}
+
+ session.user.id = token.id as string
+ session.user.email = token.email as string
+ session.user.name = token.name as string
+ session.user.avatarUrl = token.avatarUrl as string | null
+
return session
}
},
diff --git a/lib/auth.ts b/lib/auth.ts
index a38b5ce..cb9f5c8 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -3,8 +3,18 @@ import { authOptions } from "@/lib/auth-config"
import { prisma } from "@/lib/db/prisma"
export async function getServerSessionUser() {
- const session = await getServerSession(authOptions)
- return session?.user || null
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session || !session.user) {
+ return null
+ }
+
+ return session.user
+ } catch (error) {
+ console.error("Error getting server session:", error)
+ return null
+ }
}
export async function getCurrentUser() {
@@ -28,6 +38,13 @@ export async function getCurrentUser() {
}
})
+ if (!user) {
+ // Session exists but user not found in database
+ // This could happen if user was deleted or session is invalid
+ console.error("Session user not found in database:", sessionUser.id)
+ return null
+ }
+
return user
} catch (error) {
console.error("Error fetching current user:", error)
@@ -36,10 +53,16 @@ export async function getCurrentUser() {
}
export async function requireAuth() {
+ const session = await getServerSession(authOptions)
+
+ if (!session) {
+ throw new Error("Authentication required - No valid session")
+ }
+
const user = await getCurrentUser()
if (!user) {
- throw new Error("Authentication required")
+ throw new Error("Authentication required - User not found")
}
return user
diff --git a/lib/services/notification.ts b/lib/services/notification.ts
new file mode 100644
index 0000000..0f2ebdb
--- /dev/null
+++ b/lib/services/notification.ts
@@ -0,0 +1,337 @@
+import { prisma } from '@/lib/db/prisma'
+import { NotificationType } from '@prisma/client'
+
+interface CreateMentionNotificationParams {
+ recipientId: string
+ mentionedById: string
+ pageId: string
+ workspaceId: string
+ blockId?: string
+ context?: string
+}
+
+interface NotificationWithRelations {
+ id: string
+ type: NotificationType
+ title: string
+ message: string
+ read: boolean
+ createdAt: Date
+ updatedAt: Date
+ recipientId: string
+ mentionedById: string | null
+ mentionedBy: {
+ id: string
+ name: string | null
+ email: string
+ avatarUrl: string | null
+ } | null
+ page: {
+ id: string
+ title: string
+ icon: string | null
+ } | null
+ workspace: {
+ id: string
+ name: string
+ slug: string
+ } | null
+ metadata: any
+}
+
+export class NotificationService {
+ /**
+ * Create a mention notification
+ */
+ static async createMentionNotification({
+ recipientId,
+ mentionedById,
+ pageId,
+ workspaceId,
+ blockId,
+ context
+ }: CreateMentionNotificationParams) {
+ try {
+ // Don't create notification if user mentions themselves
+ if (recipientId === mentionedById) {
+ return null
+ }
+
+ // Get page and user details for the notification
+ const [page, mentionedByUser] = await Promise.all([
+ prisma.page.findUnique({
+ where: { id: pageId },
+ select: { title: true }
+ }),
+ prisma.user.findUnique({
+ where: { id: mentionedById },
+ select: { name: true, email: true }
+ })
+ ])
+
+ if (!page || !mentionedByUser) {
+ console.error('Page or user not found for mention notification')
+ return null
+ }
+
+ const notification = await prisma.notification.create({
+ data: {
+ type: NotificationType.MENTION,
+ title: `${mentionedByUser.name || mentionedByUser.email} mentioned you`,
+ message: `You were mentioned in "${page.title}"${context ? `: "${context}"` : ''}`,
+ recipientId,
+ mentionedById,
+ pageId,
+ workspaceId,
+ metadata: {
+ blockId,
+ context
+ }
+ },
+ include: {
+ mentionedBy: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ avatarUrl: true
+ }
+ },
+ page: {
+ select: {
+ id: true,
+ title: true,
+ icon: true
+ }
+ },
+ workspace: {
+ select: {
+ id: true,
+ name: true,
+ slug: true
+ }
+ }
+ }
+ })
+
+ return notification
+ } catch (error) {
+ console.error('Error creating mention notification:', error)
+ return null
+ }
+ }
+
+ /**
+ * Get notifications for a user
+ */
+ static async getUserNotifications(
+ userId: string,
+ options?: {
+ unreadOnly?: boolean
+ limit?: number
+ offset?: number
+ }
+ ): Promise {
+ const { unreadOnly = false, limit = 20, offset = 0 } = options || {}
+
+ const where: any = {
+ recipientId: userId
+ }
+
+ if (unreadOnly) {
+ where.read = false
+ }
+
+ const notifications = await prisma.notification.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: limit,
+ skip: offset,
+ include: {
+ mentionedBy: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ avatarUrl: true
+ }
+ },
+ page: {
+ select: {
+ id: true,
+ title: true,
+ icon: true
+ }
+ },
+ workspace: {
+ select: {
+ id: true,
+ name: true,
+ slug: true
+ }
+ }
+ }
+ })
+
+ return notifications
+ }
+
+ /**
+ * Mark notification as read
+ */
+ static async markAsRead(notificationId: string, userId: string) {
+ try {
+ const notification = await prisma.notification.update({
+ where: {
+ id: notificationId,
+ recipientId: userId // Ensure user owns the notification
+ },
+ data: {
+ read: true
+ }
+ })
+
+ return notification
+ } catch (error) {
+ console.error('Error marking notification as read:', error)
+ return null
+ }
+ }
+
+ /**
+ * Mark all notifications as read for a user
+ */
+ static async markAllAsRead(userId: string) {
+ try {
+ const result = await prisma.notification.updateMany({
+ where: {
+ recipientId: userId,
+ read: false
+ },
+ data: {
+ read: true
+ }
+ })
+
+ return result
+ } catch (error) {
+ console.error('Error marking all notifications as read:', error)
+ return null
+ }
+ }
+
+ /**
+ * Get unread notification count
+ */
+ static async getUnreadCount(userId: string): Promise {
+ try {
+ const count = await prisma.notification.count({
+ where: {
+ recipientId: userId,
+ read: false
+ }
+ })
+
+ return count
+ } catch (error) {
+ console.error('Error getting unread notification count:', error)
+ return 0
+ }
+ }
+
+ /**
+ * Delete a notification
+ */
+ static async deleteNotification(notificationId: string, userId: string) {
+ try {
+ const notification = await prisma.notification.delete({
+ where: {
+ id: notificationId,
+ recipientId: userId // Ensure user owns the notification
+ }
+ })
+
+ return notification
+ } catch (error) {
+ console.error('Error deleting notification:', error)
+ return null
+ }
+ }
+
+ /**
+ * Extract mentions from block content and create notifications
+ */
+ static async processMentionsInContent(
+ content: any,
+ pageId: string,
+ workspaceId: string,
+ mentionedById: string
+ ) {
+ try {
+ // Extract all mentions from the content
+ const mentions = this.extractMentions(content)
+
+ if (mentions.length === 0) {
+ return []
+ }
+
+ // Create notifications for each unique mention
+ const uniqueMentions = [...new Set(mentions.map(m => m.userId))]
+ const notifications = await Promise.all(
+ uniqueMentions.map(userId =>
+ this.createMentionNotification({
+ recipientId: userId,
+ mentionedById,
+ pageId,
+ workspaceId,
+ context: mentions.find(m => m.userId === userId)?.context
+ })
+ )
+ )
+
+ return notifications.filter(n => n !== null)
+ } catch (error) {
+ console.error('Error processing mentions in content:', error)
+ return []
+ }
+ }
+
+ /**
+ * Extract mentions from block content
+ * This needs to parse the BlockNote content structure
+ */
+ private static extractMentions(content: any): Array<{ userId: string; context?: string }> {
+ const mentions: Array<{ userId: string; context?: string }> = []
+
+ // Helper function to recursively search for mentions
+ const searchForMentions = (obj: any, context: string = '') => {
+ if (!obj) return
+
+ // Check if this is a mention inline content
+ if (obj.type === 'mention' && obj.props?.userId) {
+ mentions.push({
+ userId: obj.props.userId,
+ context: context.slice(0, 100) // Limit context length
+ })
+ }
+
+ // Recursively search in arrays
+ if (Array.isArray(obj)) {
+ obj.forEach(item => searchForMentions(item, context))
+ }
+
+ // Recursively search in objects
+ else if (typeof obj === 'object') {
+ // Build context from text content
+ if (obj.type === 'text' && obj.text) {
+ context += obj.text
+ }
+
+ Object.values(obj).forEach(value => searchForMentions(value, context))
+ }
+ }
+
+ searchForMentions(content)
+ return mentions
+ }
+}
\ No newline at end of file
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..df6dd22
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,65 @@
+import { withAuth } from "next-auth/middleware"
+import { NextResponse } from "next/server"
+import type { NextRequest } from "next/server"
+
+export default withAuth(
+ function middleware(req) {
+ // This function is called if the token exists
+ // You can access the token via req.nextauth.token
+ return NextResponse.next()
+ },
+ {
+ callbacks: {
+ authorized: ({ token, req }) => {
+ const pathname = req.nextUrl.pathname
+
+ // Allow access to public routes
+ const publicRoutes = ['/login', '/signup', '/api/auth', '/', '/demo', '/forgot-password']
+ const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route))
+
+ if (isPublicRoute) {
+ return true
+ }
+
+ // Allow access to static files and Next.js internals
+ if (pathname.startsWith('/_next') || pathname.startsWith('/api/_next')) {
+ return true
+ }
+
+ // For API routes, check token and return false to trigger 403
+ if (pathname.startsWith('/api/')) {
+ if (!token) {
+ // This will trigger a 403 response for API routes
+ return false
+ }
+ return true
+ }
+
+ // For protected pages, check if token exists
+ if (!token) {
+ // For non-API routes, returning false will redirect to login
+ return false
+ }
+
+ return true
+ },
+ },
+ pages: {
+ signIn: '/login',
+ error: '/login',
+ },
+ }
+)
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder
+ */
+ '/((?!_next/static|_next/image|favicon.ico|public).*)',
+ ],
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 61afd25..d0d7ad7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6383,8 +6383,8 @@ snapshots:
'@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -6403,7 +6403,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -6414,22 +6414,22 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.9.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -6440,7 +6440,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
diff --git a/scripts/check-user.ts b/scripts/check-user.ts
new file mode 100644
index 0000000..b601581
--- /dev/null
+++ b/scripts/check-user.ts
@@ -0,0 +1,54 @@
+import { prisma } from '../lib/db/prisma'
+
+async function checkUser() {
+ const userId = 'cmeubd19x00006zcdy52aaf1n'
+
+ console.log(`Checking for user with ID: ${userId}`)
+
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: userId }
+ })
+
+ if (user) {
+ console.log('User found:', {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ createdAt: user.createdAt
+ })
+ } else {
+ console.log('User NOT found in database')
+ console.log('\nThis user ID exists in your session but not in the database.')
+ console.log('You need to clear your browser cookies/session to fix this.')
+ console.log('\nTo clear session:')
+ console.log('1. Clear browser cookies for this site')
+ console.log('2. Or open Developer Tools > Application > Storage > Clear Site Data')
+ }
+
+ // Also list all existing users
+ console.log('\n--- All existing users ---')
+ const allUsers = await prisma.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true
+ }
+ })
+
+ if (allUsers.length === 0) {
+ console.log('No users found in database')
+ } else {
+ allUsers.forEach(u => {
+ console.log(`- ${u.email} (${u.id})`)
+ })
+ }
+
+ } catch (error) {
+ console.error('Error checking user:', error)
+ } finally {
+ await prisma.$disconnect()
+ }
+}
+
+checkUser()
\ No newline at end of file