1. Introduction
One of the most common features to add to any application is a sign up process for registering new users. The e-commerce application we've built so far only has authentication and users must be created in the Rails console or a script.
This feature is required before we can add other features. For example, to let users create wishlists, they will need to be able to sign up first before they can create a wishlist associated with their account.
Let's get started!
2. Adding Sign Up
We've already used the Rails authentication generator to allow users to login to their accounts. The generator created a User
model with email_address:string
and password_digest:string
columns in the database. It also added has_secure_password
to the User
model which handles passwords and confirmations. This takes care of most of what we need to add sign up to our application.
2.1. Adding Names To Users
It's also a good idea to collect the user's name at sign up. This allows us to personalize their experience and address them directly in the application. Let's start by adding first_name
and last_name
columns to the database.
In the terminal, create a migration with these columns:
$ rails g migration AddNamesToUsers first_name:string last_name:string
Then migrate the database:
$ rails db:migrate
Let's also add a method to combine first_name
and last_name
, so that we can display the user's full name.
Open app/models/user.rb
and add the following:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :first_name, :last_name, presence: true
def full_name
"#{first_name} #{last_name}"
end
end
Next, let's add sign up so we can register new users.
2.2. Sign Up Routes & Controller
Now that our database has all the necessary columns to register new users, the next step is to create a route for sign up and its matching controller.
In config/routes.rb
, let's add a resource for sign up:
resource :session
resources :passwords, param: :token
resource :sign_up
We're using a singular resource here because we want a singular route for /sign_up
.
This route directs requests to app/controllers/sign_ups_controller.rb
so let's create that controller file now.
class SignUpsController < ApplicationController
def show
@user = User.new
end
end
We're using the show
action to create a new User
instance, which will be used to display the sign up form.
Let's create the form next. Create app/views/sign_ups/show.html.erb
with the following code:
<h1>Sign Up</h1>
<%= form_with model: @user, url: sign_up_path do |form| %>
<% if form.object.errors.any? %>
<div>Error: <%= form.object.errors.full_messages.first %></div>
<% end %>
<div>
<%= form.label :first_name %>
<%= form.text_field :first_name, required: true, autofocus: true, autocomplete: "given-name" %>
</div>
<div>
<%= form.label :last_name %>
<%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
</div>
<div>
<%= form.label :email_address %>
<%= form.email_field :email_address, required: true, autocomplete: "email" %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, required: true, autocomplete: "new-password" %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
</div>
<div>
<%= form.submit "Sign up" %>
</div>
<% end %>
This form collects the user's name, email, and password. We're using the autocomplete
attribute to help the browser suggest the values for these fields based on the user's saved information.
You'll also notice we set url: sign_up_path
in the form alongside model: @user
. Without this url:
argument, form_with
would see we have a User
and send the form to /users
by default. Since we want the form to submit to /sign_up
, we set the url:
to override the default route.
Back in app/controllers/sign_ups_controller.rb
we can handle the form submission by adding the create
action.
class SignUpsController < ApplicationController
def show
@user = User.new
end
def create
@user = User.new(sign_up_params)
if @user.save
start_new_session_for(@user)
redirect_to root_path
else
render :show, status: :unprocessable_entity
end
end
private
def sign_up_params
params.expect(user: [ :first_name, :last_name, :email_address, :password, :password_confirmation ])
end
end
The create
action assigns parameters and attempts to save the user to the database. If successful, it logs the user in and redirects to root_path
, otherwise it re-renders the form with errors.
Authenticated users can still access SignUpsController
and create another account while they're logged in which can be confusing.
Let's fix this by adding a helper to the Authentication
module in app/controllers/concerns/authentication.rb
.
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
def unauthenticated_access_only(**options)
allow_unauthenticated_access **options
before_action -> { redirect_to root_path if authenticated? }, **options
end
# ...
The unauthenticated_only_access
class method can be used in any controller where we want to restrict actions to unauthenticated users only.
We can then use this method at the top of SignUpsController
.
class SignUpsController < ApplicationController
unauthenticated_access_only
# ...
end
2.3. Rate Limiting Sign Up
Our application will be accessible on the internet so we're bound to have malicious bots and users trying to spam our application. We can add rate limiting to sign up to slow down anyone submitting too many requests.
Rails makes this easy with the rate_limit
method in controllers.
class SignUpsController < ApplicationController
unauthenticated_only_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to sign_up_path, alert: "Try again later." }
# ...
end
This will block any form submissions that happen more than 10 times within 3 minutes.
3. Editing Passwords
Now that users can login, let's create all the usual places that users would expect to update their profile, password, email address, and other settings.
3.1. Using Namespaces
The Rails authentication generator already created at app/controllers/passwords_controller.rb
is the controller for password resets. This means we need to use a different controller for editing passwords of authenticated users.
To prevent conflicts, we can use a feature called namespaces. A namespace organizes routes, controllers, and views into folders and helps prevent conflicts like our two passwords controllers.
We'll create a namespace called "Settings" to separate out the user and store settings from the rest of our application.
In config/routes.rb
we can add the Settings namespace along with a resource for editing passwords:
namespace :settings do
resource :password, only: [:show, :update]
end
This will generate a route for /settings/password
for editing the current user's password which is separate from the password resets routes at /password
.
3.2. Adding the Namespaced Passwords Controller & View
Namespaces also move controllers into a matching module in Ruby. This controller will be in a settings
folder to match the namespace.
Let's create the folder and controller at app/controllers/settings/passwords_controller.rb
and start with the show
action.
class Settings::PasswordsController < ApplicationController
def show
end
end
Views also move to a settings
folder so let's create the folder and view at app/views/settings/passwords/show.html.erb
for this action.
<h1>Password</h1>
<%= form_with model: Current.user, url: settings_password_path do |form| %>
<% if form.object.errors.any? %>
<div><%= form.object.errors.full_messages.first %></div>
<% end %>
<div>
<%= form.label :password_challenge %>
<%= form.password_field :password_challenge, required: true, autocomplete: "current-password" %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, required: true, autocomplete: "new-password" %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
</div>
<div>
<%= form.submit "Update password" %>
</div>
<% end %>
We've set the url:
argument to ensure the form submits to our namespaced route and is processed by the Settings::PasswordsController
. Passing model: Current.user
also tells form_with
to submit a PATCH
request to process the form with the update
action.
3.3. Safely Updating Passwords
Let's add that update
action to the controller now.
class Settings::PasswordsController < Settings::BaseController
def show
end
def update
if Current.user.update(password_params)
redirect_to settings_profile_path, status: :see_other, notice: "Your password has been updated."
else
render :show, status: :unprocessable_entity
end
end
private
def password_params
params.expect(user: [ :password, :password_confirmation, :password_challenge ]).with_defaults(password_challenge: "")
end
end
For security, we need to ensure that the user is the only one who can update their password. The has_secure_password
method in our User
model provides this attribute. If password_challenge
is present, it will validate the password challenge against the user's current password in the database to confirm it matches.
A malicious user could try deleting the password_challenge
field in the browser to bypass this validation. To prevent this and ensure the validation always runs, we use .with_defaults(password_challenge: "")
to set a default value even if the password_challenge
parameter is missing.
You can now visit http://localhost:3000/settings/password to update your password.
3.4. Renaming The Password Challenge Attribute
While password_challenge
is a good name for our code, users are used to seeing "Current password" for this form field. We can rename this with locales in Rails to change how this attribute is displayed on the frontend.
Add the following to config/locales/en.yml
:
en:
hello: "Hello world"
products:
index:
title: "Products"
activerecord:
attributes:
user:
password_challenge: "Current password"
To learn more, check out the [I18n Guide])https://guides.rubyonrails.org/i18n.html#translations-for-active-record-models)
4. Editing User Profiles
Next, let's add a page so user's can edit their profile, like updating their first and last name.
4.1. Profile Routes & Controller
In config/routes.rb
, add a profile resource under the settings namespace. We can also add a root to the namespace to handle any visits to /settings
and redirect them to profile settings.
namespace :settings do
resource :password, only: [ :show, :update ]
resource :profile, only: [ :show, :update ]
root to: redirect("/settings_profile")
end
Let's create our controller for editing profiles at app/controllers/settings/profiles_controller.rb
.
class Settings::ProfilesController < ApplicationController
def show
end
def update
if Current.user.update(profile_params)
redirect_to settings_profile_path, status: :see_other, notice: "Your profile was updated successfully."
else
render :show, status: :unprocessable_entity
end
end
private
def profile_params
params.expect(user: [ :first_name, :last_name ])
end
end
This is very similar to the passwords controller but only allows updating the user's profile details like first and last name.
Then create app/views/settings/profiles/show.html.erb
to show the edit profile form.
<h1>Profile</h1>
<%= form_with model: Current.user, url: settings_profile_path do |form| %>
<% if form.object.errors.any? %>
<div>Error: <%= form.object.errors.full_messages.first %></div>
<% end %>
<div>
<%= form.label :first_name %>
<%= form.text_field :first_name, required: true, autocomplete: "given-name" %>
</div>
<div>
<%= form.label :last_name %>
<%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
</div>
<div>
<%= form.submit "Update profile" %>
</div>
<% end %>
You can now visit http://localhost:3000/settings/profile to update your name.
4.2. Updating Navigation
Let's update the navigation to include a link to Settings next to the Log out button.
Open app/views/layouts/application.html.erb
and update the navbar. We'll also add a div for any alert messages from our controllers while we're here.
<!DOCTYPE html>
<html>
<head>
<%# ... %>
</head>
<body>
<div class="notice"><%= notice %></div>
<div class="alert"><%= alert %></div>
<nav class="navbar">
<%= link_to "Home", root_path %>
<% if authenticated? %>
<%= link_to "Settings", settings_root_path %>
<%= button_to "Log out", session_path, method: :delete %>
<% else %>
<%= link_to "Sign Up", sign_up_path %>
<%= link_to "Login", new_session_path %>
<% end %>
</nav>
You'll now see a Settings link in the navbar when authenticated.
4.3. Settings Layout
While we're here, let's add a new layout for Settings so we can organize them in a sidebar. To do this, we're going to use a Nested Layout.
A nested layout allows you add HTML (like a sidebar) while still rendering the application layout. This means we don't have to duplicate our head tags or navigation in our Settings layout.
Let's create app/views/layouts/settings.html.erb
and add the following:
<%= content_for :content do %>
<section class="settings">
<nav>
<h4>Account Settings</h4>
<%= link_to "Profile", settings_profile_path %>
<%= link_to "Password", settings_password_path %>
</nav>
<div>
<%= yield %>
</div>
</section>
<% end %>
<%= render template: "layouts/application" %>
In the settings layout, we're providing HTML for the sidebar and telling Rails to render the application layout as the parent.
We need to modify the application layout to render the content from the nested layout using yield(:content)
.
<!DOCTYPE html>
<html>
<head>
<%# ... %>
</head>
<body>
<div class="notice"><%= notice %></div>
<div class="alert"><%= alert %></div>
<nav class="navbar">
<%= link_to "Home", root_path %>
<% if authenticated? %>
<%= link_to "Settings", settings_root_path %>
<%= button_to "Log out", session_path, method: :delete %>
<% else %>
<%= link_to "Sign Up", sign_up_path %>
<%= link_to "Login", new_session_path %>
<% end %>
</nav>
<main>
<%= content_for?(:content) ? yield(:content) : yield %>
</main>
</body>
</html
This allows the application controller to be used normally with yield
or it can be a parent layout if content_for(:content)
is used in a nested layout.
We now have two separate <nav>
tags, so we need to update our existing CSS selectors to avoid conflicts.
To do this, add the the .navbar
class to these selectors.
nav.navbar {
justify-content: flex-end;
display: flex;
font-size: 0.875em;
gap: 0.5rem;
max-width: 1024px;
margin: 0 auto;
padding: 1rem;
}
nav.navbar a {
display: inline-block;
}
Then add some CSS to display the Settings nav as a sidebar.
section.settings {
display: flex;
gap: 1rem;
}
section.settings nav {
width: 200px;
}
section.settings nav a {
display: block;
}
To use this new layout, we can tell the controller we want to use a specific layout. We can add layout "settings"
to any controller to change the layout that is rendered.
Since we will have many controllers that use this layout, we can create a base class to define shared configuration and use inheritance to use them.
Add app/controllers/settings/base_controller.rb
and add the following:
class Settings::BaseController < ApplicationController
layout "settings"
end
Then update app/controllers/settings/passwords_controller.rb
to inherit from this controller.
class Settings::PasswordsController < Settings::BaseController
And update app/controllers/settings/profiles_controller.rb
to inherit from it too.
class Settings::ProfilesController < Settings::BaseController
5. Deleting Accounts
Next, let's add the ability to delete your account. We'll start by adding another namespaced route for account to config/routes.rb
.
namespace :settings do
resource :account, only: [ :show, :destroy ]
resource :password, only: [ :show, :update ]
resource :profile, only: [ :show, :update ]
root to: redirect("/settings/profile")
end
To handle these new routes, create app/controllers/settings/accounts_controller.rb
and add the following:
class Settings::AccountsController < Settings::BaseController
def show
end
def destroy
terminate_session
Current.user.destroy
redirect_to root_path, notice: "Your account has been deleted."
end
end
The controller for deleting accounts is pretty straightforward. We have a show
action to display the page and a destroy
action to logout and delete the user. It also inherits from Settings::BaseController
so it will use the settings layout like the others.
Now let's add the view at app/views/settings/accounts/show.html.erb
with the following:
<h1>Account</h1>
<%= button_to "Delete my account", settings_account_path, method: :delete, data: {turbo_confirm: "Are you sure? This cannot be undone."} %>
And finally, we'll add a link to Account in the setting layout's sidebar.
<%= content_for :content do %>
<section class="settings">
<nav>
<h4>Account Settings</h4>
<%= link_to "Profile", settings_profile_path %>
<%= link_to "Password", settings_password_path %>
<%= link_to "Account", settings_account_path %>
</nav>
<div>
<%= yield %>
</div>
</section>
<% end %>
<%= render template: "layouts/application" %>
That's it! You can now delete your account.
6. Updating Email Addresses
Occassionally, users need to change the email address on their account. To do this safely, we need to store the new email address and send an email to confirm the change.
6.1. Adding Unconfirmed Email To Users
We'll start by adding a new field to the users table in our database. This will store the new email address while we're waiting for confirmation.
$ rails g migration AddUnconfirmedEmailToUsers unconfirmed_email:string
Then migrate the database.
$ rails db:migrate
6.2. Email Routes & Controller
Next we can add an email route under the :settings
namespace in config/routes.rb
.
namespace :settings do
resource :account, only: [ :show, :destroy ]
resource :email, only: [ :show, :update ]
resource :password, only: [ :show, :update ]
resource :profile, only: [ :show, :update ]
root to: redirect("/settings/profile")
end
Then we'll create app/controllers/settings/emails_controller.rb
to display this.
class Settings::EmailsController < Settings::BaseController
def show
end
end
And finally, we'll create our view at app/views/settings/emails/show.html.erb
:
<h1>Change Email</h1>
<%= form_with model: Current.user, url: settings_email_path do |form| %>
<% if form.object.errors.any? %>
<div>Error: <%= form.object.errors.full_messages.first %></div>
<% end %>
<div>
<%= form.label :unconfirmed_email, "New email address" %>
<%= form.email_field :unconfirmed_email, required: true %>
</div>
<div>
<%= form.label :password_challenge %>
<%= form.password_field :password_challenge, required: true, autocomplete: "current-password" %>
</div>
<div>
<%= form.submit "Update email address" %>
</div>
<% end %>
To keep things secure, we need to ask for the new email address and validate user's current password to ensure only the owner of the account can change the email.
In our controller, we will validate the current password and save the new email address before sending an email to confirm the new email address.
class Settings::EmailsController < Settings::BaseController
def show
end
def update
if Current.user.update(email_params)
UserMailer.with(user: Current.user).email_confirmation.deliver_later
redirect_to settings_email_path, status: :see_other, notice: "We've sent a verification email to #{Current.user.unconfirmed_email}."
else
render :edit, status: :unprocessable_entity
end
end
private
def email_params
params.expect(user: [ :password_challenge, :unconfirmed_email ]).with_defaults(password_challenge: "")
end
end
This uses the same with_defaults(password_challenge: "")
as Settings::PasswordsController
to trigger the password challenge validation.
We haven't created the UserMailer
yet, so let's do that next.
6.3. New Email Confirmation
Let's use the mailer generator to create the UserMailer
we referenced in Settings::EmailsController
:
$ rails generate mailer User email_confirmation
create app/mailers/user_mailer.rb
invoke erb
create app/views/user_mailer
create app/views/user_mailer/email_confirmation.text.erb
create app/views/user_mailer/email_confirmation.html.erb
invoke test_unit
create test/mailers/user_mailer_test.rb
create test/mailers/previews/user_mailer_preview.rb
We'll need to generate a token to include in the email body. Open app/models/user.rb
and add the following:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :first_name, :last_name, presence: true
generates_token_for :email_confirmation, expires_in: 7.days do
unconfirmed_email
end
def confirm_email
update(email_address: unconfirmed_email, unconfirmed_email: nil)
end
def full_name
"#{first_name} #{last_name}"
end
end
This adds a token generator we can use for email confirmations. The token encodes the unconfirmed email, so it becomes invalid if the email changes or the token expires.
Let's update app/mailers/user_mailer.rb
to generate a new token for the email:
class UserMailer < ApplicationMailer
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.user_mailer.email_confirmation.subject
def email_confirmation
@token = params[:user].generate_token_for(:email_confirmation)
mail to: params[:user].unconfirmed_email
end
end
We'll include the token in the HTML view at app/views/user_mailer/email_confirmation.html.erb
:
<h1>Verify your email address</h1>
<p><%= link_to "Confirm your email", email_confirmation_url(token: @token) %></p>
And app/views/user_mailer/email_confirmation.text.erb
:
Confirm your email: <%= email_confirmation_url(token: @token) %>
6.4. Email Confirmation Controller
The confirmation email includes a link to our Rails app to verify the email change.
Let's add a route for this to config/routes.rb
namespace :email do
resources :confirmations, param: :token, only: [ :show ]
end
When a user clicks a link in their email, it will open a browser and make a GET request to the app. This means we only need the show
action for this controller.
Next, add the following to app/controllers/emails/confirmations_controller.rb
class Email::ConfirmationsController < ApplicationController
allow_unauthenticated_access
def show
user = User.find_by_token_for(:email_confirmation, params[:token])
if user&.confirm_email
flash[:notice] = "Your email has been confirmed."
else
flash[:alert] = "Invalid token."
end
redirect_to root_path
end
end
We want to confirm the email address whether the user is authenticated or not, so this controller allows unauthenticated access. We use the find_by_token_for
method to validate the token and look up the matching User
record. If successful, we call the confirm_email
method to update the user's email and reset unconfirmed_email
to nil
. If the token isn't valid, the user
variable will be nil
, and we will display an alert message.
7. Separating Admins & Users
Now that anyone can sign up for an account on our store, we need to differentiate between regular users and admins.
7.1. Adding An Admin Flag
We'll start by adding a column to the User model.
$ rails g migration AddAdminToUsers admin:boolean
Then migrate the database.
$ rails db:migrate
A User
with admin
set to true
should be able to add and remove products and access other administrative areas of the store.
7.2. Readonly Attributes
We need to be very careful that admin
is not editable by any malicious users. This is easy enough by keeping the :admin
attribute out of any permitted parameters list.
Optionally, we can mark the admin attribute as readonly for added security. This will tell Rails to raise an error anytime the admin attribute is changed. It can still be set when creating a record, but provides an additional layer of security against unauthorized changes. You may want to skip this if you'll be changing the admin flag for users often but in our e-commerce store, it's a useful safeguard.
We can add attr_readonly
in our model to protect the attribute from updates.
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
attr_readonly :admin
# ...
When admin is readonly, we have to directly update this in the database instead of using Active Record.
Rails has a command called dbconsole
that will open a database console where we can directly interact with the database using SQL.
$ rails dbconsole
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite>
In the SQLite prompt, we can update the admin column for a record using an UPDATE
statement and using WHERE
to filter to a single user ID.
UPDATE users SET admin=true WHERE users.id=1;
To close the SQLite prompt, enter the following command:
.quit
8. Viewing All Users
As a store admin, we will want to view and manage users for customer support, marketing, etc.
First, we'll need to add a route in the settings namespace for users in config/routes.rb
.
namespace :settings do
resource :account, only: [ :show, :destroy ]
resource :email, only: [ :show, :update ]
resource :password, only: [ :show, :update ]
resource :profile, only: [ :show, :update ]
# Admins only
resources :users
root to: redirect("/settings/profile")
end
8.1. Adding Admin Only Access
The controller for users should be accessible to admins only. Before we create that controller, let's add a class method to the Authentication
module to restrict access to admins only.
Open app/controllers/concerns/authentication.rb
and add:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
def unauthenticated_access_only(**options)
allow_unauthenticated_access **options
before_action -> { redirect_to root_path if authenticated? }, **options
end
def admin_access_only(**options)
before_action -> { redirect_to root_path, alert: "You aren't allowed to do that." unless authenticated? && Current.user.admin? }, **options
end
# ...
Adding this to the Authentication module allows us to use this method in any controller to restrict actions to admin only access.
8.2. Users Controller & Views
We can then use this in the controller. Create app/controllers/settings/users_controller.rb
and add the following:
class Settings::UsersController < Settings::BaseController
admin_access_only
before_action :set_user, only: %i[ show edit update destroy ]
def index
@users = User.all
end
def show
end
def edit
end
def update
if @user.update(user_params)
redirect_to settings_user_path(@user), status: :see_other, notice: "User has been updated"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.expect(user: [ :first_name, :last_name, :email_address ])
end
end
This gives admins the ability to read, update, and destroy users in the database.
Next, let's create the index view at app/views/settings/users/index.html.erb
<h1><%= pluralize @users.count, "user" %></h1>
<% @users.each do |user| %>
<div>
<%= link_to user.full_name, settings_user_path(user) %>
</div>
<% end %>
Then, the edit user view at app/views/settings/users/edit.html.erb
:
<h1>Edit User</h1>
<%= render "form", user: @user %>
And the form partial at app/views/settings/users/_form.html.erb
:
<%= form_with model: user, url: settings_user_path do |form| %>
<div>
<%= form.label :first_name %>
<%= form.text_field :first_name, required: true, autofocus: true, autocomplete: "given-name" %>
</div>
<div>
<%= form.label :last_name %>
<%= form.text_field :last_name, required: true, autocomplete: "family-name" %>
</div>
<div>
<%= form.label :email_address %>
<%= form.email_field :email_address, required: true, autocomplete: "email" %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
And finally, the user show view at app/views/settings/users/show.html.erb
:
<%= link_to "Back to all users", settings_users_path %>
<h1><%= @user.full_name %></h1>
<%= tag.p @user.email_address %>
<div>
<%= link_to "Edit user", edit_settings_user_path(@user) %>
<%= button_to "Delete user", settings_user_path(@user), data: {turbo_confirm: "Are you sure?"} %>
</div>
8.3. Settings Navigation
Next, we want to add this to the Settings sidebar navigation. Since this should be only visible to admins, we need to wrap it in a conditional to ensure the current user is an admin.
Add the following to the settings layout:
<%= content_for :content do %>
<section class="settings">
<nav>
<h4>Account Settings</h4>
<%= link_to "Profile", settings_profile_path %>
<%= link_to "Email", settings_email_path %>
<%= link_to "Password", settings_password_path %>
<%= link_to "Account", settings_account_path %>
<% if Current.user.admin? %>
<h4>Store Settings</h4>
<%= link_to "Users", settings_users_path %>
<% end %>
</nav>
<div>
<%= yield %>
</div>
</section>
<% end %>
<%= render template: "layouts/application" %>
9. Separating Products Controllers
Now that we have a separation for regular users and admins, we can re-organize our Products controller to take advantage of this change. Instead of a single controller, we can split the Products controller in two: one public facing and one admin facing.
The public facing controller will handle the storefront views and the admin controller will handle managing products.
9.1. Public Products Controller
For the public storefront, we only need to let users view products. This means app/controllers/products_controller.rb
can be simplified down to the following.
class ProductsController < ApplicationController
allow_unauthenticated_access
def index
@products = Product.all
end
def show
@product = Product.find(params[:id])
end
end
We can then adjust the views for the products controller.
Starting with app/views/products/index.html.erb
, let's remove the link to "New product". We'll use the Settings area to create new products instead.
-<%= link_to "New product", new_product_path if authenticated? %>
Remove the Edit and Delete links in app/views/products/show.html.erb
- <% if authenticated? %>
- <%= link_to "Edit", edit_product_path(@product) %>
- <%= button_to "Delete", @product, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
- <% end %>
Then remove:
- app/views/products/new.html.erb
- app/views/products/edit.html.erb
- app/views/products/_form.html.erb
These views will be recreated in the settings namespace with some small adjustments.
9.2. Admin Products CRUD
First, let's add the namespaced route for products to config/routes.rb
:
namespace :settings do
resource :profile, only: [ :show, :update ]
resource :password, only: [ :show, :update ]
resource :email, only: [ :show, :update ]
resource :account, only: [ :show, :destroy ]
resources :products
resources :users
root to: redirect("/settings/profile")
end
And then update the settings layout sidebar navigation:
<%= content_for :content do %>
<section class="settings">
<nav>
<h4>Account Settings</h4>
<%= link_to "Profile", settings_profile_path %>
<%= link_to "Email", settings_email_path %>
<%= link_to "Password", settings_password_path %>
<%= link_to "Account", settings_account_path %>
<% if Current.user.admin? %>
<h4>Store Settings</h4>
<%= link_to "Products", settings_products_path %>
<%= link_to "Users", settings_users_path %>
<% end %>
</nav>
<div>
<%= yield %>
</div>
</section>
<% end %>
<%= render template: "layouts/application" %>
9.3. Admin Products Controller & Views
Next, create app/controllers/settings/products_controller.rb
with the following:
class Settings::ProductsController < Settings::BaseController
admin_access_only
before_action :set_product, only: %i[ show edit update destroy ]
def index
@products = Product.all
end
def show
end
def new
@product = Product.new
end
def create
@product = Product.new(product_params)
if @product.save
redirect_to settings_product_path(@product)
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @product.update(product_params)
redirect_to settings_product_path(@product)
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@product.destroy
redirect_to settings_products_path
end
private
def set_product
@product = Product.find(params[:id])
end
def product_params
params.expect(product: [ :name, :description, :featured_image, :inventory_count ])
end
end
This controller is almost the same as ProductsController
previously, but two important changes:
- We have
admin_access_only
to restrict access to admin users only. - Redirects use the settings namespace to keep the user in the settings area.
10. Adding Tests
Let's add some tests to verify that our features work correctly.
10.1. Authentication Test Helpers
In our test suite, we'll need to sign in users in our tests. The Rails authentication generator has been updated to include helpers for authentication, but your application may have been created before this, so let's ensure these files exist before writing our tests.
In test/test_helpers/session_test_helper.rb
, you should see the following. If you don't, go ahead and create this file.
module SessionTestHelper
def sign_in_as(user)
Current.session = user.sessions.create!
ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
cookie_jar.signed[:session_id] = Current.session.id
cookies[:session_id] = cookie_jar[:session_id]
end
end
def sign_out
Current.session&.destroy!
cookies.delete(:session_id)
end
end
In test/test_helper.rb
, you should see these lines. If not, go ahead and add them.
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
require_relative "test_helpers/session_test_helper"
module ActiveSupport
class TestCase
include SessionTestHelper
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
end
end
10.2. Testing Sign Up
We have a few different things to test for sign up. Let's start with a simple test to view the page.
Create a controller test at test/controllers/sign_ups_controller_test.rb
with the following:
require "test_helper"
class SignUpsControllerTest < ActionDispatch::IntegrationTest
test "view sign up" do
get sign_up_path
assert_response :success
end
end
This test will visit /sign_up
and ensure that it receives a 200 OK response.
Let's run the test and see if it passes:
$ bin/rails test test/controllers/sign_ups_controller.rb:4
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 5967
# Running:
.
Finished in 0.559107s, 1.7886 runs/s, 1.7886 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Next, let's sign in a user and try to visit the sign up page. In this situation, the user should be redirected because they're already authenticated.
Add the following test to the file.
test "view sign up when authenticated" do
sign_in_as users(:one)
get sign_up_path
assert_redirected_to root_path
end
Run the tests again and you should see this one passes too.
Next, let's add a test to ensure a new user is created when they fill out the form.
test "successful sign up" do
assert_difference "User.count" do
post sign_up_path, params: { user: { first_name: "Example", last_name: "User", email_address: "example@user.org", password: "password", password_confirmation: "password" } }
assert_redirected_to root_path
end
end
For this test, we need submit params with a POST request to test the create
action.
Let's also test with invalid data to ensure the controller returns an error.
test "invalid sign up" do
assert_no_difference "User.count" do
post sign_up_path, params: { user: { email_address: "example@user.org", password: "password", password_confirmation: "password" } }
assert_response :unprocessable_entity
end
end
This test is should be invalid because the user's name is missing. Since this request is invalid, we need to assert the response is a 422 Unprocessable Entity. We can also assert that there is no difference in the User.count
to ensure no User was created.
Another important test to add is ensuring that sign up does not accept the admin
attribute.
test "sign up ignores admin attribute" do
assert_difference "User.count" do
post sign_up_path, params: { user: { first_name: "Example", last_name: "User", email_address: "example@user.org", password: "password", password_confirmation: "password", admin: true } }
assert_redirected_to root_path
end
refute User.find_by(email_address: "example@user.org").admin?
end
This test is just like a successful sign up, but it tries to set admin: true
. After asserting the user is created, we also need to assert that the user is not an admin.
10.3. Testing Email Changes
Changing a user's email is a multi-step process that is important to test as well.
To start, let's create a controller test to ensure the email update form handles everything correctly.
In test/controllers/settings/emails_controller_test.rb
add the following:
require "test_helper"
class Settings::EmailsControllerTest < ActionDispatch::IntegrationTest
test "validates current password" do
user = users(:one)
sign_in_as user
patch settings_email_path, params: { user: { password_challenge: "invalid", unconfirmed_email: "new@example.org" } }
assert_response :unprocessable_entity
assert_nil user.reload.unconfirmed_email
assert_no_emails
end
end
Our first test is going to be a submission with an invalid password challenge. For this, we want to ensure the response is an error and the unconfirmed email was not changed. We can also ensure that no emails were sent in this case as well.
Then we can write a test for the success case:
test "sends email confirmation on successful update" do
user = users(:one)
sign_in_as user
patch settings_email_path, params: { user: { password_challenge: "password", unconfirmed_email: "new@example.org" } }
assert_response :redirect
assert_equal "new@example.org", user.reload.unconfirmed_email
assert_enqueued_email_with UserMailer, :email_confirmation, params: { user: user }
end
This tests submits successful params, confirms the email is saved to the database, the user was redirected and the confirmation email was queued for delivery.
Let's run these tests and make sure they pass:
$ bin/rails test test/controllers/settings/emails_controller_test.rb
Running 2 tests in a single process (parallelization threshold is 50)
Run options: --seed 31545
# Running:
..
Finished in 0.954590s, 2.0951 runs/s, 6.2854 assertions/s.
2 runs, 6 assertions, 0 failures, 0 errors, 0 skips
We also need to test the Email::ConfirmationsController
to ensure confirmation tokens are validated the email update process completes successfully.
Let's add another controller test at test/controllers/email/confirmations_controller_test.rb
with the following:
require "test_helper"
class Email::ConfirmationsControllerTest < ActionDispatch::IntegrationTest
test "invalid tokens are ignored" do
user = users(:one)
previous_email = user.email_address
user.update(unconfirmed_email: "new@example.org")
get email_confirmation_path(token: "invalid")
assert_equal "Invalid token.", flash[:alert]
user.reload
assert_equal previous_email, user.email_address
end
test "email is updated with a valid token" do
user = users(:one)
user.update(unconfirmed_email: "new@example.org")
get email_confirmation_path(token: user.generate_token_for(:email_confirmation))
assert_equal "Your email has been confirmed.", flash[:notice]
user.reload
assert_equal "new@example.org", user.email_address
assert_nil user.unconfirmed_email
end
end
The first test simulates a user confirming their email change with an invalid token. We assert the error message was set and the email address did not change.
The second test uses valid token and asserts the success notice was set and the email address was updated in the database.
We need to fix one more test related to email confirmations and that is the automatically generated tests for UserMailer
. Let's update that to match our application logic.
Change test/mailers/user_mailer_test.rb
to the following:
require "test_helper"
class UserMailerTest < ActionMailer::TestCase
test "email_confirmation" do
user = users(:one)
user.update(unconfirmed_email: "new@example.org")
mail = UserMailer.with(user: user).email_confirmation
assert_equal "Email confirmation", mail.subject
assert_equal [ "new@example.org" ], mail.to
assert_match "/email/confirmations/", mail.body.encoded
end
end
This test ensures the user has an unconfirmed_email
and the email is sent to that email address. It also ensures that the email body contains the path to /email/confirmations
so we know it contains the link for the user to click and confirm their new email address.
10.4. Testing Settings
Another area that we should test is the Settings navigation. We want to ensure the appropriate links are visible to admins and not visible to regular users.
Let's first create an admin user fixture in test/fixtures/users.yml
and add names to the fixtures so they pass validations.
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
password_digest: <%= password_digest %>
first_name: User
last_name: One
two:
email_address: two@example.com
password_digest: <%= password_digest %>
first_name: User
last_name: Two
admin:
email_address: admin@example.com
password_digest: <%= password_digest %>
first_name: Admin
last_name: User
admin: true
Then create a test file for this at test/integration/settings_test.rb
.
require "test_helper"
class SettingsTest < ActionDispatch::IntegrationTest
test "user settings nav" do
sign_in_as users(:one)
get settings_profile_path
assert_dom "h4", "User Settings"
assert_not_dom "a", "Store Settings"
end
test "admin settings nav" do
sign_in_as users(:admin)
get settings_profile_path
assert_dom "h4", "User Settings"
assert_dom "h4", "Store Settings"
end
end
These tests ensure that only admins will see the Store settings in the navbar.
You can run these tests with:
$ bin/rails test test/integration/settings_test.rb
We also want to ensure regular users cannot access the Store settings for Products and Users. Let's add some tests for that.
test "regular user cannot access /settings/products" do
sign_in_as users(:one)
get settings_products_path
assert_response :redirect
assert_equal "You aren't allowed to do that", flash[:alert]
end
test "regular user cannot access /settings/users" do
sign_in_as users(:one)
get settings_products_path
assert_response :redirect
assert_equal "You aren't allowed to do that", flash[:alert]
end
These tests use a regular user to access the admin only areas and ensures they are redirected away with a flash message.
Let's complete these tests by ensuring that admin users can access these areas.
test "admins can access settings/products" do
sign_in_as users(:admin)
get settings_products_path
assert_response :success
end
test "admins can access settings/users" do
sign_in_as users(:admin)
get settings_users_path
assert_response :success
end
Run the test file again and you should see they all pass.
$ bin/rails test test/integration/settings_test.rb
Running 6 tests in a single process (parallelization threshold is 50)
Run options: --seed 33354
# Running:
......
Finished in 0.625542s, 9.5917 runs/s, 12.7889 assertions/s.
6 runs, 8 assertions, 0 failures, 0 errors, 0 skips
And let's run the full test suite one more time to make sure all the tests pass.
$ bin/rails test
Running 18 tests in a single process (parallelization threshold is 50)
Run options: --seed 38561
# Running:
..................
Finished in 0.915621s, 19.6588 runs/s, 51.3313 assertions/s.
18 runs, 47 assertions, 0 failures, 0 errors, 0 skips
Great! Now, let's deploy this to production.
11. Deploying To Production
Since we previously setup Kamal in the Getting Started Guide, we just need to push our code changes to our Git repository and run:
$ bin/kamal deploy
This will build a new container for our application and deploy it to our production server.
11.1. Setting Admins In Production
If you added attr_readonly :admin
, you'll need to use the dbconsole to update your account.
$ bin/kamal dbc
UPDATE users SET admin=true WHERE users.email='you@example.org';
.quit
Otherwise, you can use the Rails console to update your account.
$ bin/kamal console
irb> User.find_by(email: "you@example.org").update(admin: true)
You can now access the Store settings in production with your account.
12. What's Next
You did it! Your e-commerce store now supports user sign up, account management, and an admin area for managing products and users.
Here are a few ideas to build on to this:
- Add shareable wishlists
- Write more tests to ensure the application works correctly
- Add payments to buy products
Happy building!