diff --git a/Gemfile b/Gemfile
index 566cc2ccd..54fa73a04 100644
--- a/Gemfile
+++ b/Gemfile
@@ -54,6 +54,8 @@ gem "bootstrap_form", "~> 5.3"
# Devise Authentication
gem "devise"
+gem "devise_invitable", "~> 2.0.0"
+
# Use Sass to process CSS
gem "dartsass-rails"
diff --git a/Gemfile.lock b/Gemfile.lock
index 50835f71f..2c465a1ac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -142,6 +142,9 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise_invitable (2.0.8)
+ actionmailer (>= 5.0)
+ devise (>= 4.6)
docile (1.4.0)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
@@ -476,6 +479,7 @@ DEPENDENCIES
dartsass-rails
debug
devise
+ devise_invitable (~> 2.0.0)
evil_systems (~> 1.1)
factory_bot_rails
faker
diff --git a/app/controllers/organizations/invitations_controller.rb b/app/controllers/organizations/invitations_controller.rb
new file mode 100644
index 000000000..c49959cee
--- /dev/null
+++ b/app/controllers/organizations/invitations_controller.rb
@@ -0,0 +1,30 @@
+class Organizations::InvitationsController < Devise::InvitationsController
+ before_action :require_organization_admin, only: [:new, :create]
+ layout "dashboard", only: [:new, :create]
+
+ def new
+ @user = User.new
+ @staff = StaffAccount.new(user: @user)
+ end
+
+ def create
+ @user = User.new(user_params.merge(password: SecureRandom.hex(8)).except(:staff_account_attributes))
+ @user.staff_account = StaffAccount.new(verified: true)
+
+ if @user.save
+ @user.staff_account.add_role(user_params[:staff_account_attributes][:roles])
+ @user.invite!(current_user)
+ redirect_to staff_index_path, notice: "Invite sent!"
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def user_params
+ params.require(:user)
+ .permit(:first_name, :last_name, :email,
+ staff_account_attributes: [:roles])
+ end
+end
diff --git a/app/controllers/organizations/staff_controller.rb b/app/controllers/organizations/staff_controller.rb
index abad0d4da..4d7d7128d 100644
--- a/app/controllers/organizations/staff_controller.rb
+++ b/app/controllers/organizations/staff_controller.rb
@@ -5,29 +5,4 @@ class Organizations::StaffController < Organizations::BaseController
def index
@staff_accounts = StaffAccount.all
end
-
- def new
- @user = User.new
- @staff = StaffAccount.new(user: @user)
- end
-
- def create
- @user = User.new(user_params.merge(password: SecureRandom.hex(8)).except(:staff_account_attributes))
- @user.staff_account = StaffAccount.new
-
- if @user.save
- @user.staff_account.add_role(user_params[:staff_account_attributes][:roles])
- redirect_to staff_index_path, notice: "Staff saved successfully."
- else
- render :new, status: :unprocessable_entity
- end
- end
-
- private
-
- def user_params
- params.require(:user)
- .permit(:first_name, :last_name, :email,
- staff_account_attributes: [:roles])
- end
end
diff --git a/app/models/pet.rb b/app/models/pet.rb
index 0932786e3..f9667d8aa 100644
--- a/app/models/pet.rb
+++ b/app/models/pet.rb
@@ -10,6 +10,7 @@
# name :string
# pause_reason :integer default("not_paused")
# sex :string
+# species :integer not null
# weight_from :integer not null
# weight_to :integer not null
# weight_unit :string not null
diff --git a/app/models/user.rb b/app/models/user.rb
index 35bd4a121..a637af8bb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -6,6 +6,13 @@
# email :string default(""), not null
# encrypted_password :string default(""), not null
# first_name :string not null
+# invitation_accepted_at :datetime
+# invitation_created_at :datetime
+# invitation_limit :integer
+# invitation_sent_at :datetime
+# invitation_token :string
+# invitations_count :integer default(0)
+# invited_by_type :string
# last_name :string not null
# remember_created_at :datetime
# reset_password_sent_at :datetime
@@ -13,11 +20,15 @@
# tos_agreement :boolean
# created_at :datetime not null
# updated_at :datetime not null
+# invited_by_id :bigint
# organization_id :bigint
#
# Indexes
#
# index_users_on_email (email) UNIQUE
+# index_users_on_invitation_token (invitation_token) UNIQUE
+# index_users_on_invited_by (invited_by_type,invited_by_id)
+# index_users_on_invited_by_id (invited_by_id)
# index_users_on_organization_id (organization_id)
# index_users_on_reset_password_token (reset_password_token) UNIQUE
#
@@ -35,7 +46,7 @@ class User < ApplicationRecord
end
end
- devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
+ devise :invitable, :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
validates :first_name, presence: true
validates :last_name, presence: true
diff --git a/app/views/organizations/invitations/_form.html.erb b/app/views/organizations/invitations/_form.html.erb
new file mode 100644
index 000000000..3effd12fc
--- /dev/null
+++ b/app/views/organizations/invitations/_form.html.erb
@@ -0,0 +1,39 @@
+
+<%= bootstrap_form_with model: user, url: invitation_path(user) do |form| %>
+
+
+
+
+
+ <%= form.text_field :first_name, class: 'form-control', required: true %>
+
+
+
+
+
+ <%= form.text_field :last_name, class: 'form-control', required: true %>
+
+
+
+
+
+ <%= form.text_field :email, class: 'form-control', required: true %>
+
+
+
+
+ <%= form.fields_for :staff_account do |staff_subform| %>
+
+
+ <%= staff_subform.select :roles, options_for_select([['Staff', :staff], ['Admin', :admin]]), class: 'form-control', required: true %>
+
+
+ <% end %>
+
+
+
+ <%= form.submit 'Send invite', class: 'btn btn-primary' %>
+
+
+
+<% end %>
diff --git a/app/views/organizations/invitations/edit.html.erb b/app/views/organizations/invitations/edit.html.erb
new file mode 100644
index 000000000..9ad790101
--- /dev/null
+++ b/app/views/organizations/invitations/edit.html.erb
@@ -0,0 +1,21 @@
+<%= t "devise.invitations.edit.header" %>
+
+<%= form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :put }) do |f| %>
+ <%= f.hidden_field :invitation_token, readonly: true %>
+
+ <% if f.object.class.require_password_on_accepting %>
+
+ <%= f.label :password %>
+ <%= f.password_field :password %>
+
+
+
+ <%= f.label :password_confirmation %>
+ <%= f.password_field :password_confirmation %>
+
+ <% end %>
+
+
+ <%= f.submit t("devise.invitations.edit.submit_button") %>
+
+<% end %>
diff --git a/app/views/organizations/staff/new.html.erb b/app/views/organizations/invitations/new.html.erb
similarity index 90%
rename from app/views/organizations/staff/new.html.erb
rename to app/views/organizations/invitations/new.html.erb
index 80549ce87..616ee3bda 100644
--- a/app/views/organizations/staff/new.html.erb
+++ b/app/views/organizations/invitations/new.html.erb
@@ -1,7 +1,7 @@
- <%= provide(:header_title, "New staff") %>
+ <%= provide(:header_title, "Invite staff") %>
<%= content_for :button do %>
<%= link_to "Back to staff", staff_index_path, class: "btn btn-primary" %>
@@ -23,4 +23,4 @@
-
\ No newline at end of file
+
diff --git a/app/views/organizations/mailer/invitation_instructions.html.erb b/app/views/organizations/mailer/invitation_instructions.html.erb
new file mode 100644
index 000000000..27a1c0c4c
--- /dev/null
+++ b/app/views/organizations/mailer/invitation_instructions.html.erb
@@ -0,0 +1,11 @@
+<%= t("devise.mailer.invitation_instructions.hello", email: @resource.email) %>
+
+<%= t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) %>
+
+<%= link_to t("devise.mailer.invitation_instructions.accept"), accept_invitation_url(@resource, invitation_token: @token) %>
+
+<% if @resource.invitation_due_at %>
+ <%= t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) %>
+<% end %>
+
+<%= t("devise.mailer.invitation_instructions.ignore") %>
diff --git a/app/views/organizations/mailer/invitation_instructions.text.erb b/app/views/organizations/mailer/invitation_instructions.text.erb
new file mode 100644
index 000000000..f4912bf46
--- /dev/null
+++ b/app/views/organizations/mailer/invitation_instructions.text.erb
@@ -0,0 +1,11 @@
+<%= t("devise.mailer.invitation_instructions.hello", email: @resource.email) %>
+
+<%= t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) %>
+
+<%= accept_invitation_url(@resource, invitation_token: @token) %>
+
+<% if @resource.invitation_due_at %>
+ <%= t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) %>
+<% end %>
+
+<%= t("devise.mailer.invitation_instructions.ignore") %>
diff --git a/app/views/organizations/staff/_form.html.erb b/app/views/organizations/staff/_form.html.erb
deleted file mode 100644
index 99c54ce50..000000000
--- a/app/views/organizations/staff/_form.html.erb
+++ /dev/null
@@ -1,68 +0,0 @@
-
-<%= form_with model: user, url: staff_index_path, method: :post do |form| %>
- <% if user.errors.count > 0 %>
-
-
- <%= t 'adopter_profiles.form.please_fix_the_errors' %>
-
-
- <% end %>
-
-
-
-
-
- <%= form.label :first_name, class: 'form-label' do %>
- First name
*
- <% end %>
-
- <%= form.text_field :first_name, class: 'form-control', required: true %>
-
- <% user.errors.full_messages_for(:first_name).each do |message| %>
-
<%= message %>
- <% end %>
-
-
-
- <%= form.label :last_name, class: 'form-label' do %>
- Last name
*
- <% end %>
-
- <%= form.text_field :last_name, class: 'form-control', required: true %>
-
- <% user.errors.full_messages_for(:last_name).each do |message| %>
-
<%= message %>
- <% end %>
-
-
-
- <%= form.label :email, class: 'form-label' do %>
- Email
*
- <% end %>
-
- <%= form.text_field :email, class: 'form-control', required: true %>
-
- <% user.errors.full_messages_for(:email).each do |message| %>
-
<%= message %>
- <% end %>
-
-
-
- <%= form.fields_for :staff_account do |staff_subform| %>
-
- <%= staff_subform.label :roles, class: 'form-label' do %>
- Role
*
- <% end %>
-
- <%= staff_subform.select :roles, options_for_select([['Staff', :staff], ['Admin', :admin]]), class: 'form-control', required: true %>
-
-
- <% end %>
-
-
-
- <%= form.submit 'Save profile', class: 'btn btn-primary' %>
-
-
-
-<% end %>
diff --git a/app/views/organizations/staff/index.html.erb b/app/views/organizations/staff/index.html.erb
index 7e4554896..9e87695ee 100644
--- a/app/views/organizations/staff/index.html.erb
+++ b/app/views/organizations/staff/index.html.erb
@@ -3,7 +3,7 @@
<%= content_for :button do %>
- <%= link_to "New Staff", new_staff_path, class: "btn btn-primary" %>
+ <%= link_to "Invite Staff", new_user_invitation_path, class: "btn btn-primary" %>
<% end %>
\ No newline at end of file
+
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 69c9dc95a..0e70996a8 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -134,6 +134,55 @@
# Send a notification email when the user's password is changed.
# config.send_password_change_notification = false
+ # ==> Configuration for :invitable
+ # The period the generated invitation token is valid.
+ # After this period, the invited resource won't be able to accept the invitation.
+ # When invite_for is 0 (the default), the invitation won't expire.
+ # config.invite_for = 2.weeks
+
+ # Number of invitations users can send.
+ # - If invitation_limit is nil, there is no limit for invitations, users can
+ # send unlimited invitations, invitation_limit column is not used.
+ # - If invitation_limit is 0, users can't send invitations by default.
+ # - If invitation_limit n > 0, users can send n invitations.
+ # You can change invitation_limit column for some users so they can send more
+ # or less invitations, even with global invitation_limit = 0
+ # Default: nil
+ # config.invitation_limit = 5
+
+ # The key to be used to check existing users when sending an invitation
+ # and the regexp used to test it when validate_on_invite is not set.
+ # config.invite_key = { email: /\A[^@]+@[^@]+\z/ }
+ # config.invite_key = { email: /\A[^@]+@[^@]+\z/, username: nil }
+
+ # Ensure that invited record is valid.
+ # The invitation won't be sent if this check fails.
+ # Default: false
+ # config.validate_on_invite = true
+
+ # Resend invitation if user with invited status is invited again
+ # Default: true
+ # config.resend_invitation = false
+
+ # The class name of the inviting model. If this is nil,
+ # the #invited_by association is declared to be polymorphic.
+ # Default: nil
+ # config.invited_by_class_name = 'User'
+
+ # The foreign key to the inviting model (if invited_by_class_name is set)
+ # Default: :invited_by_id
+ # config.invited_by_foreign_key = :invited_by_id
+
+ # The column name used for counter_cache column. If this is nil,
+ # the #invited_by association is declared without counter_cache.
+ # Default: nil
+ # config.invited_by_counter_cache = :invitations_count
+
+ # Auto-login after the user accepts the invite. If this is false,
+ # the user will need to manually log in after accepting the invite.
+ # Default: true
+ # config.allow_insecure_sign_in_after_accept = false
+
# ==> Configuration for :confirmable
# A period that the user is allowed to access the website even without
# confirming their account. For instance, if set to 2.days, the user will be
diff --git a/config/locales/devise_invitable.en.yml b/config/locales/devise_invitable.en.yml
new file mode 100644
index 000000000..af377fee4
--- /dev/null
+++ b/config/locales/devise_invitable.en.yml
@@ -0,0 +1,31 @@
+en:
+ devise:
+ failure:
+ invited: "You have a pending invitation, accept it to finish creating your account."
+ invitations:
+ send_instructions: "An invitation email has been sent to %{email}."
+ invitation_token_invalid: "The invitation token provided is not valid!"
+ updated: "Your password was set successfully. You are now signed in."
+ updated_not_active: "Your password was set successfully."
+ no_invitations_remaining: "No invitations remaining"
+ invitation_removed: "Your invitation was removed."
+ new:
+ header: "Send invitation"
+ submit_button: "Send an invitation"
+ edit:
+ header: "Set your password"
+ submit_button: "Set my password"
+ mailer:
+ invitation_instructions:
+ subject: "Invitation instructions"
+ hello: "Hello %{email}"
+ someone_invited_you: "Someone has invited you to %{url}, you can accept it through the link below."
+ accept: "Accept invitation"
+ accept_until: "This invitation will be due in %{due_date}."
+ ignore: "If you don't want to accept the invitation, please ignore this email."
+ time:
+ formats:
+ devise:
+ mailer:
+ invitation_instructions:
+ accept_until_format: "%B %d, %Y %I:%M %p"
diff --git a/config/routes.rb b/config/routes.rb
index 34c009ba1..8347cfcd6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,7 +3,8 @@
devise_for :users, controllers: {
registrations: "registrations",
- sessions: "users/sessions"
+ sessions: "users/sessions",
+ invitations: "organizations/invitations"
}
resources :adoptable_pets, only: [:index, :show]
diff --git a/db/migrate/20231009132343_devise_invitable_add_to_users.rb b/db/migrate/20231009132343_devise_invitable_add_to_users.rb
new file mode 100644
index 000000000..540ee41c5
--- /dev/null
+++ b/db/migrate/20231009132343_devise_invitable_add_to_users.rb
@@ -0,0 +1,26 @@
+class DeviseInvitableAddToUsers < ActiveRecord::Migration[7.0]
+ def up
+ safety_assured do
+ change_table :users do |t|
+ t.string :invitation_token
+ t.datetime :invitation_created_at
+ t.datetime :invitation_sent_at
+ t.datetime :invitation_accepted_at
+ t.integer :invitation_limit
+ t.references :invited_by, polymorphic: true
+ t.integer :invitations_count, default: 0
+ t.index :invitation_token, unique: true # for invitable
+ t.index :invited_by_id
+ end
+ end
+ end
+
+ def down
+ safety_assured do
+ change_table :users do |t|
+ t.remove_references :invited_by, polymorphic: true
+ t.remove :invitations_count, :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token, :invitation_created_at
+ end
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b8742cd22..aa17b5d85 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.0].define(version: 2023_10_05_144432) do
+ActiveRecord::Schema[7.0].define(version: 2023_10_09_132343) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -235,7 +235,18 @@
t.datetime "updated_at", null: false
t.boolean "tos_agreement"
t.bigint "organization_id"
+ t.string "invitation_token"
+ t.datetime "invitation_created_at"
+ t.datetime "invitation_sent_at"
+ t.datetime "invitation_accepted_at"
+ t.integer "invitation_limit"
+ t.string "invited_by_type"
+ t.bigint "invited_by_id"
+ t.integer "invitations_count", default: 0
t.index ["email"], name: "index_users_on_email", unique: true
+ t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
+ t.index ["invited_by_id"], name: "index_users_on_invited_by_id"
+ t.index ["invited_by_type", "invited_by_id"], name: "index_users_on_invited_by"
t.index ["organization_id"], name: "index_users_on_organization_id"
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
diff --git a/test/factories.rb b/test/factories.rb
index 3fd76bc5e..cd4eaba93 100644
--- a/test/factories.rb
+++ b/test/factories.rb
@@ -154,6 +154,12 @@
trait :unverified do
verified { false }
end
+
+ trait :admin do
+ after :create do |staff_account|
+ staff_account.add_role(:admin, staff_account.organization)
+ end
+ end
end
factory :user do
@@ -168,6 +174,10 @@
staff_account
end
+ trait :staff_admin do
+ association(:staff_account, :admin)
+ end
+
trait :unverified_staff do
staff_account { build(:staff_account, :unverified) }
end
diff --git a/test/integration/organizations/invite_staff_test.rb b/test/integration/organizations/invite_staff_test.rb
new file mode 100644
index 000000000..0b12cacff
--- /dev/null
+++ b/test/integration/organizations/invite_staff_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class Organizations::InviteStaffTest < ActionDispatch::IntegrationTest
+ setup do
+ @user_invitation_params = {
+ user: {
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com",
+ staff_account_attributes: {roles: "admin"}
+ }
+ }
+ end
+
+ test "staff admin can invite other staffs to the organization" do
+ sign_in create(:user, :staff_admin)
+
+ post(
+ user_invitation_path,
+ params: @user_invitation_params
+ )
+
+ assert_response :redirect
+
+ invited_user = User.find_by(email: "john@example.com")
+
+ assert invited_user.invited_to_sign_up?
+ assert invited_user.staff_account.has_role?(:admin)
+ assert invited_user.staff_account.verified?
+
+ assert_equal ActionMailer::Base.deliveries.count, 1
+ end
+
+ test "staff admin can not invite existing user to the organization" do
+ sign_in create(:user, :staff_admin)
+ _existing_user = create(:user, email: "john@example.com")
+
+ post(
+ user_invitation_path,
+ params: @user_invitation_params
+ )
+
+ assert_response :unprocessable_entity
+ end
+end