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

@@ -14,3 +14,27 @@
animation-duration: 0.15s;
animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1);
}
@keyframes removeRow {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(0.75rem);
}
}
.animate-remove {
animation: removeRow 0.2s ease-out forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
/* Keep the animation, so the deferred removal still completes, but make it
effectively instant. */
.animate-remove {
animation-duration: 0.01s;
}
}
@@ -0,0 +1,7 @@
<div id="<%= access_id %>">
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Access
</h5>
<%= render RsScopeListComponent.new(scopes: permissions, root: root_access?,
icon_class: "h-5 w-5") %>
</div>
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class RsAuthAccessComponent < ViewComponent::Base
include RsAuthSummary
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
end
def access_id
rs_app_dom_id(@auth.client_id, :access)
end
end
@@ -0,0 +1,35 @@
<button type="button"
id="<%= card_id %>"
class="rs-auth-card group flex h-full w-full flex-col rounded-lg border border-gray-200
bg-white p-4 text-left transition
hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
data-action="click->modal#open">
<span class="flex items-center gap-3">
<span class="h-12 w-12 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</span>
<span class="min-w-0 flex-grow">
<span class="mb-1 block truncate text-lg font-bold"><%= @auth.display_name %></span>
<% if @auth.client_id != @auth.display_name %>
<span class="block truncate text-sm text-gray-500"><%= @auth.client_id %></span>
<% end %>
</span>
<span class="flex-none text-gray-300 transition group-hover:text-gray-500" aria-hidden="true">
<%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
</span>
</span>
<span class="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1 pt-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= device_summary %>
</span>
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= category_summary %>
</span>
</span>
</button>
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class RsAuthCardComponent < ViewComponent::Base
include RsAuthSummary
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
def card_id
rs_app_dom_id(@auth.client_id, :card)
end
end
+2 -36
View File
@@ -1,38 +1,4 @@
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
<button type="button"
class="rs-auth-card group flex h-full w-full flex-col rounded-lg border border-gray-200
bg-white p-4 text-left transition
hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
data-action="click->modal#open">
<span class="flex items-center gap-3">
<span class="h-12 w-12 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</span>
<span class="min-w-0 flex-grow">
<span class="mb-1 block truncate text-lg font-bold"><%= @auth.display_name %></span>
<% if @auth.client_id != @auth.display_name %>
<span class="block truncate text-sm text-gray-500"><%= @auth.client_id %></span>
<% end %>
</span>
<span class="flex-none text-gray-300 transition group-hover:text-gray-500" aria-hidden="true">
<%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
</span>
</span>
<span class="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1 pt-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= device_summary %>
</span>
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= category_summary %>
</span>
</span>
</button>
<%= render RsAuthCardComponent.new(authorizations: @authorizations) %>
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
</div>
</div>
+4 -38
View File
@@ -1,44 +1,10 @@
# frozen_string_literal: true
# Wrapper around one app's card and its details modal. The modal's open state
# lives on this element (via the `modal` controller), so the card and the modal
# can be re-rendered independently without closing it.
class RsAuthComponent < ViewComponent::Base
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# The card summarises the app, so reflect the union of its authorizations'
# scopes (an older token may have broader access than the newest one).
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def category_summary
return "All categories and files" if root_access?
count = permissions.size
"#{count} #{'category'.pluralize(count)}"
end
def known_device_count
@authorizations.map(&:device_name).compact.uniq.size
end
def unrecorded_device?
@authorizations.any? { |auth| auth.device_name.blank? }
end
def device_summary
count = known_device_count
return "#{count} #{'device'.pluralize(count)}" unless unrecorded_device?
# An authorization without a recorded user agent may cover several devices,
# so only a lower bound can be given.
lower_bound = count + 1
"#{lower_bound}+ #{'device'.pluralize(lower_bound)}"
end
end
end
@@ -11,18 +11,14 @@
</div>
</div>
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Access
</h5>
<%= render RsScopeListComponent.new(scopes: permissions, root: root_access?,
icon_class: "h-5 w-5") %>
<%= render RsAuthAccessComponent.new(authorizations: @authorizations) %>
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Connected devices
</h5>
<ul class="divide-y divide-gray-100 rounded-lg border border-gray-200" data-turbo-prefetch="false">
<% @authorizations.each do |auth| %>
<li class="flex items-center justify-between gap-4 px-4 py-3">
<li id="<%= rs_device_dom_id(auth) %>" class="flex items-center justify-between gap-4 px-4 py-3">
<div class="min-w-0">
<p class="mb-1 truncate text-base font-medium"><%= auth.device_label %></p>
<p class="truncate text-xs text-gray-500">
@@ -50,4 +46,4 @@
Close
</button>
</div>
<% end %>
<% end %>
@@ -1,21 +1,14 @@
# frozen_string_literal: true
class RsAuthDetailsModalComponent < ViewComponent::Base
include RsAuthsHelper
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# Reflect the union of the app's scopes, matching the card's summary.
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def status_label(auth)
return "Never expires" if auth.expire_at.blank?
return "Expired on #{auth.expire_at.strftime('%-d %b %Y')}" if auth.expired?
@@ -30,4 +23,4 @@ class RsAuthDetailsModalComponent < ViewComponent::Base
def expires_at_title(auth)
auth.expire_at.strftime("%-d %b %Y, %H:%M")
end
end
end
+40
View File
@@ -0,0 +1,40 @@
# frozen_string_literal: true
# Summaries of a group of authorizations for the same app, shared by the card,
# the details modal and the Turbo Stream responses that refresh them.
module RsAuthSummary
# The union of the group's scopes: an older token may grant more than the
# newest one.
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def category_summary
return "All categories and files" if root_access?
count = permissions.size
"#{count} #{'category'.pluralize(count)}"
end
def known_device_count
@authorizations.map(&:device_name).compact.uniq.size
end
def unrecorded_device?
@authorizations.any? { |auth| auth.device_name.blank? }
end
def device_summary
count = known_device_count
return "#{count} #{'device'.pluralize(count)}" unless unrecorded_device?
# An authorization without a recorded user agent may cover several devices,
# so only a lower bound can be given.
lower_bound = count + 1
"#{lower_bound}+ #{'device'.pluralize(lower_bound)}"
end
end
@@ -14,14 +14,23 @@ class Services::RsAuthsController < Services::BaseController
end
def destroy
client_id = @auth.client_id
@auth.destroy!
remaining = current_user.remote_storage_authorizations
.where(client_id: client_id)
.order(created_at: :desc)
.includes(:web_app)
.to_a
respond_to do |format|
format.html do redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.json { head :no_content }
format.html do
redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
end
format.turbo_stream { revoke_stream(client_id, remaining) }
end
end
@@ -48,6 +57,26 @@ class Services::RsAuthsController < Services::BaseController
private
# Revoking one of several devices updates the open details modal in place.
# Revoking the app's last device leaves nothing to show, so the dashboard
# re-renders itself instead: that keeps grouping, sorting and the empty
# state in one place.
def revoke_stream(client_id, remaining)
if remaining.empty?
redirect_to apps_services_storage_url, flash: {
success: 'App authorization revoked'
}
else
render turbo_stream: [
turbo_stream.remove(helpers.rs_device_dom_id(@auth)),
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :card),
RsAuthCardComponent.new(authorizations: remaining)),
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :access),
RsAuthAccessComponent.new(authorizations: remaining))
]
end
end
def app_name_for(auth)
auth.display_name
end
@@ -66,4 +95,4 @@ class Services::RsAuthsController < Services::BaseController
@auth = current_user.remote_storage_authorizations.find_by(id: params[:id])
http_status :not_found unless @auth
end
end
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
module RsAuthsHelper
# An app is summarised by the authorizations grouped under its client_id, so
# its DOM ids derive from the client_id: revoking any single authorization must
# not invalidate them.
def rs_app_dom_id(client_id, part)
"rs_app_#{part}_#{Digest::SHA256.hexdigest(client_id)[0, 12]}"
end
def rs_device_dom_id(auth)
ActionView::RecordIdentifier.dom_id(auth, :device)
end
end
+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)
}
})
+1
View File
@@ -4,5 +4,6 @@ pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin "turbo_stream_animations"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "tailwindcss-stimulus-components" # @4.0.3
@@ -103,6 +103,62 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expect(response).to have_http_status(:not_found)
expect(RemoteStorageAuthorization.exists?(auth.id)).to be(true)
end
context "when another device remains connected to the app" do
let!(:revoked) do
user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
)
end
let!(:remaining) do
user.remote_storage_authorizations.create!(
permissions: %w(documents photos), client_id: "app.example.com",
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
)
end
after do
revoked.destroy
remaining.destroy
end
it "streams the removed device row and the refreshed card and access list" do
delete :destroy, params: { id: revoked.id }, format: :turbo_stream
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include(
%(action="remove" target="#{ActionView::RecordIdentifier.dom_id(revoked, :device)}")
)
expect(response.body).to include(
%(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :card)}")
)
expect(response.body).to include(
%(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :access)}")
)
end
it "leaves the other device connected" do
delete :destroy, params: { id: revoked.id }, format: :turbo_stream
expect(RemoteStorageAuthorization.exists?(remaining.id)).to be(true)
end
end
context "when the app's last device is revoked" do
it "redirects so that the dashboard re-renders itself" do
auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
)
delete :destroy, params: { id: auth.id }, format: :turbo_stream
expect(response).to redirect_to(apps_services_storage_url)
expect(flash[:success]).to eq("App authorization revoked")
end
end
end
end
end
+10
View File
@@ -74,6 +74,16 @@ RSpec.describe "Storage connected apps", type: :feature do
expect(page).to have_button("Revoke access", visible: false)
expect(page).to have_link("Launch app", visible: false)
end
it "renders the ids that the revoke stream targets" do
visit apps_services_storage_path
expect(page).to have_selector(".rs-auth-card[id^='rs_app_card_']")
expect(page).to have_selector("div[id^='rs_app_access_']", visible: false)
expect(page).to have_selector(
"li##{ActionView::RecordIdentifier.dom_id(auth, :device)}", visible: false
)
end
end
context "with multiple apps" do