Merge pull request #1330 from kreuzwerker/feature/user-admin-interface

Admin user management

Dominik Sander 8 年之前
父節點
當前提交
bf7c2feba4

+ 33 - 3
.env.example

@@ -40,9 +40,9 @@ DATABASE_PASSWORD=""
40 40
 # Should Rails force all requests to use SSL?
41 41
 FORCE_SSL=false
42 42
 
43
-############################
44
-#     Allowing Signups     #
45
-############################
43
+################################################
44
+#     User authentication and registration     #
45
+################################################
46 46
 
47 47
 # This invitation code will be required for users to signup with your Huginn installation.
48 48
 # You can see its use in user.rb.  PLEASE CHANGE THIS!
@@ -51,6 +51,36 @@ INVITATION_CODE=try-huginn
51 51
 # If you don't want to require new users to have an invitation code in order to sign up, set this to true.
52 52
 SKIP_INVITATION_CODE=false
53 53
 
54
+# If you'd like to require new users to confirm their email address after sign up, set this to true.
55
+REQUIRE_CONFIRMED_EMAIL=false
56
+
57
+# If REQUIRE_CONFIRMED_EMAIL is true, set this to the duration in which a user needs to confirm their email address.
58
+ALLOW_UNCONFIRMED_ACCESS_FOR=2.days
59
+
60
+# Duration for which the above confirmation token is valid
61
+CONFIRM_WITHIN=3.days
62
+
63
+# Minimum password length
64
+MIN_PASSWORD_LENGTH=8
65
+
66
+# Duration for which the reset password token is valid
67
+RESET_PASSWORD_WITHIN=6.hours
68
+
69
+# Set to 'failed_attempts' to lock user accounts for the UNLOCK_AFTER period they fail MAX_FAILED_LOGIN_ATTEMPTS login attempts. Set to 'none' to allow unlimited failed login attempts.
70
+LOCK_STRATEGY=failed_attempts
71
+
72
+# After how many failed login attempts the account is locked when LOCK_STRATEGY is set to failed_attempts.
73
+MAX_FAILED_LOGIN_ATTEMPTS=10
74
+
75
+# Can be set to 'email', 'time', 'both' or 'none'. 'none' requires manual unlocking of your users!
76
+UNLOCK_STRATEGY=both
77
+
78
+# Duration after which the user is unlocked when UNLOCK_STRATEGY is 'both' or 'time' and LOCK_STRATEGY is 'failed_attempts'
79
+UNLOCK_AFTER=1.hour
80
+
81
+# Duration for which the user will be remembered without asking for credentials again.
82
+REMEMBER_FOR=4.weeks
83
+
54 84
 #############################
55 85
 #    Email Configuration    #
56 86
 #############################

+ 94 - 0
app/controllers/admin/users_controller.rb

@@ -0,0 +1,94 @@
1
+class Admin::UsersController < ApplicationController
2
+  before_action :authenticate_admin!
3
+
4
+  before_action :find_user, only: [:edit, :destroy, :update, :deactivate, :activate]
5
+
6
+  helper_method :resource
7
+
8
+  def index
9
+    @users = User.reorder('created_at DESC').page(params[:page])
10
+
11
+    respond_to do |format|
12
+      format.html
13
+      format.json { render json: @users }
14
+    end
15
+  end
16
+
17
+  def new
18
+    @user = User.new
19
+  end
20
+
21
+  def create
22
+    admin = params[:user].delete(:admin)
23
+    @user = User.new(params[:user])
24
+    @user.requires_no_invitation_code!
25
+    @user.admin = admin
26
+
27
+    respond_to do |format|
28
+      if @user.save
29
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully created." }
30
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
31
+      else
32
+        format.html { render action: 'new' }
33
+        format.json { render json: @user.errors, status: :unprocessable_entity }
34
+      end
35
+    end
36
+  end
37
+
38
+  def edit
39
+  end
40
+
41
+  def update
42
+    admin = params[:user].delete(:admin)
43
+    params[:user].except!(:password, :password_confirmation) if params[:user][:password].blank?
44
+    @user.assign_attributes(params[:user])
45
+    @user.admin = admin
46
+
47
+    respond_to do |format|
48
+      if @user.save
49
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully updated." }
50
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
51
+      else
52
+        format.html { render action: 'edit' }
53
+        format.json { render json: @user.errors, status: :unprocessable_entity }
54
+      end
55
+    end
56
+  end
57
+
58
+  def destroy
59
+    @user.destroy
60
+
61
+    respond_to do |format|
62
+      format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was deleted." }
63
+      format.json { head :no_content }
64
+    end
65
+  end
66
+
67
+  def deactivate
68
+    @user.deactivate!
69
+
70
+    respond_to do |format|
71
+      format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was deactivated." }
72
+      format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
73
+    end
74
+  end
75
+
76
+  def activate
77
+    @user.activate!
78
+
79
+    respond_to do |format|
80
+      format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was activated." }
81
+      format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
82
+    end
83
+  end
84
+
85
+  private
86
+
87
+  def find_user
88
+    @user = User.find(params[:id])
89
+  end
90
+
91
+  def resource
92
+    @user
93
+  end
94
+end

