build User Invitations (#6)

* allow users to invite teammates - WIP.

* hide Sign Up link in views.

* build invitations spec.

* install missing node modules in CircleCI.

* install balloon.css via Yarn.

* disable Hound for JS.

* lint.

* add error messages to invitation view.
This commit is contained in:
Justin Licata 2017-07-21 14:56:42 -05:00 committed by GitHub
parent 87ca860557
commit c294394704
36 changed files with 563 additions and 63 deletions

@ -33,6 +33,7 @@ jobs:
name: install dependencies
command: |
bundle install --jobs=4 --retry=3 --path vendor/bundle
npm install
- save_cache:
paths:

@ -3,3 +3,5 @@
ruby:
enabled: true
config_file: .rubocop.yml
jshint:
enabled: false

@ -10,6 +10,8 @@ gem 'bootstrap', '~> 4.0.0.alpha6'
gem 'devise', '~> 4.3.0'
gem 'devise_invitable', '~> 1.7', '>= 1.7.2'
gem 'foreman'
gem 'local_time', '~> 1.0', '>= 1.0.3'
gem 'paranoia', '~> 2.3', '>= 2.3.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.7'
gem 'rails', '~> 5.1.1'

@ -66,6 +66,13 @@ GEM
xpath (~> 2.0)
childprocess (0.7.1)
ffi (~> 1.0, >= 1.0.11)
coffee-rails (4.2.1)
coffee-script (>= 2.2.0)
railties (>= 4.0.0, < 5.2.x)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.0.5)
devise (4.3.0)
bcrypt (~> 3.0)
@ -94,6 +101,8 @@ GEM
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
local_time (1.0.3)
coffee-rails
loofah (2.0.3)
nokogiri (>= 1.5.9)
mail (2.6.6)
@ -124,6 +133,8 @@ GEM
nokogiri (1.8.0)
mini_portile2 (~> 2.2.0)
orm_adapter (0.5.0)
paranoia (2.3.1)
activerecord (>= 4.0, < 5.2)
pg (0.21.0)
public_suffix (2.0.5)
puma (3.9.1)
@ -220,8 +231,10 @@ DEPENDENCIES
factory_girl_rails (~> 4.8)
foreman
listen (>= 3.0.5, < 3.2)
local_time (~> 1.0, >= 1.0.3)
minitest-ci
minitest-rails-capybara
paranoia (~> 2.3, >= 2.3.1)
pg (~> 0.18)
puma (~> 3.7)
rails (~> 5.1.1)

@ -1,2 +1,7 @@
//= require rails-ujs
//= require clipboard/dist/clipboard
//= require local_time
//= require turbolinks
//= require_tree ./globals
//= require dispatcher

@ -0,0 +1,21 @@
//= require ./utils/namespace
namespace('Storm', exports => {
exports.Dispatcher = class Dispatcher {
constructor() {
this.pageName = document.body.dataset.page;
}
route() {
switch (this.pageName) {
case 'users:instructions:index':
new Storm.Globals.CopyToClipboard().init();
break;
}
}
};
});
document.addEventListener('turbolinks:load', () => {
new Storm.Dispatcher().route();
});

@ -0,0 +1,41 @@
//= require ../utils/namespace
const selector = '[data-behavior="copy-to-clipboard"]';
class CopyToClipboard {
constructor() {
this.clipboardButtons = document.querySelectorAll(selector);
this.clipboard = null;
}
init() {
this.clipboard = new Clipboard(selector, this.bindEventListeners());
this.clipboard.on('success', event => {
this.showTooltip(event.trigger);
});
}
bindEventListeners() {
this.clipboardButtons.forEach(button => {
button.addEventListener('mouseleave', this.clearTooltip);
button.addEventListener('blur', this.clearTooltip);
});
}
clearTooltip(event) {
event.currentTarget.removeAttribute('data-balloon-visible');
event.currentTarget.removeAttribute('data-balloon');
event.currentTarget.removeAttribute('data-balloon-pos');
}
showTooltip(el) {
el.setAttribute('data-balloon-visible', true);
el.setAttribute('data-balloon', 'Copied!');
el.setAttribute('data-balloon-pos', 'up');
}
}
namespace('Storm.Globals', exports => {
exports.CopyToClipboard = CopyToClipboard;
});

