From ce77e112caec97a8843785c9a623cfc88f182941 Mon Sep 17 00:00:00 2001 From: M1ngdaXie Date: Sun, 15 Mar 2026 09:57:31 +0000 Subject: [PATCH 1/4] fix: guest login now restores redirect URL from sessionStorage --- frontend/src/pages/LoginPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 60910bd..3ee9e42 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -40,7 +40,8 @@ function LoginPage() { setGuestLoading(true); const token = await guestLogin(); await login(token); - const redirect = searchParams.get('redirect'); + const redirect = searchParams.get('redirect') || sessionStorage.getItem('oauth_redirect'); + sessionStorage.removeItem('oauth_redirect'); navigate(redirect ? decodeURIComponent(redirect) : '/'); } catch (err) { console.error('Guest login failed:', err); From 7b5558bc94ad5cad03ec155cc4e52af6fedf593d Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:17:28 -0700 Subject: [PATCH 2/4] fix: ensure redirect handling in LoginPage after user login --- frontend/src/pages/LoginPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 3ee9e42..0b4cb35 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -15,9 +15,10 @@ function LoginPage() { useEffect(() => { if (!loading && user) { - navigate('/'); + const redirect = searchParams.get('redirect'); + navigate(redirect ? decodeURIComponent(redirect) : '/'); } - }, [user, loading, navigate]); + }, [user, loading, navigate, searchParams]); const saveRedirectAndGo = (oauthUrl: string) => { const redirect = searchParams.get('redirect'); From aa67446f7c45543924174d21ddce39e3395f1dad Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:55:59 +0800 Subject: [PATCH 3/4] fix: allow share-link users to view/edit and autosave via share token Users without personal document access can still load/save state when a valid share token is present, matching the permission level granted by the share link. Co-Authored-By: Claude Sonnet 5 --- backend/internal/handlers/document.go | 27 ++++++++++++++- backend/internal/handlers/document_test.go | 39 ++++++++++++++++++++-- frontend/src/api/document.ts | 8 +++-- frontend/src/hooks/useAutoSave.ts | 6 ++-- frontend/src/hooks/useYjsDocument.ts | 2 +- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/backend/internal/handlers/document.go b/backend/internal/handlers/document.go index 28be434..84b5061 100644 --- a/backend/internal/handlers/document.go +++ b/backend/internal/handlers/document.go @@ -137,6 +137,15 @@ func (h *DocumentHandler) GetDocumentState(c *gin.Context) { respondInternalError(c, "Failed to check permissions", err) return } + if !canView && shareToken != "" { + // Logged-in user without personal permission: fall back to share link + valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken) + if err != nil { + respondInternalError(c, "Failed to validate share token", err) + return + } + canView = valid + } if !canView { respondForbidden(c, "Access denied") return @@ -189,12 +198,28 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) { return } - // Check edit permission + // Check edit permission (personal share OR edit-level share link) + shareToken := c.Query("share") canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID) if err != nil { respondInternalError(c, "Failed to check permissions", err) return } + if !canEdit && shareToken != "" { + valid, err := h.store.ValidateShareToken(c.Request.Context(), id, shareToken) + if err != nil { + respondInternalError(c, "Failed to validate share token", err) + return + } + if valid { + perm, err := h.store.GetShareLinkPermission(c.Request.Context(), id) + if err != nil { + respondInternalError(c, "Failed to get token permission", err) + return + } + canEdit = perm == "edit" + } + } if !canEdit { respondForbidden(c, "Edit access denied") return diff --git a/backend/internal/handlers/document_test.go b/backend/internal/handlers/document_test.go index 2796912..51773dd 100644 --- a/backend/internal/handlers/document_test.go +++ b/backend/internal/handlers/document_test.go @@ -381,8 +381,6 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_Success() { s.assertSuccessResponse(w, http.StatusOK) s.Equal("application/octet-stream", w.Header().Get("Content-Type")) - // State should be empty bytes for new document - s.NotNil(w.Body.Bytes()) } func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() { @@ -416,6 +414,17 @@ func (s *DocumentHandlerSuite) TestGetDocumentState_InvalidID() { s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID") } +func (s *DocumentHandlerSuite) TestGetDocumentState_AuthenticatedWithShareToken() { + // Charlie (logged in, not owner/shared) reads Alice's public doc via share link + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken) + w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertSuccessResponse(w, http.StatusOK) + s.Equal("application/octet-stream", w.Header().Get("Content-Type")) +} + // ======================================== // UpdateDocumentState Tests // ======================================== @@ -458,6 +467,32 @@ func (s *DocumentHandlerSuite) TestUpdateDocumentState_ViewOnlyDenied() { s.router.ServeHTTP(w, httpReq) s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied") } +func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithEditShareToken() { + // Give Alice's public doc an "edit" share link + ctx := context.Background() + editToken, err := s.store.GenerateShareToken(ctx, s.testData.AlicePublicDoc, "edit") + s.Require().NoError(err) + + // Charlie (logged in, not owner/shared) edits via edit share link + req := models.UpdateStateRequest{State: []byte("shared edit")} + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, editToken) + w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertSuccessResponse(w, http.StatusOK) +} +func (s *DocumentHandlerSuite) TestUpdateDocumentState_AuthenticatedWithViewShareTokenDenied() { + // Alice's public doc has a "view" share link (from seed). + // Charlie (logged in) cannot write via a view-only share link. + req := models.UpdateStateRequest{State: []byte("attempt write")} + path := fmt.Sprintf("/api/documents/%s/state?share=%s", s.testData.AlicePublicDoc, s.testData.PublicShareToken) + w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.CharlieID) + s.Require().NoError(err) + + s.router.ServeHTTP(w, httpReq) + s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied") +} func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() { req := models.UpdateStateRequest{ diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 8504040..1cc69d6 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -64,12 +64,16 @@ export const documentsApi = { }, // Update document Yjs state - updateState: async (id: string, state: Uint8Array): Promise => { + updateState: async (id: string, state: Uint8Array, shareToken?: string): Promise => { // Create a new ArrayBuffer copy to ensure compatibility const buffer = new ArrayBuffer(state.byteLength); new Uint8Array(buffer).set(state); - const response = await authFetch(`${API_BASE_URL}/documents/${id}/state`, { + const url = shareToken + ? `${API_BASE_URL}/documents/${id}/state?share=${shareToken}` + : `${API_BASE_URL}/documents/${id}/state`; + + const response = await authFetch(url, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: buffer, diff --git a/frontend/src/hooks/useAutoSave.ts b/frontend/src/hooks/useAutoSave.ts index a4a80d3..b68ce26 100644 --- a/frontend/src/hooks/useAutoSave.ts +++ b/frontend/src/hooks/useAutoSave.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import * as Y from 'yjs'; import { documentsApi } from '../api/document'; -export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { +export const useAutoSave = (documentId: string, ydoc: Y.Doc | null, shareToken?: string) => { const saveTimeoutRef = useRef(null); const isSavingRef = useRef(false); @@ -25,7 +25,7 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { isSavingRef.current = true; try { const state = Y.encodeStateAsUpdate(ydoc); - await documentsApi.updateState(documentId, state); + await documentsApi.updateState(documentId, state, shareToken); console.log('✓ Document saved to database'); } catch (error) { console.error('Failed to save document:', error); @@ -44,5 +44,5 @@ export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => { clearTimeout(saveTimeoutRef.current); } }; - }, [documentId, ydoc]); + }, [documentId, ydoc, shareToken]); }; diff --git a/frontend/src/hooks/useYjsDocument.ts b/frontend/src/hooks/useYjsDocument.ts index 7845977..406d2b6 100644 --- a/frontend/src/hooks/useYjsDocument.ts +++ b/frontend/src/hooks/useYjsDocument.ts @@ -17,7 +17,7 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { const [role, setRole] = useState(null); // Enable auto-save when providers are ready - useAutoSave(documentId, providers?.ydoc || null); + useAutoSave(documentId, providers?.ydoc || null, shareToken); // Fetch permission when component mounts useEffect(() => { From f363193fba9623d8d27dfb3d4b689d98e59aca42 Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:56:10 +0800 Subject: [PATCH 4/4] chore: remove redundant target from tsconfig.node.json Co-Authored-By: Claude Sonnet 5 --- frontend/tsconfig.node.json | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 8a67f62..9fa8d74 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -1,7 +1,6 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", "types": ["node"],