From 81f23c1deb92299f2e773a5d94e13c337136cd1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 2 Nov 2026 13:04:42 +0400 Subject: [PATCH] Add storage dashboard card and per-app details modal - Rework RsAuthComponent into a card that shows the app, its device, an access summary and the permission status - Add RsAuthDetailsModalComponent with the full scope list, device, connected date and permission expiry, plus Launch and Revoke actions (built on the existing ModalComponent) - Add RemoteStorageAuthorization#device_name (parsed from the user agent) and #expired? - Sort connected apps by name Part of #254 --- app/components/rs_auth_component.html.erb | 56 +++++++++-------- app/components/rs_auth_component.rb | 28 +++++++++ .../rs_auth_details_modal_component.html.erb | 38 ++++++++++++ .../rs_auth_details_modal_component.rb | 34 +++++++++++ .../services/rs_auths_controller.rb | 5 +- app/models/remote_storage_authorization.rb | 28 +++++++++ app/views/services/rs_auths/index.html.erb | 2 +- spec/features/services/rs_auths_spec.rb | 60 +++++++++++++++++++ .../remote_storage_authorization_spec.rb | 52 ++++++++++++++++ 9 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 app/components/rs_auth_details_modal_component.html.erb create mode 100644 app/components/rs_auth_details_modal_component.rb create mode 100644 spec/features/services/rs_auths_spec.rb diff --git a/app/components/rs_auth_component.html.erb b/app/components/rs_auth_component.html.erb index a26d35d..2bd7d6c 100644 --- a/app/components/rs_auth_component.html.erb +++ b/app/components/rs_auth_component.html.erb @@ -1,27 +1,35 @@ -
-
- <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %> +
+
+
+ <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %> +
+
+

<%= name %>

+

<%= @auth.client_id %>

+
-
-

- <%= @web_app&.name || @auth.app_name %> -

-

- <%= @auth.client_id %> -

+ +
+
+
Access
+
<%= scope_summary %>
+
+
+
Device
+
<%= device_name %>
+
+
+
Status
+
"><%= expiry_label %>
+
+
+ +
+
- <%= render DropdownComponent.new do %> - <%= render DropdownLinkComponent.new( - href: launch_app_services_storage_rs_auth_url(@auth), - open_in_new_tab: true - ) do %> - Launch app - <% end %> - <%= render DropdownLinkComponent.new( - href: revoke_services_storage_rs_auth_url(@auth), - separator: true, add_class: "text-red-700" - ) do %> - Revoke access - <% end %> - <% end %> + + <%= render RsAuthDetailsModalComponent.new(auth: @auth) %>
diff --git a/app/components/rs_auth_component.rb b/app/components/rs_auth_component.rb index ed588bf..eec3236 100644 --- a/app/components/rs_auth_component.rb +++ b/app/components/rs_auth_component.rb @@ -1,8 +1,36 @@ # frozen_string_literal: true class RsAuthComponent < ViewComponent::Base + include OauthHelper + def initialize(auth:) @auth = auth @web_app = auth.web_app end + + def name + @web_app&.name.presence || @auth.app_name.presence || @auth.client_id + end + + def root_access? + (@auth.permissions & [":r", ":rw"]).any? + end + + def scope_summary + return "All folders and files" if root_access? + + count = @auth.permissions.size + "#{count} #{'folder'.pluralize(count)}" + end + + def device_name + @auth.device_name.presence || "Unknown device" + end + + def expiry_label + return "Never expires" if @auth.expire_at.blank? + return "Expired" if @auth.expired? + + "Expires #{@auth.expire_at.strftime('%-d %b %Y')}" + end end diff --git a/app/components/rs_auth_details_modal_component.html.erb b/app/components/rs_auth_details_modal_component.html.erb new file mode 100644 index 0000000..8fc9cf5 --- /dev/null +++ b/app/components/rs_auth_details_modal_component.html.erb @@ -0,0 +1,38 @@ +<%= render ModalComponent.new do %> +
+
+ <%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %> +
+
+

