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 @@
-
+
+ - <%= link_to 'Users', users_path %>
+
+ <% 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 %>
+
+
+
+ <%= 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