diff --git a/Gemfile b/Gemfile index 691f7566..dd31e2f8 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,8 @@ gem 'dotenv-rails', groups: [:development, :test] group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ] + gem "bullet" + gem "faker" end group :development do @@ -61,9 +63,7 @@ group :development do # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] # gem "rack-mini-profiler" - # Speed up commands on slow machines / big apps [https://github.com/rails/spring] - # gem "spring" - + gem "letter_opener" end group :test do @@ -73,5 +73,6 @@ group :test do gem "rspec-rails" gem "factory_bot_rails" - gem "faker" end + +gem "passwordless", "~> 1.1" diff --git a/Gemfile.lock b/Gemfile.lock index 7556d6da..0c61afed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,11 +78,15 @@ GEM addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) base64 (0.2.0) + bcrypt (3.1.20) bigdecimal (3.1.4) bindex (0.8.1) bootsnap (1.17.0) msgpack (~> 1.2) builder (3.2.4) + bullet (7.1.4) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) capybara (3.39.2) addressable matrix @@ -129,6 +133,10 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + launchy (2.5.2) + addressable (~> 2.8) + letter_opener (1.8.1) + launchy (>= 2.2, < 3) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -159,6 +167,9 @@ GEM racc (~> 1.4) nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) + passwordless (1.1.1) + bcrypt (>= 3.1.11) + rails (>= 5.1.4) pg (1.5.4) psych (5.1.1.1) stringio @@ -257,6 +268,7 @@ GEM railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uniform_notifier (1.16.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -279,6 +291,7 @@ PLATFORMS DEPENDENCIES bootsnap + bullet capybara debug dotenv-rails @@ -286,6 +299,8 @@ DEPENDENCIES faker importmap-rails jbuilder + letter_opener + passwordless (~> 1.1) pg (~> 1.1) puma (>= 5.0) rails (~> 7.1.1) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d12..1ce30dbe 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,17 @@ class ApplicationController < ActionController::Base + include Passwordless::ControllerHelpers + + helper_method :current_user + + private + + def current_user + @current_user ||= authenticate_by_session(User) + end + + def require_user! + return if current_user + save_passwordless_redirect_location!(User) + redirect_to auth_sign_in_url, flash: { notice: 'Please sign in.' } + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 00000000..1ba688e9 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,6 @@ +class DashboardController < ApplicationController + before_action :require_user! + + def show + end +end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb new file mode 100644 index 00000000..a94ddfc2 --- /dev/null +++ b/app/helpers/dashboard_helper.rb @@ -0,0 +1,2 @@ +module DashboardHelper +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..155e84d6 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,8 @@ +class User < ApplicationRecord + validates :email, + presence: true, + uniqueness: { case_sensitive: false }, + format: { with: URI::MailTo::EMAIL_REGEXP } + + passwordless_with :email +end diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb new file mode 100644 index 00000000..007c64b0 --- /dev/null +++ b/app/views/dashboard/show.html.erb @@ -0,0 +1,4 @@ +
+

Dashboard#show

+

Find me in app/views/dashboard/show.html.erb

+
diff --git a/app/views/passwordless/mailer/sign_in.text.erb b/app/views/passwordless/mailer/sign_in.text.erb new file mode 100644 index 00000000..e5d78504 --- /dev/null +++ b/app/views/passwordless/mailer/sign_in.text.erb @@ -0,0 +1 @@ +<%= t("passwordless.mailer.sign_in.body", token: @token, magic_link: @magic_link) %> diff --git a/app/views/passwordless/sessions/new.html.erb b/app/views/passwordless/sessions/new.html.erb new file mode 100644 index 00000000..ebabdf0b --- /dev/null +++ b/app/views/passwordless/sessions/new.html.erb @@ -0,0 +1,38 @@ +
+
+
+ Your Company +

Sign in to your account

+
+ +
+
+ <%= render("shared/flash") %> + <%= form_with(model: @session, url: url_for(action: 'new'), html: { class: "space-y-6" }, data: { turbo: 'false' }) do |f| %> + <% email_field_name = :"passwordless[#{email_field}]" %> +
+ <%= f.label email_field_name, + t("passwordless.sessions.new.email.label"), + class: "block text-sm font-medium leading-6 text-gray-900", + for: "passwordless_#{email_field}" %> +
+ <%= email_field_tag email_field_name, + params.fetch(email_field_name, nil), + required: true, + autofocus: true, + placeholder: t("passwordless.sessions.new.email.placeholder"), + class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %> +
+
+ +
+ <%= f.submit t("passwordless.sessions.new.submit"), class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %> +
+ <% end %> +

+ StaffPlan does not use passwords. You will receive an email with a link to sign in. +

+
+
+
+
diff --git a/app/views/passwordless/sessions/show.html.erb b/app/views/passwordless/sessions/show.html.erb new file mode 100644 index 00000000..e2b84935 --- /dev/null +++ b/app/views/passwordless/sessions/show.html.erb @@ -0,0 +1,30 @@ +
+
+
+ Your Company +

