fix: allow users to always discard their own drafts without write permission (#9659)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Diego Imbert
2026-06-19 15:34:38 +02:00
committed by GitHub
parent 4296a6ae1f
commit 6833a554ae
5 changed files with 35 additions and 7 deletions
+11 -1
View File
@@ -276,7 +276,17 @@ async fn update_draft(
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let path = path.to_path();
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
// Saving a draft requires write permission on the underlying path. Deleting
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
// authed user, so they can always discard it even after losing write access
// to the underlying item (e.g. a draft-only item whose folder perms changed).
// The DELETE below is scoped to `email = authed.email`, so it can only ever
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
// — they keep the write gate.
let is_own_discard = req.value.is_none() && !req.legacy;
if !is_own_discard {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
}
let applied_at = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
@@ -167,14 +167,23 @@
)
}
// Why a row can't be deployed/discarded (drives the disabled-checkbox tooltip
// and the Discard button's title). `undefined` ⇒ actionable.
// Why a row can't be deployed (drives the disabled-checkbox tooltip).
// `undefined` ⇒ actionable.
function blockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
if (!item.can_write) return "You don't have write permission on this path"
return undefined
}
// Why a row can't be discarded (drives the Discard button's title).
// Discarding only removes the caller's own draft row, which they always own,
// so — unlike deploy — it never requires write permission on the path. The
// only block is someone else's draft (view-only in the "all drafts" view).
function discardBlockedReason(item: Row): string | undefined {
if (!item.mine) return 'This draft belongs to another user'
return undefined
}
// The Draft Items list only carries the *deployed* summary, so the draft's
// (new) display name isn't known yet. Fetch each item's draft blob once and
// cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load,
@@ -628,7 +637,7 @@
</Button>
{/if}
{:else}
{@const discardBlock = blockedReason(draftItem)}
{@const discardBlock = discardBlockedReason(draftItem)}
<!-- Show diff fetches the *current user's* draft overlay, so it's only
meaningful for your own/legacy rows. Another user's draft (view-only,
`mine=false`) would diff against the wrong draft or 404 — hide it. -->
@@ -211,7 +211,10 @@
}
},
type: 'delete',
disabled: !canEdit,
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: $userStore?.operator
},
{
@@ -230,7 +230,10 @@
}
},
type: 'delete',
disabled: !owner,
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton,
hide: $userStore?.operator
}
]
@@ -296,7 +296,10 @@
}
},
type: dlt,
disabled: !canEdit
// A draft-only row is always the authed user's own draft (the
// list endpoint only surfaces own/legacy draft-only rows), so
// discarding it never requires write permission on the path.
disabled: !showEditButton
}
]
}