From 592c88e63916f281f7b7f684990a415066671a93 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 18 Dec 2024 12:38:53 -0600 Subject: [PATCH] Add session transfer feature --- app/controllers/qr_codes_controller.rb | 11 ++++++ .../sessions/transfers_controller.rb | 15 ++++++++ app/helpers/forms_helper.rb | 8 ++++ app/helpers/qr_codes_helper.rb | 6 +++ app/models/qr_code_link.rb | 26 +++++++++++++ app/models/user.rb | 2 + app/models/user/transferable.rb | 15 ++++++++ app/views/accounts/users/index.html.erb | 5 --- app/views/sessions/transfers/show.html.erb | 1 + app/views/users/_transfer.html.erb | 37 +++++++++++++++++++ app/views/users/show.html.erb | 37 +++++++++++-------- config/routes.rb | 8 +++- .../sessions/transfers_controller_test.rb | 18 +++++++++ test/models/qr_code_link_test.rb | 11 ++++++ 14 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 app/controllers/qr_codes_controller.rb create mode 100644 app/controllers/sessions/transfers_controller.rb create mode 100644 app/helpers/forms_helper.rb create mode 100644 app/helpers/qr_codes_helper.rb create mode 100644 app/models/qr_code_link.rb create mode 100644 app/models/user/transferable.rb create mode 100644 app/views/sessions/transfers/show.html.erb create mode 100644 app/views/users/_transfer.html.erb create mode 100644 test/controllers/sessions/transfers_controller_test.rb create mode 100644 test/models/qr_code_link_test.rb diff --git a/app/controllers/qr_codes_controller.rb b/app/controllers/qr_codes_controller.rb new file mode 100644 index 000000000..707166930 --- /dev/null +++ b/app/controllers/qr_codes_controller.rb @@ -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 diff --git a/app/controllers/sessions/transfers_controller.rb b/app/controllers/sessions/transfers_controller.rb new file mode 100644 index 000000000..d30c263b6 --- /dev/null +++ b/app/controllers/sessions/transfers_controller.rb @@ -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 diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb new file mode 100644 index 000000000..3220d2d78 --- /dev/null +++ b/app/helpers/forms_helper.rb @@ -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 diff --git a/app/helpers/qr_codes_helper.rb b/app/helpers/qr_codes_helper.rb new file mode 100644 index 000000000..64bf243a2 --- /dev/null +++ b/app/helpers/qr_codes_helper.rb @@ -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 diff --git a/app/models/qr_code_link.rb b/app/models/qr_code_link.rb new file mode 100644 index 000000000..81a65fa25 --- /dev/null +++ b/app/models/qr_code_link.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index bfa562008..02c20ef98 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + include Transferable + belongs_to :account has_many :sessions, dependent: :destroy diff --git a/app/models/user/transferable.rb b/app/models/user/transferable.rb new file mode 100644 index 000000000..096913aab --- /dev/null +++ b/app/models/user/transferable.rb @@ -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 diff --git a/app/views/accounts/users/index.html.erb b/app/views/accounts/users/index.html.erb index 5d93666d8..1980efb1f 100644 --- a/app/views/accounts/users/index.html.erb +++ b/app/views/accounts/users/index.html.erb @@ -13,11 +13,6 @@ <%= image_tag "bolt.svg", aria: { hidden: true }, size: 24 %> Workflows <% end %> - - <%= button_to session_path, method: :delete, class: "btn" do %> - <%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %> - Sign out - <% end %> <% end %> diff --git a/app/views/sessions/transfers/show.html.erb b/app/views/sessions/transfers/show.html.erb new file mode 100644 index 000000000..897e39922 --- /dev/null +++ b/app/views/sessions/transfers/show.html.erb @@ -0,0 +1 @@ +<%= auto_submit_form_with method: :put %> diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb new file mode 100644 index 000000000..314d8ff39 --- /dev/null +++ b/app/views/users/_transfer.html.erb @@ -0,0 +1,37 @@ +
+ <% url = session_transfer_url(user.transfer_id) %> + + + +
+
+ <%= tag.button class: "btn", data: { action: "dialog#open" } do %> + <%= image_tag "qr-code.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Show auto-login QR code + <% end %> + + + <%= qr_code_image(url) %> + +
+ +
+
+
+ + <%= button_to_copy_to_clipboard(url) do %> + <%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Copy auto-login link + <% end %> +
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index c36fc5a63..9fe91b249 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -14,22 +14,29 @@
<%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}", class: "avatar", size: 128 %>
- <% if @user.active? %> -
-

<%= @user.name %>

-
<%= mail_to @user.email_address %>
-
- <% if Current.user == @user %> -
- - <%#= render "users/profiles/transfer", user: @user %> - <% end %> - <% else %> -
-

<%= @user.name %>

-
<%= @user.name %> is no longer on this account
+
+

<%= @user.name %>

+
+ <% if @user.active? %> + <%= mail_to @user.email_address %> + <% else %> + <%= @user.name %> is no longer on this account + <% end %>
- <% end %> +
+ +<% if Current.user == @user %> +
+ <%= render "users/transfer", user: @user %> +
+ +
+ <%= button_to session_path, method: :delete, class: "btn center txt-medium" do %> + <%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %> + Sign out + <% end %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index c11aff6ee..705b43dd6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,7 +38,13 @@ Rails.application.routes.draw do resources :filters 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 scope module: :users do diff --git a/test/controllers/sessions/transfers_controller_test.rb b/test/controllers/sessions/transfers_controller_test.rb new file mode 100644 index 000000000..d8cbe9842 --- /dev/null +++ b/test/controllers/sessions/transfers_controller_test.rb @@ -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 diff --git a/test/models/qr_code_link_test.rb b/test/models/qr_code_link_test.rb new file mode 100644 index 000000000..47f189b41 --- /dev/null +++ b/test/models/qr_code_link_test.rb @@ -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