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:
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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user