From faa7bf61468178c6480184e084ec4ff45135b455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 3 Nov 2026 17:45:37 +0400 Subject: [PATCH] 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 --- .../stylesheets/components/animations.css | 24 ++++++++ .../rs_auth_access_component.html.erb | 7 +++ app/components/rs_auth_access_component.rb | 15 +++++ .../rs_auth_card_component.html.erb | 35 ++++++++++++ app/components/rs_auth_card_component.rb | 16 ++++++ app/components/rs_auth_component.html.erb | 38 +------------ app/components/rs_auth_component.rb | 42 ++------------ .../rs_auth_details_modal_component.html.erb | 10 +--- .../rs_auth_details_modal_component.rb | 13 +---- app/components/rs_auth_summary.rb | 40 +++++++++++++ .../services/rs_auths_controller.rb | 39 +++++++++++-- app/helpers/rs_auths_helper.rb | 14 +++++ app/javascript/application.js | 1 + app/javascript/turbo_stream_animations.js | 30 ++++++++++ config/importmap.rb | 1 + .../services/rs_auths_controller_spec.rb | 56 +++++++++++++++++++ spec/features/services/rs_auths_spec.rb | 10 ++++ 17 files changed, 295 insertions(+), 96 deletions(-) create mode 100644 app/components/rs_auth_access_component.html.erb create mode 100644 app/components/rs_auth_access_component.rb create mode 100644 app/components/rs_auth_card_component.html.erb create mode 100644 app/components/rs_auth_card_component.rb create mode 100644 app/components/rs_auth_summary.rb create mode 100644 app/helpers/rs_auths_helper.rb create mode 100644 app/javascript/turbo_stream_animations.js diff --git a/app/assets/stylesheets/components/animations.css b/app/assets/stylesheets/components/animations.css index 23b7664..315ab7f 100644 --- a/app/assets/stylesheets/components/animations.css +++ b/app/assets/stylesheets/components/animations.css @@ -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; + } +} diff --git a/app/components/rs_auth_access_component.html.erb b/app/components/rs_auth_access_component.html.erb new file mode 100644 index 0000000..cc50b66 --- /dev/null +++ b/app/components/rs_auth_access_component.html.erb @@ -0,0 +1,7 @@ +
+
+ Access +
+ <%= render RsScopeListComponent.new(scopes: permissions, root: root_access?, + icon_class: "h-5 w-5") %> +
\ No newline at end of file diff --git a/app/components/rs_auth_access_component.rb b/app/components/rs_auth_access_component.rb new file mode 100644 index 0000000..605d742 --- /dev/null +++ b/app/components/rs_auth_access_component.rb @@ -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 \ No newline at end of file diff --git a/app/components/rs_auth_card_component.html.erb b/app/components/rs_auth_card_component.html.erb new file mode 100644 index 0000000..3ff311e --- /dev/null +++ b/app/components/rs_auth_card_component.html.erb @@ -0,0 +1,35 @@ + \ No newline at end of file diff --git a/app/components/rs_auth_card_component.rb b/app/components/rs_auth_card_component.rb new file mode 100644 index 0000000..ea8e7f1 --- /dev/null +++ b/app/components/rs_auth_card_component.rb @@ -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 \ No newline at end of file diff --git a/app/components/rs_auth_component.html.erb b/app/components/rs_auth_component.html.erb index 3bc335e..2244194 100644 --- a/app/components/rs_auth_component.html.erb +++ b/app/components/rs_auth_component.html.erb @@ -1,38 +1,4 @@
- - + <%= render RsAuthCardComponent.new(authorizations: @authorizations) %> <%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %> -
+ \ No newline at end of file diff --git a/app/components/rs_auth_component.rb b/app/components/rs_auth_component.rb index 5984941..cb29f0c 100644 --- a/app/components/rs_auth_component.rb +++ b/app/components/rs_auth_component.rb @@ -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 \ No newline at end of file diff --git a/app/components/rs_auth_details_modal_component.html.erb b/app/components/rs_auth_details_modal_component.html.erb index 36da0f5..2462dc6 100644 --- a/app/components/rs_auth_details_modal_component.html.erb +++ b/app/components/rs_auth_details_modal_component.html.erb @@ -11,18 +11,14 @@ -
- Access -
- <%= render RsScopeListComponent.new(scopes: permissions, root: root_access?, - icon_class: "h-5 w-5") %> + <%= render RsAuthAccessComponent.new(authorizations: @authorizations) %>
Connected devices