From debc0816fb66eadff30416e5218670c97dd44b00 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 19:25:39 -0400 Subject: [PATCH 01/21] Initial pass at household invites --- app/controllers/concerns/invitable.rb | 1 + app/controllers/invitations_controller.rb | 31 ++++++++++++++++++ app/controllers/registrations_controller.rb | 32 +++++++++++++------ app/helpers/invitations_helper.rb | 2 ++ app/mailers/invitation_mailer.rb | 11 +++++++ app/models/family.rb | 1 + app/models/invitation.rb | 26 +++++++++++++++ .../invitation_mailer/invite_email.html.erb | 11 +++++++ app/views/invitations/new.html.erb | 23 +++++++++++++ app/views/registrations/new.html.erb | 21 ++++++++++-- .../settings/profiles/_invite_modal.html.erb | 25 +++++++++++++++ app/views/settings/profiles/show.html.erb | 5 +++ .../locales/mailers/invitation_mailer/en.yml | 8 +++++ config/locales/views/invitations/en.yml | 18 +++++++++++ config/locales/views/registrations/en.yml | 6 +++- config/routes.rb | 4 +++ .../20241030222235_create_invitations.rb | 18 +++++++++++ db/schema.rb | 22 +++++++++++-- 18 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 app/controllers/invitations_controller.rb create mode 100644 app/helpers/invitations_helper.rb create mode 100644 app/mailers/invitation_mailer.rb create mode 100644 app/models/invitation.rb create mode 100644 app/views/invitation_mailer/invite_email.html.erb create mode 100644 app/views/invitations/new.html.erb create mode 100644 app/views/settings/profiles/_invite_modal.html.erb create mode 100644 config/locales/mailers/invitation_mailer/en.yml create mode 100644 config/locales/views/invitations/en.yml create mode 100644 db/migrate/20241030222235_create_invitations.rb diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb index 1e0dfed85ef..5e12d2df994 100644 --- a/app/controllers/concerns/invitable.rb +++ b/app/controllers/concerns/invitable.rb @@ -7,6 +7,7 @@ module Invitable private def invite_code_required? + return false if @invitation.present? self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true" end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 00000000000..fb01664b28e --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,31 @@ +class InvitationsController < ApplicationController + skip_authentication only: :accept + def new + @invitation = Invitation.new + end + + def create + @invitation = Current.family.invitations.build(invitation_params) + @invitation.inviter = Current.user + + if @invitation.save + InvitationMailer.invite_email(@invitation).deliver_later + flash[:notice] = t(".success") + else + flash[:alert] = t(".failure") + end + + redirect_to settings_profile_path + end + + def accept + @invitation = Invitation.pending.find_by!(token: params[:id]) + redirect_to new_registration_path(invitation: @invitation.token) + end + + private + + def invitation_params + params.require(:invitation).permit(:email, :role) + end +end \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8c55e58d03a..88c9a57bfa4 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,36 +4,50 @@ class RegistrationsController < ApplicationController layout "auth" before_action :set_user, only: :create + before_action :load_invitation, if: :invitation_token? before_action :claim_invite_code, only: :create, if: :invite_code_required? def new - @user = User.new + @user = User.new(email: @invitation&.email) end def create - family = Family.new - @user.family = family - @user.role = :admin + if @invitation + @user.family = @invitation.family + @user.role = @invitation.role + @user.email = @invitation.email + else + family = Family.new + @user.family = family + @user.role = :admin + end if @user.save - Category.create_default_categories(@user.family) + @invitation&.update!(accepted_at: Time.current) + Category.create_default_categories(@user.family) unless @invitation @session = create_session_for(@user) - flash[:notice] = t(".success") - redirect_to root_path + redirect_to root_path, notice: t(".success") else - flash[:alert] = t(".failure") render :new, status: :unprocessable_entity end end private + def load_invitation + @invitation = Invitation.pending.find_by!(token: params.dig(:user, :invitation) || params[:invitation]) + end + + def invitation_token? + params.dig(:user, :invitation).present? + end + def set_user @user = User.new user_params.except(:invite_code) end def user_params - params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code) + params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation) end def claim_invite_code diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb new file mode 100644 index 00000000000..1483b9eeb7f --- /dev/null +++ b/app/helpers/invitations_helper.rb @@ -0,0 +1,2 @@ +module InvitationsHelper +end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb new file mode 100644 index 00000000000..7b6ee256dc8 --- /dev/null +++ b/app/mailers/invitation_mailer.rb @@ -0,0 +1,11 @@ +class InvitationMailer < ApplicationMailer + def invite_email(invitation) + @invitation = invitation + @accept_url = accept_invitation_url(@invitation.token) + + mail( + to: @invitation.email, + subject: t(".subject", inviter: @invitation.inviter.display_name) + ) + end +end \ No newline at end of file diff --git a/app/models/family.rb b/app/models/family.rb index ddfdab3e386..1c58e460abd 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -4,6 +4,7 @@ class Family < ApplicationRecord include Providable has_many :users, dependent: :destroy + has_many :invitations, dependent: :destroy has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy has_many :institutions, dependent: :destroy diff --git a/app/models/invitation.rb b/app/models/invitation.rb new file mode 100644 index 00000000000..7b43056a3b6 --- /dev/null +++ b/app/models/invitation.rb @@ -0,0 +1,26 @@ +class Invitation < ApplicationRecord + belongs_to :family + belongs_to :inviter, class_name: 'User' + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :role, presence: true, inclusion: { in: %w[admin member] } + validates :token, presence: true, uniqueness: true + + before_validation :generate_token, on: :create + before_create :set_expiration + + scope :pending, -> { where(accepted_at: nil).where('expires_at > ?', Time.current) } + + private + + def generate_token + loop do + self.token = SecureRandom.hex(32) + break unless self.class.exists?(token: token) + end + end + + def set_expiration + self.expires_at = 3.days.from_now + end +end \ No newline at end of file diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb new file mode 100644 index 00000000000..048574c4c2f --- /dev/null +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -0,0 +1,11 @@ +

