diff --git a/app/components/rs_auth_component.html.erb b/app/components/rs_auth_component.html.erb index a26d35d..863af3a 100644 --- a/app/components/rs_auth_component.html.erb +++ b/app/components/rs_auth_component.html.erb @@ -1,27 +1,41 @@ -
-
- <%= 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 %>
+
+ <% if multiple_authorizations? %> +
+
Tokens
+
<%= @authorizations.size %>
+
+ <% end %> +
+ +
+
- <%= 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(authorizations: @authorizations) %>
diff --git a/app/components/rs_auth_component.rb b/app/components/rs_auth_component.rb index ed588bf..56e003a 100644 --- a/app/components/rs_auth_component.rb +++ b/app/components/rs_auth_component.rb @@ -1,8 +1,41 @@ # frozen_string_literal: true class RsAuthComponent < ViewComponent::Base - def initialize(auth:) - @auth = auth - @web_app = auth.web_app + include OauthHelper + + def initialize(authorizations:) + @authorizations = authorizations + @auth = authorizations.first + @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 + + def multiple_authorizations? + @authorizations.size > 1 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..ce0233a --- /dev/null +++ b/app/components/rs_auth_details_modal_component.html.erb @@ -0,0 +1,47 @@ +<%= 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 %>

+ +
+ <%= @authorizations.size > 1 ? "Connected tokens" : "Connected" %> +
+
    + <% @authorizations.each do |auth| %> +
  • + + <%= auth.created_at.strftime('%-d %b %Y') %> + · + <%= status_label(auth) %> + + <%= link_to "Revoke access", revoke_services_storage_rs_auth_path(auth), + class: "text-red-700 whitespace-nowrap" %> +
  • + <% end %> +
+ +
+ <%= link_to "Launch app", launch_app_services_storage_rs_auth_path(@auth), + class: "btn-md btn-blue", target: "_blank", rel: "noopener" %> +
+<% 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..257329c --- /dev/null +++ b/app/components/rs_auth_details_modal_component.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class RsAuthDetailsModalComponent < ViewComponent::Base + include OauthHelper + + def initialize(authorizations:) + @authorizations = authorizations + @auth = authorizations.first + @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 status_label(auth) + 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 c081538..3518b2a 100644 --- a/app/controllers/services/rs_auths_controller.rb +++ b/app/controllers/services/rs_auths_controller.rb @@ -5,8 +5,12 @@ 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_auth_groups = current_user.remote_storage_authorizations + .includes(:web_app) + .group_by { |auth| [auth.client_id, auth.device_name] } + .values + .map { |auths| auths.sort_by(&:created_at).reverse } + .sort_by { |auths| app_name_for(auths.first).downcase } end def destroy @@ -38,6 +42,10 @@ class Services::RsAuthsController < Services::BaseController private + def app_name_for(auth) + auth.web_app&.name.presence || auth.app_name.presence || auth.client_id + end + def require_service_enabled unless current_user.service_enabled?(:remotestorage) http_status :forbidden diff --git a/app/models/remote_storage_authorization.rb b/app/models/remote_storage_authorization.rb index a8dfd1c..b27e5c2 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..9141066 100644 --- a/app/views/services/rs_auths/index.html.erb +++ b/app/views/services/rs_auths/index.html.erb @@ -10,10 +10,10 @@ <%= render partial: "shared/tabnav_remotestorage" %>
- <% if @rs_auths.any? %> -
- <% @rs_auths.each do |auth| %> - <%= render RsAuthComponent.new(auth: auth) %> + <% if @rs_auth_groups.any? %> +
+ <% @rs_auth_groups.each do |authorizations| %> + <%= render RsAuthComponent.new(authorizations: authorizations) %> <% end %>
<% else %> diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb new file mode 100644 index 0000000..9fd69b0 --- /dev/null +++ b/spec/features/services/rs_auths_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe "Storage connected apps", type: :feature do + let(:user) { create :user } + + let(:firefox_ua) do + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0" + end + + let(:chrome_ua) do + "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" + end + + 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 + + def create_auth(client:, device:, permissions: %w(documents:rw)) + user.remote_storage_authorizations.create!( + permissions: permissions, + client_id: client, + redirect_uri: "https://#{client}", + expire_at: 1.month.from_now, + user_agent: device + ) + end + + context "with a connected app" do + before do + AppCatalog::WebApp.create!(url: "https://notes.example.com", name: "Notes", metadata: {}) + end + + let!(:auth) do + create_auth(client: "notes.example.com", device: firefox_ua, + permissions: %w(documents:rw photos:r)) + 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_selector("a", text: "Revoke access", visible: false, count: 1) + end + end + + context "with duplicates" do + let!(:first) { create_auth(client: "notes.example.com", device: firefox_ua) } + let!(:second) { create_auth(client: "notes.example.com", device: firefox_ua) } + let!(:other) { create_auth(client: "notes.example.com", device: chrome_ua) } + + after do + first.destroy + second.destroy + other.destroy + end + + it "groups authorizations for the same app and device into one card" do + visit apps_services_storage_path + + expect(page).to have_selector(".rs-auth-card", count: 2) + expect(page).to have_content("Firefox on Linux") + expect(page).to have_content("Chrome on macOS") + expect(page).to have_content("Tokens") + end + + it "lists every authorization in the group with its own revoke action" do + visit apps_services_storage_path + + expect(page).to have_selector("a", text: "Revoke access", visible: false, count: 3) + 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 378ebb6..70e0377 100644 --- a/spec/models/remote_storage_authorization_spec.rb +++ b/spec/models/remote_storage_authorization_spec.rb @@ -303,4 +303,55 @@ 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