- <% @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/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb b/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb
new file mode 100644
index 0000000..d310450
--- /dev/null
+++ b/db/migrate/20261102130000_add_user_agent_to_rs_authorizations.rb
@@ -0,0 +1,6 @@
+class AddUserAgentToRsAuthorizations < ActiveRecord::Migration[8.1]
+ def change
+ add_column :remote_storage_authorizations, :user_agent, :text
+ add_column :rs_authorization_codes, :user_agent, :text
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 79a53bc..4518582 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_11_02_120000) do
+ActiveRecord::Schema[8.1].define(version: 2026_11_02_130000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -110,6 +110,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_02_120000) do
t.string "redirect_uri"
t.string "token"
t.datetime "updated_at", null: false
+ t.text "user_agent"
t.integer "user_id", null: false
t.integer "web_app_id"
t.index ["permissions"], name: "index_remote_storage_authorizations_on_permissions"
@@ -128,6 +129,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_02_120000) do
t.text "permissions", default: "[]"
t.string "redirect_uri", null: false
t.datetime "updated_at", null: false
+ t.text "user_agent"
t.integer "user_id", null: false
t.index ["code_digest"], name: "index_rs_authorization_codes_on_code_digest", unique: true
t.index ["user_id"], name: "index_rs_authorization_codes_on_user_id"
diff --git a/spec/controllers/rs/oauth_controller_spec.rb b/spec/controllers/rs/oauth_controller_spec.rb
index 6e2c233..e06be1a 100644
--- a/spec/controllers/rs/oauth_controller_spec.rb
+++ b/spec/controllers/rs/oauth_controller_spec.rb
@@ -346,6 +346,7 @@ RSpec.describe Rs::OauthController, type: :controller do
context "when user is signed in" do
before do
sign_in user
+ request.user_agent = "RSpec Device"
end
after do
@@ -370,6 +371,10 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r))
end
+ it "stores the device's user agent" do
+ expect(@auth.user_agent).to eq("RSpec Device")
+ end
+
it "redirects to the redirect_uri" do
expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123")
end
@@ -499,6 +504,7 @@ RSpec.describe Rs::OauthController, type: :controller do
expect(@code.permissions).to eq(%w(documents photos contacts:rw))
expect(@code.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
expect(@code.code_challenge_method).to eq("S256")
+ expect(@code.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do
diff --git a/spec/controllers/services/rs_auths_controller_spec.rb b/spec/controllers/services/rs_auths_controller_spec.rb
index 1c619c2..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" 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,17 +25,21 @@ 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
@auth.destroy
end
- it "redirects to the given URL with the correct RS URL fragment params" do
+ it "hands the app the user address in the URL fragment" do
launch_url = "https://app.example.com#remotestorage=#{user.address}"
expect(response).to redirect_to(launch_url)
end
+
+ it "does not hand over an access token" do
+ expect(response.location).not_to include("access_token")
+ end
end
context "when remoteStorage is not enabled for the user" do
@@ -44,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
@@ -53,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
diff --git a/spec/services/rs_oauth_manager/exchange_code_spec.rb b/spec/services/rs_oauth_manager/exchange_code_spec.rb
index 8bf1661..e0169a2 100644
--- a/spec/services/rs_oauth_manager/exchange_code_spec.rb
+++ b/spec/services/rs_oauth_manager/exchange_code_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe RsOauthManager::ExchangeCode do
permissions: %w(documents:rw),
code_challenge: code_challenge,
code_challenge_method: "S256",
+ user_agent: "RSpec Device",
**overrides
)
end
@@ -43,6 +44,7 @@ RSpec.describe RsOauthManager::ExchangeCode do
expect(auth.permissions).to eq(%w(documents:rw))
expect(auth.client_id).to eq("example.com")
expect(auth.token).to be_present
+ expect(auth.user_agent).to eq("RSpec Device")
expect(RsAuthorizationCode.exists?(code.id)).to be(false)
end
diff --git a/spec/services/rs_oauth_manager/issue_code_spec.rb b/spec/services/rs_oauth_manager/issue_code_spec.rb
index c80593f..3e424d1 100644
--- a/spec/services/rs_oauth_manager/issue_code_spec.rb
+++ b/spec/services/rs_oauth_manager/issue_code_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe RsOauthManager::IssueCode do
permissions: %w(documents:rw),
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
- expire_at: 1.month.from_now
+ expire_at: 1.month.from_now,
+ user_agent: "RSpec Device"
)
end
@@ -21,6 +22,7 @@ RSpec.describe RsOauthManager::IssueCode do
expect(subject.client_id).to eq("example.com")
expect(subject.permissions).to eq(%w(documents:rw))
expect(subject.code_challenge).to eq("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ expect(subject.user_agent).to eq("RSpec Device")
end
it "does not create a remote storage authorization" do