diff --git a/web/hooks/useDocument.hook.ts b/web/hooks/useDocument.hook.ts index 93a9418..6f2060d 100644 --- a/web/hooks/useDocument.hook.ts +++ b/web/hooks/useDocument.hook.ts @@ -173,6 +173,25 @@ async function resolveLocalFallbackDocument( }; } +/** + * Fetches a document whose active access may have just been revoked (e.g. it was moved to + * trash from another tab/device). Returns the trashed copy when the caller is the owner - + * trashed documents are served read-only to their owner over REST (includeTrashed=true) - + * or null when the document is truly inaccessible (active but revoked, permanently deleted, + * or not the owner). + */ +async function loadTrashedDocumentIfVisible( + documentId: string, + token: string +): Promise { + try { + const result = await documentService.getCloudDocument(documentId, token); + return result.meta.deletedAt ? result : null; + } catch { + return null; + } +} + export function useDocument(documentId: string, options?: UseDocumentOptions) { const id = documentId; const isSharedDocument = options?.isSharedDocument === true; @@ -205,6 +224,29 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { // the module-level singleton at render time, which may be stale) const [ydoc, setLocalYDoc] = useState(null); + // When the owner loses active access because their document was moved to trash + // (e.g. from another tab/device), surface the read-only trash view instead of a + // spurious "access restricted" error. + const applyTrashedDocumentView = useCallback( + (documentId: string, result: DocumentLoadResult) => { + setLocalYDoc(result.ydoc); + setYDoc(result.ydoc); + dispatch( + setCurrentDocument({ + id: documentId, + meta: result.meta, + }) + ); + setAccessLevel('VIEW'); + // The trashed doc renders read-only; don't leave a stale cached level (e.g. EDIT) + // behind that could misrepresent permissions. + clearCachedDocumentAccessLevel(documentId); + setErrorState(null); + dispatch(setError(null)); + }, + [dispatch] + ); + useEffect(() => { accessTokenRef.current = accessToken; }, [accessToken]); @@ -501,6 +543,11 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { !isOnline || isLoading || errorState !== null || + // Trashed documents are served as a read-only, REST-only view (restore/trash UI). + // The realtime server strictly rejects access checks for trashed documents, so + // connecting would trigger a 1008 close followed by a spurious "access restricted" + // error for the document owner. + !!meta?.deletedAt || isCloudReadInBackoff() || !isAuthenticated || !accessTokenRef.current @@ -543,6 +590,19 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { const myAccess = await documentService.getMyAccess(resolvedDocumentId, token); if (closeHandlerCancelled) return; if (!myAccess.allowed || !myAccess.accessLevel) { + // The realtime server rejected this connection (1008). Before treating it as + // a revocation, check whether the user can still view the document from trash + // (e.g. it was moved to trash from another tab/device). Trashed documents are + // served read-only to their owner over REST. + const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, token); + if (closeHandlerCancelled) return; + if (trashedCopy) { + applyTrashedDocumentView(resolvedDocumentId, trashedCopy); + provider.shouldConnect = false; + setIsRealtimeConnected(false); + setRealtimeProvider((current) => (current === provider ? null : current)); + return; + } handleAccessRevoked(404); return; } @@ -623,12 +683,14 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { errorState, isAuthenticated, meta?.createdBy, + meta?.deletedAt, user?.id, user?.email, user?.displayName, isCloudReadInBackoff, refresh, dispatch, + applyTrashedDocumentView, ]); // Listen for server-pushed access-level changes and apply them immediately. @@ -725,6 +787,13 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { try { const myAccess = await documentService.getMyAccess(resolvedDocumentId, accessToken); if (!myAccess.allowed || !myAccess.accessLevel) { + // Before treating this as a revocation, check whether the user can still view + // the document from trash (e.g. it was moved to trash from another tab/device). + const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, accessToken); + if (trashedCopy) { + applyTrashedDocumentView(resolvedDocumentId, trashedCopy); + return; + } clearCachedDocumentAccessLevel(resolvedDocumentId); const restrictedError = buildDocumentErrorState( new DocumentServiceApiError('The requested resource was not found.', 404) @@ -783,6 +852,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { isCloudReadInBackoff, refresh, isRealtimeConnected, + applyTrashedDocumentView, ]); const updateMeta = useCallback( diff --git a/web/tests/unit/hooks/useDocument.hook.test.tsx b/web/tests/unit/hooks/useDocument.hook.test.tsx index 1ef37fc..a47a788 100644 --- a/web/tests/unit/hooks/useDocument.hook.test.tsx +++ b/web/tests/unit/hooks/useDocument.hook.test.tsx @@ -1306,5 +1306,125 @@ describe('useDocument', () => { }); expect(result.current.ydoc).toBeNull(); }); + + it('should not establish realtime connection for a trashed document owned by the user', async () => { + const ydoc = new Y.Doc(); + const trashedMeta = { + title: 'Trashed Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-trashed', + isInitializing: false, + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + + const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // The trashed document loads for the owner without an access check and stays open. + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + expect(result.current.isReadOnly).toBe(true); + expect(result.current.errorState).toBeNull(); + expect(result.current.ydoc).toBe(ydoc); + expect(getMyAccessSpy).not.toHaveBeenCalled(); + + // No realtime provider should be created for a trashed document: the realtime + // server strictly rejects access checks for trashed docs (1008 close), which would + // otherwise trigger a spurious "access restricted" error for the owner. + expect(mockOn).not.toHaveBeenCalled(); + expect(registeredStatusHandler).toBeNull(); + expect(registeredCloseHandler).toBeNull(); + }); + + it('should keep a trashed document open as a read-only trash view when realtime access is revoked after the doc is trashed elsewhere', async () => { + const ydoc = new Y.Doc(); + const activeMeta = { + title: 'Active Doc', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + const trashedMeta = { + ...activeMeta, + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-trashed-elsewhere', + isInitializing: false, + }); + + // getCloudDocument: initial load returns the active doc; the access-revocation + // re-check returns the trashed copy (owner can still view it via REST). + getCloudDocumentSpy + .mockResolvedValueOnce({ ydoc, meta: activeMeta }) + .mockResolvedValueOnce({ ydoc, meta: trashedMeta }); + + // getMyAccess: the loadDoc check and the immediate revalidation check are allowed; + // the realtime close-handler recheck reports access revoked for the trashed doc. + getMyAccessSpy + .mockResolvedValueOnce({ + documentId: validUuid, + allowed: true, + accessLevel: 'EDIT', + owner: false, + }) + .mockResolvedValueOnce({ + documentId: validUuid, + allowed: true, + accessLevel: 'EDIT', + owner: false, + }) + .mockResolvedValueOnce({ + documentId: validUuid, + allowed: false, + accessLevel: null, + owner: false, + }); + + const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() }); + + await act(async () => { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(registeredCloseHandler).not.toBeNull(); + expect(registeredStatusHandler).not.toBeNull(); + + act(() => { + registeredStatusHandler!({ status: 'connected' }); + }); + + // Simulate the realtime server closing the connection after its periodic access + // revalidation rejects the now-trashed document. + act(() => { + registeredCloseHandler!({ code: 1008 }); + }); + + // The owner keeps seeing the document as a read-only trash view instead of a + // spurious "access restricted" error. + await waitFor(() => { + expect(result.current.errorState).toBeNull(); + }); + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + expect(result.current.accessLevel).toBe('VIEW'); + expect(result.current.isReadOnly).toBe(true); + expect(result.current.ydoc).toBe(ydoc); + }); }); });