@ -0,0 +1,24 @@
// Creates a namespaced class or method
//
// Example usages:
// namespace('Storm.NotifySlackBot', (exports) => {
// exports.Init = class Init {
// })
const slice = [].slice;
namespace = function(target, name, block) {
let i, item, len, ref, ref1, top;
if (arguments.length < 3) {
(ref = [typeof exports !== 'undefined' ? exports : window].concat(
slice.call(arguments)
)), (target = ref[0]), (name = ref[1]), (block = ref[2]);
}
top = target;
ref1 = name.split('.');
for (i = 0, len = ref1.length; i < len; i++) {
item = ref1[i];
target = target[item] || (target[item] = {});
}
return block(target, top);
};

@ -1,5 +1,16 @@
@import "base/base";
.table {
td {
vertical-align: middle;
}
.td-actions {
text-align: right;
white-space: nowrap;
}
}
.table-border-around {
border: 1px solid $table-border-color;
}

@ -4,6 +4,27 @@ body {
background-color: $gray-lightest;
}
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
h1,
h2,
h3,
h4,
h5,
h6,
.h1,
.h2,
.h3,
.h4,
.h5,
.h6 {
font-weight: 500;
}
.text-horizontal {
overflow-x: scroll;
overflow-y: hidden;
white-space: nowrap;
}
.badge {
line-height: 1.25;
}

@ -1,2 +1,3 @@
@import "base/base";
@import "bootstrap";
@import "balloon-css/balloon";

@ -10,11 +10,9 @@ class SettingsController < ApplicationController
@setting = Setting.first
if @setting.update(setting_params)
flash[:notice] = 'Your settings have been updated.'
redirect_to root_path
flash[:success] = 'Your settings have been updated.'
redirect_to edit_settings_path
else
flash[:alert] = 'Something went wrong updating settings.
Please check and try again'
render :edit
end
end

@ -0,0 +1,12 @@
module Users
module Invitations
# InstructionsController
class InstructionsController < ApplicationController
before_action(:authenticate_user!)
def index
@user = User.find(params[:id])
end
end
end
end

@ -0,0 +1,48 @@
module Users
# InvitationsController
class InvitationsController < Devise::InvitationsController
layout('application')
private
def invite_resource(&block)
# find all users, including unscoped (deleted).
@user = User.unscoped.find_by(email: invite_params[:email])
# only re-invite if user has been deleted.
if @user && @user.deleted? && @user.email != current_user.email
@user.assign_attributes(
reinvite_attributes.merge(skip_invitation_params)
)
@user.invite!(current_user)
@user
else
# instance method, invite!, returns a User instance
resource_class.invite!(
invite_params.merge(skip_invitation_params),
current_inviter,
&block
)
end
end
def after_invite_path_for(_inviter, invitee)
users_invitations_instructions_path(
id: invitee.id,
token: invitee.raw_invitation_token
)
end
def reinvite_attributes
{
deleted_at: nil # "un-delete" a deleted user.
}
end
def skip_invitation_params
{
skip_invitation: true # don't send an email.
}
end
end
end

@ -3,6 +3,19 @@ module ApplicationHelper
user_signed_in? ? 'logged-in' : 'logged-out'
end
def body_data_page
action = case action_name
when 'create' then 'new'
when 'update' then 'edit'
else action_name
end.downcase
path = controller.controller_path.split('/')
namespace = path.first if path.second
[namespace, controller.controller_name, action].compact.join(':')
end
def nav_link(title, link, html_options = {})
classes = ['nav-item']
classes << 'active' if current_page?(link)

@ -1,4 +1,6 @@
class Ping < ApplicationRecord
acts_as_paranoid
belongs_to(:website)
validates(:status, presence: true)

