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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,22 @@ public Page<DocumentResponse> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions realtime/tests/unit/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
2 changes: 2 additions & 0 deletions realtime/tests/unit/yjs-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe('Yjs Utils', () => {
});

afterEach(() => {
docs.forEach((doc) => doc.destroy());
docs.clear();
jest.clearAllMocks();
});

Expand Down
2 changes: 1 addition & 1 deletion web/app/doc/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('@/components/Editor'), { ssr: false });
const Editor = dynamic(() => import('@/components/editor'), { ssr: false });

export default function DocPage() {
return <Editor />;
Expand Down
109 changes: 64 additions & 45 deletions web/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Suspense } from 'react';
import Sidebar from '@/components/Sidebar';
import Sidebar from '@/components/sidebar';
import { AuthModal } from '@/components/AuthModal';
import { ToastContainer } from '@/components/ToastContainer';
import { LocalDocsPromotionModal } from '@/components/LocalDocsPromotionModal';
import { RegistrationSyncOverlay } from '@/components/RegistrationSyncOverlay';
import { useAppDispatch } from '@/stores/hooks';
import { useAppDispatch, useAppSelector } from '@/stores/hooks';
import { refreshSessionThunk } from '@/stores/auth/auth.slice';
import {
setAuthModalOpen,
setLocalDocsModalOpen,
setLocalDocsToPromote,
setImportingLocalDocs,
setLocalDocsError,
setRegistrationSyncOverlayOpen,
resetPromotionFlow,
} from '@/stores/ui/ui.slice';
import { useAuth } from '@/hooks/useAuth.hook';
import { documentService } from '@/services/document.service';
import { isUntitledTitle, isEmptyLocalDocument } from '@/lib/document-content.util';
Expand Down Expand Up @@ -57,15 +67,15 @@ export function getDocsEligibleForAccountMove(docs: StoredDocument[]): StoredDoc

