diff --git a/Gemfile b/Gemfile index 5ce21fae3..55ae610e3 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,7 @@ gem "sqlite3", ">= 2.0" # Features gem "bcrypt", "~> 3.1.7" +gem "rqrcode" group :development, :test do gem "brakeman", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 92a762dbe..73081ff94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,6 +118,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chunky_png (1.4.0) concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) @@ -213,6 +214,10 @@ GEM io-console (~> 0.5) rexml (3.3.6) strscan + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rubocop (1.64.1) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -309,6 +314,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails! + rqrcode rubocop-rails-omakase selenium-webdriver sqlite3 (>= 2.0) diff --git a/app/assets/images/copy-paste.svg b/app/assets/images/copy-paste.svg new file mode 100644 index 000000000..6196f3f0a --- /dev/null +++ b/app/assets/images/copy-paste.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/qr-code.svg b/app/assets/images/qr-code.svg new file mode 100644 index 000000000..4ead6f237 --- /dev/null +++ b/app/assets/images/qr-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/refresh.svg b/app/assets/images/refresh.svg new file mode 100644 index 000000000..d641d849a --- /dev/null +++ b/app/assets/images/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/share.svg b/app/assets/images/share.svg new file mode 100644 index 000000000..43c62715c --- /dev/null +++ b/app/assets/images/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/controllers/accounts/join_codes_controller.rb b/app/controllers/accounts/join_codes_controller.rb new file mode 100644 index 000000000..8123cea05 --- /dev/null +++ b/app/controllers/accounts/join_codes_controller.rb @@ -0,0 +1,19 @@ +class Accounts::JoinCodesController < ApplicationController + def show + respond_to do |format| + format.svg { render inline: qr_code_svg } + end + end + + def update + Current.account.reset_join_code + redirect_to account_users_path + end + + private + def qr_code_svg + join_url(Current.account.join_code).then do |url| + RQRCode::QRCode.new(url).as_svg(viewbox: true, fill: :white, color: :black) + end + end +end diff --git a/app/controllers/accounts/users_controller.rb b/app/controllers/accounts/users_controller.rb new file mode 100644 index 000000000..c387c62a9 --- /dev/null +++ b/app/controllers/accounts/users_controller.rb @@ -0,0 +1,17 @@ +class Accounts::UsersController < ApplicationController + before_action :set_user, only: %i[ destroy ] + + def index + @users = Current.account.users.active + end + + def destroy + @user.deactivate + redirect_to users_url + end + + private + def set_user + @user = Current.account.users.active.find(params[:id]) + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index ed4da50e4..b097025c0 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -7,6 +7,11 @@ module Authentication end class_methods do + def require_unauthenticated_access(**options) + allow_unauthenticated_access **options + before_action :redirect_authenticated_user, **options + end + def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options before_action :resume_session, **options @@ -22,7 +27,6 @@ module Authentication resume_session || request_authentication end - def resume_session if session = find_session_by_cookie set_current_session session @@ -43,6 +47,10 @@ module Authentication session.delete(:return_to_after_authenticating) || root_url end + def redirect_authenticated_user + redirect_to root_url if authenticated? + end + def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 000000000..aa8fea8d2 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,24 @@ +class UsersController < ApplicationController + require_unauthenticated_access + + before_action :set_account_from_join_code + + def new + @user = User.new + end + + def create + @user = @account.users.create!(user_params) + start_new_session_for @user + redirect_to root_url + end + + private + def set_account_from_join_code + @account = Account.find_by_join_code!(params[:join_code]) + end + + def user_params + params.require(:user).permit(:name, :email_address, :password) + end +end diff --git a/app/helpers/clipboard_helper.rb b/app/helpers/clipboard_helper.rb new file mode 100644 index 000000000..b079dc6a9 --- /dev/null +++ b/app/helpers/clipboard_helper.rb @@ -0,0 +1,8 @@ +module ClipboardHelper + def button_to_copy_to_clipboard(url, &) + tag.button class: "btn", data: { + controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", + copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url + }, & + end +end diff --git a/app/helpers/translations_helper.rb b/app/helpers/translations_helper.rb index 2794d3a41..5898b3a88 100644 --- a/app/helpers/translations_helper.rb +++ b/app/helpers/translations_helper.rb @@ -1,5 +1,6 @@ module TranslationsHelper TRANSLATIONS = { + user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome" }, email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email" }, password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha" } } diff --git a/app/javascript/controllers/copy_to_clipboard_controller.js b/app/javascript/controllers/copy_to_clipboard_controller.js new file mode 100644 index 000000000..f15c70a8e --- /dev/null +++ b/app/javascript/controllers/copy_to_clipboard_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { content: String } + static classes = [ "success" ] + + async copy(event) { + event.preventDefault() + this.reset() + + try { + await navigator.clipboard.writeText(this.contentValue) + this.element.classList.add(this.successClass) + } catch {} + } + + reset() { + this.element.classList.remove(this.successClass) + this.#forceReflow() + } + + #forceReflow() { + this.element.offsetWidth + } +} diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index dce37902b..512aac9db 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -4,6 +4,10 @@ export default class extends Controller { static targets = [ "dialog" ] open() { - this.dialogTarget.show() + this.dialogTarget.showModal() + } + + close() { + this.dialogTarget.close() } } diff --git a/app/models/account.rb b/app/models/account.rb index 97bf11278..623862de8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,4 +1,6 @@ class Account < ApplicationRecord + include Joinable + has_many :users, dependent: :destroy has_many :projects, dependent: :destroy diff --git a/app/models/account/joinable.rb b/app/models/account/joinable.rb new file mode 100644 index 000000000..3f313ce3a --- /dev/null +++ b/app/models/account/joinable.rb @@ -0,0 +1,16 @@ +module Account::Joinable + extend ActiveSupport::Concern + + included do + before_create { self.join_code = generate_join_code } + end + + def reset_join_code + update! join_code: generate_join_code + end + + private + def generate_join_code + SecureRandom.alphanumeric(12).scan(/.{4}/).join("-") + end +end diff --git a/app/views/accounts/users/_invite.html.erb b/app/views/accounts/users/_invite.html.erb new file mode 100644 index 000000000..9f3f028a8 --- /dev/null +++ b/app/views/accounts/users/_invite.html.erb @@ -0,0 +1,40 @@ +
+ <% url = join_url(Current.account.join_code) %> + + + +
+
+ <%= 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 + <% end %> + + + <%= image_tag account_join_code_path(format: "svg"), class: "qr-code center", alt: "QR Code", loading: "lazy" %> + +
+ +
+
+
+ + <%= button_to_copy_to_clipboard(url) do %> + <%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Copy join link + <% end %> + + <%= button_to account_join_code_path, method: :put, class: "btn btn--regenerate" do %> + <%= image_tag "refresh.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Regenerate join link + <% end %> +
+
diff --git a/app/views/accounts/users/_user.html.erb b/app/views/accounts/users/_user.html.erb new file mode 100644 index 000000000..8acc9ffa6 --- /dev/null +++ b/app/views/accounts/users/_user.html.erb @@ -0,0 +1,13 @@ +
+ + <%= user.name %> + + + + + <%= button_to account_user_path(user), method: :delete, class: "btn btn--small btn--negative flex-item-no-shrink", data: { + turbo_confirm: "Are you sure you want to permanently remove this person from the account?" } do %> + <%= image_tag "minus.svg", aria: { hidden: true }, size: 24 %> + Remove <%= user.name %> from the account + <% end %> +
diff --git a/app/views/accounts/users/index.html.erb b/app/views/accounts/users/index.html.erb new file mode 100644 index 000000000..54229880a --- /dev/null +++ b/app/views/accounts/users/index.html.erb @@ -0,0 +1,18 @@ +<% @page_title = "People on the account" %> + +<% content_for :header do %> + +<% end %> + +
+ <%= render "accounts/users/invite" %> +
+ +
+ <%= render partial: "accounts/users/user", collection: @users %> +
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb new file mode 100644 index 000000000..3add264c2 --- /dev/null +++ b/app/views/users/new.html.erb @@ -0,0 +1,41 @@ +<% @page_title = "Create your account" %> + +<% content_for :header do %> + +<% end %> + +
+

