From c294394704de298eb368498b17492e209bb8d83b Mon Sep 17 00:00:00 2001 From: Justin Licata Date: Fri, 21 Jul 2017 14:56:42 -0500 Subject: [PATCH] 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. --- .circleci/config.yml | 1 + .hound.yml | 2 + Gemfile | 2 + Gemfile.lock | 13 ++++ app/assets/javascripts/application.js | 5 ++ app/assets/javascripts/dispatcher.js | 21 ++++++ .../javascripts/globals/CopyToClipboard.js | 41 ++++++++++++ app/assets/javascripts/utils/namespace.js | 24 +++++++ app/assets/stylesheets/patterns/_table.scss | 11 ++++ app/assets/stylesheets/utils/_typography.scss | 23 ++++++- app/assets/stylesheets/vendor/_base.scss | 1 + app/controllers/settings_controller.rb | 6 +- .../invitations/instructions_controller.rb | 12 ++++ .../users/invitations_controller.rb | 48 ++++++++++++++ app/helpers/application_helper.rb | 13 ++++ app/models/ping.rb | 2 + app/models/user.rb | 7 +- app/models/website.rb | 2 + app/views/devise/invitations/edit.html.erb | 51 +++++++++++---- app/views/devise/invitations/new.html.erb | 40 +++++++++--- app/views/devise/shared/_links.html.erb | 16 +++-- app/views/layouts/application.html.erb | 2 +- app/views/settings/_form.html.erb | 3 + app/views/users/_user.html.erb | 9 ++- app/views/users/index.html.erb | 42 +++++++----- .../invitations/instructions/index.html.erb | 34 ++++++++++ app/views/welcome/index.html.erb | 1 + config/initializers/devise.rb | 48 ++++++++++++++ config/locales/devise_invitable.en.yml | 2 +- config/routes.rb | 9 ++- ...20170721181533_add_deleted_at_to_tables.rb | 18 +++++ db/schema.rb | 8 ++- package.json | 5 +- test/controllers/settings_controller_test.rb | 6 +- .../users/invitations_controller_test.rb | 65 +++++++++++++++++++ yarn.lock | 33 ++++++++++ 36 files changed, 563 insertions(+), 63 deletions(-) create mode 100644 app/assets/javascripts/dispatcher.js create mode 100644 app/assets/javascripts/globals/CopyToClipboard.js create mode 100644 app/assets/javascripts/utils/namespace.js create mode 100644 app/controllers/users/invitations/instructions_controller.rb create mode 100644 app/controllers/users/invitations_controller.rb create mode 100644 app/views/users/invitations/instructions/index.html.erb create mode 100644 db/migrate/20170721181533_add_deleted_at_to_tables.rb create mode 100644 test/controllers/users/invitations_controller_test.rb create mode 100644 yarn.lock diff --git a/.circleci/config.yml b/.circleci/config.yml index e8f369f..f2c334a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,6 +33,7 @@ jobs: name: install dependencies command: | bundle install --jobs=4 --retry=3 --path vendor/bundle + npm install - save_cache: paths: diff --git a/.hound.yml b/.hound.yml index 8fe8777..ce2c1f7 100644 --- a/.hound.yml +++ b/.hound.yml @@ -3,3 +3,5 @@ ruby: enabled: true config_file: .rubocop.yml +jshint: + enabled: false diff --git a/Gemfile b/Gemfile index a63fd78..9893b11 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index a61a1f6..3771cd7 100644 --- a/Gemfile.lock +++ b/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) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 0522aba..e3fdb23 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,2 +1,7 @@ //= require rails-ujs +//= require clipboard/dist/clipboard +//= require local_time //= require turbolinks + +//= require_tree ./globals +//= require dispatcher diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js new file mode 100644 index 0000000..020d1fe --- /dev/null +++ b/app/assets/javascripts/dispatcher.js @@ -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(); +}); diff --git a/app/assets/javascripts/globals/CopyToClipboard.js b/app/assets/javascripts/globals/CopyToClipboard.js new file mode 100644 index 0000000..88fba57 --- /dev/null +++ b/app/assets/javascripts/globals/CopyToClipboard.js @@ -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; +}); diff --git a/app/assets/javascripts/utils/namespace.js b/app/assets/javascripts/utils/namespace.js new file mode 100644 index 0000000..fe293a6 --- /dev/null +++ b/app/assets/javascripts/utils/namespace.js @@ -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); +}; diff --git a/app/assets/stylesheets/patterns/_table.scss b/app/assets/stylesheets/patterns/_table.scss index 8ec7d7e..a7def59 100644 --- a/app/assets/stylesheets/patterns/_table.scss +++ b/app/assets/stylesheets/patterns/_table.scss @@ -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; } diff --git a/app/assets/stylesheets/utils/_typography.scss b/app/assets/stylesheets/utils/_typography.scss index d3fbb35..5ea4de2 100644 --- a/app/assets/stylesheets/utils/_typography.scss +++ b/app/assets/stylesheets/utils/_typography.scss @@ -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; +} diff --git a/app/assets/stylesheets/vendor/_base.scss b/app/assets/stylesheets/vendor/_base.scss index aef1764..2f76ec6 100644 --- a/app/assets/stylesheets/vendor/_base.scss +++ b/app/assets/stylesheets/vendor/_base.scss @@ -1,2 +1,3 @@ @import "base/base"; @import "bootstrap"; +@import "balloon-css/balloon"; diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index c34d754..484ab4a 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -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 diff --git a/app/controllers/users/invitations/instructions_controller.rb b/app/controllers/users/invitations/instructions_controller.rb new file mode 100644 index 0000000..115bbb3 --- /dev/null +++ b/app/controllers/users/invitations/instructions_controller.rb @@ -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 diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb new file mode 100644 index 0000000..13d9d42 --- /dev/null +++ b/app/controllers/users/invitations_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2bbb267..1f426d7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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) diff --git a/app/models/ping.rb b/app/models/ping.rb index 184d52b..0614636 100644 --- a/app/models/ping.rb +++ b/app/models/ping.rb @@ -1,4 +1,6 @@ class Ping < ApplicationRecord + acts_as_paranoid + belongs_to(:website) validates(:status, presence: true) diff --git a/app/models/user.rb b/app/models/user.rb index ab16443..2570410 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/website.rb b/app/models/website.rb index 05c1cac..d834585 100644 --- a/app/models/website.rb +++ b/app/models/website.rb @@ -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 }) diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb index ddd2931..c2d7248 100644 --- a/app/views/devise/invitations/edit.html.erb +++ b/app/views/devise/invitations/edit.html.erb @@ -1,16 +1,43 @@ -

