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 @@ +