<%= t(".greeting") %>

+ +

+ <%= t(".body", + inviter: @invitation.inviter.display_name, + family: @invitation.family.name) %> +

+ +<%= link_to t(".accept_button"), @accept_url, class: "button" %> + +

<%= t(".expiry_notice", days: 7) %>

\ No newline at end of file diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb new file mode 100644 index 00000000000..deb19f2ac10 --- /dev/null +++ b/app/views/invitations/new.html.erb @@ -0,0 +1,23 @@ +<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %> + <%= styled_form_with model: @invitation, class: "space-y-4" do |form| %> + <%= form.email_field :email, + required: true, + placeholder: t(".email_placeholder"), + label: t(".email_label") %> + + <%= form.select :role, + options_for_select([ + [t(".role_member"), "member"], + [t(".role_admin"), "admin"] + ]), + {}, + { label: t(".role_label") } %> + +
+ + <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2" %> +
+ <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index df7200fe211..6ad1f066e07 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,5 +1,5 @@ <% - header_title t(".title") + header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title") %> <% if self_hosted_first_login? %> @@ -7,14 +7,29 @@

<%= t(".welcome_title") %>

<%= t(".welcome_body") %>

+<% elsif @invitation %> +
+

+ <%= t(".invitation_message", + inviter: @invitation.inviter.display_name, + role: t(".role_#{@invitation.role}")) %> +

+
<% end %> <%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %> - <%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com", label: true %> + <%= form.email_field :email, + autofocus: false, + autocomplete: "email", + required: "required", + placeholder: "you@example.com", + label: true, + disabled: @invitation.present? %> <%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %> <%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %> - <% if invite_code_required? %> + <% if invite_code_required? && !@invitation %> <%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %> <% end %> + <%= form.hidden_field :invitation, value: @invitation&.token %> <%= form.submit t(".submit") %> <% end %> diff --git a/app/views/settings/profiles/_invite_modal.html.erb b/app/views/settings/profiles/_invite_modal.html.erb new file mode 100644 index 00000000000..a3d4e6f9898 --- /dev/null +++ b/app/views/settings/profiles/_invite_modal.html.erb @@ -0,0 +1,25 @@ +<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %> + <%= styled_form_with model: Invitation.new, class: "space-y-4 relative" do |form| %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "off", + placeholder: t(".email_placeholder"), + label: t(".email_label") %> + + <%= form.select :role, + options_for_select([ + [t(".role_member"), "member"], + [t(".role_admin"), "admin"] + ]), + {}, + { label: t(".role_label") } %> + +
+ + <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2" %> +
+ <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c1c7cb8d098..738406c6762 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -43,6 +43,11 @@

<%= Current.user.role %>

