Compare commits

..
Author SHA1 Message Date
raucao d94fda6d26 Polish the storage dashboard and details modal
CI / Test (pull_request) Successful in 44s
Visual refinement pass on the connected-apps dashboard introduced for #254.

Card:
- Restructure into a header row (app icon, name, host, chevron) with the
  device/category summary as a full-width footer, so the icon aligns with
  the name and the metrics sit on a consistent baseline
- Always show the host alongside the app name, except when it is identical
- Render the icon fallback as a neutral rounded tile instead of a faint
  watermark, and stop distorting real icons (object-contain)
- Summarise the union of the app's scopes, with monitor/folder icons

Details modal:
- Replace the duplicated blue buttons with a secondary Close and a primary
  Launch app, split 50:50 on desktop like the consent dialog
- Tighten the header, section spacing and device rows; hide a redundant host
- Add exact date/time tooltips to the connected and expiry labels
- Align the scope-list folder icons with their labels

Misc:
- Use fewer near-identical grays across the cards and modal
- Drop the redundant top margin on the app grid

Part of #254
2026-11-03 14:36:39 +04:00
raucao 30e45d3d34 Address second-round review nits in the storage dashboard
CI / Test (pull_request) Successful in 43s
Show the union of the app's authorizations' scopes in the modal's Access
section too, so it matches the card's summary, and cover the union
summary with a feature spec.

Part of #254
2026-11-03 12:01:56 +04:00
raucao 27168dde06 Address review nits in the storage dashboard
- Move the app display name and a root-access helper onto
  RemoteStorageAuthorization, used by both components and the controller,
  removing the duplicated fallback chain and the unused OauthHelper includes
- Summarise the card's categories from the union of the app's authorizations'
  scopes, so an older token with broader access is not understated

Part of #254
2026-11-03 12:00:40 +04:00
raucao f2248ea744 Rework storage dashboard and fix authorization revocation
- One card per app (grouped by client_id) showing distinct device and
  category counts; the whole card opens the details modal (with hover and
  focus highlight) instead of a separate Details button
- Details modal: access list styled like the OAuth consent dialog via a
  shared RsScopeListComponent, plus per-token device, connected date and
  expiry, each with its own Revoke
- "folder" -> "category" in the card, modal, consent dialog and the
  auth-created notification
- Make revoke a real DELETE: it was a GET link that Turbo 8's
  prefetch-on-hover fired on its own, silently deleting authorizations.
  Missing or foreign authorizations now return 404 instead of 500.
- Move "Launch app" to the app level
  (GET /services/storage/apps/:client_id/launch); no token id in the URL

Part of #254
2026-11-03 12:00:39 +04:00
raucao c4df1772e0 Determine device names with device_detector
Replace the hand-rolled user-agent regexes with the device_detector gem.
Its UA database recognises many more clients (and, e.g., GNOME Web when
its UA carries the Epiphany token). WebKit "Safari" user agents on Linux
(WebKitGTK, which GNOME Web sends) are labelled "WebKit on Linux", since
Safari never runs on Linux.

Part of #254
2026-11-03 12:00:39 +04:00
raucao c1548035ec Add storage dashboard card and per-app details modal
- Rework RsAuthComponent into a card that shows the app, its device, an
  access summary and the permission status
- Group connected apps by app and device, so repeated authorizations for
  the same app and device (e.g. after a browser update) are shown together,
  each with its own revoke action
- Add RsAuthDetailsModalComponent with the full scope list, device,
  connected tokens and permission expiry, plus Launch and Revoke actions
  (built on the existing ModalComponent)
- Add RemoteStorageAuthorization#device_name (parsed from the user agent)
  and #expired?

Part of #254
2026-11-03 12:00:38 +04:00
raucao 3dcd263d43 Store the remoteStorage user agent as text, not varchar(255)
CI / Test (pull_request) Failing after 45s
Real-world user agents (in-app browsers, Android WebView variants) commonly
exceed 255 characters. On PostgreSQL that would raise and 500 the consent
POST and token exchange; SQLite dev/test does not enforce the limit, so it
went unnoticed. Use text columns for user_agent on both tables.

