From 2d845bc235992e6695539f76da89a7426a1243db Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 8 Jun 2026 09:30:33 +0530 Subject: [PATCH 1/6] document service: Fix collaborator access for documents. Previously, accessing the shared documents by collaborator resulted in NOT FOUND. This commit fix it by checking if the user is the owner or has valid collaborator/public access. --- .../api/document/service/DocumentService.java | 19 ++++++-- .../document/service/DocumentServiceTest.java | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java index 1ca3a4d..0dfe484 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java @@ -118,9 +118,22 @@ public Page list(UUID userId, Pageable pageable, boolean trash public DocumentResponse get(UUID userId, UUID documentId, boolean includeTrashed) { Document document; if (includeTrashed) { - document = documentRepository - .findByIdAndUser_Id(documentId, userId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + document = documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (document.getDeletedAt() != null) { + // Document is in trash - only the owner can access it + if (!document.getUser().getId().equals(userId)) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } else { + // Active document - check if the user is the owner or has valid collaborator/public access + if (!document.getUser().getId().equals(userId)) { + DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); + if (effectiveAccess == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + } } else { document = findAccessibleActiveDocument(userId, documentId, false); } diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java index 4f2d9bc..dacc901 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java @@ -231,6 +231,54 @@ void update_prefersCollaboratorReadOnlyOverGeneralEditAccess() { verify(documentRepository, never()).save(any(Document.class)); } + @Test + void get_allowsCollaboratorAccessWhenIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); // link access allows VIEW + + var response = documentService.get(requesterId, documentId, true); + + assertEquals(documentId, response.id()); + assertEquals("Shared doc", response.title()); + } + + @Test + void get_rejectsCollaboratorAccessWhenDocumentIsTrashedAndIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + document.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void get_rejectsAccessWhenCollaboratorHasNoAccessAndIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + document.setGeneralAccessMode(DocumentGeneralAccessMode.RESTRICTED); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { User owner = User.builder() .id(UUID.randomUUID()) From ac7116f22ec643745f2d73e701a93d81d568f3ce Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 10 Jun 2026 14:53:21 +0530 Subject: [PATCH 2/6] realtime: Fix open handles in tests. Update the afterEach hook to explicitly call .destroy() on all active Yjs documents. Also prevents TypeError when running Jest diagnostics. --- realtime/tests/unit/server.test.ts | 1 + realtime/tests/unit/yjs-utils.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/realtime/tests/unit/server.test.ts b/realtime/tests/unit/server.test.ts index 3ab2b0c..bcc0db6 100644 --- a/realtime/tests/unit/server.test.ts +++ b/realtime/tests/unit/server.test.ts @@ -30,6 +30,7 @@ jest.mock('../../src/logger', () => ({ jest.mock('../../src/yjs-utils', () => ({ __esModule: true, setupWSConnection: jest.fn(), + updateConnectionAccessLevel: jest.fn(), })); jest.mock('../../src/config', () => ({ diff --git a/realtime/tests/unit/yjs-utils.test.ts b/realtime/tests/unit/yjs-utils.test.ts index 166c427..c3fc609 100644 --- a/realtime/tests/unit/yjs-utils.test.ts +++ b/realtime/tests/unit/yjs-utils.test.ts @@ -40,6 +40,8 @@ describe('Yjs Utils', () => { }); afterEach(() => { + docs.forEach((doc) => doc.destroy()); + docs.clear(); jest.clearAllMocks(); }); From 1d593171b072d58be40ed52eef4a051b3a115e39 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 15 Jun 2026 12:00:00 +0530 Subject: [PATCH 3/6] sidebar: Refactor monolithic Sidebar into modular components and centralize icons. Decompose the 1390-line Sidebar.tsx into focused modules under components/sidebar/, extracting the resize logic into a custom hook and sharing types and constants through a single types file. Extract inline SVGs into reusable icon components under web/icons/, replacing duplicates in SharePanel, AuthModal, SettingsModal, and CommentsSidebarHeader. Fix the isSharedLoading default from false to true and add a missing setIsSharedLoading(true) to the isInitializing guard to prevent a flash of empty content before the API responds. --- web/components/AppShell.tsx | 2 +- web/components/AuthModal.tsx | 41 +- web/components/SettingsModal.tsx | 14 +- web/components/SharePanel.tsx | 87 +- web/components/Sidebar.tsx | 1390 ----------------- .../comments/CommentsSidebarHeader.tsx | 10 +- .../sidebar/DocumentActionsButton.tsx | 36 + .../sidebar/DocumentActionsMenu.tsx | 55 + web/components/sidebar/DocumentsPanel.tsx | 312 ++++ .../sidebar/DocumentsPanelSkeleton.tsx | 40 + web/components/sidebar/ProfileMenuPopup.tsx | 66 + web/components/sidebar/Sidebar.tsx | 775 +++++++++ .../sidebar/SidebarDocumentSection.tsx | 140 ++ web/components/sidebar/index.ts | 1 + web/components/sidebar/types.ts | 16 + web/components/sidebar/useSidebarResize.ts | 56 + web/hooks/useDocumentList.hook.ts | 3 +- web/icons/ChainLink.tsx | 20 + web/icons/Check.tsx | 7 + web/icons/ChevronDown.tsx | 7 + web/icons/Close.tsx | 7 + web/icons/GitHub.tsx | 15 + web/icons/GlobeSolid.tsx | 15 + web/icons/Google.tsx | 29 + web/icons/Lock.tsx | 19 + web/icons/UserCircle.tsx | 8 + web/icons/index.ts | 9 + web/tests/unit/components/AppShell.test.tsx | 2 +- web/tests/unit/components/Sidebar.test.tsx | 2 +- 29 files changed, 1656 insertions(+), 1528 deletions(-) delete mode 100644 web/components/Sidebar.tsx create mode 100644 web/components/sidebar/DocumentActionsButton.tsx create mode 100644 web/components/sidebar/DocumentActionsMenu.tsx create mode 100644 web/components/sidebar/DocumentsPanel.tsx create mode 100644 web/components/sidebar/DocumentsPanelSkeleton.tsx create mode 100644 web/components/sidebar/ProfileMenuPopup.tsx create mode 100644 web/components/sidebar/Sidebar.tsx create mode 100644 web/components/sidebar/SidebarDocumentSection.tsx create mode 100644 web/components/sidebar/index.ts create mode 100644 web/components/sidebar/types.ts create mode 100644 web/components/sidebar/useSidebarResize.ts create mode 100644 web/icons/ChainLink.tsx create mode 100644 web/icons/Check.tsx create mode 100644 web/icons/ChevronDown.tsx create mode 100644 web/icons/Close.tsx create mode 100644 web/icons/GitHub.tsx create mode 100644 web/icons/GlobeSolid.tsx create mode 100644 web/icons/Google.tsx create mode 100644 web/icons/Lock.tsx create mode 100644 web/icons/UserCircle.tsx diff --git a/web/components/AppShell.tsx b/web/components/AppShell.tsx index 5ac859c..135a29a 100644 --- a/web/components/AppShell.tsx +++ b/web/components/AppShell.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Suspense } from 'react'; -import Sidebar from '@/components/Sidebar'; +import Sidebar from '@/components/sidebar'; import { AuthModal } from '@/components/AuthModal'; import { LocalDocsPromotionModal } from '@/components/LocalDocsPromotionModal'; import { RegistrationSyncOverlay } from '@/components/RegistrationSyncOverlay'; diff --git a/web/components/AuthModal.tsx b/web/components/AuthModal.tsx index 7bc64dc..82d355b 100644 --- a/web/components/AuthModal.tsx +++ b/web/components/AuthModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useId, useRef, useState, type FormEvent } from 'react'; import { useAppDispatch } from '@/stores/hooks'; import { clearError } from '@/stores/auth/auth.slice'; import { useAuth } from '@/hooks/useAuth.hook'; +import { GitHub, Google } from '@/icons'; type Mode = 'login' | 'signup'; @@ -276,47 +277,9 @@ function OAuthButton({ provider }: { provider: 'google' | 'github' }) { className="w-full flex items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground/50 cursor-not-allowed opacity-60" > - {provider === 'google' ? : } + {provider === 'google' ? : } Continue with {provider === 'google' ? 'Google' : 'GitHub'} ); } - -function GoogleIcon() { - return ( - - ); -} - -function GitHubIcon() { - return ( - - ); -} diff --git a/web/components/SettingsModal.tsx b/web/components/SettingsModal.tsx index b69e87e..8462a1f 100644 --- a/web/components/SettingsModal.tsx +++ b/web/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react'; import { useTheme, type Theme } from '@/hooks/useTheme.hook'; +import { Close } from '@/icons'; interface SettingsModalProps { onClose: () => void; @@ -187,18 +188,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) { hover:bg-sidebar-accent hover:text-foreground transition-colors cursor-pointer" aria-label="Close settings" > - - - + diff --git a/web/components/SharePanel.tsx b/web/components/SharePanel.tsx index 9cde033..68f23f0 100644 --- a/web/components/SharePanel.tsx +++ b/web/components/SharePanel.tsx @@ -10,6 +10,7 @@ import { } from '@/services/document.service'; import { useAuth } from '@/hooks/useAuth.hook'; import { getPresenceColor } from '@/lib/realtime.util'; +import { ChainLink, Check, ChevronDown, Close, GlobeSolid, Lock } from '@/icons'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -131,20 +132,10 @@ function AccessDropdown({ aria-expanded={open} > {selected?.label ?? value} - + /> {open && ( @@ -185,17 +176,7 @@ function AccessDropdown({ `} > - {isSelected && ( - - - - )} + {isSelected && } {opt.label} @@ -207,47 +188,6 @@ function AccessDropdown({ ); } -// ─── Icons ──────────────────────────────────────────────────────────────────── - -function GlobeIcon() { - return ( - - ); -} - -function LockIcon() { - return ( - - ); -} - -function ChainLinkIcon() { - return ( - - ); -} - // ─── Main component ─────────────────────────────────────────────────────────── export function SharePanel({ documentId, isOpen, onClose, anchorRef }: SharePanelProps) { @@ -645,14 +585,7 @@ export function SharePanel({ documentId, isOpen, onClose, anchorRef }: SharePane transition-all cursor-pointer " > - - - + @@ -678,7 +611,11 @@ export function SharePanel({ documentId, isOpen, onClose, anchorRef }: SharePane } `} > - {isAnyoneWithLink ? : } + {isAnyoneWithLink ? ( + + ) : ( + + )}
@@ -725,7 +662,7 @@ export function SharePanel({ documentId, isOpen, onClose, anchorRef }: SharePane active:scale-95 transition-all cursor-pointer " > - + {copied ? 'Copied!' : 'Copy link'} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx deleted file mode 100644 index 205a26a..0000000 --- a/web/components/Sidebar.tsx +++ /dev/null @@ -1,1390 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { memo } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import { - useDocumentList, - type LocalDocumentEntry, - type SharedDocumentEntry, -} from '@/hooks/useDocumentList.hook'; -import { documentService } from '@/services/document.service'; -import { - NewDocument, - Search, - ChevronRight, - DocumentText, - Settings, - Login, - Logout, - MoreHorizontal, - Trash, - Restore, - NextDocs, - CloseSidebar, - OpenSidebar, -} from '@/icons'; -import { ConfirmationModal } from '@/components/ConfirmationModal'; -import { PopupMenuItem } from '@/components/PopupMenuItem'; -import { SettingsModal } from '@/components/SettingsModal'; -import { useTheme } from '@/hooks/useTheme.hook'; -import { useAuth } from '@/hooks/useAuth.hook'; -import { useOfflineDocumentSelect } from '@/hooks/useOfflineDocumentSelect.hook'; -import { generateDocumentId } from '@/lib/document-id.util'; -import { OFFLINE_DOCUMENT_SELECT_EVENT } from '@/lib/offline-navigation.util'; -import { resolveRootDocumentId } from '@/lib/root-document.util'; - -const emptySubscribe = () => () => {}; -const SIDEBAR_VISIBLE_COUNT = 7; -const SIDEBAR_COLLAPSE_HOVER_GUARD_MS = 260; -type DocumentsPanelMode = 'all' | 'shared' | 'trash' | null; -type DocActionType = 'move-to-trash' | 'leave-shared'; -type DocActionsAnchor = { - documentId: string; - actionType: DocActionType; - x: number; - y: number; -}; -type SidebarSectionDocument = LocalDocumentEntry | SharedDocumentEntry; - -type DocumentActionsButtonProps = { - documentId: string; - documentTitle: string; - actionType: DocActionType; - isOpen: boolean; - onToggle: ( - event: React.MouseEvent, - documentId: string, - actionType: DocActionType - ) => void; -}; - -function DocumentActionsButton({ - documentId, - documentTitle, - actionType, - isOpen, - onToggle, -}: DocumentActionsButtonProps) { - return ( - - ); -} - -function DocumentsPanelSkeleton({ - rows = 6, - compact = false, -}: { - rows?: number; - compact?: boolean; -}) { - return ( -