diff --git a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json index 8944a6001f..dfc8540468 100644 --- a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json +++ b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "id", + "name": "id!", "type_info": "Uuid" } ], @@ -16,7 +16,7 @@ ] }, "nullable": [ - false + null ] }, "hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319" diff --git a/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json similarity index 69% rename from backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json rename to backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json index 4796901a81..e432c394b9 100644 --- a/backend/.sqlx/query-ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5.json +++ b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json @@ -1,17 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n ORDER BY email NULLS LAST", + "query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", "describe": { "columns": [ { "ordinal": 0, - "name": "email", + "name": "username?", "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" } ], "parameters": { @@ -50,13 +45,13 @@ ] } } - } + }, + "Text" ] }, "nullable": [ - true, false ] }, - "hash": "ca70f58d3c301c5b31749ea753dd1f63989c2fdae78e7e33053986862853dda5" + "hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c" } diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json index f0c91daf16..b492b38f49 100644 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json @@ -13,4 +13,4 @@ "nullable": [] }, "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json new file mode 100644 index 0000000000..6ae3f60e49 --- /dev/null +++ b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7ce83404ce..ac02d9dd9c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7827,41 +7827,9 @@ paths: items: type: string - /w/{workspace}/drafts/users_with_draft/{kind}/{path}: - get: - summary: list users with a saved draft on a path - operationId: listUsersWithDraftOnPath - tags: - - draft - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - name: kind - in: path - required: true - schema: - $ref: "#/components/schemas/UserDraftItemKind" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: list of users (by email) with a draft; `null` represents a legacy workspace-level draft - content: - application/json: - schema: - type: array - items: - type: object - properties: - email: - type: string - nullable: true - created_at: - type: string - format: date-time - required: [created_at] - /w/{workspace}/drafts/get/{kind}/{path}: get: - summary: fetch a single draft's content (own draft, another user's, or the legacy workspace-level row) + summary: fetch a single draft's content by workspace username (or the legacy workspace-level row) operationId: getDraftForUser tags: - draft @@ -7873,10 +7841,10 @@ paths: schema: $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" - - name: email + - name: username in: query required: false - description: Owner of the draft to fetch. Omit to fetch the legacy workspace-level (NULL email) row. + description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row. schema: type: string responses: diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 77c6d0fc25..d9858393a2 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -22,10 +22,6 @@ use windmill_common::{ pub fn workspaced_service() -> Router { Router::new() - .route( - "/users_with_draft/{kind}/{*path}", - get(list_users_with_draft_on_path), - ) .route("/get/{kind}/{*path}", get(get_draft_for_user)) .route("/save_draft/{kind}/{*path}", post(save_draft)) .route("/list_drafts", get(list_drafts)) @@ -229,45 +225,13 @@ async fn get_draft( .ok_or_else(|| Error::NotFound(format!("no draft for current user at {path}"))) } -#[derive(Serialize, Debug)] -pub struct UserWithDraft { - /// `None` represents a legacy workspace-level draft (no owner). - pub email: Option, - pub created_at: chrono::DateTime, -} - -async fn list_users_with_draft_on_path( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, -) -> Result>> { - let path = path.to_path(); - require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; - - let rows = sqlx::query_as!( - UserWithDraft, - r#"SELECT email, created_at - FROM draft - WHERE workspace_id = $1 - AND path = $2 - AND typ = $3 - ORDER BY email NULLS LAST"#, - &w_id, - path, - kind as UserDraftItemKind, - ) - .fetch_all(&db) - .await?; - - Ok(Json(rows)) -} - #[derive(Deserialize, Debug)] pub struct GetDraftQuery { - /// Owner of the draft to fetch. Omit to fetch the legacy - /// workspace-level (NULL email) row, if any. - pub email: Option, + /// Workspace username of the draft owner to fetch. Omit to fetch the + /// legacy workspace-level (NULL email) row, if any. Emails are not + /// part of the public draft API — the username is resolved to an + /// email server-side. + pub username: Option, } #[derive(Serialize, Debug)] @@ -277,9 +241,10 @@ pub struct DraftForUser { } /// Fetch a specific user's (or the legacy NULL row's) draft content at a -/// path. Used by the "other users' drafts" modal in editors after the list -/// endpoint has surfaced who has a draft. Same path-permission check as -/// the list endpoint. +/// path. Used by the "other users' drafts" banner in editors after the +/// list of other owners has been surfaced on the deployed-overlay +/// response. The caller identifies the owner by workspace username so +/// emails never reach the client. async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, @@ -290,6 +255,29 @@ async fn get_draft_for_user( let path = path.to_path(); require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; + // Username -> email lookup, scoped to the workspace. None signals + // "fetch the legacy NULL-email row" (kept distinct from a username + // that simply has no draft, which falls through to 404 below). + let owner_email: Option = if let Some(username) = &query.username { + let email = sqlx::query_scalar!( + r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#, + &w_id, + username, + ) + .fetch_optional(&db) + .await?; + match email { + Some(e) => Some(e), + None => { + return Err(Error::NotFound(format!( + "no user with username {username} in workspace" + ))) + } + } + } else { + None + }; + let row = sqlx::query_as!( DraftForUser, r#"SELECT value as "value!: sqlx::types::Json>", created_at @@ -301,7 +289,7 @@ async fn get_draft_for_user( &w_id, path, kind as UserDraftItemKind, - query.email, + owner_email, ) .fetch_optional(&db) .await?; @@ -309,7 +297,7 @@ async fn get_draft_for_user( row.map(Json).ok_or_else(|| { Error::NotFound(format!( "no draft for {} at {path}", - query.email.as_deref().unwrap_or("") + query.username.as_deref().unwrap_or("") )) }) } diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index b6de64904a..0230f4b404 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -83,6 +83,16 @@ pub struct WithDraftQuery { /// `Serialize` on its response type — most read-only response shapes /// (e.g. `ScriptWithStarred`) only derive `Serialize`, and requiring /// `DeserializeOwned` would force derive cascades through many crates. +/// One row of `other_drafts_users` — represents a draft on the same path +/// owned by someone other than the authed user. `username` is `None` for +/// the legacy NULL-email row (workspace-scoped pre-migration draft), which +/// the frontend surfaces as a "Legacy draft" entry with an info tooltip. +#[derive(Debug, Serialize)] +pub struct OtherDraftUser { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} + #[derive(Debug, Serialize)] pub struct WithDraftOverlay { #[serde(flatten)] @@ -102,13 +112,56 @@ pub struct WithDraftOverlay { /// deployed (the rest of the response) for diff/restore UI. #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option, + /// Other users with a draft on the same path (excludes the authed + /// user). Frontend surfaces this list in a banner so the user can + /// view another's JSON or fork it. Empty list is omitted to keep + /// the common-case response lean. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub other_drafts_users: Vec, +} + +/// List every other user (and the legacy NULL-email row, if any) that +/// has a draft at `(workspace, kind, path)`. Returns usernames only — +/// emails never leave the server. LEFT JOIN against `usr` so an +/// orphaned draft (user removed from the workspace) still surfaces, with +/// its `username` falling back to `None` rather than dropping the row. +/// The authed user is excluded via `email <> authed_email`; the legacy +/// row matches because `email IS NULL` fails that comparison. +async fn fetch_other_drafts_users( + db: &DB, + w_id: &str, + authed_email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + let rows = sqlx::query_as!( + OtherDraftUser, + r#"SELECT u.username as "username?" + FROM draft d + LEFT JOIN usr u + ON u.workspace_id = d.workspace_id + AND u.email = d.email + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ = $3 + AND (d.email IS NULL OR d.email <> $4) + ORDER BY d.email NULLS LAST"#, + w_id, + path, + kind as UserDraftItemKind, + authed_email, + ) + .fetch_all(db) + .await?; + Ok(rows) } /// If `get_draft` is true AND the authed user has a draft saved for /// `(workspace, kind, path)`, attach it as `draft` on the response. /// The deployed payload (`deployed`) is always serialized into `inner` /// untouched — the wire response is ` + is_draft + -/// draft? + draft_saved_at?` regardless of whether a draft exists. +/// draft? + draft_saved_at? + other_drafts_users?` regardless of whether +/// the authed user has a draft. pub async fn maybe_overlay_draft( db: &DB, w_id: &str, @@ -130,6 +183,7 @@ where draft_saved_at: None, no_deployed: false, draft: None, + other_drafts_users: Vec::new(), }); } @@ -149,6 +203,8 @@ where .fetch_optional(db) .await?; + let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?; + let Some(row) = row else { return Ok(WithDraftOverlay { inner, @@ -156,6 +212,7 @@ where draft_saved_at: None, no_deployed: false, draft: None, + other_drafts_users, }); }; @@ -167,6 +224,7 @@ where draft_saved_at: Some(row.created_at), no_deployed: false, draft: Some(draft_json), + other_drafts_users, }) } @@ -242,6 +300,7 @@ pub async fn fetch_draft_only( }; let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?; Ok(Some(WithDraftOverlay { // Best-effort stand-in for the missing deployed — same JSON as // `draft`. Frontend should read `.draft` for the editor state @@ -251,5 +310,6 @@ pub async fn fetch_draft_only( draft_saved_at: Some(row.created_at), no_deployed: true, draft: Some(draft_json), + other_drafts_users, })) } diff --git a/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte new file mode 100644 index 0000000000..772d69d4a9 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte @@ -0,0 +1,92 @@ + + + +
+
+ +
+

+ Someone else (another tab, browser, or teammate) saved a newer version of this draft. Your + autosave was rejected to avoid overwriting their work. +

+ {#if conflictHandle.conflict} +

+ Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()} +

+ {/if} +
+
+ +
+ + + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte index 5446c466f6..80c309f84e 100644 --- a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -1,198 +1,190 @@ -{#if open} -