From c42ae39bb8390e7f2eb398cbc9c18fab7a5294c2 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Thu, 21 May 2026 20:38:53 -0400 Subject: [PATCH] Fixed Sharing Issue and added Shared Drive Folder --- server/src/collab.rs | 80 +++++++++++- server/src/docs.rs | 22 ++++ server/src/handlers.rs | 29 ++++- server/src/main.rs | 3 + server/src/models.rs | 10 ++ src/lib/components/ShareModal.svelte | 78 ++++++++++-- src/lib/components/Toolbar.svelte | 2 +- src/routes/dashboard/+page.svelte | 176 +++++++++++++++++++-------- 8 files changed, 334 insertions(+), 66 deletions(-) diff --git a/server/src/collab.rs b/server/src/collab.rs index b6955c6..851f31d 100644 --- a/server/src/collab.rs +++ b/server/src/collab.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - models::{Collaborator, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest}, + models::{Collaborator, CollaboratorView, Comment, CreateCommentRequest, Invitation, InviteRequest, UpdateCommentRequest}, AppState, }; @@ -32,7 +32,7 @@ pub async fn invite_collaborator( return Err((StatusCode::FORBIDDEN, "Only the owner can invite collaborators".to_string())); } - let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash FROM users WHERE email = ?") + let invited_user = sqlx::query_as::<_, crate::models::User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?") .bind(&payload.email) .fetch_optional(&state.db) .await @@ -103,6 +103,82 @@ pub async fn accept_invite( Ok(Json(collab)) } +pub async fn list_collaborators( + State(state): State, + Path(doc_id): Path, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + // Only owner or collaborators on the document can see the list + let has_access = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ? \ + UNION ALL SELECT COUNT(*) FROM collaborators WHERE document_id = ? AND user_id = ?" + ) + .bind(&doc_id).bind(&user_id).bind(&doc_id).bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_iter().sum::() > 0; + + if !has_access { + return Err((StatusCode::FORBIDDEN, "Access denied".to_string())); + } + + let collaborators = sqlx::query_as::<_, CollaboratorView>( + "SELECT c.id, c.user_id, u.username, u.email, c.role, c.created_at \ + FROM collaborators c \ + INNER JOIN users u ON u.id = c.user_id \ + WHERE c.document_id = ? \ + ORDER BY c.created_at ASC" + ) + .bind(&doc_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(collaborators)) +} + +pub async fn remove_collaborator( + State(state): State, + Path((doc_id, collab_id)): Path<(String, String)>, + jar: SignedCookieJar, +) -> Result { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + // Only the document owner can remove collaborators + let is_owner = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM documents WHERE id = ? AND owner_id = ?" + ) + .bind(&doc_id) + .bind(&user_id) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? > 0; + + if !is_owner { + return Err((StatusCode::FORBIDDEN, "Only the document owner can remove collaborators".to_string())); + } + + let result = sqlx::query( + "DELETE FROM collaborators WHERE id = ? AND document_id = ?" + ) + .bind(&collab_id) + .bind(&doc_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Collaborator not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + pub async fn get_comments( State(state): State, Path(doc_id): Path, diff --git a/server/src/docs.rs b/server/src/docs.rs index cc6eae8..8c23423 100644 --- a/server/src/docs.rs +++ b/server/src/docs.rs @@ -17,6 +17,28 @@ pub struct ListDocsQuery { pub folder_id: Option, } +pub async fn list_shared_documents( + State(state): State, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let docs = sqlx::query_as::<_, Document>( + "SELECT d.id, d.owner_id, d.folder_id, d.title, d.content, d.thumbnail_svg, \ + d.public_role, d.created_at, d.updated_at, c.role as effective_role \ + FROM documents d \ + INNER JOIN collaborators c ON c.document_id = d.id AND c.user_id = ? \ + ORDER BY d.updated_at DESC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(docs)) +} + pub async fn list_documents( axum::extract::Query(query): axum::extract::Query, State(state): State, diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 515dbdb..8a47f23 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -208,7 +208,7 @@ pub async fn compile_handler( if has_access { if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") - .bind(doc.owner_id) + .bind(&doc.owner_id) .fetch_all(&state.db) .await { @@ -216,6 +216,19 @@ pub async fn compile_handler( files_map.insert(name, data); } } + // Also include files uploaded by collaborators specifically for this document + if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec)>( + "SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?" + ) + .bind(doc_id) + .bind(&doc.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in collab_files { + files_map.insert(name, data); + } + } } } } @@ -298,7 +311,7 @@ pub async fn export_handler( if has_access { if let Ok(files) = sqlx::query_as::<_, (String, Vec)>("SELECT name, data FROM files WHERE owner_id = ?") - .bind(doc.owner_id) + .bind(&doc.owner_id) .fetch_all(&state.db) .await { @@ -306,6 +319,18 @@ pub async fn export_handler( files_map.insert(name, data); } } + if let Ok(collab_files) = sqlx::query_as::<_, (String, Vec)>( + "SELECT name, data FROM files WHERE document_id = ? AND owner_id != ?" + ) + .bind(doc_id) + .bind(&doc.owner_id) + .fetch_all(&state.db) + .await + { + for (name, data) in collab_files { + files_map.insert(name, data); + } + } } } } diff --git a/server/src/main.rs b/server/src/main.rs index 0e0c7a2..bc59a85 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -113,10 +113,13 @@ async fn main() { .route("/files", get(files::list_files).post(files::upload_file_global)) .route("/files/{id}", delete(files::delete_file).patch(files::update_file)) .route("/files/{id}/data", get(files::get_file_data)) + .route("/docs/shared", get(docs::list_shared_documents)) .route("/docs", get(docs::list_documents).post(docs::create_document)) .route("/docs/accept-invite", get(collab::accept_invite)) .route("/docs/{id}", get(docs::get_document).delete(docs::delete_document).patch(docs::update_document)) .route("/docs/{id}/files", post(docs::upload_file)) + .route("/docs/{id}/collaborators", get(collab::list_collaborators)) + .route("/docs/{id}/collaborators/{collab_id}", delete(collab::remove_collaborator)) .route("/docs/{id}/invite", post(collab::invite_collaborator)) .route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment)) .route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version)) diff --git a/server/src/models.rs b/server/src/models.rs index df967cd..3b59a2b 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -133,6 +133,16 @@ pub struct Collaborator { pub created_at: String, } +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct CollaboratorView { + pub id: String, + pub user_id: String, + pub username: String, + pub email: String, + pub role: String, + pub created_at: String, +} + #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Invitation { pub id: String, diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte index af589c3..abc9914 100644 --- a/src/lib/components/ShareModal.svelte +++ b/src/lib/components/ShareModal.svelte @@ -4,16 +4,42 @@ import Icon from '@iconify/svelte'; let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>(); - + + type CollaboratorView = { id: string; user_id: string; username: string; email: string; role: string; created_at: string }; + let link = $state(''); let copied = $state(false); let role = $state('editor'); - + + let collaborators = $state([]); + let collabLoading = $state(false); + let removingId = $state(null); + let inviteEmail = $state(''); let inviteRole = $state('editor'); let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle'); let inviteMessage = $state(''); + async function loadCollaborators() { + if (!docId) return; + collabLoading = true; + try { + const res = await fetch(`/api/docs/${docId}/collaborators`); + if (res.ok) collaborators = await res.json(); + } catch {} + collabLoading = false; + } + + async function removeCollaborator(collab: CollaboratorView) { + if (!docId) return; + removingId = collab.id; + try { + const res = await fetch(`/api/docs/${docId}/collaborators/${collab.id}`, { method: 'DELETE' }); + if (res.ok) collaborators = collaborators.filter(c => c.id !== collab.id); + } catch {} + removingId = null; + } + async function inviteUser(e: Event) { e.preventDefault(); if (!docId || !inviteEmail.trim()) return; @@ -24,9 +50,7 @@ try { const res = await fetch(`/api/docs/${docId}/invite`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }) }); @@ -34,25 +58,23 @@ inviteStatus = 'success'; inviteMessage = 'User invited successfully!'; inviteEmail = ''; + loadCollaborators(); } else { const text = await res.text(); inviteStatus = 'error'; inviteMessage = text || 'Failed to invite user'; } } catch (err) { - console.error(err); inviteStatus = 'error'; inviteMessage = 'Network error occurred'; } } onMount(() => { - const baseUrl = window.location.origin; const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href; - - link = `${docUrl}?role=${role}`; + loadCollaborators(); }); $effect(() => { @@ -125,6 +147,44 @@ {/if} + {#if collabLoading} +
+ + Loading collaborators... +
+ {:else if collaborators.length > 0} +
+
+
People with access
+ {#each collaborators as collab (collab.id)} +
+
+ {collab.username[0].toUpperCase()} +
+
+

{collab.username}

+

{collab.email}

+
+ + {collab.role} + + +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index fb7a40a..66609d4 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -716,7 +716,7 @@ {#if isShareModalOpen} - (isShareModalOpen = false)} /> + (isShareModalOpen = false)} /> {/if} {#if isPageSettingsOpen} diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index dc8ca79..35febd6 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -161,6 +161,7 @@ } function navigateToBreadcrumb(index: number) { + inSharedDrive = false; if (index === -1) { folderPath = []; currentFolderId = null; @@ -314,6 +315,26 @@ } } + let inSharedDrive = $state(false); + let sharedDocs = $state([]); + let sharedDocsLoading = $state(false); + + async function loadSharedDocs() { + sharedDocsLoading = true; + try { + const res = await fetch('/api/docs/shared'); + if (res.ok) sharedDocs = await res.json(); + } catch {} + sharedDocsLoading = false; + } + + function enterSharedDrive() { + inSharedDrive = true; + folderPath = []; + currentFolderId = null; + loadSharedDocs(); + } + let showShareModal = $state(false); let shareTarget = $state(null); @@ -421,95 +442,146 @@
- - {#each folderPath as folder, index} + {#if inSharedDrive} - - {/each} + + Shared with me + + {:else} + {#each folderPath as folder, index} + + + {/each} + {/if}
- {#if loading} + {#if inSharedDrive} + {#if sharedDocsLoading} +
+
+ +

Loading shared documents...

+
+
+ {:else if sharedDocs.length === 0} +
+
+
+ +
+

No shared documents

+

Documents shared with you by other users will appear here.

+
+
+ {:else} +
+ {#each sharedDocs as doc} + activeMenu = id} + {openInfo} + openRename={() => {}} + {shareItem} + deleteDoc={() => {}} + /> + {/each} +
+ {/if} + + {:else if loading}

Loading your workspace...

- {:else if documents.length === 0 && folders.length === 0 && files.length === 0 && currentFolderId === null} -
-
-
- -
-

No documents yet

-

Get started by creating your first Typst document. It's fast, collaborative, and beautiful.

- -
-
{:else} - - {#if folders.length > 0} + + {#if currentFolderId === null || folders.length > 0}
-
- Folders -
+
Folders
+ {#if currentFolderId === null} + +
e.key === 'Enter' && enterSharedDrive()} + > +
+ +
+ Shared with me +
+ {/if} {#each folders as folder} - dragOverFolderId = id} + dragOverFolderId = id} /> {/each}
{/if} - {#if documents.length > 0 || files.length > 0}
{#each documents as doc} - activeMenu = id} - {openInfo} - {openRename} - {shareItem} - {deleteDoc} + activeMenu = id} + {openInfo} + {openRename} + {shareItem} + {deleteDoc} /> {/each} - {#each files as file} {/each}
{/if} - - {#if documents.length === 0 && folders.length === 0 && files.length === 0} -
+ + + {#if documents.length === 0 && files.length === 0 && currentFolderId !== null && folders.length === 0} +

This folder is empty.

+ {:else if documents.length === 0 && files.length === 0 && folders.length === 0 && currentFolderId === null} +
+
+ +
+

No documents yet

+

Create your first Typst document to get started.

+ +
{/if} {/if}