Add session transfer feature
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
class QrCodesController < ApplicationController
|
||||||
|
allow_unauthenticated_access
|
||||||
|
|
||||||
|
def show
|
||||||
|
qr_code_link = QrCodeLink.from_signed(params[:id])
|
||||||
|
svg = RQRCode::QRCode.new(qr_code_link.url).as_svg(viewbox: true, fill: :white, color: :black)
|
||||||
|
|
||||||
|
expires_in 1.year, public: true
|
||||||
|
render plain: svg, content_type: "image/svg+xml"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class Sessions::TransfersController < ApplicationController
|
||||||
|
require_unauthenticated_access
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if user = User.active.find_by_transfer_id(params[:id])
|
||||||
|
start_new_session_for user
|
||||||
|
redirect_to root_path
|
||||||
|
else
|
||||||
|
head :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module FormsHelper
|
||||||
|
def auto_submit_form_with(**attributes, &)
|
||||||
|
data = attributes.delete(:data) || {}
|
||||||
|
data[:controller] = "auto-submit #{data[:controller]}".strip
|
||||||
|
|
||||||
|
form_with **attributes, data: data, &
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module QrCodesHelper
|
||||||
|
def qr_code_image(url)
|
||||||
|
qr_code_link = QrCodeLink.new(url)
|
||||||
|
image_tag qr_code_path(qr_code_link.signed), class: "qr-code center", alt: "QR Code"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class QrCodeLink
|
||||||
|
attr_reader :url
|
||||||
|
|
||||||
|
def initialize(url)
|
||||||
|
@url = url
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed
|
||||||
|
self.class.verifier.generate(@url, purpose: :qr_code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_signed(signed)
|
||||||
|
new verifier.verify(signed, purpose: :qr_code)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
class << self
|
||||||
|
def verifier
|
||||||
|
ActiveSupport::MessageVerifier.new(secret, url_safe: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def secret
|
||||||
|
Rails.application.key_generator.generate_key("qr_codes")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
include Transferable
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module User::Transferable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def find_by_transfer_id(id)
|
||||||
|
find_signed(id, purpose: :transfer)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transfer_id
|
||||||
|
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,11 +13,6 @@
|
|||||||
<%= image_tag "bolt.svg", aria: { hidden: true }, size: 24 %>
|
<%= image_tag "bolt.svg", aria: { hidden: true }, size: 24 %>
|
||||||
<span class="for-screen-reader">Workflows</span>
|
<span class="for-screen-reader">Workflows</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= button_to session_path, method: :delete, class: "btn" do %>
|
|
||||||
<%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %>
|
|
||||||
<span class="for-screen-reader">Sign out</span>
|
|
||||||
<% end %>
|
|
||||||
</nav>
|
</nav>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= auto_submit_form_with method: :put %>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
|
||||||
|
<% url = session_transfer_url(user.transfer_id) %>
|
||||||
|
|
||||||
|
<label class="flex flex-column gap full-width">
|
||||||
|
<div class="flex align-center gap justify-center">
|
||||||
|
<strong id="session_transfer_label" class="txt-medium">Link to automatically log in on another device</strong>
|
||||||
|
</div>
|
||||||
|
<span class="flex align-center gap margin-inline">
|
||||||
|
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex align-center gap">
|
||||||
|
<div data-controller="dialog" data-dialog-modal-value="true" class="flex-inline">
|
||||||
|
<%= tag.button class: "btn", data: { action: "dialog#open" } do %>
|
||||||
|
<%= image_tag "qr-code.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
|
||||||
|
<span class="for-screen-reader">Show auto-login QR code</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<dialog class="dialog panel shadow" data-dialog-target="dialog">
|
||||||
|
<%= qr_code_image(url) %>
|
||||||
|
|
||||||
|
<form method="dialog" class="margin-block-start flex justify-center">
|
||||||
|
<button class="btn">
|
||||||
|
<%= image_tag "remove.svg", aria: { hidden: true }, size: 24 %>
|
||||||
|
<span class="for-screen-reader">Close</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= button_to_copy_to_clipboard(url) do %>
|
||||||
|
<%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %>
|
||||||
|
<span class="for-screen-reader">Copy auto-login link</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -14,22 +14,29 @@
|
|||||||
<div class="avatar txt-xx-large center fill-white">
|
<div class="avatar txt-xx-large center fill-white">
|
||||||
<%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}", class: "avatar", size: 128 %>
|
<%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}", class: "avatar", size: 128 %>
|
||||||
</div>
|
</div>
|
||||||
<% if @user.active? %>
|
|
||||||
<div class="flex flex-column gap-half">
|
|
||||||
<h1 class="txt-x-large txt-tight-lines margin-none"><%= @user.name %></h1>
|
|
||||||
<div class="txt-medium"><%= mail_to @user.email_address %></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if Current.user == @user %>
|
<div class="flex flex-column gap-half margin-block-end">
|
||||||
<hr class="margin-block-start borderless">
|
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
|
||||||
|
<div class="txt-medium">
|
||||||
<%#= render "users/profiles/transfer", user: @user %>
|
<% if @user.active? %>
|
||||||
<% end %>
|
<%= mail_to @user.email_address %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="flex flex-column gap-half margin-block-end">
|
<%= @user.name %> is no longer on this account
|
||||||
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
|
<% end %>
|
||||||
<div class="txt-medium"><%= @user.name %> is no longer on this account</div>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if Current.user == @user %>
|
||||||
|
<div class="panel shadow center margin-block-double">
|
||||||
|
<%= render "users/transfer", user: @user %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel shadow center margin-block-double">
|
||||||
|
<%= button_to session_path, method: :delete, class: "btn center txt-medium" do %>
|
||||||
|
<%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %>
|
||||||
|
<span class="for-screen-reader">Sign out</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|||||||
+7
-1
@@ -38,7 +38,13 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :filters
|
resources :filters
|
||||||
resource :first_run
|
resource :first_run
|
||||||
resource :session
|
resources :qr_codes
|
||||||
|
|
||||||
|
resource :session do
|
||||||
|
scope module: "sessions" do
|
||||||
|
resources :transfers, only: %i[ show update ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :users do
|
resources :users do
|
||||||
scope module: :users do
|
scope module: :users do
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "show renders when not signed in" do
|
||||||
|
get session_transfer_url("some-token")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update establishes a session when the code is valid" do
|
||||||
|
user = users(:david)
|
||||||
|
|
||||||
|
put session_transfer_url(user.transfer_id)
|
||||||
|
|
||||||
|
assert_redirected_to root_url
|
||||||
|
assert parsed_cookies.signed[:session_token]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class QrCodeLinkTest < ActiveSupport::TestCase
|
||||||
|
test "links can be signed and verified" do
|
||||||
|
link = QrCodeLink.new "https://example.com"
|
||||||
|
signed_link = link.signed
|
||||||
|
|
||||||
|
verified = QrCodeLink.from_signed(signed_link)
|
||||||
|
assert_equal link.url, verified.url
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user