Welcome to the eleventh part of our tutorial about building a web application with matestack.
Introduction
Our app looks great after finishing the previous guide. To make it more of a real-world example, we add a private area, which is only accessible for logged in admins.
In this guide, we will
install and set up the devise gem
add a second matestack app for our private administration area
move some of the CRUD functionality into the private admin app
add a link to the administration area in our demo app
Prerequisites
We expect you to have successfully finished the previous guide.
Setting up Devise
For authentication we use the popular library devise. To install it, we add gem 'devise' to our Gemfile and run bundle install afterwards. To finish the devise installation we run rails generate devise:install.
After a successful setup we can now generate our admin model. Our admins don't need any extra data, so it's enough to run devise generator and specify admin as a name for our model.
rails generate devise admin
Finally, we need to migrate the database by running
Our demo app is responsible for all pages available to everyone, but some actions should only be available by admins. This is one reason to create a seperate admin app. The other reason is that we want another layout for our admin app. Therefore we create a second matestack app under the Admin namespace in app/matestack/admin/app.rb.
class Admin::App < Matestack::Ui::App
def response
navigation
notifications
main id: 'page-content' do
yield_page slots: { loading_state: loading_state_element }
end
footer
end
private
def navigation
nav class: 'navbar navbar-expand-md navbar-dark bg-dark fixed-top' do
transition class: 'navbar-brand font-weight-bold', path: root_path, text: 'AdminApp', delay: 300
if admin_signed_in?
navbar_toggle_button
div id: 'navbar', class: 'collapse navbar-collapse justify-content-end' do
div class: 'w-100' do
navbar_right
end
navbar_left
end
end
end
end
def navbar_toggle_button
button class: 'navbar-toggler', attributes: {
"data-target": "#navbar", role:"button", "data-toggle": "collapse",
"aria-controls": "navbar", "aria-expanded": "false"
} do
span class: 'navbar-toggler-icon'
end
end
def navbar_right
ul class: 'navbar-nav mr-0' do
li class: 'nav-item' do
transition class: 'nav-link text-light', path: admin_persons_path, text: 'All persons', delay: 300
end
end
end
def navbar_left
ul class: 'navbar-nav mr-0' do
li class: 'nav-item' do
link class: 'nav-link text-light mr-3', path: persons_path, text: 'Demo App', delay: 300
end
li class: 'nav-item' do
action logout_action_config do
span class: 'btn-nav btn btn-primary', text: I18n.t('devise.sessions.logout')
end
end
end
end
def loading_state_element
slot do
div id: 'spinner', class: 'spinner-border', role: 'status' do
span class: 'sr-only', text: 'Loading...'
end
end
end
def footer
div class: 'jumbotron jumbotron-fluid bg-dark mb-0 footer' do
div class: 'container py-5' do
div class: 'd-flex align-items-center justify-content-center' do
heading class: 'm-0 mr-1 font-weight-normal text-light', size: 5,
text: 'This demo application and corresponding guides are provided by'
img path: asset_path('matestack'), height: '48px'
end
end
end
end
def logout_action_config
{
method: :get,
path: destroy_admin_session_path,
success: {
redirect: {
follow_response: true
}
}
}
end
end
In our Admin::App we call a custom notifications component which we will now create under app/matestack/admin/components/notifications.rb. Why do we this time put our components folder inside our admin folder? Because the notifications component should only be used in the admin context and we therefore store components that should only be used in the admin context inside the admin folder. We even create another registry in app/matestack/admin/components/registry.rb to seperate the two contexts better.
module Admin::Components::Registry
Matestack::Ui::Core::Component::Registry.register_components(
notifications: Admin::Components::Notifications
)
end
class Admin::Components::Shared::Notifications < Matestack::Ui::Component
def response
div class: 'alert-wrapper' do
# alerts for different events will be added later here
end
end
end
While it's similar to the Demo::App, the Admin::App does have some differences. Notice how we hide a part of the navigation if no admin is currently signed in, making use of a Devise helper:
if admin_signed_in?
There is also a logout button, using an action component.
We could now use the Admin::App as layout, but we need to set it with matestack_app in the corresponding controller and we need to include our new registry with include Admin::Component::Registry.
Let's create our routes for our admin area and after it the controllers we referred to in our routes.
devise_for :admins
namespace :admin do
root to: 'persons#index'
resources :persons, except: [:show]
end
class Admin::PersonsController < ApplicationController
include Admin::Components::Registry
matestack_app Admin::App
end
Notice the include of our registry and the call for setting our matestack app. Now that we have our admin persons controller and admin app we can create our different pages.
Admin persons index, edit, new pages
First we create an index page in app/matestack/admin/pages/persons/index.rb, which shows all persons in a table. To implement it we use the collection component.
class Admin::Pages::Persons::Index < Matestack::Ui::Page
include Matestack::Ui::Core::Collection::Helper
def prepare
person_collection_id = "persons-collection"
current_filter = get_collection_filter(person_collection_id)
person_query = Person.all.order(id: :asc)
filtered_person_query = person_query
.where("last_name LIKE ?", "%#{current_filter[:last_name]}%")
@person_collection = set_collection({
id: person_collection_id,
data: filtered_person_query,
init_limit: 10,
filtered_count: filtered_person_query.count,
base_count: person_query.count
})
end
def response
div class: 'container my-5' do
div class: 'text-right' do
transition class: 'btn btn-success mb-3', text: '+ New person', path: new_admin_person_path, delay: 300
end
filter
async id: 'collection', rerender_on: 'persons-collection-update' do
collection_content @person_collection.config do
table class: 'table table-striped table-light table-hover mt-3' do
table_head
table_body
end
paginator
end
end
end
end
private
def filter
collection_filter @person_collection.config do
div class: 'd-flex' do
collection_filter_input key: :last_name, type: :text, placeholder: 'Filter by Last name', class: 'form-control'
collection_filter_submit do
button class: 'btn btn-outline-primary ml-1', text: 'Apply'
end
collection_filter_reset do
button class: 'btn btn-outline-secondary ml-1', text: 'Reset'
end
end
end
end
def table_head
tr do
th text: '#'
th text: 'Last name'
th text: 'First name'
th text: 'Role'
th
end
end
def table_body
@person_collection.paginated_data.each do |person|
tr do
td text: person.id
td do
transition path: edit_admin_person_path(person), text: person.last_name, class: 'text-dark font-weight-bold', delay: 300
end
td text: person.first_name
td text: person.role
td class: 'text-right' do
action delete_person_config(person) do
button text: 'Delete', class: 'btn btn-outline-primary'
end
end
end
end
end
def paginator
ul class: 'pagination justify-content-center' do
li class: 'page-item' do
collection_content_previous do
button class: 'page-link', text: 'previous'
end
end
@person_collection.pages.each do |page|
li class: 'page-item' do
collection_content_page_link page: page do
button class: 'page-link', text: page
end
end
end
li class: 'page-item' do
collection_content_next do
button class: 'page-link', text: 'next'
end
end
end
end
def delete_person_config(person)
{
method: :delete,
path: admin_person_path(person),
success: {
emit: 'persons-collection-update'
},
confirm: {
text: "Do you really want to delete '#{person.first_name} #{person.last_name}'?"
}
}
end
end
Now we create the new and edit pages. Like we did earlier in our demo page, we create a form page which will contain a form partial, because both views will use the same view. By excluding it in a partial in a form page, both new and edit can inherit from it and reuse our form.
app/matestack/pages/persons/form.rb
class Admin::Pages::Persons::Form < Matestack::Ui::Page
protected
def person_form(save_button)
form person_form_config, :include do
form_group label: 'First name:' do
form_input key: :first_name, class: 'form-control', type: :text
end
form_group label: 'Last name:' do
form_input key: :last_name, class: 'form-control', type: :text
end
form_group label: 'Last name:' do
form_select key: :role, type: :dropdown, class: 'form-control', options: Person.roles.keys
end
transition path: admin_persons_path, class: 'btn btn-secondary my-3', text: 'Cancel', delay: 300
form_submit do
button class: 'btn btn-primary', text: save_button
end
end
end
def person_form_config
raise 'implement in inheriting class'
end
def form_group(label: '', &block)
div class: 'form-group row' do
label class: 'col-sm-4 col-form-label col-form-label-md', text: label
div class: 'col-sm-8' do
yield
end
end
end
end
Take a closer look at the form group partial here. Every form element has the same wrapping elements and the same label with its classes. In order to not repeat ourselfs and write less code we can use a partial, which takes a label text and a block and renders the wrapping elements, label and yields the block. Our form code therefore looks much cleaner now and we kept it DRY (don't repeat yourself).
app/matestack/pages/persons/new.rb
class Admin::Pages::Persons::New < Admin::Pages::Persons::Form
def prepare
@person = Person.new
end
def response
div class: 'container' do
div class: 'row' do
div class: 'col-md-6 offset-md-3 text-center' do
heading size: 2, text: 'Create new person', class: 'my-3'
person_form 'Create'
end
end
end
end
def person_form_config
{
for: @person,
method: :post,
path: :admin_persons_path,
success: {
emit: 'person_form_success',
transition: {
follow_response: true,
delay: 300
}
},
failure: {
emit: 'person_form_failure'
}
}
end
end
app/matestack/pages/persons/edit.rb
class Admin::Pages::Persons::Edit < Admin::Pages::Persons::Form
def response
div class: 'container my-5' do
div class: 'row' do
div class: 'col-md-6 offset-md-3 text-center' do
heading size: 2, text: "Edit Person: #{@person.first_name} #{@person.last_name}", class: 'my-3'
person_form 'Save changes'
end
end
end
end
def person_form_config
{
for: @person,
method: :patch,
path: admin_person_path(@person),
success: {
emit: 'person_form_success'
},
failure: {
emit: 'person_form_failure'
}
}
end
end
Nothing special for our new and edit page. Both inheriting from the form page, overwriting the form config and using the form partial.
With the pages we added the possibility to maintain the persons as admin, by editing, deleting existing ones or creating new ones.
But we still need a login page. Go ahead and add it in app/matestack/admin/pages/sessions/sign_in.rb with this content:
class Admin::Pages::Sessions::SignIn < Matestack::Ui::Page
def response
div class: 'container my-5' do
div class: 'row' do
div class: 'col-md-4 offset-md-4' do
div class: 'card' do
div class: 'card-body text-center' do
heading text: t('devise.sessions.new.login')
login_form
end
end
end
end
end
end
private
def login_form
form form_config, :include do
form_group label: 'E-Mail' do
form_input key: :email, type: :text
end
form_group label: 'Password' do
form_input key: :password, type: :password
end
form_submit class: 'text-center d-block' do
button class: 'btn btn-primary text-center', text: 'Login'
end
end
end
def form_group(label: '', &block)
div class: 'form-group row' do
label class: 'col-sm-12 col-form-label col-form-label-md', text: label
div class: 'col-sm-12' do
yield
end
end
end
def form_config
{
for: :admin,
method: :post,
path: admin_session_path,
success: {
redirect: {
follow_response: true
}
},
failure: {
emit: "login_failure"
}
}
end
end
Admin Controllers
Now that we have our pages, we will need to update our persons controller
Update it accordingly:
class Admin::PersonsController < Admin::BaseController
matestack_app Admin::App
before_action :find_person, only: [:show, :edit, :update, :destroy]
def new
render Admin::Pages::Persons::New
end
def index
render Admin::Pages::Persons::Index
end
def edit
render Admin::Pages::Persons::Edit
end
def update
if @person.update person_params
render json: { }, status: :ok
else
render json: { errors: @person.errors }, status: :unprocessable_entity
end
end
def create
person = Person.create(person_params)
if person.valid?
render json: { transition_to: edit_admin_person_path(person) }, status: :created
else
render json: { errors: person.errors }, status: :unprocessable_entity
end
end
def destroy
if @person.destroy
render json: { transition_to: persons_path }, status: :ok
else
render json: {errors: @person.errors}, status: :unprocessable_entity
end
end
protected
def find_person
@person = Person.find_by(id: params[:id])
end
def person_params
params.require(:person).permit(
:first_name,
:last_name,
:active,
:role
)
end
end
We added all required actions according to our routes. Did you notice our controller now inherits from Admin::BaseController? We need to create it in the next step, but why did we create one? Because all routes and corresponding actions belonging to the admin should not be visible without logging in as admin. Therefore we implement a before_action hook which calls devise authenticate_admin! helper, making sure that every action can only be called by a logged in admin. We could do this in our persons controller, but as we might add other controllers later they only need to inherit from our base controller and are also protected. Let's create the base controller in app/controllers/admin/base_controller.rb.
class Admin::BaseController < ApplicationController
include Admin::Components::Registry
layout 'administration'
before_action :authenticate_admin!
end
In order for devise to use our sign in page, we need to create a custom session controller. We also specify a path admins should be redirected to after sign out.
See below on how to create the session controller for devise.
app/controllers/admin/sessions_controller.rb
class Admin::SessionsController < Devise::SessionsController
include Admin::Components::Registry
matestack_app Admin::App
layout 'administration'
def new
render Admin::Pages::Sessions::SignIn
end
private
def after_sign_out_path_for(resource_or_scope)
new_admin_session_path
end
end
We also need to create a custom failure app to return the expected response for login attempts with wrong credentials.
lib/devise/json_failure_app.rb
class JsonFailureApp < Devise::FailureApp
def respond
return super unless request.content_type == 'application/json'
self.status = 401
self.content_type = :json
end
end
And tell devise to use it by requiring it in the initializer and updating the config. We also need to update the sign_out_via configuration parameter to use http GET requests for sign out instead of DELETE.
config/intializers/devise.rb
require "#{Rails.root}/lib/devise/json_failure_app"
#...
config.warden do |manager|
manager.failure_app = JsonFailureApp
end
Finally remember to update the routes in order to tell devise to use the correct controller.
config/routes.rb
Rails.application.routes.draw do
root to: 'persons#index'
get '/first_page', to: 'demo#first_page'
get '/second_page', to: 'demo#second_page'
resources :persons
devise_for :admins, controllers: {
sessions: 'admin/sessions'
}
namespace :admin do
root to: 'persons#index'
resources :persons
end
end
If you want more information on why these changes and configurations are necessary take a look at our devise guide.
But if you try to start your application locally, visiting the admin pages doesn't work yet - what's going on?
Styling, Layout & JavaScript
Notice that we added layout 'administration' inside our admin controllers. This looks for file called administration.html.erb in app/views/layouts/, which we need to create. Add the following content to it:
In there, we reference a different javascript_pack_tag(that is, 'administration') than in our application.html.erb layout (which uses 'application') - so we need to set it up via Webpacker!
Add a new file in app/javascript/packs/administration.js, with the following content:
When you compare it to app/javascript/packs/application.js which sits right next to it, you will see that the two diverge in one point: The admin pack imports 'css/custom-admin-bootstrap', so we need to create it.
Here's the custom-admin-bootstrap.scss which you need to add in app/javascript/css/. It looks very familiar because it is. There's not much difference between it and our custom-bootstrap.scss. But we may want to theme our admin app differently than our demo app, so in case we can by just changing the colors in this file for example.
As you might saw or experienced, our admin person edit page doesn't really do anything if we update a user. Thats because we have no show page to which we would normally transition. If we transition to the edit page, you wouldn't see any difference and therefore will not know if the update was successfull. Here comes our notification component in handy. We will implement bootstrap alerts popping up showing success or failure messages by using the toggle component in combination with the form success: { emit: '...' } and failure: { emit: '...' } configuration options. As you can see above we emit a person_form_success or person_form_failure in our edit and new forms depending on the result. We therefore can use a toggle component with these events. Let's update our notification component to do that.
class Admin::Components::Shared::Notifications < Matestack::Ui::Component
def response
div class: 'alert-wrapper' do
# alerts for new and edit person forms
notification_badge :person_form_success, :success, 'Person successfully updated'
notification_badge :person_form_failure, :danger, 'There was an error while saving the person.'
# alerts for login
notification_badge :login_failure, :danger, 'Login incorrect'
end
end
private
def notification_badge(event, type, message)
toggle show_on: event, hide_after: 3000 do
div class: "alert alert-#{type} alert-dismissible" do
plain message
button class: 'close', attributes: { 'data-dismiss': :alert, 'aria-label': 'Close' } do
span text: '×'.html_safe
end
end
end
end
end
We again created a partial which will take a few parameters and a block in order to wrapp the block inside a bootstrap alert. We use a toggle component with show_on to display the alert when the corresponding event is triggered and use hide_after to hide it after a 3000ms automatically. The .alert-wrapper was styled so it appears in the right corner beneath the navigation.
When we now edit a user we will see an alert in the corner communicating the status of the update.
We also added a notification for our earlier defined login_failure event of our sign in page in order to communicate a failed login attempt.
Reducing functionality in the DemoApp and adding an admin login link
After creating the Admin::App, let's cut some functionality from the Demo::App and make it exclusively available to a logged-in admin!
Remove the following lines from both app/matestack/demo/pages/edit.rb and app/matestack/demo/pages/new.rb:
div class: 'form-group row' do
label class: 'col-sm-4 col-form-label col-form-label-md', text: 'Person role:'
div class: 'col-sm-8' do
# edit.rb
form_select key: :role, type: :dropdown, class: 'form-control', options: Person.roles.keys, init: @person.role
# new.rb
form_select key: :role, type: :dropdown, class: 'form-control', options: Person.roles.keys, init: Person.roles.keys.first
end
end
Also, remove :role from the person_params in app/controllers/persons_controller.rb - if we forget to do that, a sophisticated, not-logged-in user still could edit roles through sending requests directly to our backend!
Since any page visitor can now create new person records in the database but only admins can edit the :role attribute, let's add a default value through a migration:
rails g migration AddDefaultToPersonRole
Changing the default is quite straightforward:
class AddDefaultToPersonRole < ActiveRecord::Migration[6.0]
def change
change_column_default :person, :role, from: nil, to: 0
end
end
And, to make it work, we need to migrate the database by running
rake db:migrate
Finally, add a link to the sign_in page to the navigation partial in app/matestack/demo/app.rb:
def navigation
nav class: 'navbar navbar-expand-md navbar-light bg-white fixed-top' do
transition class: 'navbar-brand font-weight-bold text-primary', path: root_path, text: 'DemoApp', delay: 300
navbar_button
div id: 'navbar-default', class: 'collapse navbar-collapse' do
ul class: 'navbar-nav mr-auto' do
li class: 'nav-item' do
transition class: 'nav-link text-dark', path: persons_path, text: 'Persons', delay: 300
end
li class: 'nav-item' do
transition class: 'nav-link text-dark', path: new_person_path, text: 'New', delay: 300
end
li class: 'nav-item' do
link class: 'nav-link text-secondary', path: new_admin_session_path, text: 'Login'
end
end
end
end
end
To finish things off, let's add the recent changes to Git via
git commit -m "Add admin login to DemoApp, add default :role to Person model, restrict role modification to admins"
More information on Devise & Matestack
What exactly is going on under the hood with all the admin sign in stuff, you may wonder?
Here's a quick overview: Instead of implementing loads of (complex) functionality with a load of implications and edge cases, we use the Devise gem for a rock-solid authentication. It takes care of hashing, salting and storing the password and managing session cookies. All that's left for us to do is check for authentication of admins by using the authenticate_admin! helper.
Devise could do a lot more, but as this is a basic guide, we will leave it with that. For even more fine-grained control over access rights (authorization) within your application (e.g. by introducing a superadmin or having regional and national manager roles) we recommend you take a look at two other popular gems: Pundit and CanCanCan.
If you want to know more about using devise with matestack, checkout our devise guide.
Creating a admin
In order to use the admin app we need to create an admin with credentials which we can now use to sign in.
a = Admin.create(email: 'admin@example.com', password: 'OnlyForSuperMates', password_confirmation: 'OnlyForSuperMates')
Recap & outlook
By adding a working authentication functionality and an admin app protected via a login, our project now much better resembles a real-world software application! On the way, we covered some advanced topics like authentication via the Devise gem, serving different JavaScript packs using Webpacker and Rails layouts. We leared how to structure components and pages with different namespaces and how to use different registries.
While the application is good as it is right now, go ahead and check out the next part of this guide where we will deploy our application to heroku.