+ <%= link_to new_invitation_path, + class: "bg-gray-100 text-gray-500 rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= t(".invite_member") %> + <% end %> <% end %> diff --git a/config/locales/mailers/invitation_mailer/en.yml b/config/locales/mailers/invitation_mailer/en.yml new file mode 100644 index 00000000000..0d6e0065450 --- /dev/null +++ b/config/locales/mailers/invitation_mailer/en.yml @@ -0,0 +1,8 @@ +en: + invitation_mailer: + invite_email: + subject: "%{inviter} has invited you to join their household" + greeting: "You've been invited!" + body: "%{inviter} has invited you to join their household '%{family}' as a %{role}." + accept_button: "Accept Invitation" + expiry_notice: "This invitation will expire in %{days} days." diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml new file mode 100644 index 00000000000..769f9f76158 --- /dev/null +++ b/config/locales/views/invitations/en.yml @@ -0,0 +1,18 @@ +en: + invitations: + new: + title: "Invite Member" + subtitle: "Invite someone to join your household" + email_label: "Email" + email_placeholder: "Enter their email address" + role_label: "Role" + role_member: "Member" + role_admin: "Admin" + submit: "Send Invitation" + cancel: "Cancel" + create: + success: "Invitation sent successfully" + failure: "Could not send invitation" + accept: + success: "Welcome! You've successfully joined the account" + failure: "There was a problem accepting the invitation" \ No newline at end of file diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 3f2666ee914..34c3eb65e09 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -14,7 +14,11 @@ en: success: You have signed up successfully. new: submit: Create account - title: Create an account + title: Create your account + join_family_title: "Join %{family}" + invitation_message: "%{inviter} has invited you to join as a %{role}" + role_member: "member" + role_admin: "administrator" welcome_body: To get started, you must sign up for a new account. You will then be able to configure additional settings within the app. welcome_title: Welcome to Self Hosted Maybe! diff --git a/config/routes.rb b/config/routes.rb index 70e5903292b..f4a499cbf93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,10 @@ resources :exchange_rate_provider_missings, only: :update end + resources :invitations, only: [:new, :create] do + get :accept, on: :member + end + # For managing self-hosted upgrades and release notifications resources :upgrades, only: [] do member do diff --git a/db/migrate/20241030222235_create_invitations.rb b/db/migrate/20241030222235_create_invitations.rb new file mode 100644 index 00000000000..7b7b2638730 --- /dev/null +++ b/db/migrate/20241030222235_create_invitations.rb @@ -0,0 +1,18 @@ +class CreateInvitations < ActiveRecord::Migration[7.2] + def change + create_table :invitations, id: :uuid do |t| + t.string :email + t.string :role + t.string :token + t.references :family, null: false, foreign_key: true, type: :uuid + t.references :inviter, null: false, foreign_key: { to_table: :users }, type: :uuid + t.datetime :accepted_at + t.datetime :expires_at + + t.timestamps + end + + add_index :invitations, :token, unique: true + add_index :invitations, :email + end +end diff --git a/db/schema.rb b/db/schema.rb index 48a76165aa9..4db6b1457b2 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[7.2].define(version: 2024_10_30_121302) do +ActiveRecord::Schema[7.2].define(version: 2024_10_30_222235) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -119,7 +119,7 @@ t.boolean "is_active", default: true, null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.string "mode" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" @@ -418,6 +418,22 @@ t.datetime "updated_at", null: false end + create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email" + t.string "role" + t.string "token" + t.uuid "family_id", null: false + t.uuid "inviter_id", null: false + t.datetime "accepted_at" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_invitations_on_email" + t.index ["family_id"], name: "index_invitations_on_family_id" + t.index ["inviter_id"], name: "index_invitations_on_inviter_id" + t.index ["token"], name: "index_invitations_on_token", unique: true + end + create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "token", null: false t.datetime "created_at", null: false @@ -605,6 +621,8 @@ add_foreign_key "import_rows", "imports" add_foreign_key "imports", "families" add_foreign_key "institutions", "families" + add_foreign_key "invitations", "families" + add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" add_foreign_key "security_prices", "securities" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" From 10862a19b21589369e9fd994a42dcc795ad46034 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 19:52:12 -0400 Subject: [PATCH 02/21] Invitee setup --- app/controllers/onboardings_controller.rb | 18 ++++++---------- app/controllers/registrations_controller.rb | 7 +++--- app/models/invitation.rb | 2 ++ app/views/onboardings/profile.html.erb | 24 +++++++++++---------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index 4fb5386f8f2..fda65f95f23 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -1,19 +1,15 @@ class OnboardingsController < ApplicationController layout "application" - before_action :set_user + before_action :load_invitation - def show - end + private - def profile + def set_user + @user = Current.user end - def preferences + def load_invitation + @invitation = Invitation.accepted.most_recent_for_email(Current.user.email) end - - private - def set_user - @user = Current.user - end -end +end \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 88c9a57bfa4..104ac5aaad6 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -35,15 +35,16 @@ def create private def load_invitation - @invitation = Invitation.pending.find_by!(token: params.dig(:user, :invitation) || params[:invitation]) + token = params[:invitation] || params.dig(:user, :invitation) + @invitation = Invitation.pending.find_by!(token: token) end def invitation_token? - params.dig(:user, :invitation).present? + params[:invitation].present? || params.dig(:user, :invitation).present? end def set_user - @user = User.new user_params.except(:invite_code) + @user = User.new user_params.except(:invite_code, :invitation) end def user_params diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 7b43056a3b6..eee2c7ca743 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -10,6 +10,8 @@ class Invitation < ApplicationRecord before_create :set_expiration scope :pending, -> { where(accepted_at: nil).where('expires_at > ?', Time.current) } + scope :accepted, -> { where.not(accepted_at: nil) } + scope :most_recent_for_email, ->(email) { where(email: email).order(accepted_at: :desc).first } private diff --git a/app/views/onboardings/profile.html.erb b/app/views/onboardings/profile.html.erb index e2bda5b7567..0e0b99f5732 100644 --- a/app/views/onboardings/profile.html.erb +++ b/app/views/onboardings/profile.html.erb @@ -9,7 +9,8 @@ <%= styled_form_with model: @user do |form| %> - <%= form.hidden_field :redirect_to, value: "onboarding_preferences" %> + <%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %> + <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %>

<%= t(".profile_image") %>

@@ -20,16 +21,17 @@ <%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name"), container_class: "bg-white w-1/2", required: true %> <%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-white w-1/2", required: true %>
- -
- <%= form.fields_for :family do |family_form| %> - <%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %> - - <%= family_form.select :country, - country_options, - { label: t(".country") }, required: true %> - <% end %> -
+ <% unless @invitation %> +
+ <%= form.fields_for :family do |family_form| %> + <%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %> + + <%= family_form.select :country, + country_options, + { label: t(".country") }, required: true %> + <% end %> +
+ <% end %> <%= form.submit t(".submit") %> <% end %> From f89585c4e042b89b07406f0d125ae6fb79e8ec6a Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:10:45 -0400 Subject: [PATCH 03/21] Clean up add member form --- .../settings/profiles_controller.rb | 1 + app/views/settings/profiles/show.html.erb | 29 +++++++++++-------- config/locales/views/invitations/en.yml | 2 +- config/locales/views/settings/en.yml | 1 + 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0caca54c196..256962d9923 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,5 +1,6 @@ class Settings::ProfilesController < SettingsController def show @user = Current.user + @users = Current.family.users.order(:created_at) end end diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 738406c6762..c12122ab69b 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -34,19 +34,24 @@

<%= Current.family.name %> · <%= Current.family.users.size %>

-
-
-

<%= Current.user.initial %>

+ <% @users.each do |user| %> +
+
+ <%= render "settings/user_avatar", user: user %> +
+

<%= user.display_name %>

+
+

<%= user.role %>

+
-

<%= Current.user.display_name %>

-
-

<%= Current.user.role %>

