diff --git a/Gemfile b/Gemfile index 90398b9..3e518c4 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,8 @@ group :development do gem 'web-console', '>= 3.3.0' gem 'listen', '~> 3.2' gem 'bullet' + gem 'letter_opener' + gem 'letter_opener_web' end group :test do @@ -65,4 +67,4 @@ gem 'active_model_serializers' gem 'webpacker-react', '~> 0.3.2' gem 'js-routes' gem 'rollbar' -gem 'newrelic_rpm' \ No newline at end of file +gem 'newrelic_rpm' diff --git a/Gemfile.lock b/Gemfile.lock index 0b9471a..0775373 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -124,6 +124,14 @@ GEM activerecord kaminari-core (= 1.2.1) kaminari-core (1.2.1) + launchy (2.5.0) + addressable (~> 2.7) + letter_opener (1.7.0) + launchy (~> 2.2) + letter_opener_web (1.4.0) + actionmailer (>= 3.2) + letter_opener (~> 1.0) + railties (>= 3.2) listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -293,6 +301,8 @@ DEPENDENCIES jbuilder (~> 2.7) js-routes kaminari + letter_opener + letter_opener_web listen (~> 3.2) newrelic_rpm pg (>= 0.18, < 2.0) diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index 5b3de0a..cebc8a8 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -19,21 +19,30 @@ def create p = task_params p['author_id'] = current_user.id task = current_user.my_tasks.new(p) - task.save + + if task.save + UserMailer.with({ user: current_user, task: task }).task_created.deliver_now + end respond_with(task, serializer: TaskSerializer, location: nil) end def update task = Task.find(params[:id]) - task.update(task_params) + + if task.update(task_params) + UserMailer.with({ task: task }).task_updated.deliver_now + end respond_with(task, serializer: TaskSerializer) end def destroy task = Task.find(params[:id]) - task.destroy + + if task.destroy + UserMailer.with({ task: task }).task_deleted.deliver_now + end respond_with(task) end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 7b55375..aae214b 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,13 +1,13 @@ class Api::V1::UsersController < Api::V1::ApplicationController - def show - user = User.find(params[:id]) - - respond_with(user, serializer: UserSerializer) - end - - def index - users = User.ransack(ransack_params).result.page(page).per(per_page) - - respond_with(users, each_serializer: UserSerializer, meta: build_meta(users), root: 'items') - end - end \ No newline at end of file + def show + user = User.find(params[:id]) + + respond_with(user, serializer: UserSerializer) + end + + def index + users = User.ransack(ransack_params).result.page(page).per(per_page) + + respond_with(users, each_serializer: UserSerializer, meta: build_meta(users), root: 'items') + end +end diff --git a/app/controllers/web/recovery_passwords_controller.rb b/app/controllers/web/recovery_passwords_controller.rb new file mode 100644 index 0000000..76f30ff --- /dev/null +++ b/app/controllers/web/recovery_passwords_controller.rb @@ -0,0 +1,32 @@ +class Web::RecoveryPasswordsController < Web::ApplicationController + def create + user = User.find_by_email(recovery_password_params[:email]) + user.send_password_reset if user + redirect_to(new_session_path) + end + + def edit + @user = User.find_by_password_reset_token!(params[:id]) + end + + def update + @user = User.find_by_password_reset_token!(params[:id]) + if @user.password_reset_sent_at < 24.hour.ago + redirect_to(new_recovery_password_path) + elsif @user.update(user_params) + redirect_to(new_session_path) + else + render(:edit) + end + end + + private + + def user_params + params.require(:admin).permit(:password) + end + + def recovery_password_params + params.require(:recovery_passwords).permit(:email) + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 286b223..8c24e9d 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,3 @@ class ApplicationMailer < ActionMailer::Base default from: 'from@example.com' - layout 'mailer' end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..d39eddd --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,27 @@ +class UserMailer < ApplicationMailer + layout 'task_mailer' + + def task_created + user = params[:user] + @task = params[:task] + + mail(from: 'noreply@taskmanager.com', to: user.email, subject: 'New Task Created') + end + + def task_updated + @task = params[:task] + + mail(from: 'noreply@taskmanager.com', to: @task.author.email, subject: 'Task Updated') + end + + def task_deleted + @task = params[:task] + + mail(from: 'noreply@taskmanager.com', to: @task.author.email, subject: 'Task Deleted') + end + + def forgot_password + @user = params + mail(from: 'noreply@taskmanager.com', to: @user.email, subject: 'Reset password instructions') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b8d33dc..195414d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,4 +7,17 @@ class User < ApplicationRecord validates :first_name, length: { minimum: 2 }, presence: true validates :last_name, length: { minimum: 2 }, presence: true validates :email, format: { with: /\A\S+@.+\.\S+\z/ }, uniqueness: true, presence: true + + def send_password_reset + generate_token(:password_reset_token) + self.password_reset_sent_at = Time.zone.now + save! + UserMailer.with(self).forgot_password.deliver_now + end + + def generate_token(column) + begin + self[column] = SecureRandom.urlsafe_base64 + end while User.exists?(column => self[column]) + end end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 6109046..fba34c7 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,3 +1,3 @@ class UserSerializer < ApplicationSerializer - attributes :id, :first_name, :last_name, :email - end \ No newline at end of file + attributes :id, :first_name, :last_name, :email +end diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb deleted file mode 100644 index cbd34d2..0000000 --- a/app/views/layouts/mailer.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - <%= yield %> - - diff --git a/app/views/layouts/mailer.text.slim b/app/views/layouts/mailer.text.slim new file mode 100644 index 0000000..0a90f09 --- /dev/null +++ b/app/views/layouts/mailer.text.slim @@ -0,0 +1 @@ += yield diff --git a/app/views/layouts/task_mailer.html.slim b/app/views/layouts/task_mailer.html.slim new file mode 100644 index 0000000..566dfe6 --- /dev/null +++ b/app/views/layouts/task_mailer.html.slim @@ -0,0 +1,22 @@ +doctype html +html lang="en" + head + meta http-equiv="Content-Type" content="text/html; charset=utf-8" + title Email template + body bgcolor="#efefef" style="padding: 0; margin: 0;" + table border="0" cellpadding="0" cellspacing="0" width="100%" + tr + td align="center" + table width="600" border="0" cellpadding="0" cellspacing="0" + tr + td bgcolor="#3F52B5" align="center" style="padding: 30px 0;" + span style="color:#ffffff; font-size: 24px; font-family:Arial, Helvetica, sans-serif;" + b Task Board Project + tr + td bgcolor="#FFFFFF" align="center" style="padding: 30px 0;" + span style="color:#000000; font-size: 18px; font-family:Arial, Helvetica, sans-serif;" + create =yield + tr + td align="center" style="padding: 30px 0;" + span style="color:#b9b9b9; font-size: 14px; font-family:Arial, Helvetica, sans-serif;" + create © 2020, TaskBoard Project \ No newline at end of file diff --git a/app/views/user_mailer/forgot_password.html.slim b/app/views/user_mailer/forgot_password.html.slim new file mode 100644 index 0000000..b4f72bf --- /dev/null +++ b/app/views/user_mailer/forgot_password.html.slim @@ -0,0 +1,6 @@ +p Hi =@user.first_name +p To reset your password, click the URL below: + +p =edit_recovery_password_url(@user.password_reset_token) + +p If you did not request your password to be reset, just ignore this e-mail and your password will stay the same. \ No newline at end of file diff --git a/app/views/user_mailer/task_created.html.slim b/app/views/user_mailer/task_created.html.slim new file mode 100644 index 0000000..d0be475 --- /dev/null +++ b/app/views/user_mailer/task_created.html.slim @@ -0,0 +1 @@ +| Task #{@task.id} was created \ No newline at end of file diff --git a/app/views/user_mailer/task_deleted.html.slim b/app/views/user_mailer/task_deleted.html.slim new file mode 100644 index 0000000..1c2923c --- /dev/null +++ b/app/views/user_mailer/task_deleted.html.slim @@ -0,0 +1 @@ +| Task #{@task.id} was deleted \ No newline at end of file diff --git a/app/views/user_mailer/task_updated.html.slim b/app/views/user_mailer/task_updated.html.slim new file mode 100644 index 0000000..b580df6 --- /dev/null +++ b/app/views/user_mailer/task_updated.html.slim @@ -0,0 +1 @@ +| Task #{@task.id} was updated \ No newline at end of file diff --git a/app/views/web/recovery_passwords/edit.html.slim b/app/views/web/recovery_passwords/edit.html.slim new file mode 100644 index 0000000..7681a88 --- /dev/null +++ b/app/views/web/recovery_passwords/edit.html.slim @@ -0,0 +1,15 @@ +h4 Reset Password + += simple_form_for @user, url: recovery_password_path(params[:id]) do |f| + = if @user.errors.any? + div class='error_messages' + h4 Form is invalid + ul + = for message in @user.errors.full_messages + li + = message + p + = f.input :password + = f.input :password_confirmation + p + = f.submit 'Update password' \ No newline at end of file diff --git a/app/views/web/recovery_passwords/new.html.slim b/app/views/web/recovery_passwords/new.html.slim new file mode 100644 index 0000000..251df56 --- /dev/null +++ b/app/views/web/recovery_passwords/new.html.slim @@ -0,0 +1,7 @@ +h3 Reset Password + += simple_form_for :recovery_passwords, url: recovery_passwords_path do |f| + p + = f.input :email + p + = f.button :submit, "Reset Password" \ No newline at end of file diff --git a/app/views/web/sessions/new.html.slim b/app/views/web/sessions/new.html.slim index c255aa8..983b7cc 100644 --- a/app/views/web/sessions/new.html.slim +++ b/app/views/web/sessions/new.html.slim @@ -4,4 +4,6 @@ h4 Log in = f.input :email = f.input :password p - = f.button :submit, "Sign in" \ No newline at end of file + = f.button :submit, "Sign in" + p + = link_to 'Forgot password?', new_recovery_password_path \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 7f3864f..9c6f3b5 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -67,4 +67,10 @@ # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker + + config.action_mailer.delivery_method = :letter_opener_web + + config.action_mailer.perform_caching = true + + config.action_mailer.default_url_options = {:host =>'localhost:3000'} end diff --git a/config/environments/production.rb b/config/environments/production.rb index cfe4e80..c9beeca 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -51,7 +51,7 @@ config.log_level = :debug # Prepend all log lines with the following tags. - config.log_tags = [ :request_id ] + config.log_tags = [:request_id] # Use a different cache store in production. # config.cache_store = :mem_cache_store @@ -80,7 +80,7 @@ # require 'syslog/logger' # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') - if ENV["RAILS_LOG_TO_STDOUT"].present? + if ENV['RAILS_LOG_TO_STDOUT'].present? logger = ActiveSupport::Logger.new(STDOUT) logger.formatter = config.log_formatter config.logger = ActiveSupport::TaggedLogging.new(logger) @@ -109,4 +109,15 @@ # config.active_record.database_selector = { delay: 2.seconds } # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + user_name: ENV['MAILER_USERNAME'], + password: ENV['MAILER_PASSWORD'], + address: ENV['MAILER_ADDRESS'], + port: ENV['MAILER_PORT'], + domain: ENV['MAILER_DOMAIN'], + authentication: ENV['MAILER_AUTHENTICATION'], + enable_starttls_auto: true, + } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 470dee4..fc8ac37 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -45,4 +45,5 @@ # Raises error for missing translations. # config.action_view.raise_on_missing_translations = true + config.action_mailer.delivery_method = :test end diff --git a/config/routes.rb b/config/routes.rb index d4fea57..1005493 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,12 @@ Rails.application.routes.draw do + mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? root :to => "web/boards#show" scope module: :web do resource :board, only: :show resource :session, only: [:new, :create, :destroy] resources :developers, only: [:new, :create] + resources :recovery_passwords, only: [:new, :create, :edit, :update] end namespace :admin do diff --git a/db/migrate/20210615161358_add_password_reset_to_users.rb b/db/migrate/20210615161358_add_password_reset_to_users.rb new file mode 100644 index 0000000..f849904 --- /dev/null +++ b/db/migrate/20210615161358_add_password_reset_to_users.rb @@ -0,0 +1,6 @@ +class AddPasswordResetToUsers < ActiveRecord::Migration[6.0] + def change + add_column :users, :password_reset_token, :string + add_column :users, :password_reset_sent_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index ff001bb..33a64c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_04_22_083316) do +ActiveRecord::Schema.define(version: 2021_06_15_161358) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -35,6 +35,8 @@ t.string "type" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.string "password_reset_token" + t.datetime "password_reset_sent_at" end end diff --git a/test/api/v1/tasks_controller_test.rb b/test/controllers/api/v1/tasks_controller_test.rb similarity index 76% rename from test/api/v1/tasks_controller_test.rb rename to test/controllers/api/v1/tasks_controller_test.rb index 9e25d08..7111b3a 100644 --- a/test/api/v1/tasks_controller_test.rb +++ b/test/controllers/api/v1/tasks_controller_test.rb @@ -16,17 +16,22 @@ class Api::V1::TasksControllerTest < ActionController::TestCase test 'should post create' do author = create(:user) sign_in(author) + assignee = create(:user) task_attributes = attributes_for(:task). merge({ assignee_id: assignee.id }) - post :create, params: { task: task_attributes, format: :json } + + assert_emails 1 do + post :create, params: { task: task_attributes, format: :json } + end assert_response :created data = JSON.parse(response.body) created_task = Task.find(data['task']['id']) assert created_task.present? - assert_equal task_attributes.stringify_keys, created_task.slice(*task_attributes.keys) + assert created_task.assignee.id == assignee.id + assert created_task.author.id == author.id end test 'should put update' do @@ -37,7 +42,9 @@ class Api::V1::TasksControllerTest < ActionController::TestCase merge({ author_id: author.id, assignee_id: assignee.id }). stringify_keys - patch :update, params: { id: task.id, format: :json, task: task_attributes } + assert_emails 1 do + patch :update, params: { id: task.id, format: :json, task: task_attributes } + end assert_response :success task.reload @@ -47,7 +54,10 @@ class Api::V1::TasksControllerTest < ActionController::TestCase test 'should delete destroy' do author = create(:user) task = create(:task, author: author) - delete :destroy, params: { id: task.id, format: :json } + + assert_emails 1 do + delete :destroy, params: { id: task.id, format: :json } + end assert_response :success assert !Task.where(id: task.id).exists? diff --git a/test/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb similarity index 74% rename from test/api/v1/users_controller_test.rb rename to test/controllers/api/v1/users_controller_test.rb index 0b5779e..cab6b22 100644 --- a/test/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -1,16 +1,14 @@ require 'test_helper' class Api::V1::UsersControllerTest < ActionController::TestCase - - test "should get show" do - user = create :user + test 'should get show' do + user = create(:user) get :show, params: { id: user.id, format: :json } assert_response :success end - test "should get index" do + test 'should get index' do get :index, params: { format: :json } assert_response :success end - - end \ No newline at end of file +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..6db67c6 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,26 @@ +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + def task_created + user = User.first + task = Task.first + params = { user: user, task: task } + + UserMailer.with(params).task_created + end + + def task_updated + user = User.first + task = Task.first + params = { user: user, task: task } + + UserMailer.with(params).task_updated + end + + def task_deleted + user = User.first + task = Task.first + params = { user: user, task: task } + + UserMailer.with(params).task_deleted + end +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..0eaf8fe --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,51 @@ +require 'test_helper' + +class UserMailerTest < ActionMailer::TestCase + test 'task created' do + user = create(:user) + task = create(:task, author: user) + params = { user: user, task: task } + email = UserMailer.with(params).task_created + + assert_emails 1 do + email.deliver_now + end + + assert_equal ['noreply@taskmanager.com'], email.from + assert_equal [user.email], email.to + assert_equal 'New Task Created', email.subject + assert email.body.to_s.include?("Task #{task.id} was created") + end + + test 'task updated' do + user = create(:user) + task = create(:task, author: user) + params = { task: task } + email = UserMailer.with(params).task_updated + + assert_emails 1 do + email.deliver_now + end + + assert_equal ['noreply@taskmanager.com'], email.from + assert_equal [user.email], email.to + assert_equal 'Task Updated', email.subject + assert email.body.to_s.include?("Task #{task.id} was updated") + end + + test 'task deleted' do + user = create(:user) + task = create(:task, author: user) + params = { task: task } + email = UserMailer.with(params).task_deleted + + assert_emails 1 do + email.deliver_now + end + + assert_equal ['noreply@taskmanager.com'], email.from + assert_equal [user.email], email.to + assert_equal 'Task Deleted', email.subject + assert email.body.to_s.include?("Task #{task.id} was deleted") + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4a359eb..0b88bed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,6 +8,7 @@ class ActiveSupport::TestCase include FactoryBot::Syntax::Methods include AuthHelper + include ActionMailer::TestHelper # Run tests in parallel with specified workers parallelize(workers: :number_of_processors)