Part of #254
2026-11-03 11:59:52 +04:00
raucao eeb399ff83 Document the storage-first launch flow
Add an explicit spec for the storage-first handoff (remoteStorage spec
section 11): launching an app passes only `#remotestorage=<address>`,
never an access token. The app then runs the application-first
(now PKCE-capable) flow itself.

Part of #254
2026-11-03 11:59:51 +04:00
raucao 7c4bad860a Store per-device user agent for remoteStorage authorizations
- Add user_agent to remote_storage_authorizations and rs_authorization_codes
- Capture the browser's user agent at consent, carry it on the authorization
  code, and store it on the authorization when the code is exchanged
- Store it directly for the implicit flow too

With the reuse shortcut gone, each authorization is already a distinct
per-device token; this adds the metadata needed to label and display them.

Part of #254
2026-11-03 11:59:51 +04:00
27 changed files with 569 additions and 85 deletions

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ gem 'flipper-ui'
gem 'gpgme', '~> 2.0.24'
gem 'zbase32', '~> 0.1.1'
gem 'kramdown'
gem 'device_detector'
# HTTP requests
gem 'faraday'
+2
View File
@@ -136,6 +136,7 @@ GEM
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.5.1)
device_detector (1.1.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@@ -543,6 +544,7 @@ DEPENDENCIES
capybara
cssbundling-rails
database_cleaner
device_detector
devise (~> 5.0)
devise_ldap_authenticatable
discourse_api
@@ -1,5 +1,7 @@
<% if @image_url %>
<%= image_tag @image_url, class: "h-full w-full" %>
<%= image_tag @image_url, class: "h-full w-full rounded-lg object-contain" %>
<% else %>
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-full w-full p-0.5 text-gray-200" } %>
<span class="flex h-full w-full items-center justify-center rounded-lg bg-gray-100">
<%= render partial: "icons/remotestorage", locals: { custom_class: "h-1/2 w-1/2 text-gray-400" } %>
</span>
<% end %>
+37 -26
View File
@@ -1,27 +1,38 @@
<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">
<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>
</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 %>
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
<button type="button"
class="rs-auth-card group flex h-full w-full flex-col rounded-lg border border-gray-200
bg-white p-4 text-left transition
hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
data-action="click->modal#open">
<span class="flex items-center gap-3">
<span class="h-12 w-12 flex-none">
<%= render AppCatalog::WebAppIconComponent.new(web_app: @web_app) %>
</span>
<span class="min-w-0 flex-grow">
<span class="mb-1 block truncate text-lg font-bold"><%= @auth.display_name %></span>
<% if @auth.client_id != @auth.display_name %>
<span class="block truncate text-sm text-gray-500"><%= @auth.client_id %></span>
<% end %>
</span>
<span class="flex-none text-gray-300 transition group-hover:text-gray-500" aria-hidden="true">
<%= render partial: "icons/chevron-right", locals: { custom_class: "h-5 w-5" } %>
</span>
</span>
<span class="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1 pt-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/monitor", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= device_summary %>
</span>
<span class="inline-flex items-center gap-1.5">
<%= render partial: "icons/folder", locals: { custom_class: "h-4 w-4 flex-none text-gray-400" } %>
<%= category_summary %>
</span>
</span>
</button>
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
</div>
+29 -3
View File
@@ -1,8 +1,34 @@
# frozen_string_literal: true
class RsAuthComponent < ViewComponent::Base
def initialize(auth:)
@auth = auth
@web_app = auth.web_app
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# The card summarises the app, so reflect the union of its authorizations'
# scopes (an older token may have broader access than the newest one).
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def category_summary
return "All categories and files" if root_access?
count = permissions.size
"#{count} #{'category'.pluralize(count)}"
end
def device_count
@authorizations.map { |auth| auth.device_name.presence || "Unknown device" }.uniq.size
end
def device_summary
"#{device_count} #{'device'.pluralize(device_count)}"
end
end
@@ -0,0 +1,53 @@
<%= render ModalComponent.new(show_close_button: false) 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-xl font-bold truncate"><%= @auth.display_name %></h4>
<% if @auth.client_id != @auth.display_name %>
<p class="text-sm text-gray-500 truncate"><%= @auth.client_id %></p>
<% end %>
</div>
</div>
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Access
</h5>
<%= render RsScopeListComponent.new(scopes: permissions, root: root_access?,
icon_class: "h-5 w-5") %>
<h5 class="mt-8 mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
Connected devices
</h5>
<ul class="divide-y divide-gray-100 rounded-lg border border-gray-200" data-turbo-prefetch="false">
<% @authorizations.each do |auth| %>
<li class="flex items-center justify-between gap-4 px-4 py-3">
<div class="min-w-0">
<p class="mb-1 truncate text-base font-medium"><%= device_name(auth) %></p>
<p class="truncate text-xs text-gray-500">
<%= tag.span "Connected #{auth.created_at.strftime('%-d %b %Y')}",
title: connected_at_title(auth) %>
<span class="mx-1">&middot;</span>
<%= tag.span status_label(auth),
title: (expires_at_title(auth) if auth.expire_at.present?),
class: ("text-red-700" if auth.expired?) %>
</p>
</div>
<%= 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" %>
</li>
<% end %>
</ul>
<div class="mt-8 flex flex-col sm:flex-row gap-3 sm:gap-2">
<%= link_to "Launch app", launch_app_services_storage_path(@auth.client_id),
class: "btn-md btn-blue w-full sm:grow sm:order-last text-center",
target: "_blank", rel: "noopener", data: { turbo: false } %>
<button type="button" class="btn-md btn-gray w-full sm:grow text-center"
data-action="click->modal#close:prevent">
Close
</button>
</div>
<% end %>
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class RsAuthDetailsModalComponent < ViewComponent::Base
def initialize(authorizations:)
@authorizations = authorizations
@auth = authorizations.first
@web_app = @auth.web_app
end
# Reflect the union of the app's scopes, matching the card's summary.
def permissions
@authorizations.flat_map(&:permissions).uniq
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def device_name(auth)
auth.device_name.presence || "Unknown device"
end
def status_label(auth)
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
def connected_at_title(auth)
auth.created_at.strftime("%-d %b %Y, %H:%M")
end
def expires_at_title(auth)
auth.expire_at.strftime("%-d %b %Y, %H:%M")
end
end
@@ -0,0 +1,22 @@
<div class="space-y-2">
<% if @root %>
<p class="scope mb-0 flex items-center gap-2 text-lg">
<%= render partial: "icons/alert-triangle",
locals: { custom_class: "#{icon_classes} text-red-700" } %>
<span class="text-red-700">All categories and files</span>
<% if read_only? %>
<span class="text-sm text-gray-500">(read only)</span>
<% end %>
</p>
<% else %>
<% @scopes.each do |scope| %>
<p class="scope mb-0 flex items-center gap-2 text-gray-600">
<%= render partial: "icons/folder", locals: { custom_class: icon_classes } %>
<span class="text-lg"><%= scope_name(scope) %></span>
<% if scope_permissions(scope) == "r" %>
<span>(read only)</span>
<% end %>
</p>
<% end %>
<% end %>
</div>
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class RsScopeListComponent < ViewComponent::Base
include OauthHelper
def initialize(scopes:, root: false, icon_class: nil)
@scopes = scopes
@root = root
@icon_class = icon_class
end
def read_only?
@scopes.include?(":r")
end
def icon_classes
["flex-none", @icon_class].compact.join(" ")
end
end
+3 -1
View File
@@ -112,6 +112,7 @@ class Rs::OauthController < ApplicationController
redirect_uri: redirect_uri,
permissions: permissions,
expire_at: expire_at,
user_agent: request.user_agent,
code_challenge: challenge,
code_challenge_method: method
)
@@ -124,7 +125,8 @@ class Rs::OauthController < ApplicationController
client_id: client_id,
redirect_uri: redirect_uri,
app_name: client_id,
expire_at: expire_at
expire_at: expire_at,
user_agent: request.user_agent
)
redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state),
@@ -2,11 +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_auths = current_user.remote_storage_authorizations
# TODO sort by app name?
@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
@@ -22,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}"
@@ -29,13 +39,19 @@ class Services::RsAuthsController < Services::BaseController
current_user.address
end
launch_url = "#{@auth.launch_url}#remotestorage=#{user_address}"
# 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}"
redirect_to launch_url, allow_other_host: true
end
private
def app_name_for(auth)
auth.display_name
end
def require_service_enabled
unless current_user.service_enabled?(:remotestorage)
http_status :forbidden
@@ -47,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
+1 -1
View File
@@ -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"
@@ -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?
client = DeviceDetector.new(user_agent)
name = client.name.presence
os = client.os_name.presence
os = "Linux" if os == "GNU/Linux"
os = "macOS" if os == "Mac"
# Safari never runs on Linux. A WebKit "Safari" user agent on Linux is a
# WebKitGTK browser (e.g. GNOME Web), which sends a Safari-compatible UA.
name = "WebKit" if name == "Safari" && os == "Linux"
[name, os].compact.join(" on ").presence
end
def display_name
web_app&.name.presence || app_name.presence || client_id
end
def root_access?
(permissions & [":r", ":rw"]).any?
end
def delete_token_from_redis
key = "authorizations:#{user.cn}:#{token}"
redis.srem? key, redis.smembers(key)
@@ -36,7 +36,8 @@ module RsOauthManager
client_id: auth_code.client_id,
redirect_uri: auth_code.redirect_uri,
app_name: auth_code.client_id,
expire_at: auth_code.permission_expire_at
expire_at: auth_code.permission_expire_at,
user_agent: auth_code.user_agent
)
end
+3 -1
View File
@@ -2,7 +2,7 @@ module RsOauthManager
class IssueCode < RsOauthManagerService
def initialize(user:, client_id:, redirect_uri:, permissions:,
code_challenge:, code_challenge_method: "S256",
expire_at: nil)
expire_at: nil, user_agent: nil)
@user = user
@client_id = client_id
@redirect_uri = redirect_uri
@@ -10,6 +10,7 @@ module RsOauthManager
@code_challenge = code_challenge
@code_challenge_method = code_challenge_method
@expire_at = expire_at
@user_agent = user_agent
end
def call
@@ -19,6 +20,7 @@ module RsOauthManager
redirect_uri: @redirect_uri,
permissions: @permissions,
permission_expire_at: @expire_at,
user_agent: @user_agent,
code_challenge: @code_challenge,
code_challenge_method: @code_challenge_method
)
+2 -26
View File
@@ -5,34 +5,10 @@
<p class="mb-8">
The app on
<%= link_to @client_id, "https://#{@client_id}" %>
is asking for access to these folders:
is asking for access to these categories:
</p>
<% if @root_access_requested %>
<p class="scope text-lg">
<span class="text-red-700">
<%= render partial: "icons/alert-triangle",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
All files and directories
</span>
<% if (@scopes & [":r"]).any? %>
<span class="text-sm text-gray-500">(read only)</span>
<% end %>
</p>
<% else %>
<% @scopes.each do |scope| %>
<p class="scope text-gray-600">
<span class="text-lg">
<%= render partial: "icons/folder",
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
<%= scope_name(scope) %>
</span>
<% if scope_permissions(scope) == "r" %>
<span>(read only)</span>
<% end %>
</p>
<% 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 %>
+4 -4
View File
@@ -10,10 +10,10 @@
<%= render partial: "shared/tabnav_remotestorage" %>
<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">
<% @rs_auths.each do |auth| %>
<%= render RsAuthComponent.new(auth: auth) %>
<% if @rs_apps.any? %>
<div class="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<% @rs_apps.each do |authorizations| %>
<%= render RsAuthComponent.new(authorizations: authorizations) %>
<% end %>
</div>
<% else %>
+3 -6
View File
@@ -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
@@ -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
+3 -1
View File
@@ -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"
@@ -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
@@ -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,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
+1 -1
View File
@@ -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
+150
View File
@@ -0,0 +1,150 @@
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
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 "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
@@ -303,4 +303,73 @@ 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
end
@@ -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
@@ -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