diff --git a/Gemfile b/Gemfile index 3ecd69d4a..2c31d8655 100755 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ gem 'turbolinks', '~> 5' gem 'colorize', require: false gem 'cookies_eu' gem 'devise', '>= 4.6.2' +gem 'devise_invitable', '~> 2.0' gem 'font-awesome-sass', '>= 4.4.0' gem 'gabba' gem 'intercom-rails' diff --git a/Gemfile.lock b/Gemfile.lock index a502feb5a..e357eae77 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,6 +93,9 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise_invitable (2.0.3) + actionmailer (>= 5.0) + devise (>= 4.6) docile (1.3.2) erubi (1.10.0) execjs (2.7.0) @@ -305,6 +308,7 @@ DEPENDENCIES colorize cookies_eu devise (>= 4.6.2) + devise_invitable (~> 2.0) font-awesome-sass (>= 4.4.0) foreman gabba diff --git a/README.md b/README.md index 7929fea09..5c3c4becf 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,20 @@ which will install/upgrade the Node module, and then save that dependency to `pa Then check in the updated `package.json` and `yarn.lock` files. +## I'd like to use a new Ruby Gem + +Typically you would simply do: + +``` +bin/docker r bundle add foobar +``` + +which will install the new Gem, and then save that dependency to `Gemfile`. + +Then check in the updated `package.json` and `yarn.lock` files. For good measure +run the `bin/setup_docker`. + + ## I'd like to test SSL There's a directory `.ssl` that contains they key and cert files used for SSL. This is a self signed generated certificate for use in development ONLY! @@ -451,11 +465,11 @@ heroku restart -a quepid-staging ## Seed Data -The following accounts are created through the seeds. The all follow the following format: +The following accounts are created through the seeds. They all follow the following format: ``` email: quepid+[type]@o19s.com -password: quepid+[type] +password: password ``` where type is one of the following: @@ -498,7 +512,6 @@ To comply with GDPR, and be a good citizen, the hosted version of Quepid asks if EMAIL_MARKETING_MODE=true # Enables a checkbox on user signup to consent to emails ``` - # Thank You's Quepid would not be possible without the contributions from many individuals and organizations. diff --git a/UPGRADING_RAILS_NOTES.md b/UPGRADING_RAILS_NOTES.md index c3e423707..171b45989 100644 --- a/UPGRADING_RAILS_NOTES.md +++ b/UPGRADING_RAILS_NOTES.md @@ -14,8 +14,33 @@ Todos: * DONE (Changed my mind, I used it to reduce some extra sql joins etc) rip out extra dev analystics stuff * DONE (no issue!) export of general and detail from js doesn't work. * DONE Look at session in home_controller, do we use it??? +* DONE, (password blank works fine). Chase down why :password="" is needed when inviting a user. +* Deal with the format of the emails! Make them quepid qlassy. +* DONE Deal with environment variable for disabling forms. +https://github.com/gonzalo-bulnes/simple_token_authentication +https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html +https://github.com/lynndylanhurley/devise_token_auth + +https://www.codementor.io/@gowiem/deviseinvitable-rails-api-9wzmbisus + + +User.invite!(email: 'joe@example.com', name: 'Joe', password:'password') + +user = User.invite!(email: 'joe3@example.com', name: 'joe3', password:'password') do |u| + u.skip_invitation = true +end + +user = User.invite!({ email: 'joe8@example.com', name: 'Joe8', password:'password' }, current_user) +User.invite!({ email: 'new_user@example.com' }, current_user) + + +User.accept_invitation!(invitation_token: params[:invitation_token], password: 'ad97nwj3o2', name: 'John Doe') +User.accept_invitation!(invitation_token: '9ngHVdcWyvSNrg54a8yj', password: 'ad97nwj3o2', name: 'John Doe') + + +user = User.invite!({ email: 'joe9@example.com' }, current_user) KEY! http://railsdiff.org/4.2.11/5.2.4.4 diff --git a/app/assets/javascripts/components/add_member/add_member.html b/app/assets/javascripts/components/add_member/add_member.html index 476880998..f2307eb2a 100644 --- a/app/assets/javascripts/components/add_member/add_member.html +++ b/app/assets/javascripts/components/add_member/add_member.html @@ -1,28 +1,40 @@
- + - - - Add + Add user + + + + Send invitation + +

- Start typing an email to add user as a member to this team. - Once you select a user, click on "Add" to add them as a member. + Start typing an email address to add an existing user to this team. + If the email address doesn't match anyone, you will be able to invite them to join Quepid and this team.

+
diff --git a/app/assets/javascripts/components/add_member/add_member_controller.js b/app/assets/javascripts/components/add_member/add_member_controller.js index 2ad0f87ae..aefd944c7 100644 --- a/app/assets/javascripts/components/add_member/add_member_controller.js +++ b/app/assets/javascripts/components/add_member/add_member_controller.js @@ -22,6 +22,8 @@ angular.module('QuepidApp') ctrl.addMember = addMember; ctrl.getUsers = getUsers; ctrl.selectMember = selectMember; + ctrl.testIsEmailAddress = testIsEmailAddress; + ctrl.inviteUserToJoin = inviteUserToJoin; function selectMember($item) { ctrl.selectedMember = $item; @@ -53,5 +55,26 @@ angular.module('QuepidApp') return response.data.users; }); } + + function inviteUserToJoin() { + teamSvc.inviteUserToJoin(ctrl.team, ctrl.selected) + .then(function() { + flash.success = 'Invitation sent to ' + ctrl.selected; + ctrl.selected = ''; + }, function(response) { + flash.error = response.data.error; + }); + + } + + function testIsEmailAddress(val) { + var emailVer = /^[a-z]+[a-z0-9._]+@[a-z]+\.[a-z.]{2,5}$/; + if( emailVer.test(val) ){ + return true; + } + else { + return false; + } + } } ]); diff --git a/app/assets/javascripts/components/move_query/_modal.html b/app/assets/javascripts/components/move_query/_modal.html index f91e7fdf8..4abcf1b91 100644 --- a/app/assets/javascripts/components/move_query/_modal.html +++ b/app/assets/javascripts/components/move_query/_modal.html @@ -10,7 +10,13 @@