-
-
- <%= link_to new_invitation_path, - class: "bg-gray-100 text-gray-500 rounded-lg px-4 py-2 w-full text-center", - data: { turbo_frame: :modal } do %> - <%= t(".invite_member") %> + <% end %> + <% if Current.user.admin? %> + <%= link_to new_invitation_path, + class: "bg-gray-100 flex items-center justify-center gap-2 text-gray-500 mt-1 hover:bg-gray-200 rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %> + <%= t(".invite_member") %> + <% end %> <% end %>
diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 769f9f76158..9b8b20d5487 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -1,7 +1,7 @@ en: invitations: new: - title: "Invite Member" + title: "Add member" subtitle: "Invite someone to join your household" email_label: "Email" email_placeholder: "Enter their email address" diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 0e2fe635bf2..657ef5fe15c 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -41,6 +41,7 @@ en: theme_title: Theme profiles: show: + invite_member: "Add member" confirm_delete: body: Are you sure you want to permanently delete your account? This action is irreversible. From 45698d376069c15ed53cf4dca36409cefa3ef3cd Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:15:10 -0400 Subject: [PATCH 04/21] Lint and other tweaks --- app/controllers/invitations_controller.rb | 8 +++---- app/controllers/onboardings_controller.rb | 14 ++++++------ app/mailers/invitation_mailer.rb | 4 ++-- app/models/invitation.rb | 22 +++++++++---------- .../invitation_mailer/invite_email.html.erb | 2 +- config/locales/views/invitation_mailer/en.yml | 7 ++++++ config/routes.rb | 2 +- 7 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 config/locales/views/invitation_mailer/en.yml diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index fb01664b28e..781fdf18fe5 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -25,7 +25,7 @@ def accept private - def invitation_params - params.require(:invitation).permit(:email, :role) - end -end \ No newline at end of file + def invitation_params + params.require(:invitation).permit(:email, :role) + end +end diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index fda65f95f23..8926519ddca 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -5,11 +5,11 @@ class OnboardingsController < ApplicationController private - def set_user - @user = Current.user - end + def set_user + @user = Current.user + end - def load_invitation - @invitation = Invitation.accepted.most_recent_for_email(Current.user.email) - end -end \ No newline at end of file + def load_invitation + @invitation = Invitation.accepted.most_recent_for_email(Current.user.email) + end +end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb index 7b6ee256dc8..d43a42fe60b 100644 --- a/app/mailers/invitation_mailer.rb +++ b/app/mailers/invitation_mailer.rb @@ -2,10 +2,10 @@ class InvitationMailer < ApplicationMailer def invite_email(invitation) @invitation = invitation @accept_url = accept_invitation_url(@invitation.token) - + mail( to: @invitation.email, subject: t(".subject", inviter: @invitation.inviter.display_name) ) end -end \ No newline at end of file +end diff --git a/app/models/invitation.rb b/app/models/invitation.rb index eee2c7ca743..90828b0f3a2 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -1,6 +1,6 @@ class Invitation < ApplicationRecord belongs_to :family - belongs_to :inviter, class_name: 'User' + belongs_to :inviter, class_name: "User" validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member] } @@ -9,20 +9,20 @@ class Invitation < ApplicationRecord before_validation :generate_token, on: :create before_create :set_expiration - scope :pending, -> { where(accepted_at: nil).where('expires_at > ?', Time.current) } + scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) } scope :accepted, -> { where.not(accepted_at: nil) } scope :most_recent_for_email, ->(email) { where(email: email).order(accepted_at: :desc).first } private - def generate_token - loop do - self.token = SecureRandom.hex(32) - break unless self.class.exists?(token: token) + def generate_token + loop do + self.token = SecureRandom.hex(32) + break unless self.class.exists?(token: token) + end end - end - def set_expiration - self.expires_at = 3.days.from_now - end -end \ No newline at end of file + def set_expiration + self.expires_at = 3.days.from_now + end +end diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb index 048574c4c2f..2cc3f462777 100644 --- a/app/views/invitation_mailer/invite_email.html.erb +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -8,4 +8,4 @@ <%= link_to t(".accept_button"), @accept_url, class: "button" %> -

<%= t(".expiry_notice", days: 7) %>

\ No newline at end of file +

<%= t(".expiry_notice", days: 3) %>

