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:
parent
87ca860557
commit
c294394704
@ -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
|
||||
|
2
Gemfile
2
Gemfile
@ -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'
|
||||
|
13
Gemfile.lock
13
Gemfile.lock
@ -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
|
||||
|
21
app/assets/javascripts/dispatcher.js
Normal file
21
app/assets/javascripts/dispatcher.js
Normal file
@ -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();
|
||||
});
|
41
app/assets/javascripts/globals/CopyToClipboard.js
Normal file
41
app/assets/javascripts/globals/CopyToClipboard.js
Normal file
@ -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;
|
||||
});
|
24
app/assets/javascripts/utils/namespace.js
Normal file
24
app/assets/javascripts/utils/namespace.js
Normal file
@ -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
app/assets/stylesheets/vendor/_base.scss
vendored
1
app/assets/stylesheets/vendor/_base.scss
vendored
@ -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
|
||||
|
12
app/controllers/users/invitations/instructions_controller.rb
Normal file
12
app/controllers/users/invitations/instructions_controller.rb
Normal file
@ -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
|
48
app/controllers/users/invitations_controller.rb
Normal file
48
app/controllers/users/invitations_controller.rb
Normal file
@ -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>
|
||||
|
34
app/views/users/invitations/instructions/index.html.erb
Normal file
34
app/views/users/invitations/instructions/index.html.erb
Normal file
@ -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
|
||||
|
18
db/migrate/20170721181533_add_deleted_at_to_tables.rb
Normal file
18
db/migrate/20170721181533_add_deleted_at_to_tables.rb
Normal file
@ -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)
|
||||
|
65
test/controllers/users/invitations_controller_test.rb
Normal file
65
test/controllers/users/invitations_controller_test.rb
Normal file
@ -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
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"
|
Loading…
Reference in New Issue
Block a user