<%= t 'devise.invitations.edit.header' %>

+
+
+ <%= 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 %> +

+ Welcome, set your password +

- <% if f.object.class.require_password_on_accepting %> -

<%= f.label :password %>
- <%= f.password_field :password %>

+
+
+ <%= form_for(resource, + as: resource_name, + url: invitation_path(resource_name), + html: { method: :put }) do |f| %> + <%= render("shared/flash") %> + <%= f.hidden_field :invitation_token %> -

<%= f.label :password_confirmation %>
- <%= f.password_field :password_confirmation %>

- <% end %> +
+ <%= f.label :password %> + <%= f.password_field :password, autofocus: true, + autocomplete: "off", class: "form-control" %> + <%= error_message_on(f.object, :password) %> +
-

<%= f.submit t("devise.invitations.edit.submit_button") %>

-<% end %> +
+ <%= f.label :password_confirmation %> + <%= f.password_field :password_confirmation, autocomplete: "off", class: "form-control" %> + <%= error_message_on(f.object, :password_confirmation) %> +
+ +
+ <%= f.submit t("devise.invitations.edit.submit_button"), + class: "btn btn-primary btn-block" %> +
+ <% end %> +
+
+ + <%= render "devise/shared/links" %> +
+
diff --git a/app/views/devise/invitations/new.html.erb b/app/views/devise/invitations/new.html.erb index b5acf47..16b96a1 100644 --- a/app/views/devise/invitations/new.html.erb +++ b/app/views/devise/invitations/new.html.erb @@ -1,12 +1,34 @@ -

<%= t "devise.invitations.new.header" %>

+
+

<%= t "devise.invitations.new.header" %>

-<%= 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") %> +
-<% resource.class.invite_key_fields.each do |field| -%> -

<%= f.label field %>
- <%= f.text_field field %>

-<% end -%> +
+
+
+
+ <%= form_for( + resource, + as: resource_name, + url: invitation_path(resource_name), + html: { method: :post } + ) do |f| %> -

<%= f.submit t("devise.invitations.new.submit_button") %>

