- <% @rs_auths.each do |auth| %>
- <%= render RsAuthComponent.new(auth: auth) %>
+ <% if @rs_apps.any? %>
+
+ <% @rs_apps.each do |authorizations| %>
+ <%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
<% else %>
diff --git a/config/importmap.rb b/config/importmap.rb
index cb396cc..495f9f2 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -4,5 +4,7 @@ pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
+pin "turbo_stream_animations"
+pin "local_time"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "tailwindcss-stimulus-components" # @4.0.3
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..cd4813a 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,108 @@ 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
+
+ context "when another device remains connected to the app" do
+ let!(:revoked) do
+ 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
+ )
+ end
+
+ let!(:remaining) do
+ user.remote_storage_authorizations.create!(
+ permissions: %w(documents photos), client_id: "app.example.com",
+ redirect_uri: "https://app.example.com", expire_at: 2.days.from_now
+ )
+ end
+
+ after do
+ revoked.destroy
+ remaining.destroy
+ end
+
+ it "streams the removed device row and the refreshed card and access list" do
+ delete :destroy, params: { id: revoked.id }, format: :turbo_stream
+
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include(
+ %(action="remove" target="#{ActionView::RecordIdentifier.dom_id(revoked, :device)}")
+ )
+ expect(response.body).to include(
+ %(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :card)}")
+ )
+ expect(response.body).to include(
+ %(action="replace" target="#{controller.helpers.rs_app_dom_id('app.example.com', :access)}")
+ )
+ end
+
+ it "leaves the other device connected" do
+ delete :destroy, params: { id: revoked.id }, format: :turbo_stream
+
+ expect(RemoteStorageAuthorization.exists?(remaining.id)).to be(true)
+ end
+ end
+
+ context "when the app's last device is revoked" do
+ it "redirects so that the dashboard re-renders itself" 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
+ )
+
+ delete :destroy, params: { id: auth.id }, format: :turbo_stream
+
+ expect(response).to redirect_to(apps_services_storage_url)
+ expect(flash[:success]).to eq("App authorization revoked")
+ end
+ 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
new file mode 100644
index 0000000..edb2f20
--- /dev/null
+++ b/spec/features/services/rs_auths_spec.rb
@@ -0,0 +1,206 @@
+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 with device and category counts" do
+ visit apps_services_storage_path
+
+ 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 "does not show the permission expiry on the card" do
+ visit apps_services_storage_path
+
+ 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)
+ end
+
+ it "renders the ids that the revoke stream targets" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector(".rs-auth-card[id^='rs_app_card_']")
+ expect(page).to have_selector("div[id^='rs_app_access_']", visible: false)
+ expect(page).to have_selector(
+ "li##{ActionView::RecordIdentifier.dom_id(auth, :device)}", visible: false
+ )
+ end
+
+ it "marks the device timestamps with machine-readable UTC times" do
+ visit apps_services_storage_path
+
+ timestamps = page.all("time[datetime]", visible: false)
+ .map { |element| Time.iso8601(element["datetime"]) }
+
+ expect(timestamps).to contain_exactly(
+ be_within(1.second).of(auth.created_at),
+ be_within(1.second).of(auth.expire_at)
+ )
+ end
+ end
+
+ 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
+ notes.destroy
+ photos.destroy
+ end
+
+ it "shows one card per app" do
+ visit apps_services_storage_path
+
+ expect(page).to have_selector(".rs-auth-card", count: 2)
+ 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 "shows a single card with the device count" do
+ visit apps_services_storage_path
+
+ 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
+
+ context "with one app on multiple devices and differing scopes" do
+ let!(:laptop) do
+ create_auth(client: "notes.example.com", device: firefox_ua, permissions: %w(documents:rw))
+ end
+
+ let!(:phone) do
+ create_auth(client: "notes.example.com", device: chrome_ua,
+ permissions: %w(documents:rw photos:r))
+ end
+
+ after do
+ laptop.destroy
+ phone.destroy
+ end
+
+ it "summarises the union of the app's scopes in the card and the modal" do
+ visit apps_services_storage_path
+
+ within ".rs-auth-card" do
+ expect(page).to have_content("2 devices")
+ expect(page).to have_content("2 categories")
+ end
+
+ expect(page).to have_selector("p.scope", text: "documents", visible: false)
+ expect(page).to have_selector("p.scope", text: "photos", visible: false)
+ end
+ end
+
+ context "with an authorization from before user agents were recorded" do
+ let!(:legacy) { create_auth(client: "notes.example.com", device: nil) }
+
+ after { legacy.destroy }
+
+ it "only claims a lower-bound device count and labels the device as unknown" do
+ visit apps_services_storage_path
+
+ within ".rs-auth-card" do
+ expect(page).to have_content("1+ device")
+ end
+
+ expect(page).to have_selector("p", text: "Unknown device(s)", visible: false)
+ end
+ end
+
+ context "with an unrecorded authorization alongside a recorded device" do
+ let!(:legacy) { create_auth(client: "notes.example.com", device: nil) }
+ let!(:laptop) { create_auth(client: "notes.example.com", device: firefox_ua) }
+
+ after do
+ legacy.destroy
+ laptop.destroy
+ end
+
+ it "counts the unrecorded authorization as a possible extra device" do
+ visit apps_services_storage_path
+
+ within ".rs-auth-card" do
+ expect(page).to have_content("2+ devices")
+ end
+ 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..7616603 100644
--- a/spec/models/remote_storage_authorization_spec.rb
+++ b/spec/models/remote_storage_authorization_spec.rb
@@ -303,4 +303,88 @@ 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 Mobile 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("Mobile Safari on iOS")
+ end
+
+ it "recognizes GNOME Web on Linux (Epiphany's Safari-like user agent)" do
+ ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
+ "Version/16.0 Safari/605.1.15 Epiphany/45.0"
+ expect(auth_with_user_agent(ua).device_name).to eq("GNOME Web on Linux")
+ end
+
+ it "treats a WebKit Safari user agent on Linux as WebKit (WebKitGTK)" do
+ ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
+ "Version/60.5 Safari/605.1.15"
+ expect(auth_with_user_agent(ua).device_name).to eq("WebKit on Linux")
+ end
+
+ it "keeps Safari on macOS as Safari" do
+ ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " \
+ "(KHTML, like Gecko) Version/17.1 Safari/605.1.15"
+ expect(auth_with_user_agent(ua).device_name).to eq("Safari on macOS")
+ 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
+
+ describe "#device_label" do
+ def auth_with_user_agent(user_agent)
+ RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
+ end
+
+ it "falls back to the unrecorded placeholder without a user agent" do
+ expect(auth_with_user_agent(nil).device_label).to eq("Unknown device(s)")
+ end
+
+ it "returns the device name when one can be determined" do
+ ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
+ expect(auth_with_user_agent(ua).device_label).to eq("Firefox on Linux")
+ end
+ end
end