+ <%= render RsScopeListComponent.new(scopes: @auth.permissions, root: root_access?) %>
+
+
+ Connected devices
+
+
<% @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" %>
+
-
+
+
<%= device_name(auth) %>
+
+ Connected <%= auth.created_at.strftime('%-d %b %Y') %>
+ ·
+ "><%= status_label(auth) %>
+
+
+ <%= button_to "Revoke access", services_storage_rs_auth_path(auth), method: :delete,
+ form: { class: "flex-none" },
+ class: "whitespace-nowrap text-sm text-red-700 hover:underline bg-transparent border-0 p-0 cursor-pointer" %>
<% end %>
- <%= link_to "Launch app", launch_app_services_storage_rs_auth_path(@auth),
- class: "btn-md btn-blue", target: "_blank", rel: "noopener" %>
+ <%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id),
+ class: "btn-md btn-blue", target: "_blank", rel: "noopener", data: { turbo: false } %>
<% end %>
diff --git a/app/components/rs_auth_details_modal_component.rb b/app/components/rs_auth_details_modal_component.rb
index 257329c..3bd9481 100644
--- a/app/components/rs_auth_details_modal_component.rb
+++ b/app/components/rs_auth_details_modal_component.rb
@@ -17,13 +17,8 @@ class RsAuthDetailsModalComponent < ViewComponent::Base
(@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"
+ def device_name(auth)
+ auth.device_name.presence || "Unknown device"
end
def status_label(auth)
diff --git a/app/components/rs_scope_list_component.html.erb b/app/components/rs_scope_list_component.html.erb
new file mode 100644
index 0000000..42ae256
--- /dev/null
+++ b/app/components/rs_scope_list_component.html.erb
@@ -0,0 +1,25 @@
+<% if @root %>
+
+
+ <%= render partial: "icons/alert-triangle",
+ locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
+ All categories and files
+
+ <% if read_only? %>
+ (read only)
+ <% end %>
+
+<% else %>
+ <% @scopes.each do |scope| %>
+
+
+ <%= render partial: "icons/folder",
+ locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
+ <%= scope_name(scope) %>
+
+ <% if scope_permissions(scope) == "r" %>
+ (read only)
+ <% end %>
+
+ <% end %>
+<% end %>
diff --git a/app/components/rs_scope_list_component.rb b/app/components/rs_scope_list_component.rb
new file mode 100644
index 0000000..198ef08
--- /dev/null
+++ b/app/components/rs_scope_list_component.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class RsScopeListComponent < ViewComponent::Base
+ include OauthHelper
+
+ def initialize(scopes:, root: false)
+ @scopes = scopes
+ @root = root
+ end
+
+ def read_only?
+ @scopes.include?(":r")
+ end
+end
diff --git a/app/controllers/services/rs_auths_controller.rb b/app/controllers/services/rs_auths_controller.rb
index 3518b2a..b60a684 100644
--- a/app/controllers/services/rs_auths_controller.rb
+++ b/app/controllers/services/rs_auths_controller.rb
@@ -2,15 +2,15 @@ class Services::RsAuthsController < Services::BaseController
before_action :authenticate_user!
before_action :require_service_enabled
before_action :require_service_available
- before_action :find_rs_auth, only: [:destroy, :launch_app]
+ before_action :find_rs_auth, only: [:destroy]
def index
- @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 }
+ @rs_apps = current_user.remote_storage_authorizations
+ .includes(:web_app)
+ .group_by(&:client_id)
+ .values
+ .map { |auths| auths.sort_by(&:created_at).reverse }
+ .sort_by { |auths| app_name_for(auths.first).downcase }
end
def destroy
@@ -26,6 +26,12 @@ class Services::RsAuthsController < Services::BaseController
end
def launch_app
+ auth = current_user.remote_storage_authorizations
+ .where(client_id: params[:client_id])
+ .order(created_at: :desc)
+ .first
+ http_status(:not_found) and return unless auth
+
user_address =
if Rails.env.development?
"#{current_user.cn}@#{request.host_with_port}"
@@ -35,7 +41,7 @@ class Services::RsAuthsController < Services::BaseController
# Storage-first bearer token issuance (remoteStorage spec §11): the app
# receives the address and runs the application-first flow itself.
- launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
+ launch_url = "#{auth.launch_url}#remotestorage=#{user_address}"
redirect_to launch_url, allow_other_host: true
end
@@ -57,7 +63,7 @@ class Services::RsAuthsController < Services::BaseController
end
def find_rs_auth
- @auth = current_user.remote_storage_authorizations.find(params[:id])
- http_status :not_found unless @auth.present?
+ @auth = current_user.remote_storage_authorizations.find_by(id: params[:id])
+ http_status :not_found unless @auth
end
end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index d281380..0f2414b 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -11,7 +11,7 @@ class NotificationMailer < ApplicationMailer
@auth = params[:auth]
@permissions = @auth.permissions.map do |p|
access = p.split(":")[1] == 'r' ? 'read' : 'read/write'
- directory = p.split(':')[0] == '' ? 'all folders and files' : p.split(':')[0]
+ directory = p.split(':')[0] == '' ? 'all categories and files' : p.split(':')[0]
"#{access} #{directory}"
end
@subject = "New app connected to your storage"
diff --git a/app/views/rs/oauth/new.html.erb b/app/views/rs/oauth/new.html.erb
index 4b7bc25..2668c14 100644
--- a/app/views/rs/oauth/new.html.erb
+++ b/app/views/rs/oauth/new.html.erb
@@ -5,34 +5,10 @@
The app on
<%= link_to @client_id, "https://#{@client_id}" %>
- is asking for access to these folders:
+ is asking for access to these categories:
- <% if @root_access_requested %>
-
-
- <%= render partial: "icons/alert-triangle",
- locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
- All files and directories
-
- <% if (@scopes & [":r"]).any? %>
- (read only)
- <% end %>
-
- <% else %>
- <% @scopes.each do |scope| %>
-
-
- <%= render partial: "icons/folder",
- locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
- <%= scope_name(scope) %>
-
- <% if scope_permissions(scope) == "r" %>
- (read only)
- <% end %>
-
- <% end %>
- <% end %>
+ <%= render RsScopeListComponent.new(scopes: @scopes, root: @root_access_requested) %>
<%= form_with(url: rs_oauth_path, method: :post, data: { turbo: false }) do |f| %>
<%= f.hidden_field :redirect_uri, value: @redirect_uri %>
diff --git a/app/views/services/rs_auths/index.html.erb b/app/views/services/rs_auths/index.html.erb
index 9141066..edd6ade 100644
--- a/app/views/services/rs_auths/index.html.erb
+++ b/app/views/services/rs_auths/index.html.erb
@@ -10,9 +10,9 @@
<%= render partial: "shared/tabnav_remotestorage" %>
- <% if @rs_auth_groups.any? %>
+ <% if @rs_apps.any? %>
- <% @rs_auth_groups.each do |authorizations| %>
+ <% @rs_apps.each do |authorizations| %>
<%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index d8af5b2..5b78bad 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -52,12 +52,9 @@ Rails.application.routes.draw do
resource :storage, controller: 'remotestorage', only: [:show] do
get :apps, to: "rs_auths#index"
- resources :rs_auths, only: [:index, :destroy] do
- member do
- get :revoke, to: 'rs_auths#destroy'
- get :launch_app
- end
- end
+ get "apps/:client_id/launch", to: "rs_auths#launch_app", as: :launch_app,
+ constraints: { client_id: /[^\/]+/ }
+ resources :rs_auths, only: [:index, :destroy]
end
end
diff --git a/spec/controllers/services/rs_auths_controller_spec.rb b/spec/controllers/services/rs_auths_controller_spec.rb
index 812a842..0970c3c 100644
--- a/spec/controllers/services/rs_auths_controller_spec.rb
+++ b/spec/controllers/services/rs_auths_controller_spec.rb
@@ -11,13 +11,13 @@ RSpec.describe Services::RsAuthsController, type: :controller do
])
end
- describe "GET /services/storage/rs_auths/:id/launch_app (storage-first)" do
+ describe "GET /services/storage/apps/:client_id/launch (storage-first)" do
context "when user is signed in" do
before do
sign_in user
end
- context "token exists" do
+ context "authorization exists" do
before do
@auth = user.remote_storage_authorizations.create!(
permissions: %w(documents), client_id: "app.example.com",
@@ -25,7 +25,7 @@ RSpec.describe Services::RsAuthsController, type: :controller do
expire_at: 2.days.from_now
)
- get :launch_app, params: { id: @auth.id }
+ get :launch_app, params: { client_id: @auth.client_id }
end
after do
@@ -48,7 +48,7 @@ RSpec.describe Services::RsAuthsController, type: :controller do
{ services_enabled: [] }
])
- get :launch_app, params: { id: 1 }
+ get :launch_app, params: { client_id: "app.example.com" }
end
it "responds with forbidden" do
@@ -57,4 +57,52 @@ RSpec.describe Services::RsAuthsController, type: :controller do
end
end
end
+
+ describe "GET /services/storage/apps/:client_id/launch for an unknown app" do
+ before { sign_in user }
+
+ it "returns 404 instead of raising" do
+ get :launch_app, params: { client_id: "nonexistent.example.com" }
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "DELETE /services/storage/rs_auths/:id" do
+ context "when user is signed in" do
+ before { sign_in user }
+
+ it "revokes the authorization" do
+ auth = user.remote_storage_authorizations.create!(
+ permissions: %w(documents), client_id: "app.example.com",
+ redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
+ )
+
+ expect {
+ delete :destroy, params: { id: auth.id }
+ }.to change(RemoteStorageAuthorization, :count).by(-1)
+
+ expect(response).to redirect_to(apps_services_storage_url)
+ end
+
+ it "returns 404 for an unknown authorization" do
+ delete :destroy, params: { id: 0 }
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "returns 404 for another user's authorization" do
+ other = create :user, id: 99, cn: "otheruser", email: "other@example.com"
+ auth = other.remote_storage_authorizations.create!(
+ permissions: %w(documents), client_id: "app.example.com",
+ redirect_uri: "https://app.example.com"
+ )
+
+ delete :destroy, params: { id: auth.id }
+
+ expect(response).to have_http_status(:not_found)
+ expect(RemoteStorageAuthorization.exists?(auth.id)).to be(true)
+ end
+ end
+ end
end
diff --git a/spec/features/rs/oauth_spec.rb b/spec/features/rs/oauth_spec.rb
index de0527d..085198e 100644
--- a/spec/features/rs/oauth_spec.rb
+++ b/spec/features/rs/oauth_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do
it "shows a special permission for all files and dirs" do
within ".scope" do
- expect(page).to have_content("All files and directories")
+ expect(page).to have_content("All categories and files")
end
end
end
diff --git a/spec/features/services/rs_auths_spec.rb b/spec/features/services/rs_auths_spec.rb
index 9fd69b0..6cec9a7 100644
--- a/spec/features/services/rs_auths_spec.rb
+++ b/spec/features/services/rs_auths_spec.rb
@@ -44,50 +44,71 @@ RSpec.describe "Storage connected apps", type: :feature do
after { auth.destroy }
- it "shows the app, its device and permission status" do
+ it "shows the app with device and category counts" 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")
+ within ".rs-auth-card" do
+ expect(page).to have_content("Notes")
+ expect(page).to have_content("notes.example.com")
+ expect(page).to have_content("1 device")
+ expect(page).to have_content("2 categories")
+ end
end
- it "renders a details modal with scopes and actions" do
+ it "does not show the permission expiry on the card" 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)
+ within ".rs-auth-card" do
+ expect(page).not_to have_content("Expires")
+ end
+ end
+
+ it "renders access and per-token details in the modal" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector("p.scope", text: "documents", visible: false)
+ expect(page).to have_selector("p.scope", text: "photos", visible: false)
+ expect(page).to have_selector("h5", text: "Connected devices", visible: false)
+ expect(page).to have_selector("p", text: "Firefox on Linux", visible: false)
+ expect(page).to have_selector("p", text: /Expires/, visible: false)
+ expect(page).to have_button("Revoke access", 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) }
+ context "with multiple apps" do
+ let!(:notes) { create_auth(client: "notes.example.com", device: firefox_ua) }
+ let!(:photos) { create_auth(client: "photos.example.com", device: firefox_ua) }
after do
- first.destroy
- second.destroy
- other.destroy
+ notes.destroy
+ photos.destroy
end
- it "groups authorizations for the same app and device into one card" do
+ it "shows one card per app" 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
+ end
+
+ context "with one app on multiple devices" do
+ let!(:laptop) { create_auth(client: "notes.example.com", device: firefox_ua) }
+ let!(:phone) { create_auth(client: "notes.example.com", device: chrome_ua) }
+
+ after do
+ laptop.destroy
+ phone.destroy
end
- it "lists every authorization in the group with its own revoke action" do
+ it "shows a single card with the device count" do
visit apps_services_storage_path
- expect(page).to have_selector("a", text: "Revoke access", visible: false, count: 3)
+ expect(page).to have_selector(".rs-auth-card", count: 1)
+
+ within ".rs-auth-card" do
+ expect(page).to have_content("2 devices")
+ end
end
end