diff --git a/Gemfile b/Gemfile index d31a763..33a37e0 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,8 @@ group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: %i[mri mingw x64_mingw] gem 'factory_bot_rails' + gem 'letter_opener' + gem 'letter_opener_web' gem 'rubocop' end diff --git a/Gemfile.lock b/Gemfile.lock index ab2f5a9..5d8127e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,6 +115,15 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) + launchy (2.5.2) + addressable (~> 2.8) + letter_opener (1.8.1) + launchy (>= 2.2, < 3) + letter_opener_web (2.0.0) + actionmailer (>= 5.2) + letter_opener (~> 1.7) + railties (>= 5.2) + rexml listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -289,6 +298,8 @@ DEPENDENCIES jbuilder (~> 2.7) js-routes kaminari + letter_opener + letter_opener_web listen (~> 3.2) pg (>= 0.18, < 2.0) puma (~> 4.1) diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index 8fd7fa0..df25aa7 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -18,21 +18,20 @@ def index def create params['task']['author_id'] = current_user.id if params.dig('task', 'author_id').nil? task = current_user.my_tasks.new(task_params) - task.save - + UserMailer.with({ user: current_user, task: task }).task_created.deliver_now if task.save respond_with(task, serializer: TaskSerializer, location: nil) end def update task = Task.find(params[:id]) - task.update(task_params) + UserMailer.with({ task: task }).task_updated.deliver_now if task.update(task_params) respond_with(task, serializer: TaskSerializer) end def destroy task = Task.find(params[:id]) - task.destroy + UserMailer.with({ task: task }).task_deleted.deliver_now if task.destroy respond_with(task) end diff --git a/app/controllers/web/password_resets_controller.rb b/app/controllers/web/password_resets_controller.rb new file mode 100644 index 0000000..681ed67 --- /dev/null +++ b/app/controllers/web/password_resets_controller.rb @@ -0,0 +1,38 @@ +class Web::PasswordResetsController < Web::ApplicationController + def new; end + + def create + @user = User.find_by(email: params.dig('user', 'email')) + + if @user.present? + @token = @user.generate_token + UserMailer.with(user: @user, token: @token).reset_password.deliver_now + @user.update(reset_password_token_digest: @token) + end + redirect_to new_session_path + end + + def edit + @user = GlobalID::Locator.locate_signed(params[:token], for: 'password_reset') + if @user.nil? + redirect_to new_session_path + elsif @user.reset_password_token_digest != params[:token] + redirect_to new_session_path + end + end + + def update + @user = GlobalID::Locator.locate_signed(params[:token], for: 'password_reset') + if @user.update(password_params.merge(reset_password_token_digest: nil)) + redirect_to new_session_path + else + render :edit + end + end + + private + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..783d66c --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,29 @@ +class UserMailer < ApplicationMailer + 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] + author = @task.author + + mail(from: 'noreply@taskmanager.com', to: author.email, subject: 'Task Updated') + end + + def task_deleted + @task = params[:task] + author = @task.author + + mail(from: 'noreply@taskmanager.com', to: author.email, subject: 'Task Deleted') + end + + def reset_password + @user = params[:user] + @token = params[:token] + + mail(from: 'noreply@taskmanager.com', to: @user.email, subject: 'Password Reset') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b677190..2f9e115 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,4 +6,8 @@ class User < ApplicationRecord validates :first_name, presence: true, length: { minimum: 2 } validates :last_name, presence: true, length: { minimum: 2 } validates :email, presence: true, uniqueness: true, format: { with: /\w*@{1}\w*/ } + + def generate_token + to_sgid(for: 'password_reset', expires_in: 1.day).to_s + end end diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 89e06b3..c931ff0 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -39,4 +39,4 @@ html lang="en" footer#footer.mdl-mini-footer .mdl-mini-footer__left-section .mdl-logo - | © Dualboot Learn 2020 + | © Dualboot Learn 2023 diff --git a/app/views/layouts/mailer.html.slim b/app/views/layouts/mailer.html.slim new file mode 100644 index 0000000..e17dcf6 --- /dev/null +++ b/app/views/layouts/mailer.html.slim @@ -0,0 +1,3 @@ +html + body + = 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/user_mailer/_body.html.slim b/app/views/user_mailer/_body.html.slim new file mode 100644 index 0000000..07d9809 --- /dev/null +++ b/app/views/user_mailer/_body.html.slim @@ -0,0 +1,6 @@ +tr + td bgcolor="#ffffff" style="padding: 10px 30px;" + p + | Dear user! + p + | We are honored to announce you that Task #{@task.id} was #{action}. \ No newline at end of file diff --git a/app/views/user_mailer/_footer.html.slim b/app/views/user_mailer/_footer.html.slim new file mode 100644 index 0000000..31b0e23 --- /dev/null +++ b/app/views/user_mailer/_footer.html.slim @@ -0,0 +1,5 @@ +tr + td + small + center style="color: #d3d3d3;" + | © Dualboot Learn 2023 \ No newline at end of file diff --git a/app/views/user_mailer/_header.html.slim b/app/views/user_mailer/_header.html.slim new file mode 100644 index 0000000..cd95931 --- /dev/null +++ b/app/views/user_mailer/_header.html.slim @@ -0,0 +1,5 @@ +tr + td bgcolor="#3F52B5" align="center" style="padding: 30px 0;" + span style="color:#ffffff; font-size: 20px; font-family:Arial, Helvetica, sans-serif;" + b + Task Board Project \ No newline at end of file diff --git a/app/views/user_mailer/reset_password.html.slim b/app/views/user_mailer/reset_password.html.slim new file mode 100644 index 0000000..124504c --- /dev/null +++ b/app/views/user_mailer/reset_password.html.slim @@ -0,0 +1,19 @@ +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="30" cellspacing="0" + ==render 'header' + tr + td bgcolor="#ffffff" style="padding: 10px 30px;" + p + | Dear user! + p + = link_to 'Click this link, to reset your password', edit_password_resets_url(token: @token) + ==render 'footer' \ 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..e4f82a8 --- /dev/null +++ b/app/views/user_mailer/task_created.html.slim @@ -0,0 +1,14 @@ +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="30" cellspacing="0" + ==render 'header' + ==render 'body', object: @task, action: 'created' + ==render 'footer' \ 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..643f238 --- /dev/null +++ b/app/views/user_mailer/task_deleted.html.slim @@ -0,0 +1,14 @@ +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="30" cellspacing="0" + ==render 'header' + ==render 'body', object: @task, action: 'deleted' + ==render 'footer' \ 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..6242143 --- /dev/null +++ b/app/views/user_mailer/task_updated.html.slim @@ -0,0 +1,14 @@ +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="30" cellspacing="0" + ==render 'header' + ==render 'body', object: @task, action: 'updated' + ==render 'footer' \ No newline at end of file diff --git a/app/views/web/password_resets/edit.html.slim b/app/views/web/password_resets/edit.html.slim new file mode 100644 index 0000000..6abf988 --- /dev/null +++ b/app/views/web/password_resets/edit.html.slim @@ -0,0 +1,7 @@ +h4 Reset your password += simple_form_for :user, url: password_resets_path(token: params[:token]), method: :put do |f| + p + = f.input :password + = f.input :password_confirmation + p + = f.button :submit, "Change pass" \ No newline at end of file diff --git a/app/views/web/password_resets/new.html.slim b/app/views/web/password_resets/new.html.slim new file mode 100644 index 0000000..43cd664 --- /dev/null +++ b/app/views/web/password_resets/new.html.slim @@ -0,0 +1,7 @@ +h4 Forgot password? No problem! +p Type your email here, we'll send you link. += simple_form_for :user, url: password_resets_path do |f| + p + = f.input :email + p + = f.button :submit, "Send" \ 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 445e2e4..7db8ad7 100644 --- a/app/views/web/sessions/new.html.slim +++ b/app/views/web/sessions/new.html.slim @@ -5,3 +5,4 @@ h4 Log in = f.input :password p = f.button :submit, "Sign in" += link_to "Forgot Password?", new_password_resets_path, class: 'mdl-navigation__link' diff --git a/config/environments/development.rb b/config/environments/development.rb index 8ac1bcd..ce46256 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,7 +34,9 @@ # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false - config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :letter_opener_web + config.action_mailer.perform_caching = true + config.action_mailer.default_url_options = { host: 'localhost:3330' } # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 1791372..bffa8bb 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -61,6 +61,16 @@ # config.active_job.queue_name_prefix = "app_production" config.action_mailer.perform_caching = false + 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 + } # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/config/environments/test.rb b/config/environments/test.rb index 470dee4..2b4de2f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -34,6 +34,7 @@ config.active_storage.service = :test config.action_mailer.perform_caching = false + config.action_mailer.default_url_options = { host: 'localhost:3330' } # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the diff --git a/config/initializers/simple_form_materialize.rb b/config/initializers/simple_form_materialize.rb index dc959da..7583fc3 100644 --- a/config/initializers/simple_form_materialize.rb +++ b/config/initializers/simple_form_materialize.rb @@ -8,7 +8,8 @@ config.boolean_style = :inline config.item_wrapper_tag = :p - config.wrappers :materialize_form, tag: 'div', class: 'mdl-textfield mdl-js-textfield mdl-textfield--floating-label', error_class: 'is-invalid' do |b| + config.wrappers :materialize_form, tag: 'div', class: 'mdl-textfield mdl-js-textfield mdl-textfield--floating-label', + error_class: 'is-invalid' do |b| b.use :html5 b.use :placeholder b.optional :maxlength diff --git a/config/routes.rb b/config/routes.rb index 6679b9c..520db78 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: %i[new create destroy] resources :developers, only: %i[new create] + resource :password_resets, only: %i[new create edit update] end namespace :admin do diff --git a/db/migrate/20230105072548_add_reset_password_token_to_users.rb b/db/migrate/20230105072548_add_reset_password_token_to_users.rb new file mode 100644 index 0000000..a1e7818 --- /dev/null +++ b/db/migrate/20230105072548_add_reset_password_token_to_users.rb @@ -0,0 +1,5 @@ +class AddResetPasswordTokenToUsers < ActiveRecord::Migration[6.0] + def change + add_column :users, :reset_password_token_digest, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 032a4ac..2d85862 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: 2020_10_04_112023) do +ActiveRecord::Schema.define(version: 2023_01_05_072548) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -35,6 +35,7 @@ t.string "type" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.string "reset_password_token_digest" end end diff --git a/docker-compose.yml b/docker-compose.yml index 2a63260..1992957 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,8 @@ version: '3.7' services: web: build: . - volumes: &web-volumes + volumes: + &web-volumes - &app-volume .:/task_manager:cached - ~/.ssh:/root/.ssh - ~/.bash_history:/root/.bash_history @@ -14,7 +15,8 @@ services: - 3332:3332 depends_on: - db - environment: &web-environment + environment: + &web-environment BUNDLE_PATH: /bundle_cache GEM_HOME: /bundle_cache GEM_PATH: /bundle_cache @@ -23,6 +25,13 @@ services: DATABASE_HOST: db DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres + + MAILER_USERNAME: fake + MAILER_PASSWORD: fake + MAILER_ADDRESS: fake + MAILER_PORT: fake + MAILER_DOMAIN: fake + MAILER_AUTHENTICATION: fake command: bundle exec rails s -b '0.0.0.0' -p 3330 db: diff --git a/test/controllers/api/v1/tasks_controller_test.rb b/test/controllers/api/v1/tasks_controller_test.rb index d4ce848..d3a2e08 100644 --- a/test/controllers/api/v1/tasks_controller_test.rb +++ b/test/controllers/api/v1/tasks_controller_test.rb @@ -18,7 +18,11 @@ class Api::V1::TasksControllerTest < ActionController::TestCase 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) @@ -26,6 +30,8 @@ class Api::V1::TasksControllerTest < ActionController::TestCase assert created_task.present? assert_equal task_attributes.stringify_keys, created_task.slice(*task_attributes.keys) + assert_equal created_task.assignee, assignee + assert_equal created_task.author, author end test 'should put update' do @@ -34,17 +40,26 @@ class Api::V1::TasksControllerTest < ActionController::TestCase task = create(:task, author: author) task_attributes = attributes_for(:task).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 assert_equal task.slice(*task_attributes.keys), task_attributes + assert_equal task.assignee, assignee + assert_equal task.author, author end test 'should delete destroy' do - author = create :user - task = create :task, author: author - delete :destroy, params: { id: task.id, format: :json } + author = create(:user) + task = create(:task, author: author) + + 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/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..1d1ce52 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,35 @@ +# 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 + + def reset_password + user = User.first + token = user.generate_token + + params = { user: user, token: token } + + UserMailer.with(params).reset_password + end +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..3281b2c --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,67 @@ +require 'test_helper' + +class UserMailerTest < ActionMailer::TestCase + test 'task created' do + user = create(:user) + task = create(:task) + 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 + task = create(:task) + user = task.author + 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 + task = create(:task) + user = task.author + 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 + + test 'reset password' do + user = create(:user) + token = user.generate_token + params = { user: user, token: token } + email = UserMailer.with(params).reset_password + + assert_emails 1 do + email.deliver_now + end + + assert_equal ['noreply@taskmanager.com'], email.from + assert_equal [user.email], email.to + assert_equal 'Password Reset', email.subject + assert email.body.to_s.include?('Click this link, to reset your password') + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3c3fde2..0c03942 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,6 +6,7 @@ require 'rails/test_help' class ActiveSupport::TestCase + include ActionMailer::TestHelper include FactoryBot::Syntax::Methods include AuthHelper # Run tests in parallel with specified workers