\ No newline at end of file diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml new file mode 100644 index 00000000000..d9e81712921 --- /dev/null +++ b/config/locales/views/invitation_mailer/en.yml @@ -0,0 +1,7 @@ +en: + invitation_mailer: + invite_email: + greeting: "Welcome!" + body: "%{inviter} has invited you to join the %{family} family on Maybe!" + accept_button: "Accept Invitation" + expiry_notice: "This invitation will expire in %{days} days" diff --git a/config/routes.rb b/config/routes.rb index f4a499cbf93..83c150b8084 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,7 +111,7 @@ resources :exchange_rate_provider_missings, only: :update end - resources :invitations, only: [:new, :create] do + resources :invitations, only: [ :new, :create ] do get :accept, on: :member end From 5008497300513d9675fbe1aac001877242089281 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:20:47 -0400 Subject: [PATCH 05/21] Security cleanup --- app/controllers/invitations_controller.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 781fdf18fe5..644293aba43 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -8,6 +8,10 @@ def create @invitation = Current.family.invitations.build(invitation_params) @invitation.inviter = Current.user + if @invitation.role == "admin" && !Current.user.admin? + @invitation.role = "member" + end + if @invitation.save InvitationMailer.invite_email(@invitation).deliver_later flash[:notice] = t(".success") @@ -26,6 +30,14 @@ def accept private def invitation_params - params.require(:invitation).permit(:email, :role) + base_params = params.require(:invitation).permit(:email) + + if params[:invitation][:role].in?(%w[admin member]) + base_params[:role] = params[:invitation][:role] + else + base_params[:role] = "member" + end + + base_params end end From dfc287fe13f6a8dbc5b69dd602be74c2792354fa Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:21:54 -0400 Subject: [PATCH 06/21] Lint --- app/controllers/invitations_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 644293aba43..86fb68046d6 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -31,13 +31,13 @@ def accept def invitation_params base_params = params.require(:invitation).permit(:email) - + if params[:invitation][:role].in?(%w[admin member]) base_params[:role] = params[:invitation][:role] else base_params[:role] = "member" end - + base_params end end From 23ee356576ed1c97ea04e645ef78cf14e5f3b3ff Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:29:28 -0400 Subject: [PATCH 07/21] i18n fixes --- config/locales/views/invitations/en.yml | 15 +-------------- config/locales/views/registrations/en.yml | 1 - config/locales/views/settings/profiles/en.yml | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 config/locales/views/settings/profiles/en.yml diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 9b8b20d5487..bfc5eb07e93 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -1,18 +1,5 @@ en: invitations: - new: - title: "Add member" - subtitle: "Invite someone to join your household" - email_label: "Email" - email_placeholder: "Enter their email address" - role_label: "Role" - role_member: "Member" - role_admin: "Admin" - submit: "Send Invitation" - cancel: "Cancel" create: success: "Invitation sent successfully" - failure: "Could not send invitation" - accept: - success: "Welcome! You've successfully joined the account" - failure: "There was a problem accepting the invitation" \ No newline at end of file + failure: "Could not send invitation" \ No newline at end of file diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 34c3eb65e09..5d487ee05f0 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -9,7 +9,6 @@ en: create: Continue registrations: create: - failure: Invalid input, please try again. invalid_invite_code: Invalid invite code, please try again. success: You have signed up successfully. new: diff --git a/config/locales/views/settings/profiles/en.yml b/config/locales/views/settings/profiles/en.yml new file mode 100644 index 00000000000..1521d68341a --- /dev/null +++ b/config/locales/views/settings/profiles/en.yml @@ -0,0 +1,14 @@ +--- +en: + settings: + profiles: + invite_modal: + title: "Invite Member" + subtitle: "Invite someone to join your household" + email_label: "Email" + email_placeholder: "Enter their email address" + role_label: "Role" + role_member: "Member" + role_admin: "Admin" + submit: "Send Invitation" + cancel: "Cancel" From b30d81f11f6e4b1c85ef71cbc64e9a99e4707db3 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Wed, 30 Oct 2024 20:34:09 -0400 Subject: [PATCH 08/21] More i18n cleanup --- .../settings/profiles/_invite_modal.html.erb | 25 ------------------- config/locales/views/invitations/en.yml | 12 ++++++++- config/locales/views/settings/profiles/en.yml | 14 ----------- 3 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 app/views/settings/profiles/_invite_modal.html.erb delete mode 100644 config/locales/views/settings/profiles/en.yml diff --git a/app/views/settings/profiles/_invite_modal.html.erb b/app/views/settings/profiles/_invite_modal.html.erb deleted file mode 100644 index a3d4e6f9898..00000000000 --- a/app/views/settings/profiles/_invite_modal.html.erb +++ /dev/null @@ -1,25 +0,0 @@ -<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %> - <%= styled_form_with model: Invitation.new, class: "space-y-4 relative" do |form| %> - <%= form.email_field :email, - required: true, - autofocus: true, - autocomplete: "off", - placeholder: t(".email_placeholder"), - label: t(".email_label") %> - - <%= form.select :role, - options_for_select([ - [t(".role_member"), "member"], - [t(".role_admin"), "admin"] - ]), - {}, - { label: t(".role_label") } %> - -
- - <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2" %> -
- <% end %> -<% end %> \ No newline at end of file diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index bfc5eb07e93..de03dee4f11 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -2,4 +2,14 @@ en: invitations: create: success: "Invitation sent successfully" - failure: "Could not send invitation" \ No newline at end of file + failure: "Could not send invitation" + new: + title: Invite Someone + subtitle: Send an invitation to join your family + email_placeholder: Enter email address + email_label: Email Address + role_member: Member + role_admin: Administrator + role_label: Role + cancel: Cancel + submit: Send Invitation \ No newline at end of file diff --git a/config/locales/views/settings/profiles/en.yml b/config/locales/views/settings/profiles/en.yml deleted file mode 100644 index 1521d68341a..00000000000 --- a/config/locales/views/settings/profiles/en.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -en: - settings: - profiles: - invite_modal: - title: "Invite Member" - subtitle: "Invite someone to join your household" - email_label: "Email" - email_placeholder: "Enter their email address" - role_label: "Role" - role_member: "Member" - role_admin: "Admin" - submit: "Send Invitation" - cancel: "Cancel" From 886123d7e9bb4ac4407df9bf1027f19b7d30b6ec Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 08:05:24 -0400 Subject: [PATCH 09/21] Show pending invites --- app/controllers/settings/profiles_controller.rb | 1 + app/views/invitations/new.html.erb | 7 ++----- app/views/settings/profiles/show.html.erb | 13 +++++++++++++ config/locales/views/invitations/en.yml | 2 +- config/locales/views/settings/en.yml | 1 + 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 256962d9923..882824ecb64 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -2,5 +2,6 @@ class Settings::ProfilesController < SettingsController def show @user = Current.user @users = Current.family.users.order(:created_at) + @pending_invitations = Current.family.invitations.pending end end diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb index deb19f2ac10..f8f7174d64e 100644 --- a/app/views/invitations/new.html.erb +++ b/app/views/invitations/new.html.erb @@ -13,11 +13,8 @@ {}, { label: t(".role_label") } %> -
- - <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2" %> +
+ <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %>
<% end %> <% end %> \ No newline at end of file diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c12122ab69b..8f4cd81d5bb 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -45,6 +45,19 @@
<% end %> + <% if @pending_invitations.any? %> + <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email[0] %>
+
+

<%= invitation.email %>

+
+

<%= t(".pending") %>

