Add storage dashboard card and per-app details modal
CI / Test (pull_request) Successful in 41s

- 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
This commit is contained in:
raucao committed 2026-11-02 13:04:42 +04:00
1 parent 25856a7ca7
commit 81f23c1deb
9 files changed
+276 -27

No files matched your search

+32 -24
View File
@@ -1,27 +1,35 @@
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
<div class="flex flex-col h-full rounded border border-gray-200 p-4"
data-controller="modal" data-action="keydown.esc->modal#close">
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</div>
<div class="flex-grow min-w-0">
<h4 class="mb-1 text-lg font-bold truncate"><%= name %></h4>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
</div>
</div>
<div class="flex-grow">
<h4 class="mb-1 text-lg font-bold">
<%= @web_app&.name || @auth.app_name %>
</h4>
<p class="text-sm text-gray-500">
<%= @auth.client_id %>
</p>
<dl class="mt-4 flex-grow space-y-1 text-sm text-gray-600">
<div class="flex justify-between gap-2">
<dt>Access</dt>
<dd class="text-right"><%= scope_summary %></dd>
</div>
<div class="flex justify-between gap-2">
<dt>Device</dt>
<dd class="text-right"><%= device_name %></dd>
</div>
<div class="flex justify-between gap-2">
<dt>Status</dt>
<dd class="text-right <%= "text-red-700" if @auth.expired? %>"><%= expiry_label %></dd>
</div>
</dl>
<div class="mt-4">
<button class="btn-md btn-outline w-full" data-action="click->modal#open">
Details
</button>
</div>
<%= 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) %>
</div>
+28
View File
@@ -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
@@ -0,0 +1,38 @@
<%= render ModalComponent.new do %>
<div class="flex items-center gap-4">
<div class="h-16 w-16 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</div>
<div class="min-w-0">
<h4 class="mb-1 text-lg font-bold truncate"><%= name %></h4>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
</div>
</div>
<h5 class="mt-6 mb-1 font-bold">Access</h5>
<% if root_access? %>
<p class="text-sm text-gray-600">All folders and files</p>
<% else %>
<ul class="list-disc list-inside text-sm text-gray-600">
<% @auth.permissions.each do |scope| %>
<li><%= scope_label(scope) %></li>
<% end %>
</ul>
<% end %>
<h5 class="mt-4 mb-1 font-bold">Device</h5>
<p class="text-sm text-gray-600"><%= device_name %></p>
<h5 class="mt-4 mb-1 font-bold">Connected</h5>
<p class="text-sm text-gray-600"><%= @auth.created_at.strftime('%-d %b %Y') %></p>
<h5 class="mt-4 mb-1 font-bold">Permission</h5>
<p class="text-sm text-gray-600"><%= expiry_label %></p>
<div class="mt-6 flex flex-col sm:flex-row gap-2 sm:justify-end">
<%= 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" %>
</div>
<% end %>
@@ -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
@@ -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
@@ -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)
+1 -1
View File
@@ -11,7 +11,7 @@
<section>
<% if @rs_auths.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-10 gap-x-12 mt-4">
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-4">
<% @rs_auths.each do |auth| %>
<%= render RsAuthComponent.new(auth: auth) %>
<% end %>
+60
View File
@@ -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
@@ -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