From 42a585d880751e2dcf00e654678f96c2d40f5d6c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 11:47:40 +0000 Subject: [PATCH 01/60] feat: add user types with zod schemas --- surfsense_web/contracts/types/user.types.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 surfsense_web/contracts/types/user.types.ts diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts new file mode 100644 index 000000000..f5df17694 --- /dev/null +++ b/surfsense_web/contracts/types/user.types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const user = z.object({ + id: z.string().uuid(), + email: z.string().email(), + is_active: z.boolean(), + is_superuser: z.boolean(), + is_verified: z.boolean(), + pages_limit: z.number(), + pages_used: z.number(), +}); + +/** + * Get current user + */ +export const getMeResponse = user; + +export type User = z.infer; +export type GetMeResponse = z.infer; From 8c5ecfd4e44ac170067dd5014c7a0e1e63d67dff Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 11:58:10 +0000 Subject: [PATCH 02/60] feat: add user API service --- surfsense_web/lib/apis/user-api.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 surfsense_web/lib/apis/user-api.service.ts diff --git a/surfsense_web/lib/apis/user-api.service.ts b/surfsense_web/lib/apis/user-api.service.ts new file mode 100644 index 000000000..ea46ac116 --- /dev/null +++ b/surfsense_web/lib/apis/user-api.service.ts @@ -0,0 +1,13 @@ +import { getMeResponse } from "@/contracts/types/user.types"; +import { baseApiService } from "./base-api.service"; + +class UserApiService { + /** + * Get current authenticated user + */ + getMe = async () => { + return baseApiService.get(`/users/me`, getMeResponse); + }; +} + +export const userApiService = new UserApiService(); From d5997a6d0cad6fadd63878c60a8d29dc6881b443 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:04:39 +0000 Subject: [PATCH 03/60] feat: add user cache keys --- surfsense_web/lib/query-client/cache-keys.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 797c40b65..ae8bb0f08 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -41,4 +41,7 @@ export const cacheKeys = { detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const, communityPrompts: ["search-spaces", "community-prompts"] as const, } -}; + user: { + current: () => ["user", "me"] as const, + }, +}; \ No newline at end of file From cee27f1263b57a3d4c934b2c5a4c6d1cb4584975 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:07:47 +0000 Subject: [PATCH 04/60] feat: add current user query atom --- surfsense_web/atoms/user/user-query.atoms.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 surfsense_web/atoms/user/user-query.atoms.ts diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts new file mode 100644 index 000000000..ea3e7ec49 --- /dev/null +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -0,0 +1,13 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { userApiService } from "@/lib/apis/user-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const currentUserAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.user.current(), + staleTime: 5 * 60 * 1000, // 5 minutes + queryFn: async () => { + return userApiService.getMe(); + }, + }; +}); From 6b266ff12898ac2adcc1a60f0d206093abe9e746 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:19:33 +0000 Subject: [PATCH 05/60] refactor: migrate dashboard page from useUser to currentUserAtom --- surfsense_web/app/dashboard/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index b1525a9db..d3d88a47a 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -35,10 +35,10 @@ import { } from "@/components/ui/card"; import { Spotlight } from "@/components/ui/spotlight"; import { Tilt } from "@/components/ui/tilt"; -import { useUser } from "@/hooks"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; /** * Formats a date string into a readable format @@ -159,8 +159,7 @@ const DashboardPage = () => { const { data: searchSpaces = [], isLoading: loading, error, refetch: refreshSearchSpaces } = useAtomValue(searchSpacesAtom); const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); - // Fetch user details - const { user, loading: isLoadingUser, error: userError } = useUser(); + const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom); // Create user object for UserDropdown const customUser = { From 2e0f59522aff60e42e37b62e4453c62edf471c25 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:25:09 +0000 Subject: [PATCH 06/60] refactor: migrate AppSidebarProvider from useUser to currentUserAtom --- surfsense_web/components/sidebar/AppSidebarProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index fce8697f7..55bc8331c 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -17,10 +17,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useUser } from "@/hooks"; import { useQuery } from "@tanstack/react-query"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; interface AppSidebarProviderProps { searchSpaceId: string; @@ -68,7 +68,7 @@ export function AppSidebarProvider({ enabled: !!searchSpaceId, }); - const { user } = useUser(); + const { data: user } = useAtomValue(currentUserAtom); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); From 8691ba9d72cff145faed3236a82783f99e1acd6f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:34:10 +0000 Subject: [PATCH 07/60] refactor: migrate app-sidebar from useUser to currentUserAtom --- surfsense_web/components/sidebar/app-sidebar.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index cd42e6fe8..6f86d3808 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -38,7 +38,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useUser } from "@/hooks/use-user"; +import { useAtomValue } from "jotai"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; /** * Generates a consistent color based on a string (email) @@ -262,7 +263,7 @@ export const AppSidebar = memo(function AppSidebar({ }: AppSidebarProps) { const router = useRouter(); const { theme, setTheme } = useTheme(); - const { user, loading: isLoadingUser } = useUser(); + const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom); const [isClient, setIsClient] = useState(false); useEffect(() => { From 4bcb92c3ce4130904b11020b15068b281b5825d5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 12:44:08 +0000 Subject: [PATCH 08/60] refactor: delete unused useUser hook after migration to currentUserAtom --- surfsense_web/hooks/index.ts | 1 - surfsense_web/hooks/use-user.ts | 53 --------------------------------- 2 files changed, 54 deletions(-) delete mode 100644 surfsense_web/hooks/use-user.ts diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts index f7ef22534..d2a4ff6bf 100644 --- a/surfsense_web/hooks/index.ts +++ b/surfsense_web/hooks/index.ts @@ -1,4 +1,3 @@ export * from "./use-logs"; export * from "./use-rbac"; export * from "./use-search-source-connectors"; -export * from "./use-user"; diff --git a/surfsense_web/hooks/use-user.ts b/surfsense_web/hooks/use-user.ts deleted file mode 100644 index e81ac350b..000000000 --- a/surfsense_web/hooks/use-user.ts +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { authenticatedFetch } from "@/lib/auth-utils"; - -interface User { - id: string; - email: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; - pages_limit: number; - pages_used: number; -} - -export function useUser() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchUser = async () => { - try { - // Only run on client-side - if (typeof window === "undefined") return; - - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch user: ${response.status}`); - } - - const data = await response.json(); - setUser(data); - setError(null); - } catch (err: any) { - setError(err.message || "Failed to fetch user"); - console.error("Error fetching user:", err); - } finally { - setLoading(false); - } - }; - - fetchUser(); - }, []); - - return { user, loading, error }; -} From bc6f7e15f1b509edf961791a774fe75745dbedbd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 05:37:30 +0000 Subject: [PATCH 09/60] refact: rebase on upstream dev --- surfsense_web/lib/query-client/cache-keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index ae8bb0f08..eb2c4972a 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -40,7 +40,7 @@ export const cacheKeys = { ["search-spaces", ...(queries ? Object.values(queries) : [])] as const, detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const, communityPrompts: ["search-spaces", "community-prompts"] as const, - } + }, user: { current: () => ["user", "me"] as const, }, From 8eceb7a5cbc28602f49901312cbaab2546be9e75 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:28:30 +0530 Subject: [PATCH 10/60] feat: add note management functionality with BlockNote support - Introduced a new ENUM value 'NOTE' for document types in the database. - Implemented backend routes for creating, listing, and deleting notes. - Added a new frontend page for creating notes with a BlockNote editor. - Updated sidebar to include recent notes and an option to add new notes. - Enhanced API service for notes with validation and request/response schemas. - Updated translations to support new note-related terms. --- .../48_add_note_to_documenttype_enum.py | 47 ++++ surfsense_backend/app/db.py | 1 + surfsense_backend/app/routes/__init__.py | 2 + surfsense_backend/app/routes/editor_routes.py | 25 +- surfsense_backend/app/routes/notes_routes.py | 228 ++++++++++++++++++ .../[search_space_id]/notes/new/page.tsx | 155 ++++++++++++ .../components/sidebar/AppSidebarProvider.tsx | 57 +++++ .../components/sidebar/app-sidebar.tsx | 45 +++- .../components/sidebar/nav-notes.tsx | 214 ++++++++++++++++ .../components/sidebar/nav-projects.tsx | 13 - .../components/sidebar/page-usage-display.tsx | 44 ++-- surfsense_web/lib/apis/notes-api.service.ts | 148 ++++++++++++ surfsense_web/messages/en.json | 5 +- 13 files changed, 948 insertions(+), 36 deletions(-) create mode 100644 surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py create mode 100644 surfsense_backend/app/routes/notes_routes.py create mode 100644 surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx create mode 100644 surfsense_web/components/sidebar/nav-notes.tsx create mode 100644 surfsense_web/lib/apis/notes-api.service.ts diff --git a/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py new file mode 100644 index 000000000..353b5b042 --- /dev/null +++ b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py @@ -0,0 +1,47 @@ +"""48_add_note_to_documenttype_enum + +Revision ID: 48 +Revises: 47 +Adds NOTE document type to support user-created BlockNote documents. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "48" +down_revision: str | None = "47" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# Define the ENUM type name and the new value +ENUM_NAME = "documenttype" +NEW_VALUE = "NOTE" + + +def upgrade() -> None: + """Safely add 'NOTE' to documenttype enum if missing.""" + op.execute( + f""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = '{ENUM_NAME}' AND e.enumlabel = '{NEW_VALUE}' + ) THEN + ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}'; + END IF; + END + $$; + """ + ) + + +def downgrade() -> None: + """ + Downgrade logic not implemented since PostgreSQL + does not support removing enum values. + """ + pass \ No newline at end of file diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index cf267bd3d..c338240b3 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -51,6 +51,7 @@ class DocumentType(str, Enum): LUMA_CONNECTOR = "LUMA_CONNECTOR" ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR" BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" + NOTE = "NOTE" class SearchSourceConnectorType(str, Enum): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 4b829fe84..c9d70588d 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import ( from .llm_config_routes import router as llm_config_router from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router +from .notes_routes import router as notes_router from .podcasts_routes import router as podcasts_router from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router @@ -26,6 +27,7 @@ router.include_router(search_spaces_router) router.include_router(rbac_router) # RBAC routes for roles, members, invites router.include_router(editor_router) router.include_router(documents_router) +router.include_router(notes_router) router.include_router(podcasts_router) router.include_router(chats_router) router.include_router(search_source_connectors_router) diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 7b7a15c13..3d23f66a1 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db import Document, Permission, User, get_async_session +from app.db import Document, DocumentType, Permission, User, get_async_session from app.users import current_active_user from app.utils.rbac import check_permission @@ -65,7 +65,28 @@ async def get_editor_content( else None, } - # Lazy migration: Try to generate blocknote_document from chunks + # For NOTE type documents, return empty BlockNote structure if no content exists + if document.document_type == DocumentType.NOTE: + # Return empty BlockNote structure + empty_blocknote = [ + { + "type": "paragraph", + "content": [], + "children": [], + } + ] + # Save empty structure if not already saved + if not document.blocknote_document: + document.blocknote_document = empty_blocknote + await session.commit() + return { + "document_id": document.id, + "title": document.title, + "blocknote_document": empty_blocknote, + "updated_at": document.updated_at.isoformat() if document.updated_at else None, + } + + # Lazy migration: Try to generate blocknote_document from chunks (for other document types) from app.utils.blocknote_converter import convert_markdown_to_blocknote chunks = sorted(document.chunks, key=lambda c: c.id) diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py new file mode 100644 index 000000000..1ea8dc345 --- /dev/null +++ b/surfsense_backend/app/routes/notes_routes.py @@ -0,0 +1,228 @@ +""" +Notes routes for creating and managing BlockNote documents. +""" + +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Document, DocumentType, Permission, User, get_async_session +from app.schemas import DocumentRead, PaginatedResponse +from app.users import current_active_user +from app.utils.rbac import check_permission + +router = APIRouter() + + +class CreateNoteRequest(BaseModel): + title: str + blocknote_document: list[dict[str, Any]] | None = None + + +@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead) +async def create_note( + search_space_id: int, + request: CreateNoteRequest, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Create a new note (BlockNote document). + + Requires DOCUMENTS_CREATE permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_CREATE.value, + "You don't have permission to create notes in this search space", + ) + + if not request.title or not request.title.strip(): + raise HTTPException(status_code=400, detail="Title is required") + + # Default empty BlockNote structure if not provided + blocknote_document = request.blocknote_document + if blocknote_document is None: + blocknote_document = [ + { + "type": "paragraph", + "content": [], + "children": [], + } + ] + + # Generate content hash (use title for now, will be updated on save) + import hashlib + + content_hash = hashlib.sha256(request.title.encode()).hexdigest() + + # Create document with NOTE type + from app.config import config + + document = Document( + search_space_id=search_space_id, + title=request.title.strip(), + document_type=DocumentType.NOTE, + content="", # Empty initially, will be populated on first save/reindex + content_hash=content_hash, + blocknote_document=blocknote_document, + content_needs_reindexing=False, # Will be set to True on first save + document_metadata={"NOTE": True}, + embedding=None, # Will be generated on first reindex + updated_at=datetime.now(UTC), + ) + + session.add(document) + await session.commit() + await session.refresh(document) + + return DocumentRead( + id=document.id, + title=document.title, + document_type=document.document_type, + content=document.content, + content_hash=document.content_hash, + unique_identifier_hash=document.unique_identifier_hash, + document_metadata=document.document_metadata, + search_space_id=document.search_space_id, + created_at=document.created_at, + updated_at=document.updated_at, + ) + + +@router.get("/search-spaces/{search_space_id}/notes", response_model=PaginatedResponse[DocumentRead]) +async def list_notes( + search_space_id: int, + skip: int | None = None, + page: int | None = None, + page_size: int = 50, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + List all notes in a search space. + + Requires DOCUMENTS_READ permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_READ.value, + "You don't have permission to read notes in this search space", + ) + + from sqlalchemy import func + + # Build query + query = select(Document).where( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ) + + # Get total count + count_query = select(func.count()).select_from( + select(Document).where( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ).subquery() + ) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Apply pagination + if skip is not None: + query = query.offset(skip) + elif page is not None: + query = query.offset(page * page_size) + else: + query = query.offset(0) + + if page_size > 0: + query = query.limit(page_size) + + # Order by updated_at descending (most recent first) + query = query.order_by(Document.updated_at.desc()) + + # Execute query + result = await session.execute(query) + documents = result.scalars().all() + + # Convert to response models + items = [ + DocumentRead( + id=doc.id, + title=doc.title, + document_type=doc.document_type, + content=doc.content, + content_hash=doc.content_hash, + unique_identifier_hash=doc.unique_identifier_hash, + document_metadata=doc.document_metadata, + search_space_id=doc.search_space_id, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + for doc in documents + ] + + # Calculate pagination info + actual_skip = skip if skip is not None else (page * page_size if page is not None else 0) + has_more = (actual_skip + len(items)) < total if page_size > 0 else False + + return PaginatedResponse( + items=items, + total=total, + page=page if page is not None else (actual_skip // page_size if page_size > 0 else 0), + page_size=page_size, + has_more=has_more, + ) + + +@router.delete("/search-spaces/{search_space_id}/notes/{note_id}") +async def delete_note( + search_space_id: int, + note_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Delete a note. + + Requires DOCUMENTS_DELETE permission. + """ + # Check RBAC permission + await check_permission( + session, + user, + search_space_id, + Permission.DOCUMENTS_DELETE.value, + "You don't have permission to delete notes in this search space", + ) + + # Get document + result = await session.execute( + select(Document).where( + Document.id == note_id, + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.NOTE, + ) + ) + document = result.scalars().first() + + if not document: + raise HTTPException(status_code=404, detail="Note not found") + + # Delete document (chunks will be cascade deleted) + await session.delete(document) + await session.commit() + + return {"message": "Note deleted successfully", "note_id": note_id} + diff --git a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx new file mode 100644 index 000000000..c34b8167c --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; +import { motion } from "motion/react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { notesApiService } from "@/lib/apis/notes-api.service"; + +export default function NewNotePage() { + const params = useParams(); + const router = useRouter(); + const searchSpaceId = Number(params.search_space_id); + + const [title, setTitle] = useState(""); + const [editorContent, setEditorContent] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = async () => { + if (!title.trim()) { + toast.error("Please enter a title for your note"); + return; + } + + setCreating(true); + setError(null); + + try { + const note = await notesApiService.createNote({ + search_space_id: searchSpaceId, + title: title.trim(), + blocknote_document: editorContent || undefined, + }); + + toast.success("Note created successfully!"); + // Redirect to editor + router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); + } catch (error) { + console.error("Error creating note:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to create note. Please try again."; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setCreating(false); + } + }; + + const handleCancel = () => { + router.back(); + }; + + return ( + + {/* Toolbar */} +
+
+ +
+