+ 13 - 0
app/helpers/users_helper.rb

@@ -0,0 +1,13 @@
1
+module UsersHelper
2
+  def user_account_state(user)
3
+    if !user.active?
4
+      content_tag :span, 'inactive', class: 'label label-danger'
5
+    elsif user.access_locked?
6
+      content_tag :span, 'locked', class: 'label label-danger'
7
+    elsif ENV['REQUIRE_CONFIRMED_EMAIL'] == 'true' && !user.confirmed?
8
+      content_tag :span, 'unconfirmed', class: 'label label-warning'
9
+    else
10
+      content_tag :span, 'active', class: 'label label-success'
11
+    end
12
+  end
13
+end

+ 4 - 4
app/models/agent.rb

@@ -61,8 +61,8 @@ class Agent < ActiveRecord::Base
61 61
   has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
62 62
   has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
63 63
 
64
-  scope :active,   -> { where(disabled: false) }
65
-  scope :inactive, -> { where(disabled: true) }
64
+  scope :active,   -> { where(disabled: false, deactivated: false) }
65
+  scope :inactive, -> { where(['disabled = ? OR deactivated = ?', true, true]) }
66 66
 
67 67
   scope :of_type, lambda { |type|
68 68
     type = case type
@@ -381,7 +381,7 @@ class Agent < ActiveRecord::Base
381 381
                 joins("JOIN links ON (links.receiver_id = agents.id)").
382 382
                 joins("JOIN agents AS sources ON (links.source_id = sources.id)").
383 383
                 joins("JOIN events ON (events.agent_id = sources.id AND events.id > links.event_id_at_creation)").
384
-                where("NOT agents.disabled AND (agents.last_checked_event_id IS NULL OR events.id > agents.last_checked_event_id)")
384
+                where("NOT agents.disabled AND NOT agents.deactivated AND (agents.last_checked_event_id IS NULL OR events.id > agents.last_checked_event_id)")
385 385
         if options[:only_receivers].present?
386 386
           scope = scope.where("agents.id in (?)", options[:only_receivers])
387 387
         end
@@ -432,7 +432,7 @@ class Agent < ActiveRecord::Base
432 432
     # per type of agent, so you can override this to define custom bulk check behavior for your custom Agent type.
433 433
     def bulk_check(schedule)
434 434
       raise "Call #bulk_check on the appropriate subclass of Agent" if self == Agent
435
-      where("agents.schedule = ? and disabled = false", schedule).pluck("agents.id").each do |agent_id|
435
+      where("NOT disabled AND NOT deactivated AND schedule = ?", schedule).pluck("agents.id").each do |agent_id|
436 436
         async_check(agent_id)
437 437
       end
438 438
     end

+ 41 - 5
app/models/user.rb

@@ -1,8 +1,10 @@
1 1
 # Huginn is designed to be a multi-User system.  Users have many Agents (and Events created by those Agents).
2 2
 class User < ActiveRecord::Base
3
-  devise :database_authenticatable, :registerable,
4
-         :recoverable, :rememberable, :trackable, :validatable, :lockable,
5
-         :omniauthable
3
+  DEVISE_MODULES = [:database_authenticatable, :registerable,
4
+                    :recoverable, :rememberable, :trackable,
5
+                    :validatable, :lockable, :omniauthable,
6
+                    (ENV['REQUIRE_CONFIRMED_EMAIL'] == 'true' ? :confirmable : nil)].compact
7
+  devise *DEVISE_MODULES
6 8
 
7 9
   INVITATION_CODES = [ENV['INVITATION_CODE'] || 'try-huginn']
8 10
 
@@ -16,9 +18,9 @@ class User < ActiveRecord::Base
16 18
   attr_accessible *(ACCESSIBLE_ATTRIBUTES + [:admin]), :as => :admin
17 19
 
18 20
   validates_presence_of :username
19
-  validates_uniqueness_of :username
21
+  validates :username, uniqueness: { case_sensitive: false }
20 22
   validates_format_of :username, :with => /\A[a-zA-Z0-9_-]{3,15}\Z/, :message => "can only contain letters, numbers, underscores, and dashes, and must be between 3 and 15 characters in length."
21
-  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: ->{ User.using_invitation_code? }
23
+  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: -> { !requires_no_invitation_code? && User.using_invitation_code? }
22 24
 
23 25
   has_many :user_credentials, :dependent => :destroy, :inverse_of => :user
24 26
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
@@ -41,7 +43,41 @@ class User < ActiveRecord::Base
41 43
     end
42 44
   end
43 45
 
46
+  def active?
47
+    !deactivated_at
48
+  end
49
+
50
+  def deactivate!
51
+    User.transaction do
52
+      agents.update_all(deactivated: true)
53
+      update_attribute(:deactivated_at, Time.now)
54
+    end
55
+  end
56
+
57
+  def activate!
58
+    User.transaction do
59
+      agents.update_all(deactivated: false)
60
+      update_attribute(:deactivated_at, nil)
61
+    end
62
+  end
63
+
64
+  def active_for_authentication?
65
+    super && active?
66
+  end
67
+
68
+  def inactive_message
69
+    active? ? super : :deactivated_account
70
+  end
71
+
44 72
   def self.using_invitation_code?
45 73
     ENV['SKIP_INVITATION_CODE'] != 'true'
46 74
   end
75
+
76
+  def requires_no_invitation_code!
77
+    @requires_no_invitation_code = true
78
+  end
79
+
80
+  def requires_no_invitation_code?
81
+    !!@requires_no_invitation_code
82
+  end
47 83
 end

+ 26 - 0
app/views/admin/users/_form.html.erb

@@ -0,0 +1,26 @@
1
+<%= form_for([:admin, @user], html: { class: 'form-horizontal' }) do |f| %>
2
+  <%= devise_error_messages! %>
3
+  <%= render partial: '/devise/registrations/common_registration_fields', locals: { f: f } %>
4
+
5
+  <div class="form-group">
6
+    <div class="col-md-offset-4 col-md-10">
7
+      <%= f.label :admin do %>
8
+        <%= f.check_box :admin %> Admin
9
+      <% end %>
10
+    </div>
11
+  </div>
12
+
13
+  <div class="form-group">
14
+    <div class="col-md-offset-4 col-md-10">
15
+      <%= f.submit class: "btn btn-primary" %>
16
+    </div>
17
+  </div>
18
+<% end %>
19
+
20
+<hr>
21
+
22
+<div class="row">
23
+  <div class="col-md-12">
24
+    <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, admin_users_path, class: "btn btn-default" %>
25
+  </div>
26
+</div>

+ 9 - 0
app/views/admin/users/edit.html.erb

@@ -0,0 +1,9 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <h2>Edit User</h2>
5
+
6
+      <%= render partial: 'form' %>
7
+    </div>
8
+  </div>
9
+</div>

+ 55 - 0
app/views/admin/users/index.html.erb

@@ -0,0 +1,55 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Users
7
+        </h2>
8
+      </div>
9
+
10
+      <div class='table-responsive'>
11
+        <table class='table table-striped events'>
12
+          <tr>
13
+            <th>Username</th>
14
+            <th>Email</th>
15
+            <th>State</th>
16
+            <th>Active agents</th>
17
+            <th>Deactivated agents</th>
18
+            <th>Registered since</th>
19
+            <th>Options</th>
20
+          </tr>
21
+
22
+          <% @users.each do |user| %>
23
+            <tr>
24
+              <td><%= link_to user.username, edit_admin_user_path(user) %></td>
25
+              <td><%= user.email %></td>
26
+              <td><%= user_account_state(user) %></td>
27
+              <td><%= user.agents.active.count %></td>
28
+              <td><%= user.agents.inactive.count %></td>
29
+              <td title='<%= user.created_at %>'><%= time_ago_in_words user.created_at %> ago</td>
30
+              <td>
31
+                <div class="btn-group btn-group-xs">
32
+                  <% if user != current_user %>
33
+                    <% if user.active? %>
34
+                      <%= link_to 'Deactivate', deactivate_admin_user_path(user), method: :put, class: "btn btn-default" %>
35
+                    <% else %>
36
+                      <%= link_to 'Activate', activate_admin_user_path(user), method: :put, class: "btn btn-default" %>
37
+                    <% end %>
38
+                    <%= link_to 'Delete', admin_user_path(user), method: :delete, data: { confirm: 'Are you sure? This can not be undone.' }, class: "btn btn-default" %>
39
+                  <% end %>
40
+                </div>
41
+              </td>
42
+            </tr>
43
+          <% end %>
44
+        </table>
45
+      </div>
46
+
47
+      <%= paginate @users, theme: 'twitter-bootstrap-3' %>
48
+
49
+      <div class="btn-group">
50
+        <%= link_to icon_tag('glyphicon-plus') + ' New User', new_admin_user_path, class: "btn btn-default" %>
51
+      </div>
52
+    </div>
53
+  </div>
54
+</div>
55
+

+ 9 - 0
app/views/admin/users/new.html.erb

@@ -0,0 +1,9 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <h2>Create new User</h2>
5
+
6
+      <%= render partial: 'form' %>
7
+    </div>
8
+  </div>
9
+</div>

+ 28 - 0
app/views/devise/registrations/_common_registration_fields.html.erb

@@ -0,0 +1,28 @@
1
+<div class="form-group">
2
+  <%= f.label :email, class: 'col-md-4 control-label' %>
3
+  <div class="col-md-6">
4
+    <%= f.email_field :email, autofocus: true, class: 'form-control' %>
5
+  </div>
6
+</div>
7
+
8
+<div class="form-group">
9
+  <%= f.label :username, class: 'col-md-4 control-label' %>
10
+  <div class="col-md-6">
11
+    <%= f.text_field :username, class: 'form-control' %>
12
+  </div>
13
+</div>
14
+
15
+<div class="form-group">
16
+  <%= f.label :password, class: 'col-md-4 control-label' %>
17
+  <div class="col-md-6">
18
+    <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
19
+    <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
20
+  </div>
21
+</div>
22
+
23
+<div class="form-group">
24
+  <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
25
+  <div class="col-md-6">
26
+    <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
27
+  </div>
28
+</div>

+ 1 - 28
app/views/devise/registrations/new.html.erb

@@ -41,34 +41,7 @@ bin/setup_heroku
41 41
             </div>
42 42
           <% end %>
43 43
 
44
-          <div class="form-group">
45
-            <%= f.label :email, class: 'col-md-4 control-label' %>
46
-            <div class="col-md-6">
47
-              <%= f.email_field :email, autofocus: true, class: 'form-control' %>
48
-            </div>
49
-          </div>
50
-
51
-          <div class="form-group">
52
-            <%= f.label :username, class: 'col-md-4 control-label' %>
53
-            <div class="col-md-6">
54
-              <%= f.text_field :username, class: 'form-control' %>
55
-            </div>
56
-          </div>
57
-
58
-          <div class="form-group">
59
-            <%= f.label :password, class: 'col-md-4 control-label' %>
60
-            <div class="col-md-6">
61
-              <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
62
-              <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
63
-            </div>
64
-          </div>
65
-
66
-          <div class="form-group">
67
-            <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
68
-            <div class="col-md-6">
69
-              <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
70
-            </div>
71
-          </div>
44
+          <%= render partial: 'common_registration_fields', locals: { f: f } %>
72 45
 
73 46
           <div class="form-group">
74 47
             <div class="col-md-offset-4 col-md-10">

+ 3 - 0
app/views/layouts/_navigation.html.erb

@@ -74,6 +74,9 @@
74 74
           <li>
75 75
             <%= link_to 'Job Management', jobs_path, :tabindex => '-1' %>
76 76
           </li>
77
+          <li>
78
+            <%= link_to 'User Management', admin_users_path, tabindex: '-1' %>
79
+          </li>
77 80
         <% end %>
78 81
         <li>
79 82
           <%= link_to 'About', 'https://github.com/cantino/huginn', :tabindex => "-1" %>

+ 9 - 9
config/initializers/devise.rb

@@ -103,7 +103,7 @@ Devise.setup do |config|
103 103
   # able to access the website for two days without confirming their account,
104 104
   # access will be blocked just in the third day. Default is 0.days, meaning
105 105
   # the user cannot access the website without confirming their account.
106
-  # config.allow_unconfirmed_access_for = 2.days
106
+  config.allow_unconfirmed_access_for = Utils.parse_duration(ENV['ALLOW_UNCONFIRMED_ACCESS_FOR']).presence || 2.days
107 107
 
108 108
   # A period that the user is allowed to confirm their account before their
109 109
   # token becomes invalid. For example, if set to 3.days, the user can confirm
@@ -111,7 +111,7 @@ Devise.setup do |config|
111 111
   # their account can't be confirmed with the token any more.
112 112
   # Default is nil, meaning there is no restriction on how long a user can take
113 113
   # before confirming their account.
114
-  # config.confirm_within = 3.days
114
+  config.confirm_within = Utils.parse_duration(ENV['CONFIRM_WITHIN']).presence || 3.days
115 115
 
116 116
   # If true, requires any email changes to be confirmed (exactly the same way as
117 117
   # initial account confirmation) to be applied. Requires additional unconfirmed_email
@@ -124,7 +124,7 @@ Devise.setup do |config|
124 124
 
125 125
   # ==> Configuration for :rememberable
126 126
   # The time the user will be remembered without asking for credentials again.
127
-  config.remember_for = 4.weeks
127
+  config.remember_for = Utils.parse_duration(ENV['REMEMBER_FOR']).presence || 4.weeks
128 128
 
129 129
   # Invalidates all the remember me tokens when the user signs out.
130 130
   config.expire_all_remember_me_on_sign_out = true
@@ -142,7 +142,7 @@ Devise.setup do |config|
142 142
 
143 143
   # ==> Configuration for :validatable
144 144
   # Range for password length.
145
-  config.password_length = 8..128
145
+  config.password_length = (Utils.if_present(ENV['MIN_PASSWORD_LENGTH'], :to_i) || 8)..128
146 146
 
147 147
   # Email regex used to validate email formats. It simply asserts that
148 148
   # one (and only one) @ exists in the given string. This is mainly
@@ -158,7 +158,7 @@ Devise.setup do |config|
158 158
   # Defines which strategy will be used to lock an account.
159 159
   # :failed_attempts = Locks an account after a number of failed attempts to sign in.
160 160
   # :none            = No lock strategy. You should handle locking by yourself.
161
-  config.lock_strategy = :failed_attempts
161
+  config.lock_strategy = Utils.if_present(ENV['LOCK_STRATEGY'], :to_sym) || :failed_attempts
162 162
 
163 163
   # Defines which key will be used when locking and unlocking an account
164 164
   config.unlock_keys = [ :email ]
@@ -168,14 +168,14 @@ Devise.setup do |config|
168 168
   # :time  = Re-enables login after a certain amount of time (see :unlock_in below)
169 169
   # :both  = Enables both strategies
170 170
   # :none  = No unlock strategy. You should handle unlocking by yourself.
171
-  config.unlock_strategy = :both
171
+  config.unlock_strategy = Utils.if_present(ENV['UNLOCK_STRATEGY'], :to_sym) || :both
172 172
 
173 173
   # Number of authentication tries before locking an account if lock_strategy
174 174
   # is failed attempts.
175
-  config.maximum_attempts = 10
175
+  config.maximum_attempts = Utils.if_present(ENV['MAX_FAILED_LOGIN_ATTEMPTS'], :to_i) || 10
176 176
 
177 177
   # Time interval to unlock the account if :time is enabled as unlock_strategy.
178
-  config.unlock_in = 1.hour
178
+  config.unlock_in = Utils.parse_duration(ENV['UNLOCK_AFTER']).presence || 1.hour
179 179
 
180 180
   # Warn on the last attempt before the account is locked.
181 181
   # config.last_attempt_warning = true
@@ -188,7 +188,7 @@ Devise.setup do |config|
188 188
   # Time interval you can reset your password with a reset password key.
189 189
   # Don't put a too small interval or your users won't have the time to
190 190
   # change their passwords.
191
-  config.reset_password_within = 6.hours
191
+  config.reset_password_within = Utils.parse_duration(ENV['RESET_PASSWORD_WITHIN']).presence || 6.hours
192 192
 
193 193
   # ==> Configuration for :encryptable
194 194
   # Allow you to use another encryption algorithm besides bcrypt (default). You can use

+ 3 - 0
config/locales/en.yml

@@ -2,6 +2,9 @@
2 2
 # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3 3
 
4 4
 en:
5
+  devise:
6
+    failure:
7
+      deactivated_account: "Your account has been deactivated by an administrator."
5 8
   datetime:
6 9
     distance_in_words:
7 10
       half_a_minute: "half a minute"

+ 9 - 0
config/routes.rb

@@ -66,6 +66,15 @@ Huginn::Application.routes.draw do
66 66
     end
67 67
   end
68 68
 
69
+  namespace :admin do
70
+    resources :users, except: :show do
71
+      member do
72
+        put :deactivate
73
+        put :activate
74
+      end
75
+    end
76
+  end
77
+
69 78
   get "/worker_status" => "worker_status#show"
70 79
 
71 80
   match "/users/:user_id/web_requests/:agent_id/:secret" => "web_requests#handle_request", :as => :web_requests, :via => [:get, :post, :put, :delete]

+ 17 - 0
db/migrate/20160301113717_add_confirmable_attributes_to_users.rb

@@ -0,0 +1,17 @@
1
+class AddConfirmableAttributesToUsers < ActiveRecord::Migration
2
+  def change
3
+    change_table(:users) do |t|
4
+      ## Confirmable
5
+      t.string   :confirmation_token
6
+      t.datetime :confirmed_at
7
+      t.datetime :confirmation_sent_at
8
+      t.string   :unconfirmed_email # Only if using reconfirmable
9
+    end
10
+
11
+    add_index :users, :confirmation_token,   unique: true
12
+
13
+    if ENV['REQUIRE_CONFIRMED_EMAIL'] != 'true' && ActiveRecord::Base.connection.column_exists?(:users, :confirmed_at)
14
+      User.update_all('confirmed_at = NOW()')
15
+    end
16
+  end
17
+end

+ 7 - 0
db/migrate/20160302095413_add_deactivated_at_to_users.rb

@@ -0,0 +1,7 @@
1
+class AddDeactivatedAtToUsers < ActiveRecord::Migration
2
+  def change
3
+    add_column :users, :deactivated_at, :datetime
4
+
5
+    add_index :users, :deactivated_at
6
+  end
7
+end

+ 6 - 0
db/migrate/20160307084729_add_deactivated_to_agents.rb

@@ -0,0 +1,6 @@
1
+class AddDeactivatedToAgents < ActiveRecord::Migration
2
+  def change
3
+    add_column :agents, :deactivated, :boolean, default: false
4
+    add_index :agents, [:disabled, :deactivated]
5
+  end
6
+end

+ 21 - 0
db/migrate/20160307085545_warn_about_duplicate_usernames.rb

@@ -0,0 +1,21 @@
1
+class WarnAboutDuplicateUsernames < ActiveRecord::Migration
2
+  def up
3
+    names = User.group('LOWER(username)').having('count(*) > 1').pluck('LOWER(username)')
4
+    if names.length > 0
5
+      puts "-----------------------------------------------------"
6
+      puts "--------------------- WARNiNG -----------------------"
7
+      puts "-------- Found users with duplicate usernames -------"
8
+      puts "-----------------------------------------------------"
9
+      puts "For the users to log in using their username they have to change it to a unique name"
10
+      names.each do |name|
11
+        puts
12
+        puts "'#{name}' is used multiple times:"
13
+        User.where(['LOWER(username) = ?', name]).each do |u|
14
+          puts "#{u.id}\t#{u.email}"
15
+        end
16
+      end
17
+      puts
18
+      puts
19
+    end
20
+  end
21
+end

+ 21 - 0
lib/utils.rb

@@ -130,4 +130,25 @@ module Utils
130 130
   def self.sort_tuples!(array, orders = [])
131 131
     TupleSorter.sort!(array, orders)
132 132
   end
133
+
134
+  def self.parse_duration(string)
135
+    return nil if string.blank?
136
+    case string.strip
137
+    when /\A(\d+)\.(\w+)\z/
138
+      $1.to_i.send($2.to_s)
139
+    when /\A(\d+)\z/
140
+      $1.to_i
141
+    else
142
+      STDERR.puts "WARNING: Invalid duration format: '#{string.strip}'"
143
+      nil
144
+    end
145
+  end
146
+
147
+  def self.if_present(string, method)
148
+    if string.present?
149
+      string.send(method)
150
+    else
151
+      nil
152
+    end
153
+  end
133 154
 end

+ 1 - 0
spec/env.test

@@ -11,3 +11,4 @@ WUNDERLIST_OAUTH_KEY=wunderoauthkey
11 11
 EVERNOTE_OAUTH_KEY=evernoteoauthkey
12 12
 EVERNOTE_OAUTH_SECRET=evernoteoauthsecret
13 13
 FAILED_JOBS_TO_KEEP=2
14
+REQUIRE_CONFIRMED_EMAIL=false

+ 107 - 0
spec/features/admin_users_spec.rb

@@ -0,0 +1,107 @@
1
+require 'capybara_helper'
2
+
3
+describe Admin::UsersController do
4
+  it "requires to be signed in as an admin" do
5
+    login_as(users(:bob))
6
+    visit admin_users_path
7
+    expect(page).to have_text('Admin access required to view that page.')
8
+  end
9
+
10
+  context "as an admin" do
11
+    before :each do
12
+      login_as(users(:jane))
13
+    end
14
+
15
+    it "lists all users" do
16
+      visit admin_users_path
17
+      expect(page).to have_text('bob')
18
+      expect(page).to have_text('jane')
19
+    end
20
+
21
+    it "allows to delete a user" do
22
+      visit admin_users_path
23
+      find(:css, "a[href='/admin/users/#{users(:bob).id}']").click
24
+      expect(page).to have_text("User 'bob' was deleted.")
25
+      expect(page).not_to have_text('bob@example.com')
26
+    end
27
+
28
+    context "creating new users" do
29
+      it "follow the 'new user' link" do
30
+        visit admin_users_path
31
+        click_on('New User')
32
+        expect(page).to have_text('Create new User')
33
+      end
34
+
35
+      it "creates a new user" do
36
+        visit new_admin_user_path
37
+        fill_in 'Email', with: 'test@test.com'
38
+        fill_in 'Username', with: 'usertest'
39
+        fill_in 'Password', with: '12345678'
40
+        fill_in 'Password confirmation', with: '12345678'
41
+        click_on 'Create User'
42
+        expect(page).to have_text("User 'usertest' was successfully created.")
43
+        expect(page).to have_text('test@test.com')
44
+      end
45
+
46
+      it "requires the passwords to match" do
47
+        visit new_admin_user_path
48
+        fill_in 'Email', with: 'test@test.com'
49
+        fill_in 'Username', with: 'usertest'
50
+        fill_in 'Password', with: '12345678'
51
+        fill_in 'Password confirmation', with: 'no_match'
52
+        click_on 'Create User'
53
+        expect(page).to have_text("Password confirmation doesn't match")
54
+      end
55
+    end
56
+
57
+    context "updating existing users" do
58
+      it "follows the edit link" do
59
+        visit admin_users_path
60
+        click_on('bob')
61
+        expect(page).to have_text('Edit User')
62
+      end
63
+
64
+      it "updates an existing user" do
65
+        visit edit_admin_user_path(users(:bob))
66
+        check 'Admin'
67
+        click_on 'Update User'
68
+        expect(page).to have_text("User 'bob' was successfully updated.")
69
+        visit edit_admin_user_path(users(:bob))
70
+        expect(page).to have_checked_field('Admin')
71
+      end
72
+
73
+      it "requires the passwords to match when changing them" do
74
+        visit edit_admin_user_path(users(:bob))
75
+        fill_in 'Password', with: '12345678'
76
+        fill_in 'Password confirmation', with: 'no_match'
77
+        click_on 'Update User'
78
+        expect(page).to have_text("Password confirmation doesn't match")
79
+      end
80
+    end
81
+
82
+    context "(de)activating users" do
83
+      it "does not show deactivation buttons for the current user" do
84
+        visit admin_users_path
85
+        expect(page).not_to have_css("a[href='/admin/users/#{users(:jane).id}/deactivate']")
86
+      end
87
+
88
+      it "deactivates an existing user" do
89
+        visit admin_users_path
90
+        expect(page).not_to have_text('inactive')
91
+        find(:css, "a[href='/admin/users/#{users(:bob).id}/deactivate']").click
92
+        expect(page).to have_text('inactive')
93
+        users(:bob).reload
94
+        expect(users(:bob)).not_to be_active
95
+      end
96
+
97
+      it "deactivates an existing user" do
98
+        users(:bob).deactivate!
99
+        visit admin_users_path
100
+        find(:css, "a[href='/admin/users/#{users(:bob).id}/activate']").click
101
+        expect(page).not_to have_text('inactive')
102
+        users(:bob).reload
103
+        expect(users(:bob)).to be_active
104
+      end
105
+    end
106
+  end
107
+end

+ 29 - 0
spec/lib/utils_spec.rb

@@ -172,4 +172,33 @@ describe Utils do
172 172
       expect(tuples).to eq expected
173 173
     end
174 174
   end
175
+
176
+  context "#parse_duration" do
177
+    it "works with correct arguments" do
178
+      expect(Utils.parse_duration('2.days')).to eq(2.days)
179
+      expect(Utils.parse_duration('2.seconds')).to eq(2)
180
+      expect(Utils.parse_duration('2')).to eq(2)
181
+    end
182
+
183
+    it "returns nil when passed nil" do
184
+      expect(Utils.parse_duration(nil)).to be_nil
185
+    end
186
+
187
+    it "warns and returns nil when not parseable" do
188
+      mock(STDERR).puts("WARNING: Invalid duration format: 'bogus'")
189
+      expect(Utils.parse_duration('bogus')).to be_nil
190
+    end
191
+  end
192
+
193
+  context "#if_present" do
194
+    it "returns nil when passed nil" do
195
+      expect(Utils.if_present(nil, :to_i)).to be_nil
196
+    end
197
+
198
+    it "calls the specified method when the argument is present" do
199
+      argument = mock()
200
+      mock(argument).to_i { 1 }
201
+      expect(Utils.if_present(argument, :to_i)).to eq(1)
202
+    end
203
+  end
175 204
 end

+ 41 - 0
spec/models/agent_spec.rb

@@ -3,6 +3,34 @@ require 'rails_helper'
3 3
 describe Agent do
4 4
   it_behaves_like WorkingHelpers
5 5
 
6
+  describe '.active/inactive' do
7
+    let(:agent) { agents(:jane_website_agent) }
8
+
9
+    it 'is active per default' do
10
+      expect(Agent.active).to include(agent)
11
+      expect(Agent.inactive).not_to include(agent)
12
+    end
13
+
14
+    it 'is not active when disabled' do
15
+      agent.update_attribute(:disabled, true)
16
+      expect(Agent.active).not_to include(agent)
17
+      expect(Agent.inactive).to include(agent)
18
+    end
19
+
20
+    it 'is not active when deactivated' do
21
+      agent.update_attribute(:deactivated, true)
22
+      expect(Agent.active).not_to include(agent)
23
+      expect(Agent.inactive).to include(agent)
24
+    end
25
+
26
+    it 'is not active when disabled and deactivated' do
27
+      agent.update_attribute(:disabled, true)
28
+      agent.update_attribute(:deactivated, true)
29
+      expect(Agent.active).not_to include(agent)
30
+      expect(Agent.inactive).to include(agent)
31
+    end
32
+  end
33
+
6 34
   describe ".bulk_check" do
7 35
     before do
8 36
       @weather_agent_count = Agents::WeatherAgent.where(:schedule => "midnight", :disabled => false).count
@@ -18,6 +46,12 @@ describe Agent do
18 46
       mock(Agents::WeatherAgent).async_check(anything).times(@weather_agent_count - 1)
19 47
       Agents::WeatherAgent.bulk_check("midnight")
20 48
     end
49
+
50
+    it "should skip agents of deactivated accounts" do
51
+      agents(:bob_weather_agent).user.deactivate!
52
+      mock(Agents::WeatherAgent).async_check(anything).times(@weather_agent_count - 1)
53
+      Agents::WeatherAgent.bulk_check("midnight")
54
+    end
21 55
   end
22 56
 
23 57
   describe ".run_schedule" do
@@ -335,6 +369,13 @@ describe Agent do
335 369
           Agent.receive! # and we receive it
336 370
         }.to change { agents(:bob_rain_notifier_agent).reload.last_checked_event_id }
