Revoke device authorizations in place with Turbo Streams

Revoking a device redirected away from the storage dashboard, which closed the
details modal and reloaded the page. Refreshing the affected parts instead
keeps the modal open and shows the change where it happened.

- Extract the card and the modal's access list into their own components, so
  either can be re-rendered without touching the modal's open state, and share
  the group summaries between the components
- Derive the app's DOM ids from its client_id (the grouping key) and the device
  row ids from the authorization, through one helper used by both the templates
  and the controller
- Revoke one of several devices as a Turbo Stream: remove the row and refresh
  the card behind the modal and the access list
- Revoke an app's last device with a redirect, so the dashboard re-renders
  itself and grouping, sorting and the empty state stay in one place
- Fade the removed row out, deferring Turbo's own removal until the animation
  has ended

Part of #254
This commit is contained in:
raucao committed 2026-11-03 17:45:37 +04:00
1 parent 614ead90a9
commit faa7bf6146
17 files changed
+295 -96

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "turbo_stream_animations"
+30
View File
@@ -0,0 +1,30 @@
// A Turbo Stream `remove` action deletes its target right away. Pause that until
// the element's CSS animation has finished, so a revoked device row can fade out
// instead of vanishing.
const REMOVE_ANIMATION_CLASS = "animate-remove"
const REMOVE_ANIMATION_TIMEOUT = 1000
document.addEventListener("turbo:before-stream-render", (event) => {
const renderDefault = event.detail.render
event.detail.render = (streamElement) => {
const target =
streamElement.action === "remove" ? document.getElementById(streamElement.target) : null
if (!target) return renderDefault(streamElement)
let rendered = false
const render = () => {
if (rendered) return
rendered = true
renderDefault(streamElement)
}
target.addEventListener("animationend", render, { once: true })
target.classList.add(REMOVE_ANIMATION_CLASS)
// Don't leave the element behind if the animation cannot run at all.
setTimeout(render, REMOVE_ANIMATION_TIMEOUT)
}
})