-<% end %> + <% resource.class.invite_key_fields.each do |field| %> +
+ <%= f.label(field) %> + <%= f.text_field(field, autofocus: true, class: "form-control") %> + <%= error_message_on(f.object, field) %> +
+ <% end %> + +
+ <%= f.submit t("devise.invitations.new.submit_button"), class: "btn btn-primary" %> +
+ <% end %> +
+
+
+
diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index d7ee095..2e8c8f4 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -15,13 +15,15 @@
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end -%> - <%- if devise_mapping.registerable? && controller_name != 'registrations' %> -
+ <% unless User.any? %> + <%- if devise_mapping.registerable? && controller_name != 'registrations' %> +
-

- Are you a new user? -

+

+ Are you a new user? +

- <%= 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 %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e219466..5f91eac 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,7 +8,7 @@ <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> - + <%= render("shared/navbar") %> <%= render("shared/flash") %> diff --git a/app/views/settings/_form.html.erb b/app/views/settings/_form.html.erb index d900743..e96e252 100644 --- a/app/views/settings/_form.html.erb +++ b/app/views/settings/_form.html.erb @@ -2,6 +2,7 @@
<%= 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) %>

Instructions for finding your Slack URL can be found @@ -21,11 +22,13 @@

<%= f.label :aws_key, "AWS Key" %> <%= f.text_field :aws_key, class: "form-control" %> + <%= error_message_on(f.object, :aws_key) %>
<%= f.label :aws_secret, "AWS Secret" %> <%= f.text_field :aws_secret, class: "form-control" %> + <%= error_message_on(f.object, :aws_secret) %>

Instructions for finding your AWS Key and Secret can be found diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 6b565ec..484204e 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -1,6 +1,11 @@ <%= user.email %> - <%= user.created_at %> - + + <%= local_time(user.created_at, '%B %e, %Y at %l:%M%P') %> + + + + <%= user.pending? ? 'pending' : 'member' %> + diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index cff6ffe..72dbc41 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,23 +1,29 @@ +

+

Here's your team

+ + <%= link_to("Invite member", new_user_invitation_path, + class: "btn btn-primary btn-sm mt-3") %> +
+
-
-
-

Users

- -
- - - - - - - - - - <%= render(@users) %> - -
EmailCreated on
+
+
+
+
+ + + + + + + + + + <%= render(@users) %> + +
EmailCreated onStatus
+
-
diff --git a/app/views/users/invitations/instructions/index.html.erb b/app/views/users/invitations/instructions/index.html.erb new file mode 100644 index 0000000..35a675d --- /dev/null +++ b/app/views/users/invitations/instructions/index.html.erb @@ -0,0 +1,34 @@ +
+

Instructions

+
+ +
+
+
+
+

+ You have successfully invited <%= @user.email %>. Since + we do not deliver emails, here is a fancy link for them. +

+ +
+ + + + + +
+
+
+
+
diff --git a/app/views/welcome/index.html.erb b/app/views/welcome/index.html.erb index e69de29..a285e07 100644 --- a/app/views/welcome/index.html.erb +++ b/app/views/welcome/index.html.erb @@ -0,0 +1 @@ +<%= render("shared/flash") %> diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 1c7430c..e86c683 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -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 diff --git a/config/locales/devise_invitable.en.yml b/config/locales/devise_invitable.en.yml index 2d6750d..117ad2a 100644 --- a/config/locales/devise_invitable.en.yml +++ b/config/locales/devise_invitable.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index b59d6ec..7936550 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20170721181533_add_deleted_at_to_tables.rb b/db/migrate/20170721181533_add_deleted_at_to_tables.rb new file mode 100644 index 0000000..5e01e34 --- /dev/null +++ b/db/migrate/20170721181533_add_deleted_at_to_tables.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 5783bab..2f65ac4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/package.json b/package.json index 5b3517f..ebe7fb2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "name": "storm", "private": true, - "dependencies": {} + "dependencies": { + "balloon-css": "^0.4.0", + "clipboard": "^1.7.1" + } } diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb index 6fe2aad..90a41ca 100644 --- a/test/controllers/settings_controller_test.rb +++ b/test/controllers/settings_controller_test.rb @@ -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) diff --git a/test/controllers/users/invitations_controller_test.rb b/test/controllers/users/invitations_controller_test.rb new file mode 100644 index 0000000..57e87da --- /dev/null +++ b/test/controllers/users/invitations_controller_test.rb @@ -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 diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..a8337f0 --- /dev/null +++ b/yarn.lock @@ -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"