337 371
       end
372
+
373
+      it "should not run agents of deactivated accounts" do
374
+        agents(:bob_weather_agent).user.deactivate!
375
+        Agent.async_check(agents(:bob_weather_agent).id)
376
+        mock(Agent).async_receive(agents(:bob_rain_notifier_agent).id, anything).times(0)
377
+        Agent.receive!
378
+      end
338 379
     end
339 380
 
340 381
     describe ".async_receive" do

+ 30 - 0
spec/models/users_spec.rb

@@ -19,6 +19,12 @@ describe User do
19 19
             should_not allow_value(v).for(:invitation_code)
20 20
           end
21 21
         end
22
+
23
+        it "requires no authentication code when requires_no_invitation_code! is called" do
24
+          u = User.new(username: 'test', email: 'test@test.com', password: '12345678', password_confirmation: '12345678')
25
+          u.requires_no_invitation_code!
26
+          expect(u).to be_valid
27
+        end
22 28
       end
23 29
       
24 30
       context "when configured not to use invitation codes" do
@@ -34,4 +40,28 @@ describe User do
34 40
       end
35 41
     end
36 42
   end
43
+
44
+  context '#deactivate!' do
45
+    it "deactivates the user and all her agents" do
46
+      agent = agents(:jane_website_agent)
47
+      users(:jane).deactivate!
48
+      agent.reload
49
+      expect(agent.deactivated).to be_truthy
50
+      expect(users(:jane).deactivated_at).not_to be_nil
51
+    end
52
+  end
53
+
54
+  context '#activate!' do
55
+    before do
56
+      users(:bob).deactivate!
57
+    end
58
+
59
+    it 'activates the user and all his agents' do
60
+      agent = agents(:bob_website_agent)
61
+      users(:bob).activate!
62
+      agent.reload
63
+      expect(agent.deactivated).to be_falsy
64
+      expect(users(:bob).deactivated_at).to be_nil
65
+    end
66
+  end
37 67
 end