-
+
+

+ Please create another case to move this query to first. +

+
+ +

Select a case:

-
+
@@ -51,24 +51,19 @@

- Add Member + Add Team Member

-
+

Change Owner

diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb index 32a41ea61..652742cd4 100644 --- a/app/controllers/api/api_controller.rb +++ b/app/controllers/api/api_controller.rb @@ -27,6 +27,10 @@ def test render json: { message: 'Success!' }, status: :ok end + def signup_enabled? + Rails.application.config.signup_enabled + end + protected def set_default_response_format diff --git a/app/controllers/api/v1/signups_controller.rb b/app/controllers/api/v1/signups_controller.rb index a2ca0184d..2dd50c1a0 100644 --- a/app/controllers/api/v1/signups_controller.rb +++ b/app/controllers/api/v1/signups_controller.rb @@ -8,7 +8,6 @@ class SignupsController < Api::ApiController def create @user = User.new user_params - @user.agreed_time = Time.zone.now if @user.save Analytics::Tracker.track_signup_event @user diff --git a/app/controllers/api/v1/team_members_controller.rb b/app/controllers/api/v1/team_members_controller.rb index 0289f4b0d..e60d3da75 100644 --- a/app/controllers/api/v1/team_members_controller.rb +++ b/app/controllers/api/v1/team_members_controller.rb @@ -3,8 +3,8 @@ module Api module V1 class TeamMembersController < Api::ApiController - before_action :set_team, only: [ :index, :create, :destroy ] - before_action :check_team, only: [ :index, :create, :destroy ] + before_action :set_team, only: [ :index, :create, :destroy, :invite ] + before_action :check_team, only: [ :index, :create, :destroy, :invite ] def index @members = @team.members @@ -29,6 +29,24 @@ def create end end + def invite + unless signup_enabled? + render json: { error: 'Signups are disabled!' }, status: :not_found + return + end + + @member = User.invite!({ email: params[:id], password: '' }, current_user) + + @team.members << @member unless @team.members.exists?(@member.id) + + if @team.save + Analytics::Tracker.track_member_added_to_team_event current_user, @team, @member + respond_with @member + else + render json: @member.errors, status: :bad_request + end + end + def destroy member = @team.members.where('email = ? OR id = ?', params[:id].to_s.downcase, params[:id]) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index a0b9abf75..7b038331b 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -9,7 +9,7 @@ def index @users = [] if params[:prefix] prefix = params[:prefix].downcase - @users = User.where('email LIKE :prefix OR name LIKE :prefix', prefix: "#{prefix}%").limit(8) + @users = User.where('`email` LIKE :prefix', prefix: "#{prefix}%").limit(8) end respond_with @users end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 97b668cff..3167f50b3 100755 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -23,4 +23,8 @@ class ApplicationController < ActionController::Base def ssl_enabled? 'true' == ENV['FORCE_SSL'] end + + def signup_enabled? + Rails.application.config.signup_enabled + end end diff --git a/app/controllers/concerns/authentication/current_team_manager.rb b/app/controllers/concerns/authentication/current_team_manager.rb index 64aa582c2..10b7d47bb 100644 --- a/app/controllers/concerns/authentication/current_team_manager.rb +++ b/app/controllers/concerns/authentication/current_team_manager.rb @@ -7,9 +7,10 @@ module CurrentTeamManager private def set_team - @team = current_user.teams_im_in.where(id: params[:team_id]) - .preload(:members) - .first + # @team = current_user.teams_im_in.where(id: params[:team_id]) + # .preload(:members) + # .first + @team = current_user.teams_im_in.where(id: params[:team_id]).first end def check_team diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb new file mode 100644 index 000000000..e13edd74f --- /dev/null +++ b/app/controllers/users/invitations_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Users + class InvitationsController < Devise::InvitationsController + force_ssl if: :ssl_enabled? + skip_before_action :require_login, only: [ :edit, :update ] + + def update + unless signup_enabled? + flash.now[:error] = 'Signups are disabled.' + redirect_to secure_path and return + end + + super + + @user.agreed_time = Time.zone.now + session[:current_user_id] = @user.id + Analytics::Tracker.track_signup_event @user + end + + # rubocop:disable Lint/UselessMethodDefinition + def edit + super + end + # rubocop:enable Lint/UselessMethodDefinition + + private + + def update_resource_params + params.require(:user).permit( + :name, + :email, + :invitation_token, + :password, + :password_confirmation, + :agreed, + :email_marketing + ) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 2e9d3b939..6107a118f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -68,28 +68,38 @@ class User < ApplicationRecord dependent: :destroy # Validations + + # https://davidcel.is/posts/stop-validating-email-addresses-with-regex/ validates :email, presence: true, - uniqueness: true + uniqueness: true, + format: { with: URI::MailTo::EMAIL_REGEXP } validates :password, presence: true validates_with ::DefaultScorerExistsValidator + validates :agreed, + acceptance: { message: 'You must agree to the terms and conditions.' }, + if: :terms_and_conditions? + + def terms_and_conditions? + Rails.application.config.terms_and_conditions_url.length.positive? + end + # Modules # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable - # devise :database_authenticatable, :registerable, + # devise :invitable, :database_authenticatable, :registerable, # :recoverable, :rememberable, :trackable, :validatable - devise :recoverable, reset_password_keys: [ :email ] + devise :invitable, :recoverable, reset_password_keys: [ :email ] # Callbacks before_save :encrypt_password + before_save :check_agreed_time before_create :set_defaults - # after_create :add_default_case - # Devise hacks since we only use the recoverable module attr_accessor :password_confirmation @@ -131,7 +141,7 @@ def cases_involved_with # Returns all the teams that the user is both owner of and involved in! def teams_im_in - UserTeamFinder.new(self).call + UserTeamFinder.new(self) end def locked? @@ -148,6 +158,10 @@ def unlock self.locked_at = nil end + def after_database_authentication + # required by devise_invitable + end + private def set_defaults @@ -156,15 +170,17 @@ def set_defaults self.num_logins = 0 if num_logins.nil? self.default_scorer = Scorer.system_default_scorer if self.default_scorer.nil? # rubocop:enable Style/RedundantSelf - - # this is necessary because it will rollback - # the creation/update of the user otherwise - true end def encrypt_password self[:password] = BCrypt::Password.create(password) if password.present? && password_changed? + end + + def check_agreed_time + return unless terms_and_conditions? + + return unless agreed && agreed_time.nil? - true + self[:agreed_time] = Time.zone.now end end diff --git a/app/services/user_team_finder.rb b/app/services/user_team_finder.rb index e94ea8ae9..98a677776 100644 --- a/app/services/user_team_finder.rb +++ b/app/services/user_team_finder.rb @@ -5,10 +5,18 @@ class UserTeamFinder def initialize user @user = user + @teams = Team.references(:users).for_user(@user) end - def call - Team.references(:users) - .for_user(@user) + def method_missing method_name, *arguments, &block + if @teams.respond_to? method_name + @teams.send(method_name, *arguments, &block) + else + super + end + end + + def respond_to_missing? method_name, include_private = false + @teams.respond_to?(method_name) || super end end diff --git a/app/views/api/v1/snapshots/_snapshot.json.jbuilder b/app/views/api/v1/snapshots/_snapshot.json.jbuilder index b66d6e358..a4d58b90c 100644 --- a/app/views/api/v1/snapshots/_snapshot.json.jbuilder +++ b/app/views/api/v1/snapshots/_snapshot.json.jbuilder @@ -18,6 +18,7 @@ end if with_docs json.queries do - json.array! snapshot.snapshot_queries.collect(&:query), partial: 'api/v1/queries/query', as: :query + # filter out deleted queries from the snapshot via the .compact method. + json.array! snapshot.snapshot_queries.collect(&:query).compact, partial: 'api/v1/queries/query', as: :query end end diff --git a/app/views/api/v1/team_members/_member.json.jbuilder b/app/views/api/v1/team_members/_member.json.jbuilder index e241466d2..224664f8a 100644 --- a/app/views/api/v1/team_members/_member.json.jbuilder +++ b/app/views/api/v1/team_members/_member.json.jbuilder @@ -4,3 +4,4 @@ json.id member.id json.display_name member.display_name json.email member.email json.avatar_url member.avatar_url(:big) +json.pending_invite member.created_by_invite? && !member.invitation_accepted? diff --git a/app/views/api/v1/team_members/invite.json.jbuilder b/app/views/api/v1/team_members/invite.json.jbuilder new file mode 100644 index 000000000..63757cff1 --- /dev/null +++ b/app/views/api/v1/team_members/invite.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'member', member: @member diff --git a/app/views/api/v1/teams/_team.json.jbuilder b/app/views/api/v1/teams/_team.json.jbuilder index 2d80df685..b6934ba8f 100644 --- a/app/views/api/v1/teams/_team.json.jbuilder +++ b/app/views/api/v1/teams/_team.json.jbuilder @@ -16,6 +16,7 @@ json.members team.members do |member| json.display_name member.display_name json.email member.email json.avatar_url member.avatar_url(:big) + json.pending_invite member.created_by_invite? && !member.invitation_accepted? end if load_cases diff --git a/app/views/devise/mailer/invitation_instructions.html.erb b/app/views/devise/mailer/invitation_instructions.html.erb new file mode 100644 index 000000000..8ea6556ec --- /dev/null +++ b/app/views/devise/mailer/invitation_instructions.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @resource.email %>

