diff --git a/app/assets/stylesheets/avatars.css b/app/assets/stylesheets/avatars.css index e80737055..34a75c0bc 100644 --- a/app/assets/stylesheets/avatars.css +++ b/app/assets/stylesheets/avatars.css @@ -54,3 +54,8 @@ } } } + +.avatar__form { + display: grid; + grid-template-columns: 1fr auto 1fr; +} diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 602ff7976..eb4fbc821 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -69,6 +69,13 @@ --input-border-size: 0.15rem; } } + + &:is(.avatar) { + img, + input[type="file"] { + border-radius: 50%; + } + } } .input--select { diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 6cf59bfed..761a24b5d 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -251,6 +251,12 @@ } } +/* QR Codes */ +.qr-code { + aspect-ratio: 1; + block-size: 50dvh; +} + /* Accessibility */ .for-screen-reader { block-size: 1px; 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/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index aec5e1eae..29b4c5996 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -13,6 +13,11 @@ class Users::AvatarsController < ApplicationController end end + def destroy + @user.avatar.destroy + redirect_to user_path(@user) + end + private def set_user @user = Current.account.users.find(params[:user_id]) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c0c9dbc9a..32e5c340c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,8 @@ class UsersController < ApplicationController - require_unauthenticated_access + require_unauthenticated_access only: %i[ new create ] - before_action :set_account_from_join_code + before_action :set_user, only: %i[ show edit update ] + before_action :set_account_from_join_code, only: %i[ new create ] def new @user = @account.users.build @@ -13,12 +14,27 @@ class UsersController < ApplicationController redirect_to root_path end + def show + end + + def edit + end + + def update + @user.update user_params + redirect_to user_path(@user) + end + private def set_account_from_join_code @account = Account.find_by_join_code!(params[:join_code]) end + def set_user + @user = Current.account.users.active.find(params[:id]) + end + def user_params - params.expect(user: [ :name, :email_address, :password ]) + params.expect(user: [ :name, :email_address, :password, :avatar ]) 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..907881cc5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + include Avatar, Transferable + belongs_to :account has_many :sessions, dependent: :destroy diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb new file mode 100644 index 000000000..a9d2ad12e --- /dev/null +++ b/app/models/user/avatar.rb @@ -0,0 +1,17 @@ +module User::Avatar + extend ActiveSupport::Concern + + included do + has_one_attached :avatar + end + + class_methods do + def from_avatar_token(sid) + find_signed!(sid, purpose: :avatar) + end + end + + def avatar_token + signed_id(purpose: :avatar) + end +end 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/_invite.html.erb b/app/views/accounts/users/_invite.html.erb index 48d7b6ba9..f18982dd4 100644 --- a/app/views/accounts/users/_invite.html.erb +++ b/app/views/accounts/users/_invite.html.erb @@ -9,7 +9,7 @@
-
+
<%= tag.button class: "btn", data: { action: "dialog#open" } do %> <%= image_tag "qr-code.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> Show join link QR code diff --git a/app/views/accounts/users/_user.html.erb b/app/views/accounts/users/_user.html.erb index 787d2b37f..2abb9b665 100644 --- a/app/views/accounts/users/_user.html.erb +++ b/app/views/accounts/users/_user.html.erb @@ -2,7 +2,7 @@ <%= avatar_tag user, loading: :lazy, class: "flex-item-no-shrink" %> - <%= user.name %> + <%= link_to user.name, user_path(user), class: "txt-ink btn btn--plain" %> 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/comments/show.html.erb b/app/views/comments/show.html.erb index eae7ba080..56d287418 100644 --- a/app/views/comments/show.html.erb +++ b/app/views/comments/show.html.erb @@ -1,7 +1,7 @@ <%= turbo_frame_tag dom_id(@comment) do %>
- <%= @comment.creator.name %> + <%= link_to @comment.creator.name, user_path(@comment.creator), class: "txt-ink btn btn--plain", data: { turbo_frame: "_top" } %> <%= link_to bucket_bubble_path(@comment.bubble.bucket, @comment.bubble, anchor: "comment_#{@comment.id}"), class: "txt-undecorated" do %> <%= tag.time @comment.created_at, class: "comment__timestamp" do %> <%= @comment.created_at.strftime("%b %d") %> 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/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 000000000..8b4714384 --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,62 @@ +<% @page_title = "Edit your profile" %> + +<% content_for :header do %> + +<% end %> + +
+
+ <%= form_with model: @user, method: :patch, class: "flex flex-column gap", data: { controller: "form upload-preview" } do |form| %> +
+ + + + + <% if @user.avatar.attached? %> + <%= tag.button type: :submit, form: "avatar-delete-form", class: "btn btn--negative txt-small", data: { turbo_confirm: "Are you sure you want to remove your avatar? This can't be undone." } do %> + <%= image_tag "minus.svg", aria: { hidden: "true" }, size: 20 %> + Delete avatar + <% end %> + <% end %> +
+ +
+ <%= translation_button :user_name %> + +
+
+ <%= translation_button :email_address %> + +
+
+ <%= translation_button :password %> + +
+ + <% end %> + + <%= form_with url: user_avatar_url(@user), method: :delete, id: "avatar-delete-form" %> +
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 000000000..f126ae6b0 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,49 @@ +<% @page_title = @user.name %> + +<% content_for :header do %> + +<% end %> + +
+
+
+ <%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}", class: "avatar", size: 128 %> +
+ +
+

<%= @user.name %>

+
+ <% if @user.active? %> + <%= mail_to @user.email_address %> + <% else %> + <%= @user.name %> is no longer on this account + <% 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 39e1ab674..5f16ae681 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 :uploads, only: :create get "/u/*slug" => "uploads#show", as: :upload 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