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 @@
+
+
+
+ <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
+
+
+
+ <%= @auth.display_name %>
+ <% if @auth.client_id != @auth.display_name %>
+ <%= @auth.client_id %>
+ <% end %>
+
+
+
+ <%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
+
+
+
+
+
+ <%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
+ <%= device_summary %>
+
+
+ <%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
+ <%= category_summary %>
+
+
+
\ 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 AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
-
-
-
- <%= @auth.display_name %>
- <% if @auth.client_id != @auth.display_name %>
- <%= @auth.client_id %>
- <% end %>
-
-
-
- <%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
-
-
-
-
-
- <%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
- <%= device_summary %>
-
-
- <%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
- <%= category_summary %>
-
-
-
-
+ <%= 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
<% @authorizations.each do |auth| %>
-
+
<%= auth.device_label %>
@@ -50,4 +46,4 @@
Close
-<% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/components/rs_auth_details_modal_component.rb b/app/components/rs_auth_details_modal_component.rb
index 0344e31..be140d1 100644
--- a/app/components/rs_auth_details_modal_component.rb
+++ b/app/components/rs_auth_details_modal_component.rb
@@ -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
\ No newline at end of file
diff --git a/app/components/rs_auth_summary.rb b/app/components/rs_auth_summary.rb
new file mode 100644
index 0000000..550a779
--- /dev/null
+++ b/app/components/rs_auth_summary.rb
@@ -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
\ No newline at end of file
diff --git a/app/controllers/services/rs_auths_controller.rb b/app/controllers/services/rs_auths_controller.rb
index f4df2c1..6245eab 100644
--- a/app/controllers/services/rs_auths_controller.rb
+++ b/app/controllers/services/rs_auths_controller.rb
@@ -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
\ No newline at end of file
diff --git a/app/helpers/rs_auths_helper.rb b/app/helpers/rs_auths_helper.rb
new file mode 100644
index 0000000..125ae92
--- /dev/null
+++ b/app/helpers/rs_auths_helper.rb
@@ -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
\ No newline at end of file
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 0d7b494..1e2d3dc 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -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"
\ No newline at end of file
diff --git a/app/javascript/turbo_stream_animations.js b/app/javascript/turbo_stream_animations.js
new file mode 100644
index 0000000..1cf8356
--- /dev/null
+++ b/app/javascript/turbo_stream_animations.js
@@ -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)
+ }
+})
\ No newline at end of file
diff --git a/config/importmap.rb b/config/importmap.rb
index cb396cc..692f89b 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -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
diff --git a/spec/controllers/services/rs_auths_controller_spec.rb b/spec/controllers/services/rs_auths_controller_spec.rb
index 0970c3c..cd4813a 100644
--- a/spec/controllers/services/rs_auths_controller_spec.rb
+++ b/spec/controllers/services/rs_auths_controller_spec.rb
@@ -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
diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb
index 44435c5..a8b8bc7 100644
--- a/spec/features/services/rs_auths_spec.rb
+++ b/spec/features/services/rs_auths_spec.rb
@@ -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