<%= name %>

+

<%= @auth.client_id %>

+
+
+ +
Access
+ <% if root_access? %> +

All folders and files

+ <% else %> +
    + <% @auth.permissions.each do |scope| %> +
  • <%= scope_label(scope) %>
  • + <% end %> +
+ <% end %> + +
Device
+

<%= device_name %>

+ +
Connected
+

<%= @auth.created_at.strftime('%-d %b %Y') %>

+ +
Permission
+

<%= expiry_label %>

+ +
+ <%= link_to "Launch app", launch_app_services_storage_rs_auth_path(@auth), + class: "btn-md btn-blue", target: "_blank", rel: "noopener" %> + <%= link_to "Revoke access", revoke_services_storage_rs_auth_path(@auth), + class: "btn-md btn-gray text-red-700" %> +
+<% end %> diff --git a/app/components/rs_auth_details_modal_component.rb b/app/components/rs_auth_details_modal_component.rb new file mode 100644 index 0000000..7ce0dd3 --- /dev/null +++ b/app/components/rs_auth_details_modal_component.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class RsAuthDetailsModalComponent < ViewComponent::Base + include OauthHelper + + def initialize(auth:) + @auth = auth + @web_app = auth.web_app + end + + def name + @web_app&.name.presence || @auth.app_name.presence || @auth.client_id + end + + def root_access? + (@auth.permissions & [":r", ":rw"]).any? + end + + def scope_label(scope) + permission = scope_permissions(scope) == "r" ? "read only" : "read/write" + "#{scope_name(scope)} (#{permission})" + end + + def device_name + @auth.device_name.presence || "Unknown device" + end + + def expiry_label + return "Never expires" if @auth.expire_at.blank? + return "Expired on #{@auth.expire_at.strftime('%-d %b %Y')}" if @auth.expired? + + "Expires #{@auth.expire_at.strftime('%-d %b %Y')}" + end +end diff --git a/app/controllers/services/rs_auths_controller.rb b/app/controllers/services/rs_auths_controller.rb index 2fff161..6c8d1d2 100644 --- a/app/controllers/services/rs_auths_controller.rb +++ b/app/controllers/services/rs_auths_controller.rb @@ -5,8 +5,9 @@ class Services::RsAuthsController < Services::BaseController before_action :find_rs_auth, only: [:destroy, :launch_app] def index - @rs_auths = current_user.remote_storage_authorizations - # TODO sort by app name? + @rs_auths = current_user.remote_storage_authorizations.sort_by do |auth| + (auth.web_app&.name.presence || auth.app_name.presence || auth.client_id).to_s.downcase + end end def destroy diff --git a/app/models/remote_storage_authorization.rb b/app/models/remote_storage_authorization.rb index f6a7fed..e5d4fc8 100644 --- a/app/models/remote_storage_authorization.rb +++ b/app/models/remote_storage_authorization.rb @@ -40,6 +40,34 @@ class RemoteStorageAuthorization < ApplicationRecord end end + def expired? + expire_at.present? && expire_at.past? + end + + def device_name + return nil if user_agent.blank? + + browser = + case user_agent + when /Firefox/ then "Firefox" + when /Edg\// then "Edge" + when /Chrome/ then "Chrome" + when /Safari/ then "Safari" + else "Unknown browser" + end + + os = + case user_agent + when /Android/ then "Android" + when /iPhone|iPad|iPod/ then "iOS" + when /Mac OS X/ then "macOS" + when /Windows/ then "Windows" + when /Linux/ then "Linux" + end + + os ? "#{browser} on #{os}" : browser + end + def delete_token_from_redis key = "authorizations:#{user.cn}:#{token}" redis.srem? key, redis.smembers(key) diff --git a/app/views/services/rs_auths/index.html.erb b/app/views/services/rs_auths/index.html.erb index 939df14..c00f7c7 100644 --- a/app/views/services/rs_auths/index.html.erb +++ b/app/views/services/rs_auths/index.html.erb @@ -11,7 +11,7 @@
<% if @rs_auths.any? %> -
+
<% @rs_auths.each do |auth| %> <%= render RsAuthComponent.new(auth: auth) %> <% end %> diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb new file mode 100644 index 0000000..f4fdb4d --- /dev/null +++ b/spec/features/services/rs_auths_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe "Storage connected apps", type: :feature do + let(:user) { create :user } + + before do + allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true) + allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil) + allow_any_instance_of(User).to receive(:ldap_entry).and_return( + uid: user.cn, ou: user.ou, mail: user.email, admin: nil, + services_enabled: ["remotestorage"] + ) + login_as user, scope: :user + end + + context "with a connected app" do + before do + AppCatalog::WebApp.create!(url: "https://notes.example.com", name: "Notes", metadata: {}) + end + + let!(:auth) do + user.remote_storage_authorizations.create!( + permissions: %w(documents:rw photos:r), + client_id: "notes.example.com", + redirect_uri: "https://notes.example.com", + expire_at: 1.month.from_now, + user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0" + ) + end + + after { auth.destroy } + + it "shows the app, its device and permission status" do + visit apps_services_storage_path + + expect(page).to have_content("Notes") + expect(page).to have_content("notes.example.com") + expect(page).to have_content("Firefox on Linux") + expect(page).to have_content("2 folders") + expect(page).to have_content("Expires") + end + + it "renders a details modal with scopes and actions" do + visit apps_services_storage_path + + expect(page).to have_selector("li", text: "documents (read/write)", visible: false) + expect(page).to have_selector("li", text: "photos (read only)", visible: false) + expect(page).to have_link("Launch app", visible: false) + expect(page).to have_link("Revoke access", visible: false) + end + end + + context "without connected apps" do + it "shows the empty state" do + visit apps_services_storage_path + + expect(page).to have_content("No apps connected") + end + end +end diff --git a/spec/models/remote_storage_authorization_spec.rb b/spec/models/remote_storage_authorization_spec.rb index 1791ab2..8694bc9 100644 --- a/spec/models/remote_storage_authorization_spec.rb +++ b/spec/models/remote_storage_authorization_spec.rb @@ -311,4 +311,56 @@ RSpec.describe RemoteStorageAuthorization, type: :model do end end end + + describe "#expired?" do + def auth_with_expiry(expire_at) + RemoteStorageAuthorization.new( + user: user, permissions: %w(documents:rw), client_id: "example.com", + redirect_uri: "https://example.com", expire_at: expire_at + ) + end + + it "is false without an expiry" do + expect(auth_with_expiry(nil)).not_to be_expired + end + + it "is false before the expiry" do + expect(auth_with_expiry(1.day.from_now)).not_to be_expired + end + + it "is true after the expiry" do + expect(auth_with_expiry(1.day.ago)).to be_expired + end + end + + describe "#device_name" do + def auth_with_user_agent(user_agent) + RemoteStorageAuthorization.new(user: user, user_agent: user_agent) + end + + it "returns nil without a user agent" do + expect(auth_with_user_agent(nil).device_name).to be_nil + end + + it "recognizes Firefox on Linux" do + ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0" + expect(auth_with_user_agent(ua).device_name).to eq("Firefox on Linux") + end + + it "recognizes Chrome on macOS" do + ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \ + "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" + expect(auth_with_user_agent(ua).device_name).to eq("Chrome on macOS") + end + + it "recognizes Safari on iOS" do + ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) " \ + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1" + expect(auth_with_user_agent(ua).device_name).to eq("Safari on iOS") + end + + it "falls back to the browser when the OS is unknown" do + expect(auth_with_user_agent("Firefox/120.0").device_name).to eq("Firefox") + end + end end