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.
railsgeneratedeviseadmin
Finally, we need to migrate the database by running
railsdb:migrate
and save our changes to Git via
git.&&gitcommit-m"add devise and create admin model"
Adding admin app, controllers and routes
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.
classAdmin::App<Matestack::Ui::Appdefresponse navigation notifications main id: 'page-content'do yield_page slots: { loading_state: loading_state_element }end footerendprivatedefnavigation 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: 300if admin_signed_in? navbar_toggle_button div id: 'navbar', class: 'collapse navbar-collapse justify-content-end'do div class: 'w-100'do navbar_rightend navbar_leftendendendenddefnavbar_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'endenddefnavbar_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: 300endendenddefnavbar_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: 300end li class: 'nav-item'do action logout_action_config do span class: 'btn-nav btn btn-primary', text: I18n.t('devise.sessions.logout')endendendenddefloading_state_element slot do div id: 'spinner', class: 'spinner-border', role: 'status'do span class: 'sr-only', text: 'Loading...'endendenddeffooter 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'endendendenddeflogout_action_config { method: :get, path: destroy_admin_session_path, success: { redirect: { follow_response: true } } }endend
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.
classAdmin::Components::Shared::Notifications<Matestack::Ui::Componentdefresponse div class: 'alert-wrapper'do# alerts for different events will be added later hereendendend
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 :adminsnamespace :admin do root to: 'persons#index' resources :persons, except: [:show]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.
classAdmin::Pages::Persons::Index<Matestack::Ui::PageincludeMatestack::Ui::Core::Collection::Helperdefprepare 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 })enddefresponse 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: 300end 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_bodyend paginatorendendendendprivatedeffilter 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'endendendenddeftable_head tr do th text: '#' th text: 'Last name' th text: 'First name' th text: 'Role' thendenddeftable_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: 300end 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'endendendendenddefpaginator ul class: 'pagination justify-content-center'do li class: 'page-item'do collection_content_previous do button class: 'page-link', text: 'previous'endend @person_collection.pages.each do|page| li class: 'page-item'do collection_content_page_link page: page do button class: 'page-link', text: pageendendend li class: 'page-item'do collection_content_next do button class: 'page-link', text: 'next'endendendenddefdelete_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}'?" } }endend
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.
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).
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.
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.
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.
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 :personsendend
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.
classAdmin::Components::Shared::Notifications<Matestack::Ui::Componentdefresponse 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'endendprivatedefnotification_badge(event, type, message) toggle show_on: event, hide_after: 3000do div class: "alert alert-#{type} alert-dismissible"do plain message button class: 'close', attributes: { 'data-dismiss': :alert,'aria-label':'Close' } do span text: '×'.html_safeendendendendend
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:
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:
railsgmigrationAddDefaultToPersonRole
Changing the default is quite straightforward:
classAddDefaultToPersonRole<ActiveRecord::Migration[6.0]defchange change_column_default :person, :role, from: nil, to: 0endend
And, to make it work, we need to migrate the database by running
rakedb:migrate
Finally, add a link to the sign_in page to the navigation partial in app/matestack/demo/app.rb:
defnavigation 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: 300end li class: 'nav-item'do transition class: 'nav-link text-dark', path: new_person_path, text: 'New', delay: 300end li class: 'nav-item'do link class: 'nav-link text-secondary', path: new_admin_session_path, text: 'Login'endendendendend
To finish things off, let's add the recent changes to Git via
gitcommit-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.