+ +<%= User.find_by(id: @resource.invited_by_id).name %> has invited you to join their team on Quepid at <%= root_url %>, you can accept it through the link below.

+ +

<%= accept_invitation_url(@resource, invitation_token: @token) %>

+ +

If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password.

diff --git a/app/views/devise/mailer/invitation_instructions.text.erb b/app/views/devise/mailer/invitation_instructions.text.erb new file mode 100644 index 000000000..d554c2265 --- /dev/null +++ b/app/views/devise/mailer/invitation_instructions.text.erb @@ -0,0 +1,7 @@ +Hello <%= @resource.email %> + +<%= User.find_by(id: @resource.invited_by_id).name %> has invited you to join their team on Quepid at <%= root_url %>, you can accept it through the link below. + +<%= accept_invitation_url(@resource, invitation_token: @token) %> + +If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password. diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 406e73f5e..6f1fa85c2 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -1,6 +1,6 @@

Hello <%= @resource.display_name %>!

-

Someone has requested a link to change your password.

+

Someone has requested a link to change your password on Quepid.

You can do this by using the one time link below:
diff --git a/app/views/users/invitations/edit.html.erb b/app/views/users/invitations/edit.html.erb new file mode 100644 index 000000000..7783afcad --- /dev/null +++ b/app/views/users/invitations/edit.html.erb @@ -0,0 +1,75 @@ +

+
+
+

Accept the Invitation to join the team on Quepid!

+

+

+
+
+
+ <%= form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :put }) do |f| %> + <%= f.hidden_field :invitation_token, readonly: true %> +

+ + <% if resource.errors.any? %> +
+

<%= pluralize(resource.errors.count, "error") %> prohibited this invitation from being processed:

+ + <% resource.errors.full_messages.each do |message| %> +
+ <%= message %> +
+ <% end %> +
+ <% end %> + +
+ <%= f.text_field :name, class: 'form-control name', placeholder: 'Full Name' %> +
+ +
+ <%= f.text_field :email, class: 'form-control email', placeholder: 'Email Address' %> +
+ +
+ <%= f.password_field :password, class: 'form-control password', placeholder: 'Password' %> +
+ +
+ <%= f.password_field :password_confirmation, class: 'form-control password-confirm', placeholder: 'Confirm Password' %> +
+ + <% if Rails.application.config.terms_and_conditions_url.size > 0 %> +
+ +
+ <% end %> + + <% if Rails.application.config.email_marketing_mode %> +
+ +
+ <% end %> + + <%= f.submit 'Accept Invite', class: 'btn btn-primary signup' %> + <% end %> +
+
+
+ +
+
+

+ Read more about the Quepid story. Contact us + to talk about Quepid or ask how we can help with your search problems! +

+
+
+
diff --git a/config/environments/test.rb b/config/environments/test.rb index 22e096fde..fb4afc597 100755 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -60,7 +60,9 @@ # Raises error for missing translations # config.action_view.raise_on_missing_translations = true - config.action_mailer.default_url_options = { host: ENV['QUEPID_DOMAIN'], port: ENV['PORT'] } + config.action_mailer.default_url_options = { host: 'localhost', port: '3000' } + + ENV['TC_URL'] = 'https://quepid.com/agreement' ENV['QUEPID_GA'] = 'UA-FAKE-GA-CODE-FOR-TESTING' end diff --git a/config/initializers/customize_quepid.rb b/config/initializers/customize_quepid.rb index 13cc2ea54..4c1f4dafd 100644 --- a/config/initializers/customize_quepid.rb +++ b/config/initializers/customize_quepid.rb @@ -5,6 +5,8 @@ # This file checks for various customization options passed in as environment # variables. +bool = ActiveRecord::Type::Boolean.new + # == Quepid Default Scorer # New users to Quepid need to have a recommended scorer to use, which they can then # override to their own preferred scorer, either one of the defaults shipped with Quepid @@ -17,7 +19,7 @@ # if they are willing to receive Quepid related updates via email. This feature # isn't useful to private installs, so this controls the display. # -Rails.application.config.email_marketing_mode = ENV.fetch('EMAIL_MARKETING_MODE', false) +Rails.application.config.email_marketing_mode = bool.deserialize(ENV.fetch('EMAIL_MARKETING_MODE', false)) # == Cookies Policy URL # To comply with GDPR, and be a good citizen, the hosted version of Quepid asks @@ -42,13 +44,11 @@ # == Enable signup # This parameter controls whether or not signing up via the UI is enabled. -Rails.application.config.signup_enabled = ENV.fetch('SIGNUP_ENABLED', true) +Rails.application.config.signup_enabled = bool.deserialize(ENV.fetch('SIGNUP_ENABLED', true)) # == Communal Scorers Only # Users can normally create custom scorers which run embedded javascript, this is a potential # security flaw as malicious javascript could be entered. This setting restricts users to # communal scorers only, which are controlled by admins. # -Rails.application.config.communal_scorers_only = ENV.fetch('COMMUNAL_SCORERS_ONLY', false) -Rails.application.config.communal_scorers_only = true if 'true' == Rails.application.config.communal_scorers_only -Rails.application.config.communal_scorers_only = false if 'false' == Rails.application.config.communal_scorers_only +Rails.application.config.communal_scorers_only = bool.deserialize(ENV.fetch('COMMUNAL_SCORERS_ONLY', false)) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index eb1974438..7e2a01591 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -108,6 +108,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..f6bfee403 --- /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. Your account won't be created until you access the link above and set your password." + 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 45a9f2291..c041d8243 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,15 +19,22 @@ post 'users/login' => 'sessions#create', defaults: { format: :json } get 'logout' => 'sessions#destroy' get 'secure' => 'secure#index' + get 'secure/complete' => 'secure#index' # end legacy routes resources :sessions resource :account, only: [ :update ] resource :profile, only: [ :show, :update ] - devise_for :users, only: [ :passwords ], controllers: { - passwords: 'users/passwords', + # not sure I get why we had the only: [ :passwords ] clause + devise_for :users, controllers: { + passwords: 'users/passwords', + invitations: 'users/invitations', } + # devise_for :users, only: [ :passwords ], controllers: { + # passwords: 'users/passwords', + # invitations: 'users/invitations' + # } namespace :admin do get '/' => 'home#index' @@ -119,6 +126,7 @@ resources :teams, except: [ :new, :edit ], param: :team_id resources :teams, only: [] do resources :members, only: [ :index, :create, :destroy ], controller: :team_members + post '/members/invite' => 'team_members#invite' resources :scorers, only: [ :index, :create, :destroy ], controller: :team_scorers resources :cases, only: [ :index, :create, :destroy ], controller: :team_cases resources :owners, only: [ :update ], controller: :team_owners diff --git a/db/migrate/20201210214439_devise_invitable_add_to_users.rb b/db/migrate/20201210214439_devise_invitable_add_to_users.rb new file mode 100644 index 000000000..4c3684357 --- /dev/null +++ b/db/migrate/20201210214439_devise_invitable_add_to_users.rb @@ -0,0 +1,24 @@ +class DeviseInvitableAddToUsers < ActiveRecord::Migration[5.2] + def up + 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 :invited_by_id # working aroudn the polymorphic throwing errors. + t.integer :invitations_count, default: 0 + # Had to customize this from the default Devise Invitable script to add a length: 191 + t.index :invitation_token, unique: true, length: { invitation_token: 191 } # for invitable + t.index :invited_by_id + end + end + + def down + 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 diff --git a/db/schema.rb b/db/schema.rb index df0b7299c..d724f9891 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,4 +1,3 @@ -# encoding: UTF-8 # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. @@ -11,235 +10,228 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20201203212611) do +ActiveRecord::Schema.define(version: 2020_12_10_214439) do - create_table "annotations", id: :integer, force: :cascade do |t| - t.text "message", limit: 65535 - t.string "source", limit: 255 - t.integer "user_id", limit: 4 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "annotations", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.text "message" + t.string "source" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_annotations_on_user_id" end - add_index "annotations", ["user_id"], name: "index_annotations_on_user_id", using: :btree - - create_table "case_metadata", id: :integer, force: :cascade do |t| - t.integer "user_id", limit: 4, null: false - t.integer "case_id", limit: 4, null: false + create_table "case_metadata", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "case_id", null: false t.datetime "last_viewed_at" + t.index ["case_id"], name: "case_metadata_ibfk_1" + t.index ["user_id", "case_id"], name: "case_metadata_user_id_case_id_index" end - add_index "case_metadata", ["case_id"], name: "case_metadata_ibfk_1", using: :btree - add_index "case_metadata", ["user_id", "case_id"], name: "case_metadata_user_id_case_id_index", using: :btree - - create_table "case_scores", id: :integer, force: :cascade do |t| - t.integer "case_id", limit: 4 - t.integer "user_id", limit: 4 - t.integer "try_id", limit: 4 - t.float "score", limit: 24 - t.boolean "all_rated" + create_table "case_scores", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "case_id" + t.integer "user_id" + t.integer "try_id" + t.float "score" + t.boolean "all_rated" t.datetime "created_at" - t.binary "queries", limit: 16777215 - t.integer "annotation_id", limit: 4 + t.binary "queries", limit: 16777215 + t.integer "annotation_id" t.datetime "updated_at" + t.index ["annotation_id"], name: "index_case_scores_on_annotation_id" + t.index ["case_id"], name: "case_id" + t.index ["user_id"], name: "user_id" end - add_index "case_scores", ["annotation_id"], name: "index_case_scores_on_annotation_id", using: :btree - add_index "case_scores", ["case_id"], name: "case_id", using: :btree - add_index "case_scores", ["user_id"], name: "user_id", using: :btree - - create_table "cases", id: :integer, force: :cascade do |t| - t.string "case_name", limit: 191 - t.integer "last_try_number", limit: 4 - t.integer "user_id", limit: 4 - t.boolean "archived" - t.integer "scorer_id", limit: 4 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "cases", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "case_name", limit: 191 + t.integer "last_try_number" + t.integer "user_id" + t.boolean "archived" + t.integer "scorer_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "user_id" end - add_index "cases", ["user_id"], name: "user_id", using: :btree - - create_table "curator_variables", id: :integer, force: :cascade do |t| - t.string "name", limit: 500 - t.float "value", limit: 24 - t.integer "try_id", limit: 4 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "curator_variables", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "name", limit: 500 + t.float "value" + t.integer "try_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["try_id"], name: "try_id" end - add_index "curator_variables", ["try_id"], name: "try_id", using: :btree - - create_table "default_scorers", id: :integer, force: :cascade do |t| - t.text "code", limit: 65535 - t.string "name", limit: 255 - t.string "scale", limit: 255 - t.boolean "manual_max_score", default: false - t.integer "manual_max_score_value", limit: 4 - t.boolean "show_scale_labels", default: false - t.text "scale_with_labels", limit: 65535 - t.string "state", limit: 255, default: "draft" + create_table "default_scorers", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.text "code" + t.string "name" + t.string "scale" + t.boolean "manual_max_score", default: false + t.integer "manual_max_score_value" + t.boolean "show_scale_labels", default: false + t.text "scale_with_labels" + t.string "state", default: "draft" t.datetime "published_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - create_table "permissions", id: :integer, force: :cascade do |t| - t.integer "user_id", limit: 4 - t.string "model_type", limit: 255, null: false - t.string "action", limit: 255, null: false - t.boolean "on", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "permissions", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "user_id" + t.string "model_type", null: false + t.string "action", null: false + t.boolean "on", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end - create_table "queries", id: :integer, force: :cascade do |t| - t.integer "arranged_next", limit: 8 - t.integer "arranged_at", limit: 8 - t.boolean "deleted" - t.string "query_text", limit: 191 - t.text "notes", limit: 65535 - t.float "threshold", limit: 24 - t.boolean "threshold_enbl" - t.integer "case_id", limit: 4 - t.integer "scorer_id", limit: 4 - t.boolean "scorer_enbl" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.text "options", limit: 65535 + create_table "queries", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.bigint "arranged_next" + t.bigint "arranged_at" + t.boolean "deleted" + t.string "query_text", limit: 191 + t.text "notes" + t.float "threshold" + t.boolean "threshold_enbl" + t.integer "case_id" + t.integer "scorer_id" + t.boolean "scorer_enbl" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "options" + t.index ["case_id"], name: "case_id" end - add_index "queries", ["case_id"], name: "case_id", using: :btree - - create_table "ratings", id: :integer, force: :cascade do |t| - t.string "doc_id", limit: 500 - t.integer "rating", limit: 4 - t.integer "query_id", limit: 4 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "ratings", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "doc_id", limit: 500 + t.integer "rating" + t.integer "query_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["doc_id"], name: "index_ratings_on_doc_id", length: 191 + t.index ["query_id"], name: "query_id" end - add_index "ratings", ["doc_id"], name: "index_ratings_on_doc_id", length: {"doc_id"=>191}, using: :btree - add_index "ratings", ["query_id"], name: "query_id", using: :btree - - create_table "scorers", id: :integer, force: :cascade do |t| - t.text "code", limit: 65535 - t.string "name", limit: 191 - t.integer "owner_id", limit: 4 - t.string "scale", limit: 255 - t.boolean "query_test" - t.integer "query_id", limit: 4 - t.boolean "manual_max_score", default: false - t.integer "manual_max_score_value", limit: 4, default: 100 - t.boolean "show_scale_labels", default: false - t.text "scale_with_labels", limit: 65535 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "communal", default: false + create_table "scorers", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.text "code" + t.string "name", limit: 191 + t.integer "owner_id" + t.string "scale" + t.boolean "query_test" + t.integer "query_id" + t.boolean "manual_max_score", default: false + t.integer "manual_max_score_value", default: 100 + t.boolean "show_scale_labels", default: false + t.text "scale_with_labels" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "communal", default: false end - create_table "snapshot_docs", id: :integer, force: :cascade do |t| - t.string "doc_id", limit: 500 - t.integer "position", limit: 4 - t.integer "snapshot_query_id", limit: 4 - t.text "explain", limit: 16777215 - t.boolean "rated_only", default: false, null: false + create_table "snapshot_docs", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "doc_id", limit: 500 + t.integer "position" + t.integer "snapshot_query_id" + t.text "explain", limit: 16777215 + t.boolean "rated_only", default: false, null: false + t.index ["snapshot_query_id"], name: "snapshot_query_id" end - add_index "snapshot_docs", ["snapshot_query_id"], name: "snapshot_query_id", using: :btree - - create_table "snapshot_queries", id: :integer, force: :cascade do |t| - t.integer "query_id", limit: 4 - t.integer "snapshot_id", limit: 4 + create_table "snapshot_queries", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "query_id" + t.integer "snapshot_id" + t.index ["query_id"], name: "query_id" + t.index ["snapshot_id"], name: "snapshot_id" end - add_index "snapshot_queries", ["query_id"], name: "query_id", using: :btree - add_index "snapshot_queries", ["snapshot_id"], name: "snapshot_id", using: :btree - - create_table "snapshots", id: :integer, force: :cascade do |t| - t.string "name", limit: 250 + create_table "snapshots", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "name", limit: 250 t.datetime "created_at" - t.integer "case_id", limit: 4 - t.datetime "updated_at", null: false + t.integer "case_id" + t.datetime "updated_at", null: false + t.index ["case_id"], name: "case_id" end - add_index "snapshots", ["case_id"], name: "case_id", using: :btree - - create_table "teams", id: :integer, force: :cascade do |t| - t.string "name", limit: 255 - t.integer "owner_id", limit: 4, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "teams", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "name" + t.integer "owner_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_teams_on_name", length: 191 + t.index ["owner_id"], name: "owner_id" end - add_index "teams", ["name"], name: "index_teams_on_name", length: {"name"=>191}, using: :btree - add_index "teams", ["owner_id"], name: "owner_id", using: :btree - - create_table "teams_cases", id: false, force: :cascade do |t| - t.integer "case_id", limit: 4, null: false - t.integer "team_id", limit: 4, null: false + create_table "teams_cases", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "case_id", null: false + t.integer "team_id", null: false + t.index ["case_id"], name: "index_teams_cases_on_case_id" + t.index ["team_id"], name: "index_teams_cases_on_team_id" end - add_index "teams_cases", ["case_id"], name: "index_teams_cases_on_case_id", using: :btree - add_index "teams_cases", ["team_id"], name: "index_teams_cases_on_team_id", using: :btree - - create_table "teams_members", id: false, force: :cascade do |t| - t.integer "member_id", limit: 4, null: false - t.integer "team_id", limit: 4, null: false + create_table "teams_members", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "member_id", null: false + t.integer "team_id", null: false + t.index ["member_id"], name: "index_teams_members_on_member_id" + t.index ["team_id"], name: "index_teams_members_on_team_id" end - add_index "teams_members", ["member_id"], name: "index_teams_members_on_member_id", using: :btree - add_index "teams_members", ["team_id"], name: "index_teams_members_on_team_id", using: :btree - - create_table "teams_scorers", id: false, force: :cascade do |t| - t.integer "scorer_id", limit: 4, null: false - t.integer "team_id", limit: 4, null: false + create_table "teams_scorers", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "scorer_id", null: false + t.integer "team_id", null: false + t.index ["scorer_id"], name: "index_teams_scorers_on_scorer_id" + t.index ["team_id"], name: "index_teams_scorers_on_team_id" end - add_index "teams_scorers", ["scorer_id"], name: "index_teams_scorers_on_scorer_id", using: :btree - add_index "teams_scorers", ["team_id"], name: "index_teams_scorers_on_team_id", using: :btree - - create_table "tries", id: :integer, force: :cascade do |t| - t.integer "try_number", limit: 4 - t.text "query_params", limit: 65535 - t.integer "case_id", limit: 4 - t.string "field_spec", limit: 500 - t.string "search_url", limit: 500 - t.string "name", limit: 50 - t.string "search_engine", limit: 50, default: "solr" - t.boolean "escape_query", default: true - t.integer "number_of_rows", limit: 4, default: 10 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "tries", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.integer "try_number" + t.text "query_params" + t.integer "case_id" + t.string "field_spec", limit: 500 + t.string "search_url", limit: 500 + t.string "name", limit: 50 + t.string "search_engine", limit: 50, default: "solr" + t.boolean "escape_query", default: true + t.integer "number_of_rows", default: 10 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["case_id"], name: "case_id" + t.index ["try_number"], name: "ix_queryparam_tryNo" end - add_index "tries", ["case_id"], name: "case_id", using: :btree - add_index "tries", ["try_number"], name: "ix_queryparam_tryNo", using: :btree - - create_table "users", id: :integer, force: :cascade do |t| - t.string "email", limit: 80 - t.string "password", limit: 120 + create_table "users", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", force: :cascade do |t| + t.string "email", limit: 80 + t.string "password", limit: 120 t.datetime "agreed_time" - t.boolean "agreed" - t.boolean "first_login" - t.integer "num_logins", limit: 4 - t.string "name", limit: 255 - t.boolean "administrator", default: false - t.string "reset_password_token", limit: 255 + t.boolean "agreed" + t.boolean "first_login" + t.integer "num_logins" + t.string "name" + t.boolean "administrator", default: false + t.string "reset_password_token" t.datetime "reset_password_sent_at" - t.string "company", limit: 255 - t.boolean "locked" + t.string "company" + t.boolean "locked" t.datetime "locked_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "default_scorer_id", limit: 4 - t.boolean "email_marketing", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "default_scorer_id" + t.boolean "email_marketing", default: false, null: false + t.string "invitation_token" + t.datetime "invitation_created_at" + t.datetime "invitation_sent_at" + t.datetime "invitation_accepted_at" + t.integer "invitation_limit" + t.integer "invited_by_id" + t.integer "invitations_count", default: 0 + t.index ["default_scorer_id"], name: "index_users_on_default_scorer_id" + t.index ["email"], name: "ix_user_email", unique: true + t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true, length: 191 + t.index ["invited_by_id"], name: "index_users_on_invited_by_id" + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, length: 191 end - add_index "users", ["default_scorer_id"], name: "index_users_on_default_scorer_id", using: :btree - add_index "users", ["email"], name: "ix_user_email", unique: true, using: :btree - add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, length: {"reset_password_token"=>191}, using: :btree - add_foreign_key "annotations", "users" add_foreign_key "case_metadata", "cases", name: "case_metadata_ibfk_1" add_foreign_key "case_metadata", "users", name: "case_metadata_ibfk_2" diff --git a/spec/javascripts/angular/services/teamSvc_spec.js b/spec/javascripts/angular/services/teamSvc_spec.js index 1a33db5f5..1b066b56b 100644 --- a/spec/javascripts/angular/services/teamSvc_spec.js +++ b/spec/javascripts/angular/services/teamSvc_spec.js @@ -52,6 +52,10 @@ describe('Service: teamSvc', function () { 'name': 'member', }; + var mockInvitee = { + 'email': 'newuser@example.com', + }; + var mockCase = { 'caseNo': 1, 'lastTry': 0, @@ -179,6 +183,22 @@ describe('Service: teamSvc', function () { $httpBackend.flush(); }); + it('invites a user to join the team', function() { + var url = '/api/teams/' + mockTeam.id + '/members/invite'; + var data = { + id: mockInvitee.email, + }; + var mockResponse = mockTeam; + + $httpBackend.expectPOST(url, data).respond(200); + + teamSvc.inviteUserToJoin(mockTeam, mockInvitee.email). + then(function(response) { + expect(mockTeam.members.length).toBe(1); + }); + $httpBackend.flush(); + }); + it('adds a case to the team', function() { var url = '/api/teams/' + mockTeam.id + '/cases'; var data = { id: mockCase.caseNo }; diff --git a/test/controllers/api/api_controller_test.rb b/test/controllers/api/api_controller_test.rb index 7039f46b6..f21f077d6 100644 --- a/test/controllers/api/api_controller_test.rb +++ b/test/controllers/api/api_controller_test.rb @@ -31,5 +31,11 @@ class ApiControllerTest < ActionController::TestCase assert 'Success!' == body['message'] end end + + describe 'Quepid Qonfiguration' do + test 'signup is enabled' do + assert @controller.signup_enabled? + end + end end end diff --git a/test/controllers/api/v1/signups_controller_test.rb b/test/controllers/api/v1/signups_controller_test.rb index 68c990c4b..4cdd338ba 100644 --- a/test/controllers/api/v1/signups_controller_test.rb +++ b/test/controllers/api/v1/signups_controller_test.rb @@ -43,6 +43,17 @@ class SignupsControllerTest < ActionController::TestCase assert_includes error['password'], I18n.t('errors.messages.blank') end + test 'returns an error when the agreed is false' do + data = { user: { email: 'foo@example.com', password: 'password2', agreed: false } } + + post :create, params: data + + assert_response :bad_request + + error = JSON.parse(response.body) + assert_includes error['agreed'], 'You must agree to the terms and conditions.' + end + test 'encrypts the password' do password = 'password' data = { user: { email: 'foo@example.com', password: password } } diff --git a/test/controllers/api/v1/snapshots_controller_test.rb b/test/controllers/api/v1/snapshots_controller_test.rb index 1794bfcaf..5826fe983 100644 --- a/test/controllers/api/v1/snapshots_controller_test.rb +++ b/test/controllers/api/v1/snapshots_controller_test.rb @@ -206,6 +206,22 @@ class SnapshotsControllerTest < ActionController::TestCase assert_equal data['name'], snapshot.name assert_equal data['docs'].length, snapshot.snapshot_queries.length end + + test 'returns snapshot when a query is deleted' do + query_count = acase.queries.size + acase.queries.first.soft_delete + acase.save! + + get :show, params: { case_id: acase.id, id: snapshot.id } + + assert_response :ok + + data = JSON.parse(response.body) + + assert_equal data['name'], snapshot.name + assert_equal data['docs'].length, snapshot.snapshot_queries.length + assert_equal data['queries'].length, (query_count - 1) + end end describe 'Deletes a snapshot' do diff --git a/test/controllers/api/v1/team_members_controller_test.rb b/test/controllers/api/v1/team_members_controller_test.rb index 1b5e7c0c4..5db777112 100644 --- a/test/controllers/api/v1/team_members_controller_test.rb +++ b/test/controllers/api/v1/team_members_controller_test.rb @@ -75,6 +75,19 @@ class TeamMembersControllerTest < ActionController::TestCase end end + describe 'Invites a new user to join Quepid and a team' do + test 'invites a new member successfully using the email' do + assert_difference 'team.members.count' do + invitee_email = 'newperson@example.com' + + post :invite, params: { team_id: team.id, id: invitee_email } + assert_response :ok + + assert json_response['pending_invite'] + end + end + end + describe 'Removes a member from team' do test 'deletes existing member successfully using the email' do assert_difference 'team.members.count', -1 do diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index e787f4458..0f0aec7a9 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -111,7 +111,7 @@ class UsersControllerTest < ActionController::TestCase end end - describe 'Search users' do + describe 'Lookup users' do describe 'when user is not an admin member' do let(:user) { users(:random) } @@ -129,6 +129,52 @@ class UsersControllerTest < ActionController::TestCase end end end + describe 'Search users' do + describe 'when user is not an admin member' do + let(:user) { users(:random) } + + before do + login_user user + end + + it 'returns a match on a email' do + get :index, params: { prefix: 'matt@' } + + assert_response :ok + + assert_instance_of Array, json_response['users'] + assert_equal 1, json_response['users'].size + + emails = json_response['users'].pluck('email') + assert_includes emails, 'matt@example.com' + end + + # Case Insensitive matches on name are too slow (600 ms instead of 250 ms) + # so we are removing for now. Someday if we have a real search index on + # users we could put this back. + # it 'does a case insensitive match on name' do + # get :index, params: { prefix: 'Doug T'} + + # assert_response :ok + + # assert_instance_of Array, json_response['users'] + # assert_equal 1, json_response['users'].size + + # emails = json_response['users'].pluck('email') + # assert_includes emails, 'doug@example.com' + + # get :index, params: { prefix: 'DOUG'} + + # assert_response :ok + + # assert_instance_of Array, json_response['users'] + # assert_equal 1, json_response['users'].size + + # emails = json_response['users'].pluck('email') + # assert_includes emails, 'doug@example.com' + # end + end + end end end end diff --git a/test/integration/api/user_invite_flow_test.rb b/test/integration/api/user_invite_flow_test.rb new file mode 100644 index 000000000..4301a09cc --- /dev/null +++ b/test/integration/api/user_invite_flow_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserInviteFlowTest < ActionDispatch::IntegrationTest + include ActionMailer::TestHelper + + test 'invite friend to join my team on Quepid' do + post users_login_url params: { email: 'doug@example.com', password: 'password', format: :json } + + # Asserts the difference in the ActionMailer::Base.deliveries + team = Team.find_by(name: 'valid team') + assert_emails 1 do + post api_team_members_invite_url(team), params: { id: 'friend@example.com' } + end + + mail = ActionMailer::Base.deliveries.last + + raw_token_from_email = mail.parts[0].to_s.gsub!(/.*invitation_token=(.*)[\s\r\n].*/).first.split('=')[1].strip + + invitee = User.find_by(email: 'friend@example.com') + + assert invitee.created_by_invite? + assert_not invitee.invitation_accepted? + + get logout_url params: { format: :json } + + get accept_user_invitation_url(invitation_token: raw_token_from_email) + + invitee.reload + assert_not invitee.invitation_accepted? + + # rubocop:disable Layout/HashAlignment + put user_invitation_url( + params: { + user: { + invitation_token: raw_token_from_email, + name: 'Bob', + email: 'friend@example.com', + password: 'password', + password_confirmation: 'password', + agreed: 'true', + }, + } + ) + # rubocop:enable Layout/HashAlignment + assert_response :redirect + + invitee.reload + + assert invitee.invitation_accepted? + assert_equal invitee.name, 'Bob' + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 9256b40ab..50cbf39e6 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -99,6 +99,50 @@ class UserTest < ActiveSupport::TestCase end end + describe 'Agreed' do + test 'Doesnt set agreed_time agreed is nil or false' do + password = 'password' + new_user = User.create(email: 'new@user.com', password: password, agreed: nil) + assert_nil new_user.agreed_time + + new_user = User.create(email: 'new@user.com', password: password, agreed: false) + assert_nil new_user.agreed_time + end + + test 'Sets agreed_time if nil when agreed set to true' do + user = User.create + assert user.terms_and_conditions? + + password = 'password' + new_user = User.create(email: 'new@user.com', password: password, agreed: true) + assert_not_nil new_user.agreed_time + end + end + + describe 'Email' do + test 'validates the format of the email address' do + new_user = User.create(email: nil, password: 'password') + + assert new_user.errors.added? :email, :blank # => true + assert_includes new_user.errors.messages[:email], 'can\'t be blank' + + new_user = User.create(email: 'epugh', password: 'password') + assert_includes new_user.errors.messages[:email], 'is invalid' + + new_user = User.create(email: 'epugh@', password: 'password') + assert_includes new_user.errors.messages[:email], 'is invalid' + + # turns out this is a valid format at least as far as regex validation goes! + new_user = User.create(email: 'epugh@o19s', password: 'password') + assert_empty new_user.errors.messages + + new_user = User.create(email: 'epugh@o19s.com', password: 'password') + assert_empty new_user.errors.messages + new_user = User.create(email: 'epugh+tag@o19s.com', password: 'password') + assert_empty new_user.errors.messages + end + end + describe 'Default Case' do # we used to create the default case for every user, but that assumptions doesn't make sense anymore. test 'when user is created a default case is NOT automatically created' do diff --git a/test/services/user_team_finder_test.rb b/test/services/user_team_finder_test.rb index 3c512195a..ab14f37dd 100644 --- a/test/services/user_team_finder_test.rb +++ b/test/services/user_team_finder_test.rb @@ -7,7 +7,7 @@ class UserTeamFinderTest < ActiveSupport::TestCase let(:owned_team) { teams(:owned_team) } let(:shared_team) { teams(:shared_team) } - let(:service) { UserTeamFinder.new(user).call.preload(:members) } + let(:service) { UserTeamFinder.new(user) } describe 'Find all teams' do test 'returns an array of teams' do