+
+
+ <% end %> + <% end %> <% if Current.user.admin? %> <%= link_to new_invitation_path, class: "bg-gray-100 flex items-center justify-center gap-2 text-gray-500 mt-1 hover:bg-gray-200 rounded-lg px-4 py-2 w-full text-center", diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index de03dee4f11..5a0158c2621 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -5,7 +5,7 @@ en: failure: "Could not send invitation" new: title: Invite Someone - subtitle: Send an invitation to join your family + subtitle: Send an invitation to join your family account on Maybe email_placeholder: Enter email address email_label: Email Address role_member: Member diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 657ef5fe15c..eaef2c4650e 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -61,6 +61,7 @@ en: profile_subtitle: Customize how you appear on Maybe profile_title: Profile save: Save + pending: Pending user_avatar_field: accepted_formats: JPG or PNG. 5MB max. choose: Choose From c79857d780207a7da913af9a18453b20ee72a750 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 08:24:44 -0400 Subject: [PATCH 10/21] Don't use turbo on the form --- app/views/invitations/new.html.erb | 2 +- config/locales/views/invitations/en.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb index f8f7174d64e..5b6a455062e 100644 --- a/app/views/invitations/new.html.erb +++ b/app/views/invitations/new.html.erb @@ -1,5 +1,5 @@ <%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %> - <%= styled_form_with model: @invitation, class: "space-y-4" do |form| %> + <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %> <%= form.email_field :email, required: true, placeholder: t(".email_placeholder"), diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 5a0158c2621..389cd35897f 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -11,5 +11,4 @@ en: role_member: Member role_admin: Administrator role_label: Role - cancel: Cancel submit: Send Invitation \ No newline at end of file From b46a0628cb03e64a1269eadd2ed3fe507bad2048 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 08:35:22 -0400 Subject: [PATCH 11/21] Improved email design --- .../invitation_mailer/invite_email.html.erb | 4 +- app/views/layouts/mailer.html.erb | 48 ++++++++++++++++++- .../locales/mailers/invitation_mailer/en.yml | 2 +- config/locales/views/invitation_mailer/en.yml | 2 +- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb index 2cc3f462777..c57be0a9492 100644 --- a/app/views/invitation_mailer/invite_email.html.erb +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -3,9 +3,9 @@

<%= t(".body", inviter: @invitation.inviter.display_name, - family: @invitation.family.name) %> + family: @invitation.family.name).html_safe %>

<%= link_to t(".accept_button"), @accept_url, class: "button" %> -

<%= t(".expiry_notice", days: 3) %>

