Merge pull request #167 from basecamp/users

Account users
This commit is contained in:
Jason Zimdars
2024-12-19 11:11:41 -06:00
committed by GitHub
24 changed files with 330 additions and 12 deletions
+5
View File
@@ -54,3 +54,8 @@
}
}
}
.avatar__form {
display: grid;
grid-template-columns: 1fr auto 1fr;
}
+7
View File
@@ -69,6 +69,13 @@
--input-border-size: 0.15rem;
}
}
&:is(.avatar) {
img,
input[type="file"] {
border-radius: 50%;
}
}
}
.input--select {
+6
View File
@@ -251,6 +251,12 @@
}
}
/* QR Codes */
.qr-code {
aspect-ratio: 1;
block-size: 50dvh;
}
/* Accessibility */
.for-screen-reader {
block-size: 1px;
+11
View File
@@ -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])
+19 -3
View File
@@ -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
+8
View File
@@ -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
+6
View File
@@ -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
+26
View File
@@ -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
+2
View File
@@ -1,4 +1,6 @@
class User < ApplicationRecord
include Avatar, Transferable
belongs_to :account
has_many :sessions, dependent: :destroy
+17
View File
@@ -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
+15
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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">
-5
View File
@@ -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 -1
View File
@@ -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 %>
+37
View File
@@ -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>
+62
View File
@@ -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>
+49
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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