Merge pull request 'Storage dashboard: connected-app card and details modal' (#258) from feature/rs_storage_dashboard into feature/rs_device_tokens
Reviewed-on: #258
This commit was merged in pull request #258.
This commit is contained in:
30 files changed
+860
-86
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,3 +14,27 @@
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
@keyframes removeRow {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-remove {
|
||||
animation: removeRow 0.2s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Keep the animation, so the deferred removal still completes, but make it
|
||||
effectively instant. */
|
||||
.animate-remove {
|
||||
animation-duration: 0.01s;
|
||||
}
|
||||
}
|
||||
@@ -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 %>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div id="<%= access_id %>">
|
||||
<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") %>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsAuthAccessComponent < ViewComponent::Base
|
||||
include RsAuthSummary
|
||||
include RsAuthsHelper
|
||||
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
@auth = authorizations.first
|
||||
end
|
||||
|
||||
def access_id
|
||||
rs_app_dom_id(@auth.client_id, :access)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
<button type="button"
|
||||
id="<%= card_id %>"
|
||||
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>
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsAuthCardComponent < ViewComponent::Base
|
||||
include RsAuthSummary
|
||||
include RsAuthsHelper
|
||||
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
@auth = authorizations.first
|
||||
@web_app = @auth.web_app
|
||||
end
|
||||
|
||||
def card_id
|
||||
rs_app_dom_id(@auth.client_id, :card)
|
||||
end
|
||||
end
|
||||
@@ -1,27 +1,4 @@
|
||||
<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>
|
||||
<div class="h-full" data-controller="modal" data-action="keydown.esc->modal#close">
|
||||
<%= render RsAuthCardComponent.new(authorizations: @authorizations) %>
|
||||
<%= render RsAuthDetailsModalComponent.new(authorizations: @authorizations) %>
|
||||
</div>
|
||||
@@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Wrapper around one app's card and its details modal. The modal's open state
|
||||
# lives on this element (via the `modal` controller), so the card and the modal
|
||||
# can be re-rendered independently without closing it.
|
||||
class RsAuthComponent < ViewComponent::Base
|
||||
def initialize(auth:)
|
||||
@auth = auth
|
||||
@web_app = auth.web_app
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
<%= 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>
|
||||
|
||||
<%= render RsAuthAccessComponent.new(authorizations: @authorizations) %>
|
||||
|
||||
<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 id="<%= rs_device_dom_id(auth) %>" class="flex items-center justify-between gap-6 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 truncate text-base font-medium"><%= auth.device_label %></p>
|
||||
<p class="truncate text-xs text-gray-500">
|
||||
Connected <%= timestamp_tag(auth.created_at, title: connected_at_title(auth)) %>
|
||||
<span class="mx-1">·</span>
|
||||
<%= tag.span class: ("text-red-700" if auth.expired?) do %>
|
||||
<% if auth.expire_at.present? %>
|
||||
<%= expiry_prefix(auth) %> <%= timestamp_tag(auth.expire_at, title: expires_at_title(auth)) %>
|
||||
<% else %>
|
||||
Never expires
|
||||
<% end %>
|
||||
<% end %>
|
||||
</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,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RsAuthDetailsModalComponent < ViewComponent::Base
|
||||
include RsAuthsHelper
|
||||
|
||||
def initialize(authorizations:)
|
||||
@authorizations = authorizations
|
||||
@auth = authorizations.first
|
||||
@web_app = @auth.web_app
|
||||
end
|
||||
|
||||
# "Expires" / "Expired on", or nothing when there is no expiry to describe.
|
||||
def expiry_prefix(auth)
|
||||
return nil if auth.expire_at.blank?
|
||||
|
||||
auth.expired? ? "Expired on" : "Expires"
|
||||
end
|
||||
|
||||
# Fallbacks for the hover titles, used when JavaScript is unavailable.
|
||||
# app/javascript/local_time.js replaces them with the reader's own zone.
|
||||
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
|
||||
|
||||
# A <time> element carrying the absolute (UTC) timestamp, so the reader's own
|
||||
# local time can be shown alongside the date.
|
||||
def timestamp_tag(time, title:)
|
||||
tag.time time.strftime("%-d %b %Y"), datetime: time.utc.iso8601, title: title
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Summaries of a group of authorizations for the same app, shared by the card,
|
||||
# the details modal and the Turbo Stream responses that refresh them.
|
||||
module RsAuthSummary
|
||||
# The union of the group's scopes: an older token may grant more 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 known_device_count
|
||||
@authorizations.map(&:device_name).compact.uniq.size
|
||||
end
|
||||
|
||||
def unrecorded_device?
|
||||
@authorizations.any? { |auth| auth.device_name.blank? }
|
||||
end
|
||||
|
||||
def device_summary
|
||||
count = known_device_count
|
||||
return "#{count} #{'device'.pluralize(count)}" unless unrecorded_device?
|
||||
|
||||
# An authorization without a recorded user agent may cover several devices,
|
||||
# so only a lower bound can be given.
|
||||
lower_bound = count + 1
|
||||
"#{lower_bound}+ #{'device'.pluralize(lower_bound)}"
|
||||
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>
|
||||
@@ -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
|
||||
@@ -2,26 +2,45 @@ 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
|
||||
client_id = @auth.client_id
|
||||
@auth.destroy!
|
||||
|
||||
remaining = current_user.remote_storage_authorizations
|
||||
.where(client_id: client_id)
|
||||
.order(created_at: :desc)
|
||||
.includes(:web_app)
|
||||
.to_a
|
||||
|
||||
respond_to do |format|
|
||||
format.html do redirect_to apps_services_storage_url, flash: {
|
||||
success: 'App authorization revoked'
|
||||
}
|
||||
end
|
||||
format.json { head :no_content }
|
||||
format.html do
|
||||
redirect_to apps_services_storage_url, flash: {
|
||||
success: 'App authorization revoked'
|
||||
}
|
||||
end
|
||||
format.turbo_stream { revoke_stream(client_id, remaining) }
|
||||
end
|
||||
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}"
|
||||
@@ -31,13 +50,37 @@ 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
|
||||
|
||||
private
|
||||
|
||||
# Revoking one of several devices updates the open details modal in place.
|
||||
# Revoking the app's last device leaves nothing to show, so the dashboard
|
||||
# re-renders itself instead: that keeps grouping, sorting and the empty
|
||||
# state in one place.
|
||||
def revoke_stream(client_id, remaining)
|
||||
if remaining.empty?
|
||||
redirect_to apps_services_storage_url, flash: {
|
||||
success: 'App authorization revoked'
|
||||
}
|
||||
else
|
||||
render turbo_stream: [
|
||||
turbo_stream.remove(helpers.rs_device_dom_id(@auth)),
|
||||
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :card),
|
||||
RsAuthCardComponent.new(authorizations: remaining)),
|
||||
turbo_stream.replace(helpers.rs_app_dom_id(client_id, :access),
|
||||
RsAuthAccessComponent.new(authorizations: remaining))
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def app_name_for(auth)
|
||||
auth.display_name
|
||||
end
|
||||
|
||||
def require_service_enabled
|
||||
unless current_user.service_enabled?(:remotestorage)
|
||||
http_status :forbidden
|
||||
@@ -49,7 +92,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
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module RsAuthsHelper
|
||||
# An app is summarised by the authorizations grouped under its client_id, so
|
||||
# its DOM ids derive from the client_id: revoking any single authorization must
|
||||
# not invalidate them.
|
||||
def rs_app_dom_id(client_id, part)
|
||||
"rs_app_#{part}_#{Digest::SHA256.hexdigest(client_id)[0, 12]}"
|
||||
end
|
||||
|
||||
def rs_device_dom_id(auth)
|
||||
ActionView::RecordIdentifier.dom_id(auth, :device)
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "controllers"
|
||||
import "turbo_stream_animations"
|
||||
import "local_time"
|
||||
@@ -0,0 +1,32 @@
|
||||
// Timestamps are rendered by the server in UTC, next to a <time> element's
|
||||
// machine-readable value. Rewrite those elements' hover titles so they show the
|
||||
// reader's own local time instead.
|
||||
|
||||
const LOCAL_TIME_FORMAT = new Intl.DateTimeFormat("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
|
||||
// Only absolute timestamps carry a zone to convert from: a date-only value would
|
||||
// be read as UTC midnight and shift to the previous day for most readers.
|
||||
const ABSOLUTE_TIMESTAMP = /T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
|
||||
|
||||
const localizeTimestamps = () => {
|
||||
document.querySelectorAll("time[datetime]").forEach((element) => {
|
||||
const value = element.getAttribute("datetime")
|
||||
|
||||
if (!ABSOLUTE_TIMESTAMP.test(value)) return
|
||||
|
||||
const timestamp = new Date(value)
|
||||
|
||||
if (!Number.isNaN(timestamp.getTime())) {
|
||||
element.title = LOCAL_TIME_FORMAT.format(timestamp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener("turbo:load", localizeTimestamps)
|
||||
@@ -0,0 +1,30 @@
|
||||
// A Turbo Stream `remove` action deletes its target right away. Pause that until
|
||||
// the element's CSS animation has finished, so a revoked device row can fade out
|
||||
// instead of vanishing.
|
||||
|
||||
const REMOVE_ANIMATION_CLASS = "animate-remove"
|
||||
const REMOVE_ANIMATION_TIMEOUT = 1000
|
||||
|
||||
document.addEventListener("turbo:before-stream-render", (event) => {
|
||||
const renderDefault = event.detail.render
|
||||
|
||||
event.detail.render = (streamElement) => {
|
||||
const target =
|
||||
streamElement.action === "remove" ? document.getElementById(streamElement.target) : null
|
||||
|
||||
if (!target) return renderDefault(streamElement)
|
||||
|
||||
let rendered = false
|
||||
const render = () => {
|
||||
if (rendered) return
|
||||
rendered = true
|
||||
renderDefault(streamElement)
|
||||
}
|
||||
|
||||
target.addEventListener("animationend", render, { once: true })
|
||||
target.classList.add(REMOVE_ANIMATION_CLASS)
|
||||
|
||||
// Don't leave the element behind if the animation cannot run at all.
|
||||
setTimeout(render, REMOVE_ANIMATION_TIMEOUT)
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -2,6 +2,10 @@ class RemoteStorageAuthorization < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
|
||||
|
||||
# Tokens issued before user agents were recorded may have been used from any
|
||||
# number of devices, so they cannot be attributed to a single one.
|
||||
UNKNOWN_DEVICE = "Unknown device(s)"
|
||||
|
||||
serialize :permissions, coder: YAML unless Rails.env.production?
|
||||
|
||||
validates_presence_of :permissions
|
||||
@@ -40,6 +44,38 @@ 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 device_label
|
||||
device_name || UNKNOWN_DEVICE
|
||||
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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
+3
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user