\ No newline at end of file + \ No newline at end of file diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 3aac9002edc..2d6f8c06d7e 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -2,12 +2,56 @@ + - <%= yield %> +
+ <%= yield %> +
diff --git a/config/locales/mailers/invitation_mailer/en.yml b/config/locales/mailers/invitation_mailer/en.yml index 0d6e0065450..1bb494e83e9 100644 --- a/config/locales/mailers/invitation_mailer/en.yml +++ b/config/locales/mailers/invitation_mailer/en.yml @@ -1,7 +1,7 @@ en: invitation_mailer: invite_email: - subject: "%{inviter} has invited you to join their household" + subject: "%{inviter} has invited you to join their household on Maybe!" greeting: "You've been invited!" body: "%{inviter} has invited you to join their household '%{family}' as a %{role}." accept_button: "Accept Invitation" diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml index d9e81712921..650377213ff 100644 --- a/config/locales/views/invitation_mailer/en.yml +++ b/config/locales/views/invitation_mailer/en.yml @@ -1,7 +1,7 @@ en: invitation_mailer: invite_email: - greeting: "Welcome!" + greeting: "Welcome to Maybe!" body: "%{inviter} has invited you to join the %{family} family on Maybe!" accept_button: "Accept Invitation" expiry_notice: "This invitation will expire in %{days} days" From fcc6920d0c12720a3d5c549b29fc9d7dc7710824 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 09:08:05 -0400 Subject: [PATCH 12/21] Basic tests --- app/controllers/invitations_controller.rb | 19 ++-- app/models/invitation.rb | 4 + .../invitations_controller_test.rb | 89 +++++++++++++++++++ test/fixtures/invitations.yml | 19 ++++ 4 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 test/controllers/invitations_controller_test.rb create mode 100644 test/fixtures/invitations.yml diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 86fb68046d6..0b10c6cb20c 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -23,21 +23,18 @@ def create end def accept - @invitation = Invitation.pending.find_by!(token: params[:id]) - redirect_to new_registration_path(invitation: @invitation.token) + @invitation = Invitation.find_by!(token: params[:id]) + + if @invitation.pending? + redirect_to new_registration_path(invitation: @invitation.token) + else + raise ActiveRecord::RecordNotFound + end end private def invitation_params - base_params = params.require(:invitation).permit(:email) - - if params[:invitation][:role].in?(%w[admin member]) - base_params[:role] = params[:invitation][:role] - else - base_params[:role] = "member" - end - - base_params + params.require(:invitation).permit(:email, :role) end end diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 90828b0f3a2..6bb97e861f6 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -13,6 +13,10 @@ class Invitation < ApplicationRecord scope :accepted, -> { where.not(accepted_at: nil) } scope :most_recent_for_email, ->(email) { where(email: email).order(accepted_at: :desc).first } + def pending? + accepted_at.nil? && expires_at > Time.current + end + private def generate_token diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb new file mode 100644 index 00000000000..28d446ea3d0 --- /dev/null +++ b/test/controllers/invitations_controller_test.rb @@ -0,0 +1,89 @@ +require "test_helper" + +class InvitationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @invitation = invitations(:one) + end + + test "should get new" do + get new_invitation_url + assert_response :success + end + + test "should create invitation for member" do + assert_difference("Invitation.count") do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "member" + } + } + end + end + + invitation = Invitation.order(created_at: :desc).first + assert_equal "member", invitation.role + assert_equal @user, invitation.inviter + assert_equal "new@example.com", invitation.email + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.success"), flash[:notice] + end + + test "non-admin cannot create admin invitation" do + sign_in users(:family_member) + + assert_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "admin" + } + } + end + + invitation = Invitation.last + assert_equal "member", invitation.role # Role should be downgraded to member + end + + test "admin can create admin invitation" do + assert_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "new@example.com", + role: "admin" + } + } + end + + invitation = Invitation.last + assert_equal "admin", invitation.role + assert_equal @user.family, invitation.family + assert_equal @user, invitation.inviter + end + + test "should handle invalid invitation creation" do + assert_no_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "", + role: "member" + } + } + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.failure"), flash[:alert] + end + + test "should accept invitation and redirect to registration" do + get accept_invitation_url(@invitation.token) + assert_redirected_to new_registration_path(invitation: @invitation.token) + end + + test "should not accept invalid invitation token" do + get accept_invitation_url("invalid-token") + assert_response :not_found + end +end diff --git a/test/fixtures/invitations.yml b/test/fixtures/invitations.yml new file mode 100644 index 00000000000..12ae9a052e8 --- /dev/null +++ b/test/fixtures/invitations.yml @@ -0,0 +1,19 @@ +one: + email: "test@example.com" + token: "valid-token-123" + role: "member" + inviter: family_admin + family: dylan_family + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %> + +two: + email: "another@example.com" + token: "valid-token-456" + role: "admin" + inviter: family_admin + family: dylan_family + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %> From dd1bd09e1f531ab3d67fdfdb22f57a09b7d5384f Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 09:09:55 -0400 Subject: [PATCH 13/21] Lint --- app/controllers/invitations_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 0b10c6cb20c..18597530238 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -24,7 +24,7 @@ def create def accept @invitation = Invitation.find_by!(token: params[:id]) - + if @invitation.pending? redirect_to new_registration_path(invitation: @invitation.token) else From fcd1fc80413637fd4884f8c04d008ca223905e99 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Thu, 31 Oct 2024 09:10:30 -0400 Subject: [PATCH 14/21] Update onboardings_controller.rb --- app/controllers/onboardings_controller.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index 8926519ddca..0833f4cce9e 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -3,6 +3,15 @@ class OnboardingsController < ApplicationController before_action :set_user before_action :load_invitation + def show + end + + def profile + end + + def preferences + end + private def set_user From ee27e60f7b967a1bb3095b39bf61eaf8e9a74319 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 09:28:38 -0500 Subject: [PATCH 15/21] Registration + invite cleanup --- app/controllers/invitations_controller.rb | 4 ---- app/controllers/registrations_controller.rb | 17 +++++++---------- app/models/invitation.rb | 5 +++++ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 18597530238..5be4ebcdb98 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -8,10 +8,6 @@ def create @invitation = Current.family.invitations.build(invitation_params) @invitation.inviter = Current.user - if @invitation.role == "admin" && !Current.user.admin? - @invitation.role = "member" - end - if @invitation.save InvitationMailer.invite_email(@invitation).deliver_later flash[:notice] = t(".success") diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 104ac5aaad6..9a6c7c89e49 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -4,7 +4,7 @@ class RegistrationsController < ApplicationController layout "auth" before_action :set_user, only: :create - before_action :load_invitation, if: :invitation_token? + before_action :set_invitation before_action :claim_invite_code, only: :create, if: :invite_code_required? def new @@ -34,21 +34,18 @@ def create private - def load_invitation - token = params[:invitation] || params.dig(:user, :invitation) - @invitation = Invitation.pending.find_by!(token: token) - end - - def invitation_token? - params[:invitation].present? || params.dig(:user, :invitation).present? + def set_invitation + token = params[:invitation] || user_params(:invitation) + @invitation = Invitation.pending.find_by(token: token) end def set_user @user = User.new user_params.except(:invite_code, :invitation) end - def user_params - params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation) + def user_params(specific_param = nil) + params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation) + specific_param ? params[specific_param] : params end def claim_invite_code diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 6bb97e861f6..41770b50ac4 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -5,6 +5,7 @@ class Invitation < ApplicationRecord validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member] } validates :token, presence: true, uniqueness: true + validate :inviter_is_admin before_validation :generate_token, on: :create before_create :set_expiration @@ -29,4 +30,8 @@ def generate_token def set_expiration self.expires_at = 3.days.from_now end + + def inviter_is_admin + inviter.admin? + end end From 23f88887f4804155e8a493ffc5bc4ead16a3c342 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 09:28:51 -0500 Subject: [PATCH 16/21] Lint --- app/controllers/onboardings_controller.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb index 0833f4cce9e..a98694712a8 100644 --- a/app/controllers/onboardings_controller.rb +++ b/app/controllers/onboardings_controller.rb @@ -3,14 +3,14 @@ class OnboardingsController < ApplicationController before_action :set_user before_action :load_invitation - def show - end + def show + end - def profile - end + def profile + end - def preferences - end + def preferences + end private From 533d98ac02d78f4a1508a8c5c49a8aec938846d2 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 09:32:01 -0500 Subject: [PATCH 17/21] Update brakeman.ignore --- config/brakeman.ignore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 78648c48283..1668d3fcd27 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -192,6 +192,26 @@ 22 ], "note": "" + }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "YOUR_FINGERPRINT_HERE", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/invitations_controller.rb", + "line": 34, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:invitation).permit(:email, :role)", + "render_path": null, + "location": { + "type": "method", + "class": "InvitationsController", + "method": "invitation_params" + }, + "user_input": null, + "confidence": "Medium", + "note": "Role is protected by model validations and authorization checks. Only admins can create invitations, and roles are restricted to member/admin." } ], "updated": "2024-10-17 11:30:15 -0400", From 3b83785eff0f55ba756e435500d28b2e57ae4db5 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 09:36:48 -0500 Subject: [PATCH 18/21] Update brakeman.ignore --- config/brakeman.ignore | 53 ++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 1668d3fcd27..e3134b174f6 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -38,7 +38,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -72,7 +72,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -91,6 +91,29 @@ ], "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "aaccd8db0be34afdc88e5af08d91ae2e8b7765dfea2f3fc6e1c37db0adc7b991", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/invitations_controller.rb", + "line": 34, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:invitation).permit(:email, :role)", + "render_path": null, + "location": { + "type": "method", + "class": "InvitationsController", + "method": "invitation_params" + }, + "user_input": ":role", + "confidence": "Medium", + "cwe_id": [ + 915 + ], + "note": "" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -140,7 +163,7 @@ "type": "controller", "class": "AccountsController", "method": "show", - "line": 39, + "line": 36, "file": "app/controllers/accounts_controller.rb", "rendered": { "name": "accounts/show", @@ -192,28 +215,8 @@ 22 ], "note": "" - }, - { - "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "YOUR_FINGERPRINT_HERE", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/invitations_controller.rb", - "line": 34, - "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:invitation).permit(:email, :role)", - "render_path": null, - "location": { - "type": "method", - "class": "InvitationsController", - "method": "invitation_params" - }, - "user_input": null, - "confidence": "Medium", - "note": "Role is protected by model validations and authorization checks. Only admins can create invitations, and roles are restricted to member/admin." } ], - "updated": "2024-10-17 11:30:15 -0400", - "brakeman_version": "6.2.1" + "updated": "2024-11-01 09:36:40 -0500", + "brakeman_version": "6.2.2" } From 801a27215e2f1e72934bc8b94876a77316366297 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 09:56:36 -0500 Subject: [PATCH 19/21] Self host invite links --- app/controllers/invitations_controller.rb | 2 +- app/models/invitation.rb | 5 +++- app/views/settings/profiles/show.html.erb | 36 ++++++++++++++++++----- config/locales/views/settings/en.yml | 1 + 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 5be4ebcdb98..ee3865bd491 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -9,7 +9,7 @@ def create @invitation.inviter = Current.user if @invitation.save - InvitationMailer.invite_email(@invitation).deliver_later + InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted? flash[:notice] = t(".success") else flash[:alert] = t(".failure") diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 41770b50ac4..119fbc46dd7 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -32,6 +32,9 @@ def set_expiration end def inviter_is_admin - inviter.admin? + unless inviter.admin? + errors.add(:role, "can only be set to member for non-admin inviters") + self.role = "member" + end end end diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 8f4cd81d5bb..dde691a2de4 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -47,14 +47,36 @@ <% end %> <% if @pending_invitations.any? %> <% @pending_invitations.each do |invitation| %> -
-
-
<%= invitation.email[0] %>
-
-

