@@ -54,3 +54,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar__form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@
|
||||
--input-border-size: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:is(.avatar) {
|
||||
img,
|
||||
input[type="file"] {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input--select {
|
||||
|
||||
@@ -251,6 +251,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* QR Codes */
|
||||
.qr-code {
|
||||
aspect-ratio: 1;
|
||||
block-size: 50dvh;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.for-screen-reader {
|
||||
block-size: 1px;
|
||||
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
include Avatar, Transferable
|
||||
|
||||
belongs_to :account
|
||||
|
||||
has_many :sessions, dependent: :destroy
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -9,7 +9,7 @@
|
||||
</label>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
<div data-controller="dialog" class="flex-inline">
|
||||
<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 join link QR code</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%= avatar_tag user, loading: :lazy, class: "flex-item-no-shrink" %>
|
||||
|
||||
<strong class="overflow-ellipsis">
|
||||
<%= user.name %>
|
||||
<%= link_to user.name, user_path(user), class: "txt-ink btn btn--plain" %>
|
||||
</strong>
|
||||
|
||||
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-subtle-dark); --border-style: dashed" aria-hidden="true">
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
<%= image_tag "bolt.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Workflows</span>
|
||||
<% 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>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= turbo_frame_tag dom_id(@comment) do %>
|
||||
<div class="comment__content flex-inline flex-column full-width border border-radius">
|
||||
<div class="comment__author flex align-center gap-half">
|
||||
<strong><%= @comment.creator.name %></strong>
|
||||
<strong><%= link_to @comment.creator.name, user_path(@comment.creator), class: "txt-ink btn btn--plain", data: { turbo_frame: "_top" } %></strong>
|
||||
<%= 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") %>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<% @page_title = "Edit your profile" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
<%= link_to user_path(@user), class: "btn flex-item-justify-start" do %>
|
||||
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Back</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center">
|
||||
<div class="flex flex-column gap txt-medium">
|
||||
<%= form_with model: @user, method: :patch, class: "flex flex-column gap", data: { controller: "form upload-preview" } do |form| %>
|
||||
<div class="align-center center avatar__form gap">
|
||||
<span class="btn btn--placeholder txt-small"></span>
|
||||
|
||||
<label class="btn avatar input--file txt-xx-large center fill-white">
|
||||
<%= image_tag user_avatar_path(@user), aria: { hidden: "true" }, size: 128, data: { upload_preview_target: "image" } %>
|
||||
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/*",
|
||||
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
|
||||
<span class="for-screen-reader">Profile avatar for <%= @user.name %></span>
|
||||
</label>
|
||||
|
||||
<% 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 %>
|
||||
<span class="for-screen-reader">Delete avatar</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
<%= translation_button :user_name %>
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.text_field :name, class: "input full-width", autocomplete: "name", placeholder: "Name", autofocus: true, required: true, data: { "1p-ignore": true } %>
|
||||
<%= image_tag "person.svg", aria: { hidden: "true" }, size: 30, class: "colorize--black" %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex align-center gap">
|
||||
<%= translation_button :email_address %>
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true %>
|
||||
<%= image_tag "email.svg", aria: { hidden: "true" }, size: 30, class: "colorize--black" %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex align-center gap">
|
||||
<%= translation_button :password %>
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: false, maxlength: 72 %>
|
||||
<%= image_tag "password.svg", aria: { hidden: "true" }, size: 30, class: "colorize--black" %>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" id="log_in" class="btn btn--reversed center">
|
||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Sign in</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: user_avatar_url(@user), method: :delete, id: "avatar-delete-form" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<% @page_title = @user.name %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
<%= link_to account_users_path, class: "btn flex-item-justify-start" do %>
|
||||
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Back</span>
|
||||
<% end %>
|
||||
|
||||
<% if Current.user == @user %>
|
||||
<%= link_to edit_user_path(@user), class: "btn flex-item-justify-end" do %>
|
||||
<%= image_tag "pencil.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Edit</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center txt-align-center">
|
||||
<div class="flex flex-column gap">
|
||||
<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 %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-half margin-block-end">
|
||||
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
|
||||
<div class="txt-medium">
|
||||
<% if @user.active? %>
|
||||
<%= mail_to @user.email_address %>
|
||||
<% else %>
|
||||
<%= @user.name %> is no longer on this account
|
||||
<% end %>
|
||||
</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
|
||||
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
|
||||
|
||||
@@ -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