+<% 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