<%= invitation.email %>

-
-

<%= t(".pending") %>

+
+
+
+
<%= invitation.email[0] %>
+
+
+

<%= invitation.email %>

+
+

<%= t(".pending") %>

+
+
+ <% if self_hosted? %> +
+

<%= t(".invitation_link") %>

+ + + +
+ <% end %>
<% end %> <% end %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index eaef2c4650e..4bb87e5c3ae 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -62,6 +62,7 @@ en: profile_title: Profile save: Save pending: Pending + invitation_link: Invitation link user_avatar_field: accepted_formats: JPG or PNG. 5MB max. choose: Choose From ed579b38d5ad58f2feb41179ffbf671f74ab7f8c Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 10:05:00 -0500 Subject: [PATCH 20/21] Test tweaks --- app/controllers/invitations_controller.rb | 6 ++++++ app/models/invitation.rb | 5 +---- test/controllers/invitations_controller_test.rb | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index ee3865bd491..020b4707ca9 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -5,6 +5,12 @@ def new end def create + unless Current.user.admin? + flash[:alert] = t(".failure") + redirect_to settings_profile_path + return + end + @invitation = Current.family.invitations.build(invitation_params) @invitation.inviter = Current.user diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 119fbc46dd7..41770b50ac4 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -32,9 +32,6 @@ def set_expiration end def inviter_is_admin - unless inviter.admin? - errors.add(:role, "can only be set to member for non-admin inviters") - self.role = "member" - end + inviter.admin? end end diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index 28d446ea3d0..d6bdcacbe70 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -31,10 +31,10 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest assert_equal I18n.t("invitations.create.success"), flash[:notice] end - test "non-admin cannot create admin invitation" do + test "non-admin cannot create invitations" do sign_in users(:family_member) - assert_difference("Invitation.count") do + assert_no_difference("Invitation.count") do post invitations_url, params: { invitation: { email: "new@example.com", @@ -43,8 +43,8 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest } end - invitation = Invitation.last - assert_equal "member", invitation.role # Role should be downgraded to member + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.create.failure"), flash[:alert] end test "admin can create admin invitation" do From 85e04f94724d627eaa053a27cbf148940a96efa2 Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Fri, 1 Nov 2024 10:11:38 -0500 Subject: [PATCH 21/21] Address missing param error --- app/controllers/registrations_controller.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 9a6c7c89e49..b5613d443f3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -35,7 +35,8 @@ def create private def set_invitation - token = params[:invitation] || user_params(:invitation) + token = params[:invitation] + token ||= params[:user][:invitation] if params[:user].present? @invitation = Invitation.pending.find_by(token: token) end