@ -1,8 +1,11 @@
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
acts_as_paranoid
devise(
:invitable, :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
)
# alias the Devise Invitable method to check if a user is pending creation.
alias_method(:pending?, :valid_invitation?)
end

@ -1,6 +1,8 @@
class Website < ApplicationRecord
VALID_URL_REGEX = /\A#{URI::regexp(['http', 'https'])}\z/
acts_as_paranoid
validates(:name, presence: true)
validates(:url, presence: true, format: { with: VALID_URL_REGEX })

@ -1,16 +1,43 @@
<h2><%= t 'devise.invitations.edit.header' %></h2>
<div class="container">
<div class="auth">
<%= link_to(root_path) do %>
<%= image_tag("logo.svg", class: "auth-logo mx-auto d-block mb-3") %>
<% end %>
<%= form_for resource, :as => resource_name, :url => invitation_path(resource_name), :html => { :method => :put } do |f| %>
<%= devise_error_messages! %>
<%= f.hidden_field :invitation_token %>
<h3 class="auth-title text-center mb-3">
Welcome, set your password
</h3>
<% if f.object.class.require_password_on_accepting %>
<p><%= f.label :password %><br />
<%= f.password_field :password %></p>
<div class="card">
<div class="card-block">
<%= form_for(resource,
as: resource_name,
url: invitation_path(resource_name),
html: { method: :put }) do |f| %>
<%= render("shared/flash") %>
<%= f.hidden_field :invitation_token %>
<p><%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation %></p>
<% end %>
<div class="form-group">
<%= f.label :password %>
<%= f.password_field :password, autofocus: true,
autocomplete: "off", class: "form-control" %>
<%= error_message_on(f.object, :password) %>
</div>
<p><%= f.submit t("devise.invitations.edit.submit_button") %></p>
<% end %>
<div class="form-group">
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation, autocomplete: "off", class: "form-control" %>
<%= error_message_on(f.object, :password_confirmation) %>
</div>
<div class="actions">
<%= f.submit t("devise.invitations.edit.submit_button"),
class: "btn btn-primary btn-block" %>
</div>
<% end %>
</div>
</div>
<%= render "devise/shared/links" %>
</div>
</div>

@ -1,12 +1,34 @@
<h2><%= t "devise.invitations.new.header" %></h2>
<div class="page-heading">
<h4 class="page-title"><%= t "devise.invitations.new.header" %></h4>
<%= form_for resource, :as => resource_name, :url => invitation_path(resource_name), :html => {:method => :post} do |f| %>
<%= devise_error_messages! %>
<%= link_to("Head back", users_path,
class: "btn btn-secondary btn-sm mt-3") %>
</div>
<% resource.class.invite_key_fields.each do |field| -%>
<p><%= f.label field %><br />
<%= f.text_field field %></p>
<% end -%>
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-block">
<%= form_for(
resource,
as: resource_name,
url: invitation_path(resource_name),
html: { method: :post }
) do |f| %>
<p><%= f.submit t("devise.invitations.new.submit_button") %></p>
<% end %>
<% resource.class.invite_key_fields.each do |field| %>
<div class="form-group">
<%= f.label(field) %>
<%= f.text_field(field, autofocus: true, class: "form-control") %>
<%= error_message_on(f.object, field) %>
</div>
<% end %>
<div class="actions">
<%= f.submit t("devise.invitations.new.submit_button"), class: "btn btn-primary" %>
</div>
<% end %>
</div>
</div>
</div>
</div>

@ -15,13 +15,15 @@
<div><%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %></div>
<% end -%>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<hr>
<% unless User.any? %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<hr>
<p class="mb-0">
Are you a new user?
</p>
<p class="mb-0">
Are you a new user?
</p>
<%= link_to "Sign up", new_registration_path(resource_name, organization_id: params[:organization_id]) %>
<% end -%>
<%= link_to "Sign up", new_registration_path(resource_name, organization_id: params[:organization_id]) %>
<% end -%>
<% end %>
</div>

