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 %> - - <% 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| %> - - <% 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| %> - - <% 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| %> - - <% 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 %>
@@ -140,4 +140,4 @@ -
\ 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