Sign in to your account

+
+ +
+
+ <%= render("shared/flash") %> + <%= form_with(model: @session, url: url_for(action: 'update'), scope: 'passwordless', method: 'patch', data: { turbo: false }) do |f| %> + +
+ <%= f.label :token, autocomplete: "off", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= f.text_field :token, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %> +
+
+ +
+ <%= f.submit t(".confirm"), class: "mt-2 flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %> +
+ <% end %> +

+ StaffPlan does not use passwords. You will receive an email with a link to sign in. +

+
+
+
+
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 00000000..a446e9a2 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,30 @@ +<% if flash[:error].present? %> + +<% end %> + +<% if flash[:notice] %> + +<% end %> + +<% if flash[:info].present? %> + +<% end %> + +<% if flash[:success].present? %> + +<% end %> \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb486..dc9114de 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,6 +1,15 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + config.after_initialize do + Bullet.enable = true + Bullet.alert = true + Bullet.bullet_logger = true + Bullet.console = true + Bullet.rails_logger = true + Bullet.add_footer = true + end + # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded any time @@ -38,7 +47,8 @@ # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false - + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true config.action_mailer.perform_caching = false # Print deprecation notices to the Rails logger. @@ -73,4 +83,7 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + Rails.application.routes.default_url_options[:host] = 'localhost' + Rails.application.routes.default_url_options[:port] = 3000 end diff --git a/config/environments/production.rb b/config/environments/production.rb index d2cf6202..7a9df46a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -94,4 +94,7 @@ # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + Rails.application.routes.default_url_options[:host] = 'staffplan.com' + Rails.application.routes.default_url_options[:protocol] = 'https' end diff --git a/config/environments/test.rb b/config/environments/test.rb index 0dda9f9f..b30ccdb2 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -6,6 +6,12 @@ # and recreated between test runs. Don't rely on the data there! Rails.application.configure do + config.after_initialize do + Bullet.enable = true + Bullet.bullet_logger = true + Bullet.raise = true # raise an error if n+1 query occurs + end + # Settings specified here will take precedence over those in config/application.rb. # While tests run files are not watched, reloading is not necessary. @@ -61,4 +67,7 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + Rails.application.routes.default_url_options[:host] = 'localhost' + Rails.application.routes.default_url_options[:port] = 3000 end diff --git a/config/initializers/passwordless.rb b/config/initializers/passwordless.rb new file mode 100644 index 00000000..43abed78 --- /dev/null +++ b/config/initializers/passwordless.rb @@ -0,0 +1,15 @@ +Passwordless.configure do |config| + config.default_from_address = "noreply@staffplan.com" + config.parent_mailer = "ActionMailer::Base" + config.restrict_token_reuse = true # A token/link can only be used once + config.token_generator = Passwordless::ShortTokenGenerator.new # Used to generate magic link tokens. + + config.expires_at = lambda { 1.year.from_now } # How long until a signed in session expires. + config.timeout_at = lambda { 10.minutes.from_now } # How long until a token/magic link times out. + + config.redirect_back_after_sign_in = true # When enabled the user will be redirected to their previous page, or a page specified by the `destination_path` query parameter, if available. + config.redirect_to_response_options = {} # Additional options for redirects. + config.success_redirect_path = '/' # After a user successfully signs in + config.failure_redirect_path = '/' # After a sign in fails + config.sign_out_redirect_path = '/' # After a user signs out +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index a125ef08..5bd9e12b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,11 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check - # Defines the root path route ("/") - # root "posts#index" + passwordless_for :users, at: '/', as: :auth + + resource :dashboard, only: [:show], controller: "dashboard" + + root "dashboard#show" end diff --git a/db/migrate/20231119204318_create_passwordless_sessions.passwordless_engine.rb b/db/migrate/20231119204318_create_passwordless_sessions.passwordless_engine.rb new file mode 100644 index 00000000..cb1de6d5 --- /dev/null +++ b/db/migrate/20231119204318_create_passwordless_sessions.passwordless_engine.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# This migration comes from passwordless_engine (originally 20171104221735) +class CreatePasswordlessSessions < ActiveRecord::Migration[5.1] + def change + create_table(:passwordless_sessions) do |t| + t.belongs_to( + :authenticatable, + polymorphic: true, + index: {name: "authenticatable"} + ) + + t.datetime(:timeout_at, null: false) + t.datetime(:expires_at, null: false) + t.datetime(:claimed_at) + t.string(:token_digest, null: false) + t.string(:identifier, null: false, index: {unique: true}, length: 36) + + t.timestamps + end + end +end diff --git a/db/migrate/20231119204431_create_users.rb b/db/migrate/20231119204431_create_users.rb new file mode 100644 index 00000000..531cf23e --- /dev/null +++ b/db/migrate/20231119204431_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[7.1] + def change + create_table :users do |t| + t.string :name + t.string :email + t.timestamps + + t.index :email, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f9139984..9b0948bb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,30 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 0) do +ActiveRecord::Schema[7.1].define(version: 2023_11_19_204431) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "passwordless_sessions", force: :cascade do |t| + t.string "authenticatable_type" + t.bigint "authenticatable_id" + t.datetime "timeout_at", precision: nil, null: false + t.datetime "expires_at", precision: nil, null: false + t.datetime "claimed_at", precision: nil + t.string "token_digest", null: false + t.string "identifier", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["authenticatable_type", "authenticatable_id"], name: "authenticatable" + t.index ["identifier"], name: "index_passwordless_sessions_on_identifier", unique: true + end + + create_table "users", force: :cascade do |t| + t.string "name" + t.string "email" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + end diff --git a/spec/examples.txt b/spec/examples.txt new file mode 100644 index 00000000..4658731b --- /dev/null +++ b/spec/examples.txt @@ -0,0 +1,10 @@ +example_id | status | run_time | +-------------------------------------------- | ------ | --------------- | +./spec/system/authentications_spec.rb[1:1:1] | passed | 0.27175 seconds | +./spec/system/authentications_spec.rb[1:2:1] | passed | 0.01112 seconds | +./spec/system/authentications_spec.rb[1:3:1] | passed | 0.02471 seconds | +./spec/system/authentications_spec.rb[1:3:2] | passed | 0.02664 seconds | +./spec/system/authentications_spec.rb[1:3:3] | passed | 0.34036 seconds | +./spec/system/authentications_spec.rb[1:3:4] | passed | 0.29556 seconds | +./spec/system/authentications_spec.rb[1:4:1] | passed | 0.37869 seconds | +./spec/system/authentications_spec.rb[1:5:1] | passed | 0.29928 seconds | diff --git a/spec/factories.rb b/spec/factories.rb new file mode 100644 index 00000000..3af0c59b --- /dev/null +++ b/spec/factories.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :user do + name { Faker::Name.name } + email { Faker::Internet.email } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b1ebbd84..9d57b225 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,7 +5,7 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' -# Add additional requires below this line. Rails is not loaded until this point! +require "passwordless/test_helpers" # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are diff --git a/spec/system/authentications_spec.rb b/spec/system/authentications_spec.rb new file mode 100644 index 00000000..79734e4b --- /dev/null +++ b/spec/system/authentications_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe "Authentication", type: :system do + before do + driven_by(:rack_test) + end + + context "when user is not signed in" do + it "redirects to sign in page" do + visit root_path + expect(page).to have_current_path(auth_sign_in_path) + end + end + + context "when an email does not exist" do + it "shows the user an error that their email does not exist" do + visit root_path + fill_in "passwordless[email]", with: Faker::Internet.email + click_button "Sign in" + expect(page).to have_content("We couldn't find a user with that email address") + end + end + + context "when an email exists" do + it "sends the user an email with a sign in link" do + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + + + expect { + click_button "Sign in" + }.to change { ActionMailer::Base.deliveries.count }.by(1) + + expect(page).to have_content("We've sent you an email with a secret token") + end + + it "redirects the user to the token entry page" do + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + click_button "Sign in" + expect(page).to have_current_path( + verify_auth_sign_in_path(user.passwordless_sessions.last.identifier) + ) + end + + it "signs them in after successfully entering the token" do + token = "123456" + expect(Passwordless.config.token_generator).to receive(:call).and_return(token) + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + click_button "Sign in" + fill_in "passwordless[token]", with: token + click_button "Confirm" + expect(page).to have_current_path(root_path) + end + + it "signs them in after clicking the link in the email" do + token = "123456" + expect(Passwordless.config.token_generator).to receive(:call).and_return(token) + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + click_button "Sign in" + + # click the link in the email + visit confirm_auth_sign_in_path(user.passwordless_sessions.last.identifier, token) + expect(page).to have_current_path(root_path) + end + end + + context "when the user enters an invalid token" do + it "shows the user an error that their token is invalid" do + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + click_button "Sign in" + fill_in "passwordless[token]", with: "bad token" + click_button "Confirm" + expect(page).to have_content("Token is invalid") + end + end + + context "when a user signs out" do + it "redirects the user to the sign in page" do + token = "123456" + expect(Passwordless.config.token_generator).to receive(:call).and_return(token) + + user = create(:user) + visit root_path + fill_in "passwordless[email]", with: user.email + click_button "Sign in" + fill_in "passwordless[token]", with: token + click_button "Confirm" + expect(page).to have_current_path(root_path) + + # sign yourself out + visit auth_sign_out_path + + # now visit the dashboard, see the login page + visit dashboard_path + expect(page).to have_content("Please sign in") + end + end +end diff --git a/test/controllers/dashboard_controller_test.rb b/test/controllers/dashboard_controller_test.rb new file mode 100644 index 00000000..dc4c0a6a --- /dev/null +++ b/test/controllers/dashboard_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class DashboardControllerTest < ActionDispatch::IntegrationTest + test "should get show" do + get dashboard_show_url + assert_response :success + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 00000000..6c868efa --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + email: MyString + +two: + email: MyString diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 00000000..5c07f490 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end