@ -8,7 +8,7 @@
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body class="<%= body_classes %>">
<body class="<%= body_classes %>" data-page="<%= body_data_page %>">
<%= render("shared/navbar") %>
<%= render("shared/flash") %>

@ -2,6 +2,7 @@
<div class="form-group">
<%= f.label :slack_url, "Slack URL for notifications" %>
<%= f.text_field :slack_url, autofocus: true, class: "form-control" %>
<%= error_message_on(f.object, :slack_url) %>
<p class="form-text text-muted">
Instructions for finding your Slack URL can be found
@ -21,11 +22,13 @@
<div class="form-group">
<%= f.label :aws_key, "AWS Key" %>
<%= f.text_field :aws_key, class: "form-control" %>
<%= error_message_on(f.object, :aws_key) %>
</div>
<div class="form-group">
<%= f.label :aws_secret, "AWS Secret" %>
<%= f.text_field :aws_secret, class: "form-control" %>
<%= error_message_on(f.object, :aws_secret) %>
<p class="form-text text-muted">
Instructions for finding your AWS Key and Secret can be found

@ -1,6 +1,11 @@
<tr>
<td><%= user.email %></td>
<td><%= user.created_at %></td>
<td class="td-actions">
<td><%= local_time(user.created_at, '%B %e, %Y at %l:%M%P') %></td>
<td>
<span class="badge badge-<%= user.pending? ? 'default' : 'success' %>">
<%= user.pending? ? 'pending' : 'member' %>
</span>
</td>
</tr>

@ -1,23 +1,29 @@
<div class="page-heading">
<h4 class="page-title">Here's your team</h4>
<%= link_to("Invite member", new_user_invitation_path,
class: "btn btn-primary btn-sm mt-3") %>
</div>
<div class="row">
<div class="col-md-12">
<div class="card card-block">
<h4 class="card-title">Users</h4>
<div class="table-responsive">
<table class="table table-border-around mb-0">
<thead>
<tr>
<th>Email</th>
<th>Created on</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render(@users) %>
</tbody>
</table>
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-block">
<div class="table-responsive">
<table class="table table-border-around mb-0">
<thead>
<tr>
<th>Email</th>
<th>Created on</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<%= render(@users) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,34 @@
<div class="page-heading">
<h4 class="page-title">Instructions</h4>
</div>
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-block">
<p>
You have successfully invited <code><%= @user.email %></code>. Since
we do not deliver emails, here is a fancy link for them.
</p>
<div class="input-group">
<input type="text"
class="form-control"
value="<%= accept_user_invitation_url(
invitation_token: params[:token]
) %>" />
<span class="input-group-btn">
<button class="btn btn-primary" type="button"
data-clipboard-text="<%= accept_user_invitation_url(
invitation_token: params[:token]
)%>"
data-behavior="copy-to-clipboard">
Copy link to clipboard
</button>
</span>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1 @@
<%= render("shared/flash") %>

@ -116,6 +116,54 @@ Devise.setup do |config|
# 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}
# 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 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

@ -10,7 +10,7 @@ en:
no_invitations_remaining: "No invitations remaining"
invitation_removed: "Your invitation was removed."
new:
header: "Send invitation"
header: "Send an invitation"
submit_button: "Send an invitation"
edit:
header: "Set your password"

