DEV Community

Matias Carpintini
Matias Carpintini

Posted on

Magic Links with Ruby On Rails and Devise

Passwords are a problem for users. A good practice to skip this is by sending an access link to your account by email, that link has a token that we will use to validate and finally, sign in to the user to their account. Next, I'm going to show you an implementation in Ruby On Rails 6 with Devise.

NOTE: I am going to create an application from 0 for avoid mistakes.

Setting up

Create the project: $ rails new magicLinks -T --skip-turbokinks --database=postgresql

Installing dependencies...

Add this to your Gemfile.rb

gem 'devise', '~> 4.7', '>= 4.7.1'
group :development, :test do
  gem 'letter_opener', '~> 1.7' // For open emails in the browser
end
Enter fullscreen mode Exit fullscreen mode

Then, $ bundle install
Let's install Devise: with $ rails g devise:install && rails g devise User && rails db:create && rails db:migrate

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config.action_mailer.delivery_method = :letter_opener
Enter fullscreen mode Exit fullscreen mode

Devise asks us for a root path, let's do that: $ rails g controller welcome index.
Add this in your app/config/routes.rb:

root 'welcome#index'
Enter fullscreen mode Exit fullscreen mode

Let's do it 👊

First, we need to create a table for save the generated tokens on our db.
So, $ rails g model EmailLink token expires_at:datetime user:references && rails db:migrate

Now we are going to generate the token and send the email when this happens, for that, in app/models/email_link.rb:

class EmailLink < ApplicationRecord
  belongs_to :user
  after_create :send_mail

  def self.generate(email)
    user = User.find_by(email: email)
    return nil if !user

    create(user: user, expires_at: Date.today + 1.day, token: generate_token)
  end

  def self.generate_token
    Devise.friendly_token.first(16)
  end

  private
  def send_mail
    EmailLinkMailer.sign_in_mail(self).deliver_now
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's generate the controller to trigger the previous callback when the user gives us their email and sends the form, rails g controller EmailLinks new create.

Let's add the necessary routes in our app/config/routes.rb.

root 'welcome#index'
get 'email_links/new', as: :new_magic_link
post 'email_links/create', as: :magic_link
get 'email_links/validate', as: :email_link
Enter fullscreen mode Exit fullscreen mode

Now, let's give functionality to our controller:

class EmailLinksController < ApplicationController
  def new

  end

  def create
    @email_link = EmailLink.generate(params[:email])

    if @email_link
      flash[:notice] = "Email sent! Please, check your inbox."
      redirect_to root_path
    else
      flash[:alert] = "There was an error, please try again!"
      redirect_to new_magic_link_path
    end
  end

  def validate
    email_link = EmailLink.where(token: params[:token]).where("expires_at > ?", DateTime.now).first

    unless email_link
      flash[:alert] = "Invalid or expired token!"
      redirect_to new_magic_link_path
    end

    sign_in(email_link.user, scope: :user)
    redirect_to root_path
  end
end
Enter fullscreen mode Exit fullscreen mode

At this point, our program is capable of generating the token, validating it and logging in. What we have left is to send the email and the view where the user gives us their email.

NOTE: If you want to see these alerts, you must add them in app/views/layouts/application.html.erb, inside the body tag:

<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
Enter fullscreen mode Exit fullscreen mode

Sending the email

Very easy, first we are going to generate our mailer, $ rails g mailer EmailLinkMailer and then we give it functionality:

class EmailLinkMailer < ApplicationMailer
  def sign_in_mail(email_link)
    @token = email_link.token
    @user = email_link.user

    mail to: @user.email, subject: "Here is your magic link! 🚀"
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's edit our email (app/views/email_link_mailer/sign_in_mail.html.erb) a little:

<p>Hello, <%= @user.email %>!</p>
<p>Recently someone requested a link to enter your account, if it was you, just press the button below to log in</p>

<%= link_to "Sign in to my account", email_link_url(token: @token) %>
Enter fullscreen mode Exit fullscreen mode

Brilliant! At this point our program is already capable of sending the email, we just need the main view, where the user will give us their email and we can fire all this backend.

Just add this simple form in app/views/email_links/new.html.erb:

<%= form_with(url: magic_link_path, method: :post) do %>
  <%= label_tag :email, "E-mail address" %>
  <%= email_field_tag :email, nil, placeholder:"carpintinimatias@gmail.com", autofocus: true, required: true %>
  <%= submit_tag "Send!" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

To validate on the front that this works, we can add this dummy example in app/views/welcome/index.html.erb:

<% if user_signed_in? %>
  <p>Hello, <%= current_user.email %></p>
  <%= link_to "Sign out", destroy_user_session_path, method: :delete %>
<% else %>
  <p>Hey, Sign In with Magic Links!</p>
  <%= link_to "Click here", new_magic_link_path %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

All right, that's it. Thanks for reading. 👋

Discussion (6)

Collapse
caioertai profile image
Caio Andrade

Hey, thank you for the article. I was wondering whether to use a gem or just build from scratch when I stumbled into your article. I'm tweaking here and there (using UUID instead of the default bigint and using it as the token itself) but mostly it's the same flow.

But, talking about your flow, I'd recommend you adding an index to your emails_links.token column since it's going to be searched so often.

Collapse
matiascarpintini profile image
Matias Carpintini Author

Definitely, it's a good practice!

Collapse
thenasser93 profile image
Nasser Q - ناصر

I did the whole tutorial and put
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config.action_mailer.delivery_method = :letter_opener
in development environment but it says "There was an error, please try again!"

Collapse
matiascarpintini profile image
Matias Carpintini Author

Can u share me more info? When you get that error? Server logs ss would help :D

Collapse
thenasser93 profile image
Nasser Q - ناصر • Edited on

Sorry for the late response!

This's what I got!

ibb.co/By07pQz

Thread Thread
matiascarpintini profile image
Matias Carpintini Author

Didn't see the error there. Last request has 200 response code, have you reproduced the error?