- 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:
9 files changed
+276
-27
No files matched your search
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user