export function AppShell({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const [isAuthOpen, setIsAuthOpen] = useState(false);
const [isLocalDocsModalOpen, setIsLocalDocsModalOpen] = useState(false);
const [localDocsToPromote, setLocalDocsToPromote] = useState<StoredDocument[]>([]);
const [isImportingLocalDocs, setIsImportingLocalDocs] = useState(false);
const [localDocsError, setLocalDocsError] = useState<string | null>(null);
const [isRegistrationSyncOverlayOpen, setIsRegistrationSyncOverlayOpen] = useState(false);
const openAuthModal = useCallback(() => {
setIsAuthOpen(true);
}, []);
const isAuthOpen = useAppSelector((state) => state.ui.isAuthModalOpen);
const isLocalDocsModalOpen = useAppSelector((state) => state.ui.isLocalDocsModalOpen);
const localDocsToPromote = useAppSelector((state) => state.ui.localDocsToPromote);
const isImportingLocalDocs = useAppSelector((state) => state.ui.isImportingLocalDocs);
const localDocsError = useAppSelector((state) => state.ui.localDocsError);
const isRegistrationSyncOverlayOpen = useAppSelector(
(state) => state.ui.isRegistrationSyncOverlayOpen
);

const { user, isTokenExpiringSoon, isAuthenticated, accessToken, lastAuthAction } = useAuth();
const didPromptImportRef = useRef(false);
const ownsLocalPromotionLockRef = useRef(false);
Expand Down Expand Up @@ -145,22 +155,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {

const closePromotionFlow = useCallback(() => {
didPromptImportRef.current = true;
setIsLocalDocsModalOpen(false);
setLocalDocsToPromote([]);
setIsImportingLocalDocs(false);
setLocalDocsError(null);
setIsRegistrationSyncOverlayOpen(false);
dispatch(resetPromotionFlow());
releasePromotionLockIfOwned();
}, [releasePromotionLockIfOwned]);
}, [dispatch, releasePromotionLockIfOwned]);

// Run exactly once on mount to restore session from the refresh-token cookie
useEffect(() => {
dispatch(refreshSessionThunk());

const handleOpenAuth = () => setIsAuthOpen(true);
const handleOpenAuth = () => dispatch(setAuthModalOpen(true));
window.addEventListener('open-auth-modal', handleOpenAuth);
return () => window.removeEventListener('open-auth-modal', handleOpenAuth);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [dispatch]);

// Auto-refresh token just before it expires to prevent UX drops
useEffect(() => {
Expand Down Expand Up @@ -241,16 +247,16 @@ export function AppShell({ children }: { children: React.ReactNode }) {

if (cancelled || promotableLocalDocs.length === 0) {
didPromptImportRef.current = true;
setIsRegistrationSyncOverlayOpen(false);
dispatch(setRegistrationSyncOverlayOpen(false));
releasePromotionLockIfOwned();
return;
}

if (isRegistrationFlow) {
setLocalDocsToPromote(promotableLocalDocs);
setIsRegistrationSyncOverlayOpen(true);
setIsImportingLocalDocs(true);
setLocalDocsError(null);
dispatch(setLocalDocsToPromote(promotableLocalDocs));
dispatch(setRegistrationSyncOverlayOpen(true));
dispatch(setImportingLocalDocs(true));
dispatch(setLocalDocsError(null));

await waitForNextPaint();
const syncStartedAt = Date.now();
Expand All @@ -266,14 +272,16 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return;
}

setLocalDocsToPromote(promotableLocalDocs);
setIsLocalDocsModalOpen(true);
dispatch(setLocalDocsToPromote(promotableLocalDocs));
dispatch(setLocalDocsModalOpen(true));
} catch (error) {
console.error('Failed to promote local documents:', error);
setIsImportingLocalDocs(false);
setIsRegistrationSyncOverlayOpen(lastAuthAction === 'register');
setLocalDocsError(
error instanceof Error ? error.message : 'Failed to promote local documents.'
dispatch(setImportingLocalDocs(false));
dispatch(setRegistrationSyncOverlayOpen(lastAuthAction === 'register'));
dispatch(
setLocalDocsError(
error instanceof Error ? error.message : 'Failed to promote local documents.'
)
);

if (!isRegistrationFlow) {
Expand Down Expand Up @@ -303,6 +311,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
moveLocalDocsToAccount,
releasePromotionLockIfOwned,
waitForPromotionInFlight,
dispatch,
]);

const runMoveToAccount = useCallback(async () => {
Expand All @@ -311,10 +320,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}

try {
setIsLocalDocsModalOpen(false);
setIsRegistrationSyncOverlayOpen(true);
setIsImportingLocalDocs(true);
setLocalDocsError(null);
dispatch(setLocalDocsModalOpen(false));
dispatch(setRegistrationSyncOverlayOpen(true));
dispatch(setImportingLocalDocs(true));
dispatch(setLocalDocsError(null));

await waitForNextPaint();
const syncStartedAt = Date.now();
Expand All @@ -328,12 +337,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {

closePromotionFlow();
} catch (error) {
setIsImportingLocalDocs(false);
setLocalDocsError(
error instanceof Error ? error.message : 'Failed to promote local documents.'
dispatch(setImportingLocalDocs(false));
dispatch(
setLocalDocsError(
error instanceof Error ? error.message : 'Failed to promote local documents.'
)
);
}
}, [accessToken, user?.id, localDocsToPromote, moveLocalDocsToAccount, closePromotionFlow]);
}, [
accessToken,
user?.id,
localDocsToPromote,
moveLocalDocsToAccount,
closePromotionFlow,
dispatch,
]);

const handleDiscardLocalData = async () => {
if (localDocsToPromote.length === 0) {
Expand All @@ -342,22 +360,22 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}

try {
setIsImportingLocalDocs(true);
setLocalDocsError(null);
dispatch(setImportingLocalDocs(true));
dispatch(setLocalDocsError(null));

await documentService.deleteGuestDocumentsByIds(localDocsToPromote.map((doc) => doc.id));

closePromotionFlow();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to discard local documents.';
setLocalDocsError(message);
setIsImportingLocalDocs(false);
dispatch(setLocalDocsError(message));
dispatch(setImportingLocalDocs(false));
}
};

return (
<div className="flex h-screen">
<Sidebar onOpenAuth={openAuthModal} />
<Sidebar />
<main className="nd-app-shell-main flex-1 flex flex-col min-w-0 bg-background text-foreground relative overflow-hidden">
{/* By using a nested flex-1 overflow-y-auto child, we are effectively telling
the browser that the scrollable region starts below the toolbar's height.*/}
Expand All @@ -368,7 +386,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
</div>
</main>
{isAuthOpen && <AuthModal onClose={() => setIsAuthOpen(false)} />}
{isAuthOpen && <AuthModal onClose={() => dispatch(setAuthModalOpen(false))} />}
{isLocalDocsModalOpen && (
<LocalDocsPromotionModal
count={localDocsToPromote.length}
Expand All @@ -386,6 +404,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
onRetry={runMoveToAccount}
/>
)}
<ToastContainer />
</div>
);
}
41 changes: 2 additions & 39 deletions web/components/AuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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"
>
<span className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center">
{provider === 'google' ? <GoogleIcon /> : <GitHubIcon />}
{provider === 'google' ? <Google /> : <GitHub className="text-foreground/70" />}
</span>
Continue with {provider === 'google' ? 'Google' : 'GitHub'}
</button>
);
}

function GoogleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}

function GitHubIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className="text-foreground/70"
>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);
}
Loading
Loading