diff --git a/Gemfile b/Gemfile index 924e2ec..dfb0718 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,8 @@ gem 'sass-rails', '~> 4.0.0' gem 'uglifier', '>= 1.3.0' gem 'coffee-rails', '~> 4.0.0' gem 'jquery-rails' -gem 'devise', '~> 3.1.0' +gem 'devise', '~> 3.2.0' +gem 'devise_invitable', '~> 1.3.4' gem 'apartment', '~> 0.22.1' gem 'simple_form', '~> 3.0.0', github: 'plataformatec/simple_form', branch: 'master' @@ -23,4 +24,6 @@ group :development, :test do gem 'factory_girl_rails' gem 'database_cleaner' gem 'shoulda-matchers' + gem 'letter_opener' + gem 'email_spec' end diff --git a/Gemfile.lock b/Gemfile.lock index 8de6f02..c555d0c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,12 +34,13 @@ GEM multi_json (~> 1.3) thread_safe (~> 0.1) tzinfo (~> 0.3.37) + addressable (2.3.6) apartment (0.22.1) activerecord (>= 3.1.2) rack (>= 1.3.6) arel (4.0.0) atomic (1.1.14) - bcrypt-ruby (3.1.2) + bcrypt (3.1.7) bootstrap-sass (3.1.1.0) sass (~> 3.2) bourne (1.4.0) @@ -61,16 +62,22 @@ GEM execjs coffee-script-source (1.6.3) database_cleaner (1.0.1) - devise (3.1.0) - bcrypt-ruby (~> 3.0) + devise (3.2.4) + bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 3.2.6, < 5) thread_safe (~> 0.1) warden (~> 1.2.3) + devise_invitable (1.3.4) + actionmailer (>= 3.2.6, < 5) + devise (>= 3.2.0) diff-lcs (1.2.4) em-websocket (0.5.0) eventmachine (>= 0.12.9) http_parser.rb (~> 0.5.3) + email_spec (1.5.0) + launchy (~> 2.1) + mail (~> 2.2) erubis (2.7.0) eventmachine (1.0.3) execjs (2.0.1) @@ -100,6 +107,10 @@ GEM jquery-rails (3.0.4) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) + launchy (2.4.2) + addressable (~> 2.3) + letter_opener (1.1.2) + launchy (~> 2.2) listen (1.2.2) rb-fsevent (>= 0.9.3) rb-inotify (>= 0.9) @@ -118,7 +129,7 @@ GEM multi_json (1.8.0) nokogiri (1.6.0) mini_portile (~> 0.5.0) - orm_adapter (0.4.0) + orm_adapter (0.5.0) pg (0.17.0) polyglot (0.3.3) pry (0.9.12.2) @@ -211,12 +222,15 @@ DEPENDENCIES capybara coffee-rails (~> 4.0.0) database_cleaner - devise (~> 3.1.0) + devise (~> 3.2.0) + devise_invitable (~> 1.3.4) + email_spec factory_girl_rails guard guard-livereload guard-rspec jquery-rails + letter_opener pg rails (= 4.0.0) rails-erd diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 0e1a2be..9609f4d 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -1,4 +1,5 @@ @import "bootstrap"; @import "layout"; -@import "signin"; \ No newline at end of file +@import "signin"; +@import "typography"; \ No newline at end of file diff --git a/app/assets/stylesheets/typography.css.scss b/app/assets/stylesheets/typography.css.scss new file mode 100644 index 0000000..e122255 --- /dev/null +++ b/app/assets/stylesheets/typography.css.scss @@ -0,0 +1,2 @@ +.color-success { color: #3C763D; } +.extra-margin-top { margin-top: 40px; } \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ecf4f3c..5542105 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,9 +1,17 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception - before_filter :load_schema, :authenticate_user! + before_filter :load_schema, :authenticate_user!, :set_mailer_host + before_filter :configure_permitted_parameters, if: :devise_controller? + + protected + + def configure_permitted_parameters + devise_parameter_sanitizer.for(:accept_invitation).concat([:name]) + end + + private -private def load_schema Apartment::Database.switch('public') return unless request.subdomain.present? @@ -20,7 +28,16 @@ def current_account end helper_method :current_account + def set_mailer_host + subdomain = current_account ? "#{current_account.subdomain}." : "" + ActionMailer::Base.default_url_options[:host] = "#{subdomain}lvh.me:3000" + end + def after_sign_out_path_for(resource_or_scope) new_user_session_path end + + def after_invite_path_for(resource) + users_path + end end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb index 54e08e7..30c2782 100644 --- a/app/helpers/user_helper.rb +++ b/app/helpers/user_helper.rb @@ -1,6 +1,6 @@ module UserHelper def user_status(user) - if current_account.owner == user + if current_account.owner == user || user.invitation_accepted? content_tag(:span, '', class: 'glyphicon glyphicon-ok color-success') else 'Invitation Pending' diff --git a/app/models/user.rb b/app/models/user.rb index e690e31..df48f8b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,5 @@ class User < ActiveRecord::Base - devise :database_authenticatable, :recoverable, :rememberable, :validatable + devise :invitable, :database_authenticatable, :recoverable, :rememberable, :validatable validates :name, presence: true end diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb new file mode 100644 index 0000000..925506d --- /dev/null +++ b/app/views/devise/invitations/edit.html.erb @@ -0,0 +1,15 @@ +
+
+

Create an Account

+ + <%= simple_form_for resource, :as => resource_name, + :url => invitation_path(resource_name), + :html => { :method => :put } do |f| %> + <%= f.input :invitation_token, as: :hidden %> + <%= f.input :name %> + <%= f.input :password %> + <%= f.input :password_confirmation %> + <%= f.button :submit, "Create Account", class: 'btn-primary' %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/devise/invitations/new.html.erb b/app/views/devise/invitations/new.html.erb new file mode 100644 index 0000000..6df4bc1 --- /dev/null +++ b/app/views/devise/invitations/new.html.erb @@ -0,0 +1,12 @@ +
+
+

Send Invitation

+ + <%= simple_form_for resource, :as => resource_name, + :url => invitation_path(resource_name), + :html => {:method => :post} do |f| %> + <%= f.input :email %> + <%= f.button :submit, "Create Account", class: 'btn-primary' %> + <% end %> +
+
\ No newline at end of file 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 0000000..87e434b --- /dev/null +++ b/app/views/devise/mailer/invitation_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has invited you to <%= root_url %>, you can accept it through the link below.

+ +

<%= link_to 'Accept invitation', 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/layouts/application.html.erb b/app/views/layouts/application.html.erb index 6d68184..a0c074e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -13,14 +13,14 @@ -
- <% if user_signed_in? %> + <% if user_signed_in? %> +
<%= link_to 'Sign out', destroy_user_session_path, method: 'delete', class: 'btn btn-default navbar-btn' %> - <% end %> -
- +
+ + <% end %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index b3c8d85..583072b 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -21,6 +21,14 @@ <% end %> + +

Invite User

+ + <%= simple_form_for(User.new, url: user_invitation_path, + html: { class: 'form-inline' }) do |f| %> + <%= f.input :email, placeholder: 'Email', label: false %> + <%= f.button :submit, 'Invite User', class: 'btn-primary' %> + <% end %> \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index c5e2c0d..6449af2 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -13,8 +13,8 @@ config.consider_all_requests_local = true config.action_controller.perform_caching = false - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false + config.action_mailer.raise_delivery_errors = true + config.action_mailer.delivery_method = :letter_opener # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index c666a68..193ec53 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -100,6 +100,45 @@ # Setup a pepper to generate the encrypted password. # config.pepper = '68e37fe61c0e6808c8bd6f6d79694bb3a465f183ebab9819284410137f998cc735fbe01ef468aed30042ae8ff9af7771946e4cd7862427645fd3daecc374bcab' + # ==> 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} + + # Flag that force a record to be valid before being actually invited + # 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 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 + # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without # confirming his 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 0000000..c23f8ee --- /dev/null +++ b/config/locales/devise_invitable.en.yml @@ -0,0 +1,17 @@ +en: + devise: + invitations: + send_instructions: 'An invitation email has been sent to %{email}.' + invitation_token_invalid: 'The invitation token provided is not valid!' + updated: 'Your account was created successfully. You are now signed in.' + 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' diff --git a/db/migrate/20140414210524_devise_invitable_add_to_users.rb b/db/migrate/20140414210524_devise_invitable_add_to_users.rb new file mode 100644 index 0000000..a2b05a8 --- /dev/null +++ b/db/migrate/20140414210524_devise_invitable_add_to_users.rb @@ -0,0 +1,26 @@ +class DeviseInvitableAddToUsers < ActiveRecord::Migration + 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 :invitations_count, default: 0 + t.index :invitations_count + t.index :invitation_token, :unique => true # for invitable + t.index :invited_by_id + end + + # And allow null encrypted_password and password_salt: + change_column_null :users, :encrypted_password, true + 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 6e8e3dc..add28e2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20130922162109) do +ActiveRecord::Schema.define(version: 20140414210524) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -26,15 +26,26 @@ create_table "users", force: true do |t| t.string "name" t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false + t.string "encrypted_password", default: "" t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" t.datetime "created_at" t.datetime "updated_at" + 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.string "invited_by_type" + t.integer "invitations_count", default: 0 end add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree + add_index "users", ["invitation_token"], name: "index_users_on_invitation_token", unique: true, using: :btree + add_index "users", ["invitations_count"], name: "index_users_on_invitations_count", using: :btree + add_index "users", ["invited_by_id"], name: "index_users_on_invited_by_id", using: :btree add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree end diff --git a/spec/features/invitations_feature_spec.rb b/spec/features/invitations_feature_spec.rb index 1a0fbac..411e544 100644 --- a/spec/features/invitations_feature_spec.rb +++ b/spec/features/invitations_feature_spec.rb @@ -4,13 +4,60 @@ let!(:account) { create(:account_with_schema) } let(:user) { account.owner } - before { set_subdomain(account.subdomain) } - - it 'shows the owner in the authorized users list' do + before do + set_subdomain(account.subdomain) sign_user_in(user) visit users_path + end + + it 'shows the owner in the authorized users list' do expect(page).to have_content user.name expect(page).to have_content user.email expect(page).to have_selector '.glyphicon-ok' end + + it 'validates email' do + fill_in 'Email', with: 'wrong' + click_button 'Invite User' + expect(page).to have_content 'Send Invitation' + expect(page).to have_content 'invalid' + end + + describe 'when user is invited' do + before do + fill_in 'Email', with: 'ryan@tanookilabs.com' + click_button 'Invite User' + end + + it 'shows invitation' do + expect(page).to have_content 'invitation email has been sent' + expect(page).to have_content 'ryan@tanookilabs.com' + expect(page).to have_content 'Invitation Pending' + end + + context 'user accepts invitation' do + before do + click_link 'Sign out' + + open_email 'ryan@tanookilabs.com' + visit_in_email 'Accept invitation' + + fill_in 'Name', with: 'Ryan Boland' + fill_in 'Password', with: 'pw' + fill_in 'Password confirmation', with: 'pw' + click_button 'Create Account' + end + + it 'confirms account creation' do + expect(page).to have_content 'Your account was created successfully' + end + + it 'shows a checkmark on the users page' do + visit users_path + within('tr', text: 'Ryan Boland') do + expect(page).to have_selector '.glyphicon-ok' + end + end + end + end end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 12225e8..139c0e9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ require 'rspec/autorun' require 'database_cleaner' require 'capybara/rspec' +require 'email_spec' Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } ActiveRecord::Migration.check_pending! if defined?(ActiveRecord::Migration) @@ -13,6 +14,8 @@ RSpec.configure do |config| config.include FactoryGirl::Syntax::Methods + config.include EmailSpec::Helpers + config.include EmailSpec::Matchers #config.include Devise::TestHelpers, type: :controller config.order = "random" @@ -30,5 +33,6 @@ Apartment::Database.reset drop_schemas Capybara.app_host = 'http://example.com' + reset_mailer end end