@ -9,7 +9,8 @@ Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
registrations: 'users/registrations',
invitations: 'users/invitations'
}, path_names: {
sign_in: 'sign-in',
sign_out: 'sign-out',
@ -19,4 +20,10 @@ Rails.application.routes.draw do
resource(:settings, only: %w(edit update))
resources(:users, only: %w(index))
resources(:websites, only: %w(new create))
namespace(:users) do
namespace(:invitations) do
resources(:instructions, only: %w(index))
end
end
end

@ -0,0 +1,18 @@
class AddDeletedAtToTables < ActiveRecord::Migration[5.1]
def up
add_column(:users, :deleted_at, :datetime)
add_index(:users, :deleted_at)
add_column(:websites, :deleted_at, :datetime)
add_index(:websites, :deleted_at)
add_column(:pings, :deleted_at, :datetime)
add_index(:pings, :deleted_at)
end
def down
remove_column(:users, :deleted_at)
remove_column(:websites, :deleted_at)
remove_column(:pings, :deleted_at)
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170714145639) do
ActiveRecord::Schema.define(version: 20170721181533) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -20,6 +20,8 @@ ActiveRecord::Schema.define(version: 20170714145639) do
t.integer "status"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "deleted_at"
t.index ["deleted_at"], name: "index_pings_on_deleted_at"
t.index ["website_id"], name: "index_pings_on_website_id"
end
@ -60,7 +62,9 @@ ActiveRecord::Schema.define(version: 20170714145639) do
t.string "invited_by_type"
t.integer "invited_by_id"
t.integer "invitations_count", default: 0
t.datetime "deleted_at"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["deleted_at"], name: "index_users_on_deleted_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
t.index ["invitations_count"], name: "index_users_on_invitations_count"
@ -78,7 +82,9 @@ ActiveRecord::Schema.define(version: 20170714145639) do
t.string "basic_auth_password"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "deleted_at"
t.index ["active"], name: "index_websites_on_active"
t.index ["deleted_at"], name: "index_websites_on_deleted_at"
t.index ["name"], name: "index_websites_on_name"
t.index ["url"], name: "index_websites_on_url"
end

@ -1,5 +1,8 @@
{
"name": "storm",
"private": true,
"dependencies": {}
"dependencies": {
"balloon-css": "^0.4.0",
"clipboard": "^1.7.1"
}
}

@ -1,7 +1,7 @@
require 'test_helper'
class SettingsControllerTest < ActionDispatch::IntegrationTest
feature 'as as authenticated user' do
feature 'as an authenticated user' do
before(:each) do
@user = create(:user)
sign_in(@user)
@ -18,12 +18,12 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference('Setting.count') do
patch(settings_url, params: { setting: { aws_key: 'test' } })
assert_redirected_to(root_url)
assert_redirected_to(edit_settings_url)
end
end
end
feature 'as as an unauthenticated user' do
feature 'as an unauthenticated user' do
test 'should get redirected to sign-in' do
get(users_url)
assert_redirected_to(new_user_session_path)

@ -0,0 +1,65 @@
require('test_helper')
module Users
class InvitationsControllerTest < ActionDispatch::IntegrationTest
setup do
@params = params
end
feature 'as an authenticated user' do
before(:each) do
sign_in(create(:user))
end
test 'should invite user successfully' do
assert_difference('User.unscoped.size') do
post(user_invitation_path, params: params)
end
end
test 'should invite user that was previously deleted' do
user = create(:user)
user.destroy
@params[:user][:email] = user.email
assert_not_nil(user.reload.deleted_at)
assert_no_difference('User.unscoped.size') do
post(user_invitation_path, params: @params)
end
assert_nil(user.reload.deleted_at)
end
test 'should not invite user with bad email' do
assert_no_difference('User.unscoped.size') do
post(user_invitation_path, params: bad_params)
end
end
end
feature 'as an unauthenticated user' do
test 'should not invite user successfully' do
assert_no_difference('User.unscoped.size') do
post(user_invitation_path, params: params)
end
end
end
private
def params
{
user: {
email: 'invited_user@test.com'
}
}
end
def bad_params
{
user: {
email: 'bad'
}
}
end
end
end

33
yarn.lock Normal file

@ -0,0 +1,33 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
balloon-css@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/balloon-css/-/balloon-css-0.4.0.tgz#0d6edf8bf595f16b21ef88a6a676caa5a418b955"
clipboard@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
dependencies:
good-listener "^1.2.2"
select "^1.1.2"
tiny-emitter "^2.0.0"
delegate@^3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.1.3.tgz#9a8251a777d7025faa55737bc3b071742127a9fd"
good-listener@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
dependencies:
delegate "^3.1.2"
select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
tiny-emitter@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.1.tgz#e65919d91e488e2a78f7ebe827a56c6b188d51af"