Fizzy

+ + <%= form_with model: @user, url: join_path(@account.join_code), class: "flex flex-column gap" do |form| %> +
+ <%= translation_button :user_name %> + +
+
+ <%= translation_button :email_address %> + +
+
+ <%= translation_button :password %> + +
+ + <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 66e8f7fed..0f8a2e4bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,19 @@ Rails.application.routes.draw do resource :session + resource :account do + scope module: "accounts" do + resource :join_code + + resources :users + end + end + + get "join/:join_code", to: "users#new", as: :join + post "join/:join_code", to: "users#create" + + resources :users + resources :projects do resource :access, controller: "projects/accesses" diff --git a/db/migrate/20240919191155_add_join_code_to_accounts.rb b/db/migrate/20240919191155_add_join_code_to_accounts.rb new file mode 100644 index 000000000..02249fac0 --- /dev/null +++ b/db/migrate/20240919191155_add_join_code_to_accounts.rb @@ -0,0 +1,7 @@ +class AddJoinCodeToAccounts < ActiveRecord::Migration[8.0] + def change + change_table :accounts do |t| + t.string :join_code, index: { unique: true } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0fc61b403..ac5589e7b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_09_19_141018) do +ActiveRecord::Schema[8.0].define(version: 2024_09_19_191155) do create_table "accesses", force: :cascade do |t| t.integer "project_id", null: false t.integer "user_id", null: false @@ -25,6 +25,8 @@ ActiveRecord::Schema[8.0].define(version: 2024_09_19_141018) do t.string "name", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "join_code" + t.index ["join_code"], name: "index_accounts_on_join_code", unique: true t.index ["name"], name: "index_accounts_on_name", unique: true end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index ced04edf6..587f05a44 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,2 +1,3 @@ 37s: name: 37signals + join_code: "ejpP-THlQ-Cc2f"