Jelajahi Sumber

Add admin user management interface

Dominik Sander 9 tahun lalu
induk
melakukan
4722ebfe4e

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

@@ -0,0 +1,76 @@
+class Admin::UsersController < ApplicationController
+  before_action :authenticate_admin!
+
+  before_action :find_user, only: [:edit, :destroy, :update]
+
+  helper_method :resource
+
+  def index
+    @users = User.reorder(:created_at).page(params[:page])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @users }
+    end
+  end
+
+  def new
+    @user = User.new
+  end
+
+  def create
+    admin = params[:user].delete(:admin)
+    @user = User.new(params[:user])
+    @user.requires_no_invitation_code!
+    @user.admin = admin
+
+    respond_to do |format|
+      if @user.save
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully created." }
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
+      else
+        format.html { render action: 'new' }
+        format.json { render json: @user.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def edit
+  end
+
+  def update
+    admin = params[:user].delete(:admin)
+    params[:user].except!(:password, :password_confirmation) if params[:user][:password].blank?
+    @user.assign_attributes(params[:user])
+    @user.admin = admin
+
+    respond_to do |format|
+      if @user.save
+        format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was successfully updated." }
+        format.json { render json: @user, status: :ok, location: admin_users_path(@user) }
+      else
+        format.html { render action: 'edit' }
+        format.json { render json: @user.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def destroy
+    @user.destroy
+
+    respond_to do |format|
+      format.html { redirect_to admin_users_path, notice: "User '#{@user.username}' was deleted." }
+      format.json { head :no_content }
+    end
+  end
+
+  private
+
+  def find_user
+    @user = User.find(params[:id])
+  end
+
+  def resource
+    @user
+  end
+end

+ 10 - 2
app/models/user.rb

@@ -16,9 +16,9 @@ class User < ActiveRecord::Base
   attr_accessible *(ACCESSIBLE_ATTRIBUTES + [:admin]), :as => :admin
 
   validates_presence_of :username
-  validates_uniqueness_of :username
+  validates :username, uniqueness: { case_sensitive: false }
   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."
-  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: ->{ User.using_invitation_code? }
+  validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: -> { !requires_no_invitation_code? && User.using_invitation_code? }
 
   has_many :user_credentials, :dependent => :destroy, :inverse_of => :user
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
@@ -44,4 +44,12 @@ class User < ActiveRecord::Base
   def self.using_invitation_code?
     ENV['SKIP_INVITATION_CODE'] != 'true'
   end
+
+  def requires_no_invitation_code!
+    @requires_no_invitation_code = true
+  end
+
+  def requires_no_invitation_code?
+    !!@requires_no_invitation_code
+  end
 end

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

@@ -0,0 +1,26 @@
+<%= form_for([:admin, @user], html: { class: 'form-horizontal' }) do |f| %>
+  <%= devise_error_messages! %>
+  <%= render partial: '/devise/registrations/common_registration_fields', locals: { f: f } %>
+
+  <div class="form-group">
+    <div class="col-md-offset-4 col-md-10">
+      <%= f.label :admin do %>
+        <%= f.check_box :admin %> Admin
+      <% end %>
+    </div>
+  </div>
+
+  <div class="form-group">
+    <div class="col-md-offset-4 col-md-10">
+      <%= f.submit class: "btn btn-primary" %>
+    </div>
+  </div>
+<% end %>
+
+<hr>
+
+<div class="row">
+  <div class="col-md-12">
+    <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, admin_users_path, class: "btn btn-default" %>
+  </div>
+</div>

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

@@ -0,0 +1,9 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <h2>Edit User</h2>
+
+      <%= render partial: 'form' %>
+    </div>
+  </div>
+</div>

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

@@ -0,0 +1,48 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2>
+          Users
+        </h2>
+      </div>
+
+      <div class='table-responsive'>
+        <table class='table table-striped events'>
+          <tr>
+            <th>Username</th>
+            <th>Email</th>
+            <th>State</th>
+            <th>Active agents</th>
+            <th>Inactive agents</th>
+            <th>Registered since</th>
+            <th>Options</th>
+          </tr>
+
+          <% @users.each do |user| %>
+            <tr>
+              <td><%= link_to user.username, edit_admin_user_path(user) %></td>
+              <td><%= user.email %></td>
+              <td>state</td>
+              <td><%= user.agents.active.count %></td>
+              <td><%= user.agents.inactive.count %></td>
+              <td title='<%= user.created_at %>'><%= time_ago_in_words user.created_at %> ago</td>
+              <td>
+                <div class="btn-group btn-group-xs">
+                  <%= link_to 'Delete', admin_user_path(user), method: :delete, data: { confirm: 'Are you sure? This can not be undone.' }, class: "btn btn-default" %>
+                </div>
+              </td>
+            </tr>
+          <% end %>
+        </table>
+      </div>
+
+      <%= paginate @users, theme: 'twitter-bootstrap-3' %>
+
+      <div class="btn-group">
+        <%= link_to icon_tag('glyphicon-plus') + ' New User', new_admin_user_path, class: "btn btn-default" %>
+      </div>
+    </div>
+  </div>
+</div>
+

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

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

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

@@ -0,0 +1,28 @@
+<div class="form-group">
+  <%= f.label :email, class: 'col-md-4 control-label' %>
+  <div class="col-md-6">
+    <%= f.email_field :email, autofocus: true, class: 'form-control' %>
+  </div>
+</div>
+
+<div class="form-group">
+  <%= f.label :username, class: 'col-md-4 control-label' %>
+  <div class="col-md-6">
+    <%= f.text_field :username, class: 'form-control' %>
+  </div>
+</div>
+
+<div class="form-group">
+  <%= f.label :password, class: 'col-md-4 control-label' %>
+  <div class="col-md-6">
+    <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
+    <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
+  </div>
+</div>
+
+<div class="form-group">
+  <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
+  <div class="col-md-6">
+    <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
+  </div>
+</div>

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

@@ -41,34 +41,7 @@ bin/setup_heroku
             </div>
           <% end %>
 
-          <div class="form-group">
-            <%= f.label :email, class: 'col-md-4 control-label' %>
-            <div class="col-md-6">
-              <%= f.email_field :email, autofocus: true, class: 'form-control' %>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <%= f.label :username, class: 'col-md-4 control-label' %>
-            <div class="col-md-6">
-              <%= f.text_field :username, class: 'form-control' %>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <%= f.label :password, class: 'col-md-4 control-label' %>
-            <div class="col-md-6">
-              <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
-              <% if @validatable %><span class="help-inline"><%= @minimum_password_length %> characters minimum.</span><% end %>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <%= f.label :password_confirmation, class: 'col-md-4 control-label' %>
-            <div class="col-md-6">
-              <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
-            </div>
-          </div>
+          <%= render partial: 'common_registration_fields', locals: { f: f } %>
 
           <div class="form-group">
             <div class="col-md-offset-4 col-md-10">

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

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

+ 4 - 0
config/routes.rb

@@ -66,6 +66,10 @@ Huginn::Application.routes.draw do
     end
   end
 
+  namespace :admin do
+    resources :users, except: :show
+  end
+
   get "/worker_status" => "worker_status#show"
 
   match "/users/:user_id/web_requests/:agent_id/:secret" => "web_requests#handle_request", :as => :web_requests, :via => [:get, :post, :put, :delete]

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

@@ -0,0 +1,21 @@
+class WarnAboutDuplicateUsernames < ActiveRecord::Migration
+  def up
+    names = User.group('LOWER(username)').having('count(*) > 1').pluck('LOWER(username)')
+    if names.length > 0
+      puts "-----------------------------------------------------"
+      puts "--------------------- WARNiNG -----------------------"
+      puts "-------- Found users with duplicate usernames -------"
+      puts "-----------------------------------------------------"
+      puts "For the users to log in using their username they have to change it to a unique name"
+      names.each do |name|
+        puts
+        puts "'#{name}' is used multiple times:"
+        User.where(['LOWER(username) = ?', name]).each do |u|
+          puts "#{u.id}\t#{u.email}"
+        end
+      end
+      puts
+      puts
+    end
+  end
+end

+ 82 - 0
spec/features/admin_users_spec.rb

@@ -0,0 +1,82 @@
+require 'capybara_helper'
+
+describe Admin::UsersController do
+  it "requires to be signed in as an admin" do
+    login_as(users(:bob))
+    visit admin_users_path
+    expect(page).to have_text('Admin access required to view that page.')
+  end
+
+  context "as an admin" do
+    before :each do
+      login_as(users(:jane))
+    end
+
+    it "lists all users" do
+      visit admin_users_path
+      expect(page).to have_text('bob')
+      expect(page).to have_text('jane')
+    end
+
+    it "allows to delete a user" do
+      visit admin_users_path
+      find(:css, "a[href='/admin/users/#{users(:bob).id}']").click
+      expect(page).to have_text("User 'bob' was deleted.")
+      expect(page).not_to have_text('bob@example.com')
+    end
+
+    context "creating new users" do
+      it "follow the 'new user' link" do
+        visit admin_users_path
+        click_on('New User')
+        expect(page).to have_text('Create new User')
+      end
+
+      it "creates a new user" do
+        visit new_admin_user_path
+        fill_in 'Email', with: 'test@test.com'
+        fill_in 'Username', with: 'usertest'
+        fill_in 'Password', with: '12345678'
+        fill_in 'Password confirmation', with: '12345678'
+        click_on 'Create User'
+        expect(page).to have_text("User 'usertest' was successfully created.")
+        expect(page).to have_text('test@test.com')
+      end
+
+      it "requires the passwords to match" do
+        visit new_admin_user_path
+        fill_in 'Email', with: 'test@test.com'
+        fill_in 'Username', with: 'usertest'
+        fill_in 'Password', with: '12345678'
+        fill_in 'Password confirmation', with: 'no_match'
+        click_on 'Create User'
+        expect(page).to have_text("Password confirmation doesn't match")
+      end
+    end
+
+    context "updating existing users" do
+      it "follows the edit link" do
+        visit admin_users_path
+        click_on('bob')
+        expect(page).to have_text('Edit User')
+      end
+
+      it "updates an existing user" do
+        visit edit_admin_user_path(users(:bob))
+        check 'Admin'
+        click_on 'Update User'
+        expect(page).to have_text("User 'bob' was successfully updated.")
+        visit edit_admin_user_path(users(:bob))
+        expect(page).to have_checked_field('Admin')
+      end
+
+      it "requires the passwords to match when changing them" do
+        visit edit_admin_user_path(users(:bob))
+        fill_in 'Password', with: '12345678'
+        fill_in 'Password confirmation', with: 'no_match'
+        click_on 'Update User'
+        expect(page).to have_text("Password confirmation doesn't match")
+      end
+    end
+  end
+end

+ 6 - 0
spec/models/users_spec.rb

@@ -19,6 +19,12 @@ describe User do
             should_not allow_value(v).for(:invitation_code)
           end
         end
+
+        it "requires no authentication code when requires_no_invitation_code! is called" do
+          u = User.new(username: 'test', email: 'test@test.com', password: '12345678', password_confirmation: '12345678')
+          u.requires_no_invitation_code!
+          expect(u).to be_valid
+        end
       end
       
       context "when configured not to use invitation codes" do