New Note

+

Create a new note

+
+
+ +
+ + +
+
+ + {/* Content */} +
+
+
+ {error && ( + + + +
+ + Error +
+ {error} +
+
+
+ )} + + + + Note Details + Enter a title for your note + + +
+ + setTitle(e.target.value)} + disabled={creating} + className="text-lg" + /> +
+
+
+ + + + Content + Start writing your note (optional) + + +
+ +
+
+
+
+
+
+
+ ); +} + diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index fce8697f7..27147a4b0 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -2,6 +2,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; @@ -19,6 +20,7 @@ import { } from "@/components/ui/dialog"; import { useUser } from "@/hooks"; import { useQuery } from "@tanstack/react-query"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -48,6 +50,7 @@ export function AppSidebarProvider({ }: AppSidebarProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); + const router = useRouter(); const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom); const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom); const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = @@ -70,6 +73,22 @@ export function AppSidebarProvider({ const { user } = useUser(); + // Fetch notes + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + refetch: refetchNotes, + } = useQuery({ + queryKey: ["notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 10, // Get recent 10 notes + }), + enabled: !!searchSpaceId, + }); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isClient, setIsClient] = useState(false); @@ -162,6 +181,41 @@ export function AppSidebarProvider({ // Use fallback chats if there's an error or no chats const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; + // Transform notes to the format expected by NavNotes + const recentNotes = useMemo(() => { + return notesData?.items + ? notesData.items.map((note) => ({ + name: note.title, + url: `/dashboard/${note.search_space_id}/editor/${note.id}`, + icon: "FileText", + id: note.id, + search_space_id: note.search_space_id, + actions: [ + { + name: "Delete", + icon: "Trash2", + onClick: async () => { + try { + await notesApiService.deleteNote({ + search_space_id: note.search_space_id, + note_id: note.id, + }); + refetchNotes(); + } catch (error) { + console.error("Error deleting note:", error); + } + }, + }, + ], + })) + : []; + }, [notesData, refetchNotes]); + + // Handle add note + const handleAddNote = useCallback(() => { + router.push(`/dashboard/${searchSpaceId}/notes/new`); + }, [router, searchSpaceId]); + // Memoized updated navSecondary const updatedNavSecondary = useMemo(() => { const updated = [...navSecondary]; @@ -204,6 +258,7 @@ export function AppSidebarProvider({ navSecondary={navSecondary} navMain={navMain} RecentChats={[]} + RecentNotes={[]} pageUsage={pageUsage} /> ); @@ -216,6 +271,8 @@ export function AppSidebarProvider({ navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} + RecentNotes={recentNotes} + onAddNote={handleAddNote} pageUsage={pageUsage} /> diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index cd42e6fe8..2144dd861 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -24,7 +24,6 @@ import { UserPlus, Users, } from "lucide-react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { memo, useEffect, useMemo, useState } from "react"; @@ -115,6 +114,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) { } import { NavMain } from "@/components/sidebar/nav-main"; +import { NavNotes } from "@/components/sidebar/nav-notes"; import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavSecondary } from "@/components/sidebar/nav-secondary"; import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; @@ -209,6 +209,20 @@ const defaultData = { id: 1003, }, ], + RecentNotes: [ + { + name: "Meeting Notes", + url: "#", + icon: "FileText", + id: 2001, + }, + { + name: "Project Ideas", + url: "#", + icon: "FileText", + id: 2002, + }, + ], }; interface AppSidebarProps extends React.ComponentProps { @@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps { onClick: () => void; }[]; }[]; + RecentNotes?: { + name: string; + url: string; + icon: string; + id?: number; + search_space_id?: number; + actions?: { + name: string; + icon: string; + onClick: () => void; + }[]; + }[]; user?: { name: string; email: string; @@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps { pagesUsed: number; pagesLimit: number; }; + onAddNote?: () => void; } // Memoized AppSidebar component for better performance @@ -257,7 +284,9 @@ export const AppSidebar = memo(function AppSidebar({ navMain = defaultData.navMain, navSecondary = defaultData.navSecondary, RecentChats = defaultData.RecentChats, + RecentNotes = defaultData.RecentNotes, pageUsage, + onAddNote, ...props }: AppSidebarProps) { const router = useRouter(); @@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({ ); }, [RecentChats]); + // Process RecentNotes to resolve icon names to components + const processedRecentNotes = useMemo(() => { + return ( + RecentNotes?.map((item) => ({ + ...item, + icon: iconMap[item.icon] || FileText, + })) || [] + ); + }, [RecentNotes]); + // Get user display name from email const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); @@ -412,6 +451,10 @@ export const AppSidebar = memo(function AppSidebar({ )} + +
+ +
{pageUsage && ( diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx new file mode 100644 index 000000000..14dd75c59 --- /dev/null +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { + ChevronRight, + ExternalLink, + FileText, + type LucideIcon, + MoreHorizontal, + Plus, + RefreshCw, + Share, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useState } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +// Map of icon names to their components +const actionIconMap: Record = { + ExternalLink, + FileText, + Share, + Trash2, + MoreHorizontal, + RefreshCw, +}; + +interface NoteAction { + name: string; + icon: string; + onClick: () => void; +} + +interface NoteItem { + name: string; + url: string; + icon: LucideIcon; + id?: number; + search_space_id?: number; + actions?: NoteAction[]; +} + +interface NavNotesProps { + notes: NoteItem[]; + onAddNote?: () => void; + defaultOpen?: boolean; +} + +export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) { + const t = useTranslations("sidebar"); + const { isMobile } = useSidebar(); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const [isOpen, setIsOpen] = useState(defaultOpen); + + // Handle note deletion with loading state + const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { + setIsDeleting(noteId); + try { + await deleteAction(); + } finally { + setIsDeleting(null); + } + }, []); + + // Enhanced note item component + const NoteItemComponent = useCallback( + ({ note }: { note: NoteItem }) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + router.push(note.url)} + disabled={isDeletingNote} + className={`group/item relative ${isDeletingNote ? "opacity-50" : ""}`} + size="sm" + > + + {note.name} + + + {note.actions && note.actions.length > 0 && ( + + + + + More + + + + {note.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || FileText; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteNote(note.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingNote} + className={isDeleteAction ? "text-destructive" : ""} + > + + {isDeletingNote && isDeleteAction ? "Deleting..." : action.name} + + ); + })} + + + )} + + ); + }, + [isDeleting, router, isMobile, handleDeleteNote] + ); + + return ( + + +
+ + + + + {t("notes") || "Notes"} + + + + {onAddNote && ( + + )} +
+ + + + {/* Note Items */} + {notes.length > 0 ? ( + notes.map((note) => ) + ) : ( + /* Empty state with create button */ + + {onAddNote ? ( + + + {t("create_new_note") || "Create a new note"} + + ) : ( + + + {t("no_notes") || "No notes yet"} + + )} + + )} + + + +
+
+ ); +} + diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx index b3e1435d1..3862ce75d 100644 --- a/surfsense_web/components/sidebar/nav-projects.tsx +++ b/surfsense_web/components/sidebar/nav-projects.tsx @@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) { return ( {t("recent_chats")} - - {/* Search Input */} - {showSearch && ( -
- setSearchQuery(e.target.value)} - className="h-8" - /> -
- )} - {/* Chat Items */} {filteredChats.length > 0 ? ( diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx index 51e67d2f0..80f76b14d 100644 --- a/surfsense_web/components/sidebar/page-usage-display.tsx +++ b/surfsense_web/components/sidebar/page-usage-display.tsx @@ -1,6 +1,6 @@ "use client"; -import { Mail } from "lucide-react"; +import { ChevronRight, Mail } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { SidebarGroup, @@ -8,6 +8,7 @@ import { SidebarGroupLabel, useSidebar, } from "@/components/ui/sidebar"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; interface PageUsageDisplayProps { pagesUsed: number; @@ -21,19 +22,16 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp return ( - - Page Usage - - -
- {isCollapsed ? ( - // Show only a compact progress indicator when collapsed -
- -
- ) : ( - // Show full details when expanded - <> + + + + Page Usage + + + + + +
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages @@ -54,10 +52,18 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp to increase limits

- - )} -
-
+
+
+ + + {isCollapsed && ( + // Show only a compact progress indicator when sidebar is collapsed + +
+ +
+
+ )}
); -} +} \ No newline at end of file diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts new file mode 100644 index 000000000..38a48e0d7 --- /dev/null +++ b/surfsense_web/lib/apis/notes-api.service.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +// Request/Response schemas +const createNoteRequest = z.object({ + search_space_id: z.number(), + title: z.string().min(1), + blocknote_document: z.array(z.any()).optional(), +}); + +const createNoteResponse = z.object({ + id: z.number(), + title: z.string(), + document_type: z.string(), + content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), + document_metadata: z.record(z.any()).nullable(), + search_space_id: z.number(), + created_at: z.string(), + updated_at: z.string().nullable(), +}); + +const getNotesRequest = z.object({ + search_space_id: z.number(), + skip: z.number().optional(), + page: z.number().optional(), + page_size: z.number().optional(), +}); + +const noteItem = z.object({ + id: z.number(), + title: z.string(), + document_type: z.string(), + content: z.string(), + content_hash: z.string(), + unique_identifier_hash: z.string().nullable(), + document_metadata: z.record(z.any()).nullable(), + search_space_id: z.number(), + created_at: z.string(), + updated_at: z.string().nullable(), +}); + +const getNotesResponse = z.object({ + items: z.array(noteItem), + total: z.number(), + page: z.number(), + page_size: z.number(), + has_more: z.boolean(), +}); + +const deleteNoteRequest = z.object({ + search_space_id: z.number(), + note_id: z.number(), +}); + +const deleteNoteResponse = z.object({ + message: z.string(), + note_id: z.number(), +}); + +// Type exports +export type CreateNoteRequest = z.infer; +export type CreateNoteResponse = z.infer; +export type GetNotesRequest = z.infer; +export type GetNotesResponse = z.infer; +export type NoteItem = z.infer; +export type DeleteNoteRequest = z.infer; +export type DeleteNoteResponse = z.infer; + +class NotesApiService { + /** + * Create a new note + */ + createNote = async (request: CreateNoteRequest) => { + const parsedRequest = createNoteRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, title, blocknote_document } = parsedRequest.data; + + // Send both title and blocknote_document in request body + const body = { + title, + ...(blocknote_document && { blocknote_document }), + }; + + return baseApiService.post( + `/api/v1/search-spaces/${search_space_id}/notes`, + createNoteResponse, + { body } + ); + }; + + /** + * Get list of notes + */ + getNotes = async (request: GetNotesRequest) => { + const parsedRequest = getNotesRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, skip, page, page_size } = parsedRequest.data; + + // Build query params + const params = new URLSearchParams(); + if (skip !== undefined) params.append("skip", String(skip)); + if (page !== undefined) params.append("page", String(page)); + if (page_size !== undefined) params.append("page_size", String(page_size)); + + return baseApiService.get( + `/api/v1/search-spaces/${search_space_id}/notes?${params.toString()}`, + getNotesResponse + ); + }; + + /** + * Delete a note + */ + deleteNote = async (request: DeleteNoteRequest) => { + const parsedRequest = deleteNoteRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { search_space_id, note_id } = parsedRequest.data; + + return baseApiService.delete( + `/api/v1/search-spaces/${search_space_id}/notes/${note_id}`, + deleteNoteResponse + ); + }; +} + +export const notesApiService = new NotesApiService(); + diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 140b8363d..8e9046abb 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -642,7 +642,10 @@ "no_chats_found": "No chats found", "no_recent_chats": "No recent chats", "view_all_chats": "View All Chats", - "search_space": "Search Space" + "search_space": "Search Space", + "notes": "Notes", + "no_notes": "No notes yet", + "create_new_note": "Create a new note" }, "errors": { "something_went_wrong": "Something went wrong", From e1e813702a5a16d5f4489c2e7bd5087722fb253e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:57:06 +0530 Subject: [PATCH 11/60] feat: enhance note creation and editing experience - Added dynamic title extraction from the first block of BlockNote documents for NOTE type. - Updated editor routes to support new note creation with a BlockNote editor. - Implemented unsaved changes dialog for better user experience when navigating away from the editor. - Refactored BlockNoteEditor to ensure the first block is a heading when creating new notes. - Removed the old note creation page in favor of the new streamlined editor experience. --- surfsense_backend/app/routes/editor_routes.py | 24 +++ .../editor/[documentId]/page.tsx | 97 ++++++++-- .../[search_space_id]/editor/new/page.tsx | 175 ++++++++++++++++++ .../[search_space_id]/notes/new/page.tsx | 155 ---------------- surfsense_web/components/BlockNoteEditor.tsx | 106 ++++++++++- .../components/sidebar/AppSidebarProvider.tsx | 2 +- 6 files changed, 386 insertions(+), 173 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 3d23f66a1..541a7a012 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -59,6 +59,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": document.blocknote_document, "updated_at": document.updated_at.isoformat() if document.updated_at @@ -82,6 +83,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": empty_blocknote, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -123,6 +125,7 @@ async def get_editor_content( return { "document_id": document.id, "title": document.title, + "document_type": document.document_type.value, "blocknote_document": blocknote_json, "updated_at": document.updated_at.isoformat() if document.updated_at else None, } @@ -168,6 +171,27 @@ async def save_document( if not blocknote_document: raise HTTPException(status_code=400, detail="blocknote_document is required") + # For NOTE type documents, extract title from first block (heading) + if ( + document.document_type == DocumentType.NOTE + and blocknote_document + and len(blocknote_document) > 0 + ): + first_block = blocknote_document[0] + if first_block and first_block.get("content"): + # Extract text from first block content + title_parts = [] + for item in first_block["content"]: + if isinstance(item, str): + title_parts.append(item) + elif isinstance(item, dict) and "text" in item: + title_parts.append(item["text"]) + new_title = "".join(title_parts).strip() + if new_title: + document.title = new_title + else: + document.title = "Untitled" + # Save BlockNote document document.blocknote_document = blocknote_document document.updated_at = datetime.now(UTC) diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index a6dbd4536..9e188b09a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,11 +1,21 @@ "use client"; -import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Loader2, Save, X } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -14,10 +24,40 @@ import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth- interface EditorContent { document_id: number; title: string; + document_type?: string; blocknote_document: any; updated_at: string | null; } +// Helper function to extract title from BlockNote document +// Takes the text content from the first block (should be a heading for notes) +function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { + if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { + return "Untitled"; + } + + const firstBlock = blocknoteDocument[0]; + if (!firstBlock) { + return "Untitled"; + } + + // Extract text from block content + // BlockNote blocks have a content array with inline content + if (firstBlock.content && Array.isArray(firstBlock.content)) { + const textContent = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + return textContent || "Untitled"; + } + + return "Untitled"; +} + export default function EditorPage() { const params = useParams(); const router = useRouter(); @@ -29,6 +69,7 @@ export default function EditorPage() { const [editorContent, setEditorContent] = useState(null); const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); // Fetch document content - DIRECT CALL TO FASTAPI useEffect(() => { @@ -90,6 +131,17 @@ export default function EditorPage() { } }, [editorContent, document]); + // Check if this is a NOTE type document + const isNote = document?.document_type === "NOTE"; + + // Extract title dynamically from editor content for notes, otherwise use document title + const displayTitle = useMemo(() => { + if (isNote && editorContent) { + return extractTitleFromBlockNote(editorContent); + } + return document?.title || "Untitled"; + }, [isNote, editorContent, document?.title]); + // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI @@ -142,16 +194,19 @@ export default function EditorPage() { } }; - const handleCancel = () => { + const handleBack = () => { if (hasUnsavedChanges) { - if (confirm("You have unsaved changes. Are you sure you want to leave?")) { - router.back(); - } + setShowUnsavedDialog(true); } else { router.back(); } }; + const handleConfirmLeave = () => { + setShowUnsavedDialog(false); + router.back(); + }; + if (loading) { return (
@@ -217,15 +272,15 @@ export default function EditorPage() {
-

{document.title}

+

{displayTitle}

{hasUnsavedChanges &&

Unsaved changes

}
- + +
+
+ + {/* Editor Container - matches editor page layout */} +
+
+ {error && ( + +
+ +

{error}

+
+
+ )} +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx deleted file mode 100644 index c34b8167c..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/notes/new/page.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; -import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { notesApiService } from "@/lib/apis/notes-api.service"; - -export default function NewNotePage() { - const params = useParams(); - const router = useRouter(); - const searchSpaceId = Number(params.search_space_id); - - const [title, setTitle] = useState(""); - const [editorContent, setEditorContent] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - const handleCreate = async () => { - if (!title.trim()) { - toast.error("Please enter a title for your note"); - return; - } - - setCreating(true); - setError(null); - - try { - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title: title.trim(), - blocknote_document: editorContent || undefined, - }); - - toast.success("Note created successfully!"); - // Redirect to editor - router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); - } catch (error) { - console.error("Error creating note:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to create note. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleCancel = () => { - router.back(); - }; - - return ( - - {/* Toolbar */} -
-
- -
-

New Note

-

Create a new note

-
-
- -
- - -
-
- - {/* Content */} -
-
-
- {error && ( - - - -
- - Error -
- {error} -
-
-
- )} - - - - Note Details - Enter a title for your note - - -
- - setTitle(e.target.value)} - disabled={creating} - className="text-lg" - /> -
-
-
- - - - Content - Start writing your note (optional) - - -
- -
-
-
-
-
-
-
- ); -} - diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx index 8064a0dc4..04a54fd3e 100644 --- a/surfsense_web/components/BlockNoteEditor.tsx +++ b/surfsense_web/components/BlockNoteEditor.tsx @@ -10,31 +10,119 @@ import { useCreateBlockNote } from "@blocknote/react"; interface BlockNoteEditorProps { initialContent?: any; onChange?: (content: any) => void; + useTitleBlock?: boolean; // Whether to use first block as title (Notion-style) } -export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) { +// Helper to ensure first block is a heading for title +function ensureTitleBlock(content: any[] | undefined): any[] { + if (!content || content.length === 0) { + // Return empty heading block for new notes + return [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ]; + } + + // If first block is not a heading, convert it to one + const firstBlock = content[0]; + if (firstBlock?.type !== "heading") { + // Extract text from first block + let titleText = ""; + if (firstBlock?.content && Array.isArray(firstBlock.content)) { + titleText = firstBlock.content + .map((item: any) => { + if (typeof item === "string") return item; + if (item?.text) return item.text; + return ""; + }) + .join("") + .trim(); + } + + // Create heading block with extracted text + const titleBlock = { + type: "heading", + props: { level: 1 }, + content: titleText + ? [ + { + type: "text", + text: titleText, + styles: {}, + }, + ] + : [], + children: [], + }; + + // Replace first block with heading, keep rest + return [titleBlock, ...content.slice(1)]; + } + + return content; +} + +export default function BlockNoteEditor({ initialContent, onChange, useTitleBlock = false }: BlockNoteEditorProps) { const { resolvedTheme } = useTheme(); // Track the initial content to prevent re-initialization const initialContentRef = useRef(null); const isInitializedRef = useRef(false); + // Prepare initial content - ensure first block is a heading if useTitleBlock is true + const preparedInitialContent = useMemo(() => { + if (initialContentRef.current !== null) { + return undefined; // Already initialized + } + if (initialContent === undefined) { + // New note - create empty heading block + return useTitleBlock + ? [ + { + type: "heading", + props: { level: 1 }, + content: [], + children: [], + }, + ] + : undefined; + } + // Existing note - ensure first block is heading + return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent; + }, [initialContent, useTitleBlock]); + // Creates a new editor instance - only use initialContent on first render const editor = useCreateBlockNote({ - initialContent: initialContentRef.current === null ? initialContent || undefined : undefined, + initialContent: initialContentRef.current === null ? preparedInitialContent : undefined, }); // Store initial content on first render only useEffect(() => { - if (initialContent && initialContentRef.current === null) { - initialContentRef.current = initialContent; + if (preparedInitialContent !== undefined && initialContentRef.current === null) { + initialContentRef.current = preparedInitialContent; + isInitializedRef.current = true; + } else if (preparedInitialContent === undefined && initialContentRef.current === null) { + // Mark as initialized even when initialContent is undefined (for new notes) isInitializedRef.current = true; } - }, [initialContent]); + }, [preparedInitialContent]); // Call onChange when document changes (but don't update from props) useEffect(() => { - if (!onChange || !editor || !isInitializedRef.current) return; + if (!onChange || !editor) return; + + // For new notes (no initialContent), we need to wait for editor to be ready + // Use a small delay to ensure editor is fully initialized + if (!isInitializedRef.current) { + const timer = setTimeout(() => { + isInitializedRef.current = true; + }, 100); + return () => clearTimeout(timer); + } const handleChange = () => { onChange(editor.document); @@ -43,6 +131,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE // Subscribe to document changes const unsubscribe = editor.onChange(handleChange); + // Also call onChange once with current document to capture initial state + // This ensures we capture content even if user doesn't make changes + if (editor.document) { + onChange(editor.document); + } + return () => { unsubscribe(); }; diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 27147a4b0..62c127120 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -213,7 +213,7 @@ export function AppSidebarProvider({ // Handle add note const handleAddNote = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/notes/new`); + router.push(`/dashboard/${searchSpaceId}/editor/new`); }, [router, searchSpaceId]); // Memoized updated navSecondary From 37f2b2745118032af80589fe41cab227fa3831ad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:04:57 +0000 Subject: [PATCH 12/60] feat: add Role type for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 surfsense_web/contracts/types/rbac.types.ts diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts new file mode 100644 index 000000000..f90656a49 --- /dev/null +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const role = z.object({ + id: z.number(), + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + permissions: z.array(z.string()), + is_default: z.boolean(), + is_system_role: z.boolean(), + search_space_id: z.number(), + created_at: z.string(), +}); + +export type Role = z.infer; \ No newline at end of file From 8a9f3e1c18f1e4c5b84d1d23532238eca3fd2c0b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:10:43 +0000 Subject: [PATCH 13/60] feat: add Membership type for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index f90656a49..f624bd2da 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -11,4 +11,18 @@ export const role = z.object({ created_at: z.string(), }); -export type Role = z.infer; \ No newline at end of file +export const membership = z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number(), + role_id: z.number().nullable(), + is_owner: z.boolean(), + joined_at: z.string(), + created_at: z.string(), + role: role.nullable().optional(), + user_email: z.string().nullable().optional(), + user_is_active: z.boolean().nullable().optional(), +}); + +export type Role = z.infer; +export type Membership = z.infer; From 34f0e4514cc974f46cddcbf1eed8976e6e2ab370 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:16:11 +0000 Subject: [PATCH 14/60] feat: add Invite type for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index f624bd2da..aa664fa21 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -24,5 +24,21 @@ export const membership = z.object({ user_is_active: z.boolean().nullable().optional(), }); +export const invite = z.object({ + id: z.number(), + name: z.string().max(100).nullable().optional(), + invite_code: z.string(), + search_space_id: z.number(), + created_by_id: z.string().nullable(), + role_id: z.number().nullable(), + expires_at: z.string().nullable(), + max_uses: z.number().nullable(), + uses_count: z.number(), + is_active: z.boolean(), + created_at: z.string(), + role: role.nullable().optional(), +}); + export type Role = z.infer; export type Membership = z.infer; +export type Invite = z.infer; From 18917519e94bf0317cbab402b6c587d5a16a37b0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:20:47 +0000 Subject: [PATCH 15/60] feat: add PermissionInfo type for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index aa664fa21..72a9dde4c 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -39,6 +39,13 @@ export const invite = z.object({ role: role.nullable().optional(), }); +export const permissionInfo = z.object({ + value: z.string(), + name: z.string(), + category: z.string(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; +export type PermissionInfo = z.infer; From d5af72bed620e7d24655dd3b8f0fbb210644f4ad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:34:04 +0000 Subject: [PATCH 16/60] feat: add GetPermissionsResponse schema for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 72a9dde4c..1cf3b70d3 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -45,7 +45,15 @@ export const permissionInfo = z.object({ category: z.string(), }); +/** + * Get permissions + */ +export const getPermissionsResponse = z.object({ + permissions: z.array(permissionInfo), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; export type PermissionInfo = z.infer; +export type GetPermissionsResponse = z.infer; From f11215fcef531666ca479af359bb24014bb9c266 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:38:43 +0000 Subject: [PATCH 17/60] feat: add CreateRole request/response schemas for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 1cf3b70d3..04c3874f8 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -52,8 +52,25 @@ export const getPermissionsResponse = z.object({ permissions: z.array(permissionInfo), }); +/** + * Create role + */ +export const createRoleRequest = z.object({ + search_space_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }), +}); + +export const createRoleResponse = role; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; export type PermissionInfo = z.infer; export type GetPermissionsResponse = z.infer; +export type CreateRoleRequest = z.infer; +export type CreateRoleResponse = z.infer; From 4919b4717cac3ed3bf2931dc5fc1743b724182f6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:44:31 +0000 Subject: [PATCH 18/60] feat: add GetRoles request/response schemas for RBAC --- surfsense_web/contracts/types/rbac.types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 04c3874f8..b404b52c4 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -67,6 +67,15 @@ export const createRoleRequest = z.object({ export const createRoleResponse = role; +/** + * Get roles + */ +export const getRolesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getRolesResponse = z.array(role); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -74,3 +83,5 @@ export type PermissionInfo = z.infer; export type GetPermissionsResponse = z.infer; export type CreateRoleRequest = z.infer; export type CreateRoleResponse = z.infer; +export type GetRolesRequest = z.infer; +export type GetRolesResponse = z.infer; From f02fc9f4aafad07d44b8b9c56ca1221a6c1e7ed5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:50:17 +0000 Subject: [PATCH 19/60] feat: add getRoleById request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index b404b52c4..82a45cdba 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -76,6 +76,16 @@ export const getRolesRequest = z.object({ export const getRolesResponse = z.array(role); +/** + * Get role by ID + */ +export const getRoleByIdRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const getRoleByIdResponse = role; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -85,3 +95,5 @@ export type CreateRoleRequest = z.infer; export type CreateRoleResponse = z.infer; export type GetRolesRequest = z.infer; export type GetRolesResponse = z.infer; +export type GetRoleByIdRequest = z.infer; +export type GetRoleByIdResponse = z.infer; From 6eaf37589d33e3933cb48612f52286ce82760289 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:55:18 +0000 Subject: [PATCH 20/60] feat: add updateRole request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 82a45cdba..3ab294ec6 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -86,6 +86,22 @@ export const getRoleByIdRequest = z.object({ export const getRoleByIdResponse = role; +/** + * Update role + */ +export const updateRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }).partial(), +}); + +export const updateRoleResponse = role; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -97,3 +113,5 @@ export type GetRolesRequest = z.infer; export type GetRolesResponse = z.infer; export type GetRoleByIdRequest = z.infer; export type GetRoleByIdResponse = z.infer; +export type UpdateRoleRequest = z.infer; +export type UpdateRoleResponse = z.infer; From c51612867de7d1ce8e280960aebf787204069ba9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 13:59:47 +0000 Subject: [PATCH 21/60] feat: add deleteRole request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 3ab294ec6..15f4430c7 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -102,6 +102,18 @@ export const updateRoleRequest = z.object({ export const updateRoleResponse = role; +/** + * Delete role + */ +export const deleteRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const deleteRoleResponse = z.object({ + message: z.string(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -115,3 +127,5 @@ export type GetRoleByIdRequest = z.infer; export type GetRoleByIdResponse = z.infer; export type UpdateRoleRequest = z.infer; export type UpdateRoleResponse = z.infer; +export type DeleteRoleRequest = z.infer; +export type DeleteRoleResponse = z.infer; From 289cbc627cd362426218ccbf66d5eb7af0d65923 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 14:03:33 +0000 Subject: [PATCH 22/60] feat: add getMembers request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 15f4430c7..5e69852eb 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -114,6 +114,15 @@ export const deleteRoleResponse = z.object({ message: z.string(), }); +/** + * Get members + */ +export const getMembersRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMembersResponse = z.array(membership); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -129,3 +138,5 @@ export type UpdateRoleRequest = z.infer; export type UpdateRoleResponse = z.infer; export type DeleteRoleRequest = z.infer; export type DeleteRoleResponse = z.infer; +export type GetMembersRequest = z.infer; +export type GetMembersResponse = z.infer; From 39c64103ab69a3b77fe8679152a24a4e4f12228a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 14:07:53 +0000 Subject: [PATCH 23/60] feat: add updateMembership request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 5e69852eb..653a12e61 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -123,6 +123,19 @@ export const getMembersRequest = z.object({ export const getMembersResponse = z.array(membership); +/** + * Update membership + */ +export const updateMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), + data: z.object({ + role_id: z.number(), + }), +}); + +export const updateMembershipResponse = membership; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -140,3 +153,5 @@ export type DeleteRoleRequest = z.infer; export type DeleteRoleResponse = z.infer; export type GetMembersRequest = z.infer; export type GetMembersResponse = z.infer; +export type UpdateMembershipRequest = z.infer; +export type UpdateMembershipResponse = z.infer; From 9ec5b324fdd4ccb81e97f5366b033a6bac7e5d3e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 14:13:14 +0000 Subject: [PATCH 24/60] feat: add deleteMembership request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 653a12e61..8d7b0cf39 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -136,6 +136,18 @@ export const updateMembershipRequest = z.object({ export const updateMembershipResponse = membership; +/** + * Delete membership + */ +export const deleteMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), +}); + +export const deleteMembershipResponse = z.object({ + message: z.string(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -155,3 +167,5 @@ export type GetMembersRequest = z.infer; export type GetMembersResponse = z.infer; export type UpdateMembershipRequest = z.infer; export type UpdateMembershipResponse = z.infer; +export type DeleteMembershipRequest = z.infer; +export type DeleteMembershipResponse = z.infer; From 86b8cd6eff96b4505b4503c82fa5600c254f8597 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:22:11 +0000 Subject: [PATCH 25/60] feat: add leaveSearchSpace request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 8d7b0cf39..2d2516337 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -148,6 +148,17 @@ export const deleteMembershipResponse = z.object({ message: z.string(), }); +/** + * Leave search space + */ +export const leaveSearchSpaceRequest = z.object({ + search_space_id: z.number(), +}); + +export const leaveSearchSpaceResponse = z.object({ + message: z.string(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -169,3 +180,5 @@ export type UpdateMembershipRequest = z.infer; export type UpdateMembershipResponse = z.infer; export type DeleteMembershipRequest = z.infer; export type DeleteMembershipResponse = z.infer; +export type LeaveSearchSpaceRequest = z.infer; +export type LeaveSearchSpaceResponse = z.infer; From bec21a72070df338e2eba170f368ee3244284f98 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:28:59 +0000 Subject: [PATCH 26/60] feat: add createInvite request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 2d2516337..306676195 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -159,6 +159,21 @@ export const leaveSearchSpaceResponse = z.object({ message: z.string(), }); +/** + * Create invite + */ +export const createInviteRequest = z.object({ + search_space_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + }), +}); + +export const createInviteResponse = invite; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -182,3 +197,5 @@ export type DeleteMembershipRequest = z.infer; export type DeleteMembershipResponse = z.infer; export type LeaveSearchSpaceRequest = z.infer; export type LeaveSearchSpaceResponse = z.infer; +export type CreateInviteRequest = z.infer; +export type CreateInviteResponse = z.infer; From a74e69fdc5127661d31bec6fd57d1a9b54c48f05 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:33:40 +0000 Subject: [PATCH 27/60] feat: add getInvites request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 306676195..f00d9fa52 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -174,6 +174,15 @@ export const createInviteRequest = z.object({ export const createInviteResponse = invite; +/** + * Get invites + */ +export const getInvitesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getInvitesResponse = z.array(invite); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -199,3 +208,5 @@ export type LeaveSearchSpaceRequest = z.infer; export type LeaveSearchSpaceResponse = z.infer; export type CreateInviteRequest = z.infer; export type CreateInviteResponse = z.infer; +export type GetInvitesRequest = z.infer; +export type GetInvitesResponse = z.infer; From 134c70f87fda31d778dabcd1af0c5ca15f393ffa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:36:54 +0000 Subject: [PATCH 28/60] feat: add updateInvite request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index f00d9fa52..9cfe6dcdb 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -183,6 +183,23 @@ export const getInvitesRequest = z.object({ export const getInvitesResponse = z.array(invite); +/** + * Update invite + */ +export const updateInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + is_active: z.boolean().optional(), + }), +}); + +export const updateInviteResponse = invite; + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -210,3 +227,5 @@ export type CreateInviteRequest = z.infer; export type CreateInviteResponse = z.infer; export type GetInvitesRequest = z.infer; export type GetInvitesResponse = z.infer; +export type UpdateInviteRequest = z.infer; +export type UpdateInviteResponse = z.infer; From e5f5e46312a4e29393b74d8ac45a70fad202916c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:39:39 +0000 Subject: [PATCH 29/60] feat: add deleteInvite request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 9cfe6dcdb..5bc78ab01 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -200,6 +200,18 @@ export const updateInviteRequest = z.object({ export const updateInviteResponse = invite; +/** + * Delete invite + */ +export const deleteInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), +}); + +export const deleteInviteResponse = z.object({ + message: z.string(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -229,3 +241,5 @@ export type GetInvitesRequest = z.infer; export type GetInvitesResponse = z.infer; export type UpdateInviteRequest = z.infer; export type UpdateInviteResponse = z.infer; +export type DeleteInviteRequest = z.infer; +export type DeleteInviteResponse = z.infer; From 1b41b2ff2310284ca338bf7e553955885603352c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:43:07 +0000 Subject: [PATCH 30/60] feat: add getInviteInfo request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 5bc78ab01..33ee5682f 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -212,6 +212,21 @@ export const deleteInviteResponse = z.object({ message: z.string(), }); +/** + * Get invite info by code + */ +export const getInviteInfoRequest = z.object({ + invite_code: z.string(), +}); + +export const getInviteInfoResponse = z.object({ + invite_code: z.string(), + search_space_name: z.string(), + role_name: z.string().nullable(), + expires_at: z.string().nullable(), + is_valid: z.boolean(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -243,3 +258,5 @@ export type UpdateInviteRequest = z.infer; export type UpdateInviteResponse = z.infer; export type DeleteInviteRequest = z.infer; export type DeleteInviteResponse = z.infer; +export type GetInviteInfoRequest = z.infer; +export type GetInviteInfoResponse = z.infer; From fdfba4bb4a4faf2ca3909e76d399b870e36524ac Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:47:25 +0000 Subject: [PATCH 31/60] feat: add acceptInvite request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index 33ee5682f..be3b2df3c 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -227,6 +227,18 @@ export const getInviteInfoResponse = z.object({ is_valid: z.boolean(), }); +/** + * Accept invite + */ +export const acceptInviteRequest = z.object({ + invite_code: z.string(), +}); + +export const acceptInviteResponse = z.object({ + message: z.string(), + search_space_id: z.number(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -260,3 +272,5 @@ export type DeleteInviteRequest = z.infer; export type DeleteInviteResponse = z.infer; export type GetInviteInfoRequest = z.infer; export type GetInviteInfoResponse = z.infer; +export type AcceptInviteRequest = z.infer; +export type AcceptInviteResponse = z.infer; From 86cac96fe07a215ba2a9f967cfa84231604fae29 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 19:51:01 +0000 Subject: [PATCH 32/60] feat: add getMyAccess request/response schemas --- surfsense_web/contracts/types/rbac.types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts index be3b2df3c..724b9fd3e 100644 --- a/surfsense_web/contracts/types/rbac.types.ts +++ b/surfsense_web/contracts/types/rbac.types.ts @@ -239,6 +239,21 @@ export const acceptInviteResponse = z.object({ search_space_id: z.number(), }); +/** + * Get my access + */ +export const getMyAccessRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMyAccessResponse = z.object({ + user_id: z.string(), + search_space_id: z.number(), + is_owner: z.boolean(), + permissions: z.array(z.string()), + role_name: z.string().nullable(), +}); + export type Role = z.infer; export type Membership = z.infer; export type Invite = z.infer; @@ -274,3 +289,5 @@ export type GetInviteInfoRequest = z.infer; export type GetInviteInfoResponse = z.infer; export type AcceptInviteRequest = z.infer; export type AcceptInviteResponse = z.infer; +export type GetMyAccessRequest = z.infer; +export type GetMyAccessResponse = z.infer; From ec9ed9d2154225db7f54149d338ff27ede1024f9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:19:43 +0000 Subject: [PATCH 33/60] feat: create permissions.types.ts with single responsibility --- .../contracts/types/permissions.types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 surfsense_web/contracts/types/permissions.types.ts diff --git a/surfsense_web/contracts/types/permissions.types.ts b/surfsense_web/contracts/types/permissions.types.ts new file mode 100644 index 000000000..3f75192a3 --- /dev/null +++ b/surfsense_web/contracts/types/permissions.types.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const permissionInfo = z.object({ + value: z.string(), + name: z.string(), + category: z.string(), +}); + +/** + * Get permissions + */ +export const getPermissionsResponse = z.object({ + permissions: z.array(permissionInfo), +}); + +export type PermissionInfo = z.infer; +export type GetPermissionsResponse = z.infer; From 5f156cfa85d7c36b5d962740a6517ebf700b4a6d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:23:36 +0000 Subject: [PATCH 34/60] feat: create roles.types.ts with all role-related schemas --- surfsense_web/contracts/types/roles.types.ts | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 surfsense_web/contracts/types/roles.types.ts diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts new file mode 100644 index 000000000..31ad0e970 --- /dev/null +++ b/surfsense_web/contracts/types/roles.types.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +export const role = z.object({ + id: z.number(), + name: z.string().min(1).max(100), + description: z.string().max(500).nullable(), + permissions: z.array(z.string()), + is_default: z.boolean(), + is_system_role: z.boolean(), + search_space_id: z.number(), + created_at: z.string(), +}); + +/** + * Create role + */ +export const createRoleRequest = z.object({ + search_space_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }), +}); + +export const createRoleResponse = role; + +/** + * Get roles + */ +export const getRolesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getRolesResponse = z.array(role); + +/** + * Get role by ID + */ +export const getRoleByIdRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const getRoleByIdResponse = role; + +/** + * Update role + */ +export const updateRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), + data: role.pick({ + name: true, + description: true, + permissions: true, + is_default: true, + }).partial(), +}); + +export const updateRoleResponse = role; + +/** + * Delete role + */ +export const deleteRoleRequest = z.object({ + search_space_id: z.number(), + role_id: z.number(), +}); + +export const deleteRoleResponse = z.object({ + message: z.string(), +}); + +export type Role = z.infer; +export type CreateRoleRequest = z.infer; +export type CreateRoleResponse = z.infer; +export type GetRolesRequest = z.infer; +export type GetRolesResponse = z.infer; +export type GetRoleByIdRequest = z.infer; +export type GetRoleByIdResponse = z.infer; +export type UpdateRoleRequest = z.infer; +export type UpdateRoleResponse = z.infer; +export type DeleteRoleRequest = z.infer; +export type DeleteRoleResponse = z.infer; From 226ebf2ddf64405e9cdae1ee127464934727df58 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:26:34 +0000 Subject: [PATCH 35/60] feat: create members.types.ts with membership and access schemas --- .../contracts/types/members.types.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 surfsense_web/contracts/types/members.types.ts diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts new file mode 100644 index 000000000..a6d6333ac --- /dev/null +++ b/surfsense_web/contracts/types/members.types.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const membership = z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number(), + role_id: z.number().nullable(), + is_owner: z.boolean(), + joined_at: z.string(), + created_at: z.string(), + role: role.nullable().optional(), + user_email: z.string().nullable().optional(), + user_is_active: z.boolean().nullable().optional(), +}); + +/** + * Get members + */ +export const getMembersRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMembersResponse = z.array(membership); + +/** + * Update membership + */ +export const updateMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), + data: z.object({ + role_id: z.number(), + }), +}); + +export const updateMembershipResponse = membership; + +/** + * Delete membership + */ +export const deleteMembershipRequest = z.object({ + search_space_id: z.number(), + membership_id: z.number(), +}); + +export const deleteMembershipResponse = z.object({ + message: z.string(), +}); + +/** + * Leave search space + */ +export const leaveSearchSpaceRequest = z.object({ + search_space_id: z.number(), +}); + +export const leaveSearchSpaceResponse = z.object({ + message: z.string(), +}); + +/** + * Get my access + */ +export const getMyAccessRequest = z.object({ + search_space_id: z.number(), +}); + +export const getMyAccessResponse = z.object({ + user_id: z.string(), + search_space_id: z.number(), + is_owner: z.boolean(), + permissions: z.array(z.string()), + role_name: z.string().nullable(), +}); + +export type Membership = z.infer; +export type GetMembersRequest = z.infer; +export type GetMembersResponse = z.infer; +export type UpdateMembershipRequest = z.infer; +export type UpdateMembershipResponse = z.infer; +export type DeleteMembershipRequest = z.infer; +export type DeleteMembershipResponse = z.infer; +export type LeaveSearchSpaceRequest = z.infer; +export type LeaveSearchSpaceResponse = z.infer; +export type GetMyAccessRequest = z.infer; +export type GetMyAccessResponse = z.infer; From ead71eb0260f8a47228de42df7da2eb52e61fea9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:29:04 +0000 Subject: [PATCH 36/60] feat: create invites.types.ts with all invite-related schemas --- .../contracts/types/invites.types.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 surfsense_web/contracts/types/invites.types.ts diff --git a/surfsense_web/contracts/types/invites.types.ts b/surfsense_web/contracts/types/invites.types.ts new file mode 100644 index 000000000..2a9460e53 --- /dev/null +++ b/surfsense_web/contracts/types/invites.types.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { role } from "./roles.types"; + +export const invite = z.object({ + id: z.number(), + name: z.string().max(100).nullable().optional(), + invite_code: z.string(), + search_space_id: z.number(), + created_by_id: z.string().nullable(), + role_id: z.number().nullable(), + expires_at: z.string().nullable(), + max_uses: z.number().nullable(), + uses_count: z.number(), + is_active: z.boolean(), + created_at: z.string(), + role: role.nullable().optional(), +}); + +/** + * Create invite + */ +export const createInviteRequest = z.object({ + search_space_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + }), +}); + +export const createInviteResponse = invite; + +/** + * Get invites + */ +export const getInvitesRequest = z.object({ + search_space_id: z.number(), +}); + +export const getInvitesResponse = z.array(invite); + +/** + * Update invite + */ +export const updateInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), + data: z.object({ + name: z.string().max(100).optional(), + role_id: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), + max_uses: z.number().nullable().optional(), + is_active: z.boolean().optional(), + }), +}); + +export const updateInviteResponse = invite; + +/** + * Delete invite + */ +export const deleteInviteRequest = z.object({ + search_space_id: z.number(), + invite_id: z.number(), +}); + +export const deleteInviteResponse = z.object({ + message: z.string(), +}); + +/** + * Get invite info by code + */ +export const getInviteInfoRequest = z.object({ + invite_code: z.string(), +}); + +export const getInviteInfoResponse = z.object({ + invite_code: z.string(), + search_space_name: z.string(), + role_name: z.string().nullable(), + expires_at: z.string().nullable(), + is_valid: z.boolean(), +}); + +/** + * Accept invite + */ +export const acceptInviteRequest = z.object({ + invite_code: z.string(), +}); + +export const acceptInviteResponse = z.object({ + message: z.string(), + search_space_id: z.number(), +}); + +export type Invite = z.infer; +export type CreateInviteRequest = z.infer; +export type CreateInviteResponse = z.infer; +export type GetInvitesRequest = z.infer; +export type GetInvitesResponse = z.infer; +export type UpdateInviteRequest = z.infer; +export type UpdateInviteResponse = z.infer; +export type DeleteInviteRequest = z.infer; +export type DeleteInviteResponse = z.infer; +export type GetInviteInfoRequest = z.infer; +export type GetInviteInfoResponse = z.infer; +export type AcceptInviteRequest = z.infer; +export type AcceptInviteResponse = z.infer; From ccdcec4dd4356a09006e30929708c8e1c8277c88 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:32:41 +0000 Subject: [PATCH 37/60] feat: delete old rbac.types.ts after splitting into domain-specific files --- surfsense_web/contracts/types/rbac.types.ts | 293 -------------------- 1 file changed, 293 deletions(-) delete mode 100644 surfsense_web/contracts/types/rbac.types.ts diff --git a/surfsense_web/contracts/types/rbac.types.ts b/surfsense_web/contracts/types/rbac.types.ts deleted file mode 100644 index 724b9fd3e..000000000 --- a/surfsense_web/contracts/types/rbac.types.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { z } from "zod"; - -export const role = z.object({ - id: z.number(), - name: z.string().min(1).max(100), - description: z.string().max(500).nullable(), - permissions: z.array(z.string()), - is_default: z.boolean(), - is_system_role: z.boolean(), - search_space_id: z.number(), - created_at: z.string(), -}); - -export const membership = z.object({ - id: z.number(), - user_id: z.string(), - search_space_id: z.number(), - role_id: z.number().nullable(), - is_owner: z.boolean(), - joined_at: z.string(), - created_at: z.string(), - role: role.nullable().optional(), - user_email: z.string().nullable().optional(), - user_is_active: z.boolean().nullable().optional(), -}); - -export const invite = z.object({ - id: z.number(), - name: z.string().max(100).nullable().optional(), - invite_code: z.string(), - search_space_id: z.number(), - created_by_id: z.string().nullable(), - role_id: z.number().nullable(), - expires_at: z.string().nullable(), - max_uses: z.number().nullable(), - uses_count: z.number(), - is_active: z.boolean(), - created_at: z.string(), - role: role.nullable().optional(), -}); - -export const permissionInfo = z.object({ - value: z.string(), - name: z.string(), - category: z.string(), -}); - -/** - * Get permissions - */ -export const getPermissionsResponse = z.object({ - permissions: z.array(permissionInfo), -}); - -/** - * Create role - */ -export const createRoleRequest = z.object({ - search_space_id: z.number(), - data: role.pick({ - name: true, - description: true, - permissions: true, - is_default: true, - }), -}); - -export const createRoleResponse = role; - -/** - * Get roles - */ -export const getRolesRequest = z.object({ - search_space_id: z.number(), -}); - -export const getRolesResponse = z.array(role); - -/** - * Get role by ID - */ -export const getRoleByIdRequest = z.object({ - search_space_id: z.number(), - role_id: z.number(), -}); - -export const getRoleByIdResponse = role; - -/** - * Update role - */ -export const updateRoleRequest = z.object({ - search_space_id: z.number(), - role_id: z.number(), - data: role.pick({ - name: true, - description: true, - permissions: true, - is_default: true, - }).partial(), -}); - -export const updateRoleResponse = role; - -/** - * Delete role - */ -export const deleteRoleRequest = z.object({ - search_space_id: z.number(), - role_id: z.number(), -}); - -export const deleteRoleResponse = z.object({ - message: z.string(), -}); - -/** - * Get members - */ -export const getMembersRequest = z.object({ - search_space_id: z.number(), -}); - -export const getMembersResponse = z.array(membership); - -/** - * Update membership - */ -export const updateMembershipRequest = z.object({ - search_space_id: z.number(), - membership_id: z.number(), - data: z.object({ - role_id: z.number(), - }), -}); - -export const updateMembershipResponse = membership; - -/** - * Delete membership - */ -export const deleteMembershipRequest = z.object({ - search_space_id: z.number(), - membership_id: z.number(), -}); - -export const deleteMembershipResponse = z.object({ - message: z.string(), -}); - -/** - * Leave search space - */ -export const leaveSearchSpaceRequest = z.object({ - search_space_id: z.number(), -}); - -export const leaveSearchSpaceResponse = z.object({ - message: z.string(), -}); - -/** - * Create invite - */ -export const createInviteRequest = z.object({ - search_space_id: z.number(), - data: z.object({ - name: z.string().max(100).optional(), - role_id: z.number().nullable().optional(), - expires_at: z.string().nullable().optional(), - max_uses: z.number().nullable().optional(), - }), -}); - -export const createInviteResponse = invite; - -/** - * Get invites - */ -export const getInvitesRequest = z.object({ - search_space_id: z.number(), -}); - -export const getInvitesResponse = z.array(invite); - -/** - * Update invite - */ -export const updateInviteRequest = z.object({ - search_space_id: z.number(), - invite_id: z.number(), - data: z.object({ - name: z.string().max(100).optional(), - role_id: z.number().nullable().optional(), - expires_at: z.string().nullable().optional(), - max_uses: z.number().nullable().optional(), - is_active: z.boolean().optional(), - }), -}); - -export const updateInviteResponse = invite; - -/** - * Delete invite - */ -export const deleteInviteRequest = z.object({ - search_space_id: z.number(), - invite_id: z.number(), -}); - -export const deleteInviteResponse = z.object({ - message: z.string(), -}); - -/** - * Get invite info by code - */ -export const getInviteInfoRequest = z.object({ - invite_code: z.string(), -}); - -export const getInviteInfoResponse = z.object({ - invite_code: z.string(), - search_space_name: z.string(), - role_name: z.string().nullable(), - expires_at: z.string().nullable(), - is_valid: z.boolean(), -}); - -/** - * Accept invite - */ -export const acceptInviteRequest = z.object({ - invite_code: z.string(), -}); - -export const acceptInviteResponse = z.object({ - message: z.string(), - search_space_id: z.number(), -}); - -/** - * Get my access - */ -export const getMyAccessRequest = z.object({ - search_space_id: z.number(), -}); - -export const getMyAccessResponse = z.object({ - user_id: z.string(), - search_space_id: z.number(), - is_owner: z.boolean(), - permissions: z.array(z.string()), - role_name: z.string().nullable(), -}); - -export type Role = z.infer; -export type Membership = z.infer; -export type Invite = z.infer; -export type PermissionInfo = z.infer; -export type GetPermissionsResponse = z.infer; -export type CreateRoleRequest = z.infer; -export type CreateRoleResponse = z.infer; -export type GetRolesRequest = z.infer; -export type GetRolesResponse = z.infer; -export type GetRoleByIdRequest = z.infer; -export type GetRoleByIdResponse = z.infer; -export type UpdateRoleRequest = z.infer; -export type UpdateRoleResponse = z.infer; -export type DeleteRoleRequest = z.infer; -export type DeleteRoleResponse = z.infer; -export type GetMembersRequest = z.infer; -export type GetMembersResponse = z.infer; -export type UpdateMembershipRequest = z.infer; -export type UpdateMembershipResponse = z.infer; -export type DeleteMembershipRequest = z.infer; -export type DeleteMembershipResponse = z.infer; -export type LeaveSearchSpaceRequest = z.infer; -export type LeaveSearchSpaceResponse = z.infer; -export type CreateInviteRequest = z.infer; -export type CreateInviteResponse = z.infer; -export type GetInvitesRequest = z.infer; -export type GetInvitesResponse = z.infer; -export type UpdateInviteRequest = z.infer; -export type UpdateInviteResponse = z.infer; -export type DeleteInviteRequest = z.infer; -export type DeleteInviteResponse = z.infer; -export type GetInviteInfoRequest = z.infer; -export type GetInviteInfoResponse = z.infer; -export type AcceptInviteRequest = z.infer; -export type AcceptInviteResponse = z.infer; -export type GetMyAccessRequest = z.infer; -export type GetMyAccessResponse = z.infer; From 4affdf70a6cd44424fd1ca40902fa69ec8d38758 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 15 Dec 2025 20:54:02 +0000 Subject: [PATCH 38/60] fix: correct roles-api.service.ts to use parsedRequest.data pattern --- surfsense_web/lib/apis/roles-api.service.ts | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 surfsense_web/lib/apis/roles-api.service.ts diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts new file mode 100644 index 000000000..12de889b1 --- /dev/null +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -0,0 +1,42 @@ +import { + type CreateRoleRequest, + createRoleRequest, + createRoleResponse, + type DeleteRoleRequest, + deleteRoleRequest, + deleteRoleResponse, + type GetRoleByIdRequest, + getRoleByIdRequest, + getRoleByIdResponse, + type GetRolesRequest, + getRolesRequest, + getRolesResponse, + type UpdateRoleRequest, + updateRoleRequest, + updateRoleResponse, +} from "@/contracts/types/roles.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class RolesApiService { + createRole = async (request: CreateRoleRequest) => { + const parsedRequest = createRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post( + `/api/searchspaces/${parsedRequest.data.search_space_id}/roles`, + createRoleResponse, + { + body: parsedRequest.data.data, + }, + ); + }; +} + +export const rolesApiService = new RolesApiService(); From b20c526951775ddc00d97749006645e45b2f3e45 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 05:48:18 +0000 Subject: [PATCH 39/60] feat: complete roles-api.service.ts with all CRUD methods --- surfsense_web/lib/apis/roles-api.service.ts | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts index 12de889b1..a1e7fea88 100644 --- a/surfsense_web/lib/apis/roles-api.service.ts +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -37,6 +37,73 @@ class RolesApiService { }, ); }; + + getRoles = async (request: GetRolesRequest) => { + const parsedRequest = getRolesRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/searchspaces/${parsedRequest.data.search_space_id}/roles`, + getRolesResponse, + ); + }; + + getRoleById = async (request: GetRoleByIdRequest) => { + const parsedRequest = getRoleByIdRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + getRoleByIdResponse, + ); + }; + + updateRole = async (request: UpdateRoleRequest) => { + const parsedRequest = updateRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put( + `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + updateRoleResponse, + { + body: parsedRequest.data.data, + }, + ); + }; + + deleteRole = async (request: DeleteRoleRequest) => { + const parsedRequest = deleteRoleRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete( + `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + deleteRoleResponse, + ); + }; } export const rolesApiService = new RolesApiService(); From e086dd51fa56b32f0b70ba338958f84dcd28cd30 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 05:52:52 +0000 Subject: [PATCH 40/60] feat: add permissions-api.service.ts with getPermissions method --- surfsense_web/lib/apis/permissions-api.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 surfsense_web/lib/apis/permissions-api.service.ts diff --git a/surfsense_web/lib/apis/permissions-api.service.ts b/surfsense_web/lib/apis/permissions-api.service.ts new file mode 100644 index 000000000..225ed892f --- /dev/null +++ b/surfsense_web/lib/apis/permissions-api.service.ts @@ -0,0 +1,10 @@ +import { getPermissionsResponse } from "@/contracts/types/permissions.types"; +import { baseApiService } from "./base-api.service"; + +class PermissionsApiService { + getPermissions = async () => { + return baseApiService.get(`/api/permissions`, getPermissionsResponse); + }; +} + +export const permissionsApiService = new PermissionsApiService(); From c732c5deee848f76a651a72f1dece64e4962f53a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 06:06:36 +0000 Subject: [PATCH 41/60] feat: add cache keys and permissions query atom for RBAC --- .../atoms/permissions/permissions-query.atoms.ts | 13 +++++++++++++ surfsense_web/lib/query-client/cache-keys.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/atoms/permissions/permissions-query.atoms.ts diff --git a/surfsense_web/atoms/permissions/permissions-query.atoms.ts b/surfsense_web/atoms/permissions/permissions-query.atoms.ts new file mode 100644 index 000000000..335ddd77d --- /dev/null +++ b/surfsense_web/atoms/permissions/permissions-query.atoms.ts @@ -0,0 +1,13 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { permissionsApiService } from "@/lib/apis/permissions-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const permissionsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.permissions.all(), + staleTime: 10 * 60 * 1000, // 10 minutes + queryFn: async () => { + return permissionsApiService.getPermissions(); + }, + }; +}); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index eb2c4972a..28e9396c9 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -3,6 +3,7 @@ import type { GetDocumentsRequest } from "@/contracts/types/document.types"; import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types"; import type { GetPodcastsRequest } from "@/contracts/types/podcast.types"; import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types"; +import type { GetRolesRequest } from "@/contracts/types/roles.types"; export const cacheKeys = { chats: { @@ -44,4 +45,4 @@ export const cacheKeys = { user: { current: () => ["user", "me"] as const, }, -}; \ No newline at end of file +}; From 51216f0d042b47d60c511417b2c9b2b3ab3cbf0c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 06:16:22 +0000 Subject: [PATCH 42/60] feat: add roles mutation atoms with improved type safety --- .../atoms/roles/roles-mutation.atoms.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 surfsense_web/atoms/roles/roles-mutation.atoms.ts diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts new file mode 100644 index 000000000..47ece8b68 --- /dev/null +++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts @@ -0,0 +1,67 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + CreateRoleRequest, + CreateRoleResponse, + UpdateRoleRequest, + UpdateRoleResponse, + DeleteRoleRequest, + DeleteRoleResponse, +} from "@/contracts/types/roles.types"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: CreateRoleRequest) => { + return rolesApiService.createRole(request); + }, + onSuccess: (_: CreateRoleResponse, request: CreateRoleRequest) => { + toast.success("Role created successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to create role"); + }, + }; +}); + +export const updateRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: UpdateRoleRequest) => { + return rolesApiService.updateRole(request); + }, + onSuccess: (_: UpdateRoleResponse, request: UpdateRoleRequest) => { + toast.success("Role updated successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.byId(request.search_space_id.toString(), request.role_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to update role"); + }, + }; +}); + +export const deleteRoleMutationAtom = atomWithMutation(() => { + return { + mutationFn: async (request: DeleteRoleRequest) => { + return rolesApiService.deleteRole(request); + }, + onSuccess: (_: DeleteRoleResponse, request: DeleteRoleRequest) => { + toast.success("Role deleted successfully"); + queryClient.invalidateQueries({ + queryKey: cacheKeys.roles.all(request.search_space_id.toString()), + }); + }, + onError: () => { + toast.error("Failed to delete role"); + }, + }; +}); From f69b49e4c0bc04800fc85a1e76e7f40347cee09b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 06:37:29 +0000 Subject: [PATCH 43/60] fix: add type assertions for Motion animation properties in team page --- surfsense_web/app/dashboard/[search_space_id]/team/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index dd3f25218..b73fda65d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -116,7 +116,7 @@ import { cn } from "@/lib/utils"; // Animation variants const fadeInUp = { hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" } }, + visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const} }, }; const staggerContainer = { @@ -132,7 +132,7 @@ const cardVariants = { visible: { opacity: 1, scale: 1, - transition: { type: "spring", stiffness: 300, damping: 30 }, + transition: { type: "spring" as const, stiffness: 300, damping: 30 }, }, }; @@ -882,7 +882,7 @@ function InvitesTab({ size="sm" className="gap-2" onClick={() => copyInviteLink(invite)} - disabled={isInactive} + disabled={Boolean(isInactive)} > {copiedId === invite.id ? ( <> From 635be2b4e6db071dd827bfcc432d1528b96f786e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:02:52 +0000 Subject: [PATCH 44/60] refactor: migrate team page to use React Query for permissions and roles fetching --- .../dashboard/[search_space_id]/team/page.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index b73fda65d..f25c5a576 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { type ColumnDef, type ColumnFiltersState, @@ -44,6 +45,8 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { rolesApiService } from "@/lib/apis/roles-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; import { AlertDialog, AlertDialogAction, @@ -151,13 +154,20 @@ export default function TeamManagementPage() { removeMember, } = useMembers(searchSpaceId); const { - roles, - loading: rolesLoading, - fetchRoles, - createRole, + createRole, updateRole, deleteRole, } = useRoles(searchSpaceId); + + const { + data: roles = [], + isLoading: rolesLoading, + refetch: fetchRoles, + } = useQuery({ + queryKey: cacheKeys.roles.all(searchSpaceId.toString()), + queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }), + enabled: !!searchSpaceId, + }); const { invites, loading: invitesLoading, From 7d89fea31d34a0893c5ea08a2dc654617713ed5b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:25:15 +0000 Subject: [PATCH 45/60] refactor: migrate createRole to use mutation atom with proper types in team page --- .../dashboard/[search_space_id]/team/page.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index f25c5a576..08d1efab4 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -45,6 +45,9 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; +import { createRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; +import { useAtomValue } from "jotai"; +import type { CreateRoleRequest } from "@/contracts/types/roles.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { @@ -107,7 +110,6 @@ import { type InviteCreate, type Member, type Role, - type RoleCreate, useInvites, useMembers, usePermissions, @@ -154,11 +156,23 @@ export default function TeamManagementPage() { removeMember, } = useMembers(searchSpaceId); const { - createRole, updateRole, deleteRole, } = useRoles(searchSpaceId); + const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); + + const handleCreateRole = useCallback( + async (roleData: CreateRoleRequest['data']): Promise => { + const request: CreateRoleRequest = { + search_space_id: searchSpaceId, + data: roleData, + }; + return await createRole(request); + }, + [createRole, searchSpaceId] + ); + const { data: roles = [], isLoading: rolesLoading, @@ -339,7 +353,7 @@ export default function TeamManagementPage() { {activeTab === "roles" && hasPermission("roles:create") && ( )} @@ -1168,7 +1182,7 @@ function CreateRoleDialog({ onCreateRole, }: { groupedPermissions: Record; - onCreateRole: (data: RoleCreate) => Promise; + onCreateRole: (data: CreateRoleRequest['data']) => Promise; }) { const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); @@ -1187,7 +1201,7 @@ function CreateRoleDialog({ try { await onCreateRole({ name: name.trim(), - description: description.trim() || undefined, + description: description.trim() || null, permissions: selectedPermissions, is_default: isDefault, }); From cbe6a0525392f315d57670a6a5141ee244df9378 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:29:12 +0000 Subject: [PATCH 46/60] refactor: migrate updateRole to use mutation atom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 08d1efab4..77a9f24eb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -45,9 +45,9 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { createRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; +import { createRoleMutationAtom, updateRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; import { useAtomValue } from "jotai"; -import type { CreateRoleRequest } from "@/contracts/types/roles.types"; +import type { CreateRoleRequest, UpdateRoleRequest } from "@/contracts/types/roles.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { @@ -156,11 +156,23 @@ export default function TeamManagementPage() { removeMember, } = useMembers(searchSpaceId); const { - updateRole, deleteRole, } = useRoles(searchSpaceId); const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); + const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); + + const handleUpdateRole = useCallback( + async (roleId: number, data: { permissions?: string[] }): Promise => { + const request: UpdateRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + data: data, + }; + return await updateRole(request); + }, + [updateRole, searchSpaceId] + ); const handleCreateRole = useCallback( async (roleData: CreateRoleRequest['data']): Promise => { @@ -375,7 +387,7 @@ export default function TeamManagementPage() { roles={roles} groupedPermissions={groupedPermissions} loading={rolesLoading} - onUpdateRole={updateRole} + onUpdateRole={handleUpdateRole} onDeleteRole={deleteRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} From b4cdf2dcc1a8cedf736dc63cccec955fc9d95940 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:33:34 +0000 Subject: [PATCH 47/60] refactor: migrate deleteRole to use mutation atom and remove useRoles dependency in team page --- .../dashboard/[search_space_id]/team/page.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 77a9f24eb..96849a198 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -45,9 +45,9 @@ import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { createRoleMutationAtom, updateRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; +import { createRoleMutationAtom, updateRoleMutationAtom, deleteRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; import { useAtomValue } from "jotai"; -import type { CreateRoleRequest, UpdateRoleRequest } from "@/contracts/types/roles.types"; +import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest } from "@/contracts/types/roles.types"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { @@ -113,7 +113,6 @@ import { useInvites, useMembers, usePermissions, - useRoles, useUserAccess, } from "@/hooks/use-rbac"; import { cn } from "@/lib/utils"; @@ -155,12 +154,10 @@ export default function TeamManagementPage() { updateMemberRole, removeMember, } = useMembers(searchSpaceId); - const { - deleteRole, - } = useRoles(searchSpaceId); const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom); const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom); + const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom); const handleUpdateRole = useCallback( async (roleId: number, data: { permissions?: string[] }): Promise => { @@ -174,6 +171,18 @@ export default function TeamManagementPage() { [updateRole, searchSpaceId] ); + const handleDeleteRole = useCallback( + async (roleId: number): Promise => { + const request: DeleteRoleRequest = { + search_space_id: searchSpaceId, + role_id: roleId, + }; + await deleteRole(request); + return true; + }, + [deleteRole, searchSpaceId] + ); + const handleCreateRole = useCallback( async (roleData: CreateRoleRequest['data']): Promise => { const request: CreateRoleRequest = { @@ -388,7 +397,7 @@ export default function TeamManagementPage() { groupedPermissions={groupedPermissions} loading={rolesLoading} onUpdateRole={handleUpdateRole} - onDeleteRole={deleteRole} + onDeleteRole={handleDeleteRole} canUpdate={hasPermission("roles:update")} canDelete={hasPermission("roles:delete")} /> From 55d204e05bb31640bbd1ca1f06e9554077dc3569 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:41:16 +0000 Subject: [PATCH 48/60] fix: complete usePermissions migration to permissionsAtom in team page --- .../dashboard/[search_space_id]/team/page.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 96849a198..276ca3878 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -48,6 +48,7 @@ import { toast } from "sonner"; import { createRoleMutationAtom, updateRoleMutationAtom, deleteRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; import { useAtomValue } from "jotai"; import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest } from "@/contracts/types/roles.types"; +import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { @@ -112,7 +113,6 @@ import { type Role, useInvites, useMembers, - usePermissions, useUserAccess, } from "@/hooks/use-rbac"; import { cn } from "@/lib/utils"; @@ -210,7 +210,20 @@ export default function TeamManagementPage() { createInvite, revokeInvite, } = useInvites(searchSpaceId); - const { groupedPermissions, loading: permissionsLoading } = usePermissions(); + + const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom); + const permissions = permissionsData?.permissions || []; + const groupedPermissions = useMemo(() => { + const grouped: Record = {}; + permissions.forEach((permission) => { + const category = permission.permission_name.split(":")[0]; + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(permission); + }); + return grouped; + }, [permissions]); const canManageMembers = hasPermission("members:view"); const canManageRoles = hasPermission("roles:read"); From 5b85b1d0907f39687c525449d572f66f48cd39b2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:45:44 +0000 Subject: [PATCH 49/60] fix: use correct groupedPermissions logic matching original implementation --- .../app/dashboard/[search_space_id]/team/page.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 276ca3878..630e0849e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -214,15 +214,14 @@ export default function TeamManagementPage() { const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom); const permissions = permissionsData?.permissions || []; const groupedPermissions = useMemo(() => { - const grouped: Record = {}; - permissions.forEach((permission) => { - const category = permission.permission_name.split(":")[0]; - if (!grouped[category]) { - grouped[category] = []; + const groups: Record = {}; + for (const perm of permissions) { + if (!groups[perm.category]) { + groups[perm.category] = []; } - grouped[category].push(permission); - }); - return grouped; + groups[perm.category].push(perm); + } + return groups; }, [permissions]); const canManageMembers = hasPermission("members:view"); From fcfa621a7414eacd0802a34b4b74e9ab4088c9ed Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 07:48:00 +0000 Subject: [PATCH 50/60] refactor: remove unused useRoles and usePermissions hooks after migration --- surfsense_web/hooks/use-rbac.ts | 188 -------------------------------- 1 file changed, 188 deletions(-) diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts index ee3450746..fa619407a 100644 --- a/surfsense_web/hooks/use-rbac.ts +++ b/surfsense_web/hooks/use-rbac.ts @@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) { // ============ Roles Hook ============ -export function useRoles(searchSpaceId: number) { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchRoles = useCallback(async () => { - if (!searchSpaceId) return; - - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch roles"); - } - - const data = await response.json(); - setRoles(data); - setError(null); - return data; - } catch (err: any) { - setError(err.message || "Failed to fetch roles"); - console.error("Error fetching roles:", err); - } finally { - setLoading(false); - } - }, [searchSpaceId]); - - useEffect(() => { - fetchRoles(); - }, [fetchRoles]); - - const createRole = useCallback( - async (roleData: RoleCreate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`, - { - headers: { "Content-Type": "application/json" }, - method: "POST", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to create role"); - } - - const newRole = await response.json(); - setRoles((prev) => [...prev, newRole]); - toast.success("Role created successfully"); - return newRole; - } catch (err: any) { - toast.error(err.message || "Failed to create role"); - throw err; - } - }, - [searchSpaceId] - ); - - const updateRole = useCallback( - async (roleId: number, roleData: RoleUpdate) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { - headers: { "Content-Type": "application/json" }, - method: "PUT", - body: JSON.stringify(roleData), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to update role"); - } - - const updatedRole = await response.json(); - setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r))); - toast.success("Role updated successfully"); - return updatedRole; - } catch (err: any) { - toast.error(err.message || "Failed to update role"); - throw err; - } - }, - [searchSpaceId] - ); - - const deleteRole = useCallback( - async (roleId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`, - { method: "DELETE" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to delete role"); - } - - setRoles((prev) => prev.filter((r) => r.id !== roleId)); - toast.success("Role deleted successfully"); - return true; - } catch (err: any) { - toast.error(err.message || "Failed to delete role"); - return false; - } - }, - [searchSpaceId] - ); - - return { - roles, - loading, - error, - fetchRoles, - createRole, - updateRole, - deleteRole, - }; -} - -// ============ Invites Hook ============ - export function useInvites(searchSpaceId: number) { const [invites, setInvites] = useState([]); const [loading, setLoading] = useState(true); @@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) { // ============ Permissions Hook ============ -export function usePermissions() { - const [permissions, setPermissions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchPermissions = useCallback(async () => { - try { - setLoading(true); - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`, - { method: "GET" } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to fetch permissions"); - } - - const data = await response.json(); - setPermissions(data.permissions); - setError(null); - return data.permissions; - } catch (err: any) { - setError(err.message || "Failed to fetch permissions"); - console.error("Error fetching permissions:", err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchPermissions(); - }, [fetchPermissions]); - - // Group permissions by category - const groupedPermissions = useMemo(() => { - const groups: Record = {}; - for (const perm of permissions) { - if (!groups[perm.category]) { - groups[perm.category] = []; - } - groups[perm.category].push(perm); - } - return groups; - }, [permissions]); - - return { - permissions, - groupedPermissions, - loading, - error, - fetchPermissions, - }; -} - -// ============ User Access Hook ============ - export function useUserAccess(searchSpaceId: number) { const [access, setAccess] = useState(null); const [loading, setLoading] = useState(true); From 0397f716f883b0db753c63c2bbe11ce4e1a640c2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 08:32:57 +0000 Subject: [PATCH 51/60] chore: add TODO for edit role dialog implementation --- .../app/dashboard/[search_space_id]/team/page.tsx | 7 ++++++- surfsense_web/lib/apis/permissions-api.service.ts | 2 +- surfsense_web/lib/apis/roles-api.service.ts | 10 +++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 630e0849e..a5e1b45df 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -720,7 +720,12 @@ function RolesTab({ {canUpdate && ( - + { + // TODO: Implement edit role dialog/modal + console.log("Edit role not yet implemented", role); + }} + > Edit Role diff --git a/surfsense_web/lib/apis/permissions-api.service.ts b/surfsense_web/lib/apis/permissions-api.service.ts index 225ed892f..d161879b9 100644 --- a/surfsense_web/lib/apis/permissions-api.service.ts +++ b/surfsense_web/lib/apis/permissions-api.service.ts @@ -3,7 +3,7 @@ import { baseApiService } from "./base-api.service"; class PermissionsApiService { getPermissions = async () => { - return baseApiService.get(`/api/permissions`, getPermissionsResponse); + return baseApiService.get(`/api/v1/permissions`, getPermissionsResponse); }; } diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts index a1e7fea88..92083293a 100644 --- a/surfsense_web/lib/apis/roles-api.service.ts +++ b/surfsense_web/lib/apis/roles-api.service.ts @@ -30,7 +30,7 @@ class RolesApiService { } return baseApiService.post( - `/api/searchspaces/${parsedRequest.data.search_space_id}/roles`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, createRoleResponse, { body: parsedRequest.data.data, @@ -49,7 +49,7 @@ class RolesApiService { } return baseApiService.get( - `/api/searchspaces/${parsedRequest.data.search_space_id}/roles`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`, getRolesResponse, ); }; @@ -65,7 +65,7 @@ class RolesApiService { } return baseApiService.get( - `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, getRoleByIdResponse, ); }; @@ -81,7 +81,7 @@ class RolesApiService { } return baseApiService.put( - `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, updateRoleResponse, { body: parsedRequest.data.data, @@ -100,7 +100,7 @@ class RolesApiService { } return baseApiService.delete( - `/api/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, + `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`, deleteRoleResponse, ); }; From 78035a6e90c1053575687a56e5eda4893ee73ed3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 08:44:53 +0000 Subject: [PATCH 52/60] fix: update team page implementation --- surfsense_web/app/dashboard/[search_space_id]/team/page.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index a5e1b45df..63826b407 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -47,7 +47,7 @@ import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { createRoleMutationAtom, updateRoleMutationAtom, deleteRoleMutationAtom } from "@/atoms/roles/roles-mutation.atoms"; import { useAtomValue } from "jotai"; -import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest } from "@/contracts/types/roles.types"; +import type { CreateRoleRequest, UpdateRoleRequest, DeleteRoleRequest, Role } from "@/contracts/types/roles.types"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { rolesApiService } from "@/lib/apis/roles-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; @@ -110,7 +110,6 @@ import { type Invite, type InviteCreate, type Member, - type Role, useInvites, useMembers, useUserAccess, From 66a467436049166248ddbe1a380f532bdbf4cda7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 16 Dec 2025 09:16:41 +0000 Subject: [PATCH 53/60] fix: restore RBAC cache keys after rebase --- surfsense_web/lib/query-client/cache-keys.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 28e9396c9..6ac7c6a6e 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -45,4 +45,11 @@ export const cacheKeys = { user: { current: () => ["user", "me"] as const, }, + roles: { + all: (searchSpaceId: string) => ["roles", searchSpaceId] as const, + byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const, + }, + permissions: { + all: () => ["permissions"] as const, + }, }; From e9d3d36dfcdc21019dfb498848f2ae2279b85486 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:46:18 +0530 Subject: [PATCH 54/60] feat: improve note editing and creation workflow - Enhanced the editor to handle new note creation directly, initializing with an empty state. - Updated save functionality to create a note first if it's new, followed by saving the content. - Modified breadcrumb navigation to accommodate new notes and improve user experience. - Removed the old note creation page to streamline the editing process. - Added error handling and dynamic title updates for better feedback during note creation. --- .../editor/[documentId]/page.tsx | 164 ++++++++++++---- .../[search_space_id]/editor/new/page.tsx | 175 ------------------ .../components/dashboard-breadcrumb.tsx | 16 +- .../components/sidebar/app-sidebar.tsx | 2 +- .../components/sidebar/nav-notes.tsx | 31 +++- .../contracts/enums/connectorIcons.tsx | 4 +- 6 files changed, 167 insertions(+), 225 deletions(-) delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 9e188b09a..578c838b7 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertCircle, ArrowLeft, FileText, Loader2, Save, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, FileText, Loader2, Plus, SquarePen, Save, X } from "lucide-react"; import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -19,6 +19,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { notesApiService } from "@/lib/apis/notes-api.service"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; interface EditorContent { @@ -62,6 +63,8 @@ export default function EditorPage() { const params = useParams(); const router = useRouter(); const documentId = params.documentId as string; + const searchSpaceId = Number(params.search_space_id); + const isNewNote = documentId === "new"; const [document, setDocument] = useState(null); const [loading, setLoading] = useState(true); @@ -72,8 +75,23 @@ export default function EditorPage() { const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); // Fetch document content - DIRECT CALL TO FASTAPI + // Skip fetching if this is a new note useEffect(() => { async function fetchDocument() { + // For new notes, initialize with empty state + if (isNewNote) { + setDocument({ + document_id: 0, + title: "Untitled", + document_type: "NOTE", + blocknote_document: null, + updated_at: null, + }); + setEditorContent(null); + setLoading(false); + return; + } + const token = getBearerToken(); if (!token) { console.error("No auth token found"); @@ -122,7 +140,7 @@ export default function EditorPage() { if (documentId) { fetchDocument(); } - }, [documentId, params.search_space_id]); + }, [documentId, params.search_space_id, isNewNote]); // Track changes to mark as unsaved useEffect(() => { @@ -132,7 +150,7 @@ export default function EditorPage() { }, [editorContent, document]); // Check if this is a NOTE type document - const isNote = document?.document_type === "NOTE"; + const isNote = isNewNote || document?.document_type === "NOTE"; // Extract title dynamically from editor content for notes, otherwise use document title const displayTitle = useMemo(() => { @@ -145,6 +163,7 @@ export default function EditorPage() { // TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI // Save and exit - DIRECT CALL TO FASTAPI + // For new notes, create the note first, then save const handleSave = async () => { const token = getBearerToken(); if (!token) { @@ -153,42 +172,90 @@ export default function EditorPage() { return; } - if (!editorContent) { - toast.error("No content to save"); - return; - } - setSaving(true); + setError(null); + try { - // Save blocknote_document and trigger reindexing in background - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ blocknote_document: editorContent }), + // If this is a new note, create it first + if (isNewNote) { + const title = extractTitleFromBlockNote(editorContent); + + // Create the note first + const note = await notesApiService.createNote({ + search_space_id: searchSpaceId, + title: title, + blocknote_document: editorContent || undefined, + }); + + // If there's content, save it properly and trigger reindexing + if (editorContent) { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } } - ); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); + setHasUnsavedChanges(false); + toast.success("Note created successfully! Reindexing in background..."); + + // Redirect to editor with the new document ID + setTimeout(() => { + router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); + }, 500); + } else { + // Existing document - save normally + if (!editorContent) { + toast.error("No content to save"); + setSaving(false); + return; + } + + // Save blocknote_document and trigger reindexing in background + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } + + setHasUnsavedChanges(false); + toast.success("Document saved! Reindexing in background..."); + + // Small delay before redirect to show success message + setTimeout(() => { + router.push(`/dashboard/${params.search_space_id}/documents`); + }, 500); } - - setHasUnsavedChanges(false); - toast.success("Document saved! Reindexing in background..."); - - // Small delay before redirect to show success message - setTimeout(() => { - router.push(`/dashboard/${params.search_space_id}/documents`); - }, 500); } catch (error) { console.error("Error saving document:", error); - toast.error( - error instanceof Error ? error.message : "Failed to save document. Please try again." - ); + const errorMessage = + error instanceof Error + ? error.message + : isNewNote + ? "Failed to create note. Please try again." + : "Failed to save document. Please try again."; + setError(errorMessage); + toast.error(errorMessage); } finally { setSaving(false); } @@ -248,7 +315,7 @@ export default function EditorPage() { ); } - if (!document) { + if (!document && !isNewNote) { return (
@@ -286,13 +353,20 @@ export default function EditorPage() { {saving ? ( <> - Saving... + {isNewNote ? "Creating..." : "Saving..."} ) : ( - <> - - Save & Exit - + isNewNote ? ( + <> + + Create Note + + ) : ( + <> + + Save & Exit + + ) )}
@@ -301,9 +375,21 @@ export default function EditorPage() { {/* Editor Container */}
+ {error && ( + +
+ +

{error}

+
+
+ )}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx deleted file mode 100644 index 8e3d2c911..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/new/page.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { AlertCircle, ArrowLeft, FileText, Loader2, Plus } from "lucide-react"; -import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; - -// Helper function to extract title from BlockNote document -// Takes the text content from the first block (should be a heading) -function extractTitleFromBlockNote(blocknoteDocument: any[] | null | undefined): string { - if (!blocknoteDocument || !Array.isArray(blocknoteDocument) || blocknoteDocument.length === 0) { - return "Untitled"; - } - - const firstBlock = blocknoteDocument[0]; - if (!firstBlock) { - return "Untitled"; - } - - // Extract text from block content - // BlockNote blocks have a content array with inline content - if (firstBlock.content && Array.isArray(firstBlock.content)) { - const textContent = firstBlock.content - .map((item: any) => { - if (typeof item === "string") return item; - if (item?.text) return item.text; - return ""; - }) - .join("") - .trim(); - return textContent || "Untitled"; - } - - return "Untitled"; -} - -export default function NewNotePage() { - const params = useParams(); - const router = useRouter(); - const searchSpaceId = Number(params.search_space_id); - - const [editorContent, setEditorContent] = useState(null); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); - - // Extract title dynamically from editor content - const dynamicTitle = useMemo(() => { - return extractTitleFromBlockNote(editorContent); - }, [editorContent]); - - const handleCreate = async () => { - setCreating(true); - setError(null); - - try { - // Extract title from first block of editor content - const title = extractTitleFromBlockNote(editorContent); - - // Create the note first - const note = await notesApiService.createNote({ - search_space_id: searchSpaceId, - title: title, - blocknote_document: editorContent || undefined, - }); - - // If there's content, save it properly and trigger reindexing - if (editorContent) { - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } - - // Call the save endpoint to properly save blocknote_document and trigger reindexing - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ blocknote_document: editorContent }), - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - } - - toast.success("Note created successfully! Reindexing in background..."); - // Redirect to editor - router.push(`/dashboard/${searchSpaceId}/editor/${note.id}`); - } catch (error) { - console.error("Error creating note:", error); - const errorMessage = - error instanceof Error ? error.message : "Failed to create note. Please try again."; - setError(errorMessage); - toast.error(errorMessage); - } finally { - setCreating(false); - } - }; - - const handleBack = () => { - router.back(); - }; - - return ( - - {/* Toolbar */} -
-
- -
-

{dynamicTitle}

-
-
- -
- - -
-
- - {/* Editor Container - matches editor page layout */} -
-
- {error && ( - -
- -

{error}

-
-
- )} -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index 6335f9f1a..c6b264879 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -44,6 +44,13 @@ export function DashboardBreadcrumb() { useEffect(() => { if (segments[2] === "editor" && segments[3] && searchSpaceId) { const documentId = segments[3]; + + // Skip fetch for "new" notes + if (documentId === "new") { + setDocumentTitle(null); + return; + } + const token = getBearerToken(); if (token) { @@ -110,7 +117,14 @@ export function DashboardBreadcrumb() { // Handle editor sub-sections (document ID) if (section === "editor") { - const documentLabel = documentTitle || subSection; + // Handle special cases for editor + let documentLabel: string; + if (subSection === "new") { + documentLabel = "New Note"; + } else { + documentLabel = documentTitle || subSection; + } + breadcrumbs.push({ label: t("documents"), href: `/dashboard/${segments[1]}/documents`, diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 2144dd861..bdae5b5e1 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -138,13 +138,13 @@ export const iconMap: Record = { MessageCircleMore, Settings2, SquareLibrary, + FileText, SquareTerminal, AlertCircle, Info, ExternalLink, Trash2, Podcast, - FileText, Users, }; diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index 14dd75c59..f392e25b6 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Share, Trash2, + Eye, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -149,9 +150,9 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps ); return ( - + -
+
- {onAddNote && ( +
- )} + {onAddNote && ( + + )} +
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index 583ace11e..18521edb7 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -17,7 +17,7 @@ import { IconTicket, IconWorldWww, } from "@tabler/icons-react"; -import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react"; +import { File, FileText, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react"; import { EnumConnectorName } from "./connector"; export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => { @@ -71,6 +71,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case "FILE": return ; + case "NOTE": + return ; case "EXTENSION": return ; case "DEEP": From 5da41d91c8727523aebf6ae894af21a44e2861b5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:14:35 +0530 Subject: [PATCH 55/60] feat: add All Notes sidebar component and integrate with NavNotes - Introduced a new AllNotesSidebar component for displaying all notes in a sidebar. - Enhanced NavNotes to include a button for opening the All Notes sidebar. - Updated translations to support new sidebar labels and error messages. - Improved note deletion handling and loading states within the sidebar. - Refactored AppSidebar to pass searchSpaceId to NavNotes for better context. --- .../components/sidebar/all-notes-sidebar.tsx | 404 ++++++++++++++++++ .../components/sidebar/app-sidebar.tsx | 2 +- .../components/sidebar/nav-notes.tsx | 56 ++- surfsense_web/messages/en.json | 4 +- surfsense_web/messages/zh.json | 7 +- 5 files changed, 455 insertions(+), 18 deletions(-) create mode 100644 surfsense_web/components/sidebar/all-notes-sidebar.tsx diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx new file mode 100644 index 000000000..8a3c70083 --- /dev/null +++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { + FileText, + type LucideIcon, + MoreHorizontal, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { notesApiService } from "@/lib/apis/notes-api.service"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { useEffect, useRef } from "react"; + +// Map of icon names to their components +const actionIconMap: Record = { + FileText, + Trash2, + MoreHorizontal, + RefreshCw, +}; + +interface NoteAction { + name: string; + icon: string; + onClick: () => void; +} + +interface NoteItem { + name: string; + url: string; + icon: LucideIcon; + id?: number; + search_space_id?: number; + actions?: NoteAction[]; +} + +interface AllNotesSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchSpaceId: string; + onAddNote?: () => void; + hoverTimeoutRef?: React.MutableRefObject; +} + +export function AllNotesSidebar({ + open, + onOpenChange, + searchSpaceId, + onAddNote, + hoverTimeoutRef, +}: AllNotesSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const sidebarRef = useRef(null); + const [sidebarLeft, setSidebarLeft] = useState(0); // Position from left edge of viewport + + // Calculate the sidebar's right edge position + useEffect(() => { + if (typeof window === "undefined") return; + + const updatePosition = () => { + // Find the actual sidebar element (the fixed positioned one) + const sidebarElement = document.querySelector( + '[data-slot="sidebar"][data-sidebar="sidebar"]' + ) as HTMLElement; + + if (sidebarElement) { + const rect = sidebarElement.getBoundingClientRect(); + // Set the left position to be the right edge of the sidebar + setSidebarLeft(rect.right); + } else { + // Fallback: try to find any sidebar element + const fallbackSidebar = document.querySelector('[data-slot="sidebar"]') as HTMLElement; + if (fallbackSidebar) { + const rect = fallbackSidebar.getBoundingClientRect(); + setSidebarLeft(rect.right); + } else { + // Final fallback: use CSS variable + const sidebarWidth = getComputedStyle(document.documentElement) + .getPropertyValue("--sidebar-width") + .trim(); + if (sidebarWidth) { + const remValue = parseFloat(sidebarWidth); + setSidebarLeft(remValue * 16); // Convert rem to px + } else { + setSidebarLeft(256); // Default 16rem + } + } + } + }; + + updatePosition(); + // Update on window resize and scroll + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + // Use MutationObserver to watch for sidebar state changes + const observer = new MutationObserver(updatePosition); + const sidebarWrapper = document.querySelector('[data-slot="sidebar-wrapper"]'); + if (sidebarWrapper) { + observer.observe(sidebarWrapper, { + attributes: true, + attributeFilter: ["data-state", "class"], + childList: true, + subtree: true, + }); + } + + // Also observe the sidebar element directly if it exists + const sidebarElement = document.querySelector('[data-slot="sidebar"]'); + if (sidebarElement) { + observer.observe(sidebarElement, { + attributes: true, + attributeFilter: ["data-state", "class"], + childList: false, + subtree: false, + }); + } + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + observer.disconnect(); + }; + }, []); + + // Handle Escape key to close sidebar + useEffect(() => { + if (!open) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onOpenChange(false); + } + }; + + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + // Fetch all notes + const { + data: notesData, + error: notesError, + isLoading: isLoadingNotes, + refetch: refetchNotes, + } = useQuery({ + queryKey: ["all-notes", searchSpaceId], + queryFn: () => + notesApiService.getNotes({ + search_space_id: Number(searchSpaceId), + page_size: 1000, // Get all notes + }), + enabled: !!searchSpaceId && open, // Only fetch when sidebar is open + }); + + // Handle note deletion with loading state + const handleDeleteNote = useCallback( + async (noteId: number, deleteAction: () => void) => { + setIsDeleting(noteId); + try { + await deleteAction(); + refetchNotes(); + } finally { + setIsDeleting(null); + } + }, + [refetchNotes] + ); + + // Transform notes to the format expected by the component + const allNotes = useMemo(() => { + return notesData?.items + ? notesData.items.map((note) => ({ + name: note.title, + url: `/dashboard/${note.search_space_id}/editor/${note.id}`, + icon: FileText as LucideIcon, + id: note.id, + search_space_id: note.search_space_id, + actions: [ + { + name: "Delete", + icon: "Trash2", + onClick: async () => { + try { + await notesApiService.deleteNote({ + search_space_id: note.search_space_id, + note_id: note.id, + }); + } catch (error) { + console.error("Error deleting note:", error); + } + }, + }, + ], + })) + : []; + }, [notesData]); + + // Enhanced note item component + const NoteItemComponent = useCallback( + ({ note }: { note: NoteItem }) => { + const isDeletingNote = isDeleting === note.id; + + return ( + + { + router.push(note.url); + onOpenChange(false); // Close sidebar when navigating + }} + disabled={isDeletingNote} + className={cn("group/item relative", isDeletingNote && "opacity-50")} + size="sm" + > + + {note.name} + + + {note.actions && note.actions.length > 0 && ( + + + + + More + + + + {note.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || FileText; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteNote(note.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingNote} + className={isDeleteAction ? "text-destructive" : ""} + > + + + {isDeletingNote && isDeleteAction ? "Deleting..." : action.name} + + + ); + })} + + + )} + + ); + }, + [isDeleting, router, onOpenChange, handleDeleteNote] + ); + + return ( + <> + {/* Floating Sidebar */} +
{ + // Clear any pending close timeout when hovering over sidebar + if (hoverTimeoutRef?.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }} + onMouseLeave={() => { + // Close sidebar when mouse leaves + if (hoverTimeoutRef) { + hoverTimeoutRef.current = setTimeout(() => { + onOpenChange(false); + }, 200); + } else { + onOpenChange(false); + } + }} + > +
+ {/* Header */} +
+

{t("all_notes") || "All Notes"}

+
+ + {/* Content */} + +
+ + + {isLoadingNotes ? ( + + + + {t("loading") || "Loading..."} + + + + ) : notesError ? ( + + + + {t("error_loading_notes") || "Error loading notes"} + + + + ) : allNotes.length > 0 ? ( + + {allNotes.map((note) => ( + + ))} + + ) : ( + + {onAddNote ? ( + { + onAddNote(); + onOpenChange(false); + }} + className="text-muted-foreground hover:text-sidebar-foreground text-xs" + size="sm" + > + + {t("create_new_note") || "Create a new note"} + + ) : ( + + + {t("no_notes") || "No notes yet"} + + )} + + )} + + +
+
+ + {/* Footer with Add Note button */} + {onAddNote && ( +
+ +
+ )} +
+
+ + ); +} + diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index bdae5b5e1..90288c394 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -453,7 +453,7 @@ export const AppSidebar = memo(function AppSidebar({ )}
- +
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx index f392e25b6..b89ebd4a0 100644 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ b/surfsense_web/components/sidebar/nav-notes.tsx @@ -3,6 +3,7 @@ import { ChevronRight, ExternalLink, + Eye, FileText, type LucideIcon, MoreHorizontal, @@ -10,11 +11,10 @@ import { RefreshCw, Share, Trash2, - Eye, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; +import { useCallback, useState, useRef } from "react"; import { Collapsible, CollapsibleContent, @@ -36,6 +36,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { AllNotesSidebar } from "./all-notes-sidebar"; // Map of icon names to their components const actionIconMap: Record = { @@ -66,14 +67,17 @@ interface NavNotesProps { notes: NoteItem[]; onAddNote?: () => void; defaultOpen?: boolean; + searchSpaceId?: string; } -export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps) { +export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { const t = useTranslations("sidebar"); const { isMobile } = useSidebar(); const router = useRouter(); const [isDeleting, setIsDeleting] = useState(null); const [isOpen, setIsOpen] = useState(defaultOpen); + const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + const hoverTimeoutRef = useRef(null); // Handle note deletion with loading state const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { @@ -166,18 +170,31 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true }: NavNotesProps
- + {searchSpaceId && ( + + )} {onAddNote && (