Files
Paul LütznerandClaude Opus 5.5 9c20aa8183 fix(compose): undo, unschedule, discard and attachment POSTs need a token
Six state-changing compose routes checked ownership and nothing else, so
any page the user visited could call off a send, discard a draft or add
and remove attachments. They now require the shared 'ajax' token, and
every fetch() caller in the compose controllers sends it in the
X-CSRF-Token header (unschedule's caller already did).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:09:08 +02:00

81 lines
2.7 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
import { requestFailed } from "../../request_errors.js"
import { csrfToken } from "../../csrf.js"
/**
* The Undo button in the "Sending…" toast.
*
* Wired again, and for the default path. Send has two shapes now, chosen by
* User::SETTING_COMPOSE_SEND_FEEDBACK: "hold" keeps the composer open and makes
* its own Send pill the cancel, and "optimistic" — the default — closes the
* composer at once and moves the cancel here. The wait is what people notice;
* the undo is what they rarely need.
*
* This was unwired for one release while the held shape was tried, and the
* complaint that brought it back was the obvious one: eight seconds of a window
* that will not close is a long time when you have finished writing.
*
* `hideAfter` IS the cancel window rather than a toast duration. The button
* goes when the toast goes, so a toast that outlived the window would offer a
* cancel that silently fails, and one that died early would take away a cancel
* that still worked.
*/
export default class extends Controller {
static values = {
url: String,
hideAfter: Number,
}
connect() {
this.shownAt = Date.now()
this.armHide(this.hideAfterValue)
}
armHide(delay) {
this.hideTimer = setTimeout(() => {
this.element.style.transition = "opacity 0.4s"
this.element.style.opacity = "0"
this.element.style.pointerEvents = "none"
}, Math.max(0, delay))
}
disconnect() {
clearTimeout(this.hideTimer)
}
async abort() {
clearTimeout(this.hideTimer)
this.element.style.pointerEvents = "none"
let response = null
try {
response = await fetch(this.urlValue, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRF-Token": csrfToken() },
})
} catch {
// Handled below with response still null.
}
// An undo that did not land means the mail may still go out, so that
// has to be said — and the button given back for whatever is left of the window, so a dropped
// connection can be retried rather than leaving a dead Undo.
if (requestFailed(response)) {
this.element.style.pointerEvents = ""
this.armHide(this.hideAfterValue - (Date.now() - this.shownAt))
return
}
const html = await response.text()
Turbo.renderStreamMessage(html)
// Dismiss the parent toast immediately
this.element.closest("[data-controller~='ui--toast']")
?.__stimulusController?.dismiss()
?? this.element.closest("[data-controller~='ui--toast']")?.remove()
}
}