kaze 0.8.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kaze/commands/install_command.rb +3 -1
  3. data/lib/kaze/commands/installs_hotwire_stack.rb +2 -2
  4. data/lib/kaze/commands/installs_inertia_stacks.rb +7 -7
  5. data/lib/kaze/version.rb +1 -1
  6. data/stubs/default/app/forms/update_profile_information_form.rb +8 -0
  7. data/stubs/default/app/mailers/application_mailer.rb +1 -1
  8. data/stubs/default/app/mailers/user_mailer.rb +16 -1
  9. data/stubs/default/app/models/concerns/can_reset_password.rb +1 -1
  10. data/stubs/default/app/models/concerns/must_verify_email.rb +15 -0
  11. data/stubs/default/app/models/user.rb +1 -0
  12. data/stubs/default/app/views/user_mailer/reset_password.html.erb +2 -2
  13. data/stubs/default/app/views/user_mailer/verify_email.html.erb +33 -0
  14. data/stubs/default/config/routes.rb +4 -0
  15. data/stubs/default/db/migrate/20240101000000_create_users.rb +1 -0
  16. data/stubs/default/test/factories/users.rb +5 -0
  17. data/stubs/default/test/integration/auth/authentication_test.rb +4 -6
  18. data/stubs/default/test/integration/auth/email_verification_test.rb +40 -0
  19. data/stubs/default/test/integration/auth/password_reset_test.rb +3 -3
  20. data/stubs/default/test/integration/password_update_test.rb +2 -2
  21. data/stubs/default/test/integration/profile_test.rb +16 -4
  22. data/stubs/hotwire/app/components/modal_component.rb +5 -5
  23. data/stubs/hotwire/app/controllers/auth/authenticated_session_controller.rb +3 -2
  24. data/stubs/hotwire/app/controllers/auth/email_verification_notification_controller.rb +21 -0
  25. data/stubs/hotwire/app/controllers/auth/new_password_controller.rb +2 -2
  26. data/stubs/hotwire/app/controllers/auth/password_reset_link_controller.rb +1 -1
  27. data/stubs/hotwire/app/controllers/auth/registered_user_controller.rb +6 -2
  28. data/stubs/hotwire/app/controllers/auth/verified_email_controller.rb +23 -0
  29. data/stubs/hotwire/app/controllers/concerns/authenticate.rb +10 -0
  30. data/stubs/hotwire/app/controllers/concerns/validate_signature.rb +17 -0
  31. data/stubs/hotwire/app/controllers/password_controller.rb +3 -1
  32. data/stubs/hotwire/app/controllers/profile_controller.rb +5 -3
  33. data/stubs/hotwire/app/views/auth/verify_email.html.erb +23 -0
  34. data/stubs/hotwire/app/views/profile/partials/_delete_user_form.html.erb +1 -1
  35. data/stubs/hotwire/app/views/profile/partials/_update_profile_information_form.html.erb +18 -0
  36. data/stubs/inertia-common/app/controllers/auth/authenticated_session_controller.rb +3 -3
  37. data/stubs/inertia-common/app/controllers/auth/email_verification_notification_controller.rb +21 -0
  38. data/stubs/inertia-common/app/controllers/auth/new_password_controller.rb +2 -3
  39. data/stubs/inertia-common/app/controllers/auth/password_reset_link_controller.rb +3 -3
  40. data/stubs/inertia-common/app/controllers/auth/registered_user_controller.rb +6 -2
  41. data/stubs/inertia-common/app/controllers/auth/verified_email_controller.rb +23 -0
  42. data/stubs/inertia-common/app/controllers/concerns/authenticate.rb +10 -0
  43. data/stubs/inertia-common/app/controllers/concerns/validate_signature.rb +17 -0
  44. data/stubs/inertia-common/app/controllers/password_controller.rb +3 -1
  45. data/stubs/inertia-common/app/controllers/profile_controller.rb +7 -4
  46. data/stubs/inertia-common/test/integration/password_update_test.rb +2 -2
  47. data/stubs/inertia-common/test/integration/profile_test.rb +16 -4
  48. data/stubs/inertia-react-ts/app/javascript/Components/Checkbox.tsx +1 -4
  49. data/stubs/inertia-react-ts/app/javascript/Components/DangerButton.tsx +3 -5
  50. data/stubs/inertia-react-ts/app/javascript/Components/Dropdown.tsx +4 -25
  51. data/stubs/inertia-react-ts/app/javascript/Components/InputError.tsx +1 -4
  52. data/stubs/inertia-react-ts/app/javascript/Components/InputLabel.tsx +1 -4
  53. data/stubs/inertia-react-ts/app/javascript/Components/NavLink.tsx +3 -5
  54. data/stubs/inertia-react-ts/app/javascript/Components/PrimaryButton.tsx +3 -5
  55. data/stubs/inertia-react-ts/app/javascript/Components/SecondaryButton.tsx +3 -5
  56. data/stubs/inertia-react-ts/app/javascript/Components/TextInput.tsx +1 -7
  57. data/stubs/inertia-react-ts/app/javascript/Layouts/AuthenticatedLayout.tsx +14 -52
  58. data/stubs/inertia-react-ts/app/javascript/Pages/Auth/ForgotPassword.tsx +4 -7
  59. data/stubs/inertia-react-ts/app/javascript/Pages/Auth/Login.tsx +10 -24
  60. data/stubs/inertia-react-ts/app/javascript/Pages/Auth/Register.tsx +2 -5
  61. data/stubs/inertia-react-ts/app/javascript/Pages/Auth/ResetPassword.tsx +2 -5
  62. data/stubs/inertia-react-ts/app/javascript/Pages/Auth/VerifyEmail.tsx +47 -0
  63. data/stubs/inertia-react-ts/app/javascript/Pages/Dashboard.tsx +2 -8
  64. data/stubs/inertia-react-ts/app/javascript/Pages/Profile/Edit.tsx +10 -10
  65. data/stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/DeleteUserForm.tsx +10 -20
  66. data/stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/UpdatePasswordForm.tsx +10 -18
  67. data/stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/UpdateProfileInformationForm.tsx +35 -12
  68. data/stubs/inertia-react-ts/app/javascript/Pages/Welcome.tsx +1 -2
  69. data/stubs/inertia-react-ts/app/javascript/entrypoints/application.tsx +1 -5
  70. data/stubs/inertia-react-ts/app/javascript/entrypoints/bootstrap.ts +3 -3
  71. data/stubs/inertia-react-ts/app/javascript/types/global.d.ts +4 -4
  72. data/stubs/inertia-react-ts/app/javascript/types/index.d.ts +8 -8
  73. data/stubs/inertia-react-ts/config/tailwind.config.js +1 -6
  74. data/stubs/inertia-vue-ts/app/javascript/Components/ApplicationLogo.vue +3 -1
  75. data/stubs/inertia-vue-ts/app/javascript/Components/Dropdown.vue +1 -4
  76. data/stubs/inertia-vue-ts/app/javascript/Components/Modal.vue +3 -13
  77. data/stubs/inertia-vue-ts/app/javascript/Layouts/AuthenticatedLayout.vue +10 -45
  78. data/stubs/inertia-vue-ts/app/javascript/Layouts/GuestLayout.vue +2 -6
  79. data/stubs/inertia-vue-ts/app/javascript/Pages/Auth/ForgotPassword.vue +4 -11
  80. data/stubs/inertia-vue-ts/app/javascript/Pages/Auth/Login.vue +2 -10
  81. data/stubs/inertia-vue-ts/app/javascript/Pages/Auth/Register.vue +1 -5
  82. data/stubs/inertia-vue-ts/app/javascript/Pages/Auth/ResetPassword.vue +2 -22
  83. data/stubs/inertia-vue-ts/app/javascript/Pages/Auth/VerifyEmail.vue +50 -0
  84. data/stubs/inertia-vue-ts/app/javascript/Pages/Dashboard.vue +3 -11
  85. data/stubs/inertia-vue-ts/app/javascript/Pages/Profile/Edit.vue +2 -10
  86. data/stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/DeleteUserForm.vue +5 -9
  87. data/stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/UpdatePasswordForm.vue +2 -9
  88. data/stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/UpdateProfileInformationForm.vue +26 -15
  89. data/stubs/inertia-vue-ts/app/javascript/Pages/Welcome.vue +2 -5
  90. data/stubs/inertia-vue-ts/app/javascript/entrypoints/application.ts +27 -23
  91. data/stubs/inertia-vue-ts/app/javascript/entrypoints/bootstrap.ts +3 -3
  92. data/stubs/inertia-vue-ts/app/javascript/types/global.d.ts +7 -7
  93. data/stubs/inertia-vue-ts/app/javascript/types/index.d.ts +8 -8
  94. data/stubs/inertia-vue-ts/config/tailwind.config.js +1 -6
  95. metadata +14 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed80aded8509882926dcf8c40613b78779b50a01bf6a00edd3b130769e9020f5
4
- data.tar.gz: fedfaa322d578f194d46923d05c33dec0a0638b47653eb77b6a2898c9e1be49b
3
+ metadata.gz: 27a8de2c9dc1c9297b72ca20e60fc00582579e39cf1372ad019d6ab18c28942f
4
+ data.tar.gz: fadd95b9700cc5409c2cffcaae03ba3ad68cc79b8331b6a9e27612a47d6e5ce7
5
5
  SHA512:
6
- metadata.gz: d94bc100f51b08ce70e149c898f7c36422980a0225e37e289146394bb2e216b37bed34be1a4a21c6ebdc091105abfebbe372dfcccf05d23886b348615a151673
7
- data.tar.gz: b6cbabd31838a806daeaa3cd719fc5e725242ae56783eb57a1028438e388a7b7f009edb04bb70a642383547c567bc46e7496f69053296d9834a661b0fbbd5e0e
6
+ metadata.gz: a5bd8c23395b302a93fa836e33c10da76de5681d563f84c1f16ff70317583aa3470e05d03cdfd726257c0ece38ef20c8d72ffe0974e0cbdb14d93a7688a1686c
7
+ data.tar.gz: 70cd7f58402805e19197e0c9f161ebde8891e5696eafdec0c96dc3017c52fe6f966068329c77f3f55a3e989cc983050694d91a68097502691fed80e4ea4084c0
@@ -9,6 +9,8 @@ class Kaze::Commands::InstallCommand < Thor
9
9
 
10
10
  desc 'install [STACK]', 'Install the Kaze controllers and resources. Supported stacks: hotwire, react, vue.'
11
11
  def install(stack = 'hotwire')
12
+ return say 'Kaze must be run in a new Rails application.', :red unless File.exist?("#{Dir.pwd}/bin/rails")
13
+
12
14
  if stack == 'hotwire'
13
15
  return install_hotwire_stack
14
16
  end
@@ -53,7 +55,7 @@ class Kaze::Commands::InstallCommand < Thor
53
55
  def install_migrations
54
56
  ensure_directory_exists("#{Dir.pwd}/db/migrate")
55
57
  FileUtils.copy_entry("#{File.dirname(__FILE__)}/../../../stubs/default/db/migrate", "#{Dir.pwd}/db/migrate")
56
- stdin, _ = Open3.capture3('rails version')
58
+ stdin, _ = Open3.capture3("#{Dir.pwd}/bin/rails version")
57
59
  versions = stdin.gsub!('Rails ', '').split('.')
58
60
  railsVersion = [ versions[0], versions[1] ].join('.')
59
61
  Dir.children("#{Dir.pwd}/db/migrate").each do |file|
@@ -32,7 +32,7 @@ module Kaze::Commands::InstallsHotwireStack
32
32
  FileUtils.copy_entry("#{File.dirname(__FILE__)}/../../../stubs/hotwire/app/views", "#{Dir.pwd}/app/views")
33
33
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/layouts/mailer.html.erb", "#{Dir.pwd}/app/views/layouts/mailer.html.erb")
34
34
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/layouts/mailer.text.erb", "#{Dir.pwd}/app/views/layouts/mailer.text.erb")
35
- FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/user_mailer/reset_password.html.erb", "#{Dir.pwd}/app/views/user_mailer/reset_password.html.erb")
35
+ FileUtils.copy_entry("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/user_mailer", "#{Dir.pwd}/app/views/user_mailer")
36
36
 
37
37
  # Components + Pages...
38
38
  ensure_directory_exists("#{Dir.pwd}/app/components")
@@ -57,7 +57,7 @@ module Kaze::Commands::InstallsHotwireStack
57
57
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/hotwire/config/tailwind.config.js", "#{Dir.pwd}/config/tailwind.config.js")
58
58
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/bin/dev", "#{Dir.pwd}/bin/dev")
59
59
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/Procfile.dev", "#{Dir.pwd}/Procfile.dev")
60
- run_command('rails tailwindcss:build')
60
+ run_command("#{Dir.pwd}/bin/rails tailwindcss:build")
61
61
 
62
62
  say ''
63
63
  say 'Kaze scaffolding installed successfully.', :green
@@ -34,7 +34,7 @@ module Kaze::Commands::InstallsInertiaStacks
34
34
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/inertia-react-ts/app/views/layouts/application.html.erb", "#{Dir.pwd}/app/views/layouts/application.html.erb")
35
35
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/layouts/mailer.html.erb", "#{Dir.pwd}/app/views/layouts/mailer.html.erb")
36
36
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/layouts/mailer.text.erb", "#{Dir.pwd}/app/views/layouts/mailer.text.erb")
37
- FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/user_mailer/reset_password.html.erb", "#{Dir.pwd}/app/views/user_mailer/reset_password.html.erb")
37
+ FileUtils.copy_entry("#{File.dirname(__FILE__)}/../../../stubs/default/app/views/user_mailer", "#{Dir.pwd}/app/views/user_mailer")
38
38
 
39
39
  # Components + Pages...
40
40
  ensure_directory_exists("#{Dir.pwd}/app/javascript")
@@ -60,9 +60,9 @@ module Kaze::Commands::InstallsInertiaStacks
60
60
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/bin/dev", "#{Dir.pwd}/bin/dev")
61
61
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/inertia-common/bin/vite", "#{Dir.pwd}/bin/vite")
62
62
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/Procfile.dev", "#{Dir.pwd}/Procfile.dev")
63
- File.write("#{Dir.pwd}/Procfile.dev", "#{File.read("#{Dir.pwd}/Procfile.dev")}\nvite: bin/vite dev\n")
64
- run_command('rails generate js_routes:middleware')
65
- run_command('rails tailwindcss:build')
63
+ File.write("#{Dir.pwd}/Procfile.dev", "#{File.read("#{Dir.pwd}/Procfile.dev")}\nvite: bundle exec rake js:routes:typescript && bin/vite dev\n")
64
+ run_command("#{Dir.pwd}/bin/rails generate js_routes:middleware")
65
+ run_command("#{Dir.pwd}/bin/rails tailwindcss:build")
66
66
 
67
67
  say ''
68
68
  say 'Installing and building Node dependencies.', :magenta
@@ -140,9 +140,9 @@ module Kaze::Commands::InstallsInertiaStacks
140
140
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/bin/dev", "#{Dir.pwd}/bin/dev")
141
141
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/inertia-common/bin/vite", "#{Dir.pwd}/bin/vite")
142
142
  FileUtils.copy_file("#{File.dirname(__FILE__)}/../../../stubs/default/Procfile.dev", "#{Dir.pwd}/Procfile.dev")
143
- File.write("#{Dir.pwd}/Procfile.dev", "#{File.read("#{Dir.pwd}/Procfile.dev")}\nvite: bin/vite dev\n")
144
- run_command('rails generate js_routes:middleware')
145
- run_command('rails tailwindcss:build')
143
+ File.write("#{Dir.pwd}/Procfile.dev", "#{File.read("#{Dir.pwd}/Procfile.dev")}\nvite: bundle exec rake js:routes:typescript && bin/vite dev\n")
144
+ run_command("#{Dir.pwd}/bin/rails generate js_routes:middleware")
145
+ run_command("#{Dir.pwd}/bin/rails tailwindcss:build")
146
146
 
147
147
  say ''
148
148
  say 'Installing and building Node dependencies.', :magenta
data/lib/kaze/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kaze
2
- VERSION = '0.8.0'
2
+ VERSION = '0.10.0'
3
3
  end
@@ -3,4 +3,12 @@ class UpdateProfileInformationForm < ApplicationForm
3
3
 
4
4
  validates :name, presence: true
5
5
  validates :email, presence: true, lowercase: true, email: true, uniqueness: { model: User, attribute: :email, conditions: -> { where.not(id: Current.auth.user.id) } }
6
+
7
+ def update
8
+ Current.auth.user.name = name
9
+ Current.auth.user.email = email
10
+ Current.auth.user.email_verified_at = nil if Current.auth.user.changed.include?('email')
11
+
12
+ Current.auth.user.save
13
+ end
6
14
  end
@@ -6,6 +6,6 @@ class ApplicationMailer < ActionMailer::Base
6
6
  layout 'mailer'
7
7
 
8
8
  def default_url_options
9
- { host: ENV.fetch('APP_URL', 'http://localhost') }
9
+ { host: ENV.fetch('APP_URL', 'http://localhost:3000') }
10
10
  end
11
11
  end
@@ -1,8 +1,23 @@
1
1
  class UserMailer < ApplicationMailer
2
- def reset_password
2
+ def reset_password(token)
3
+ @reset_url = password_reset_url(token: token)
4
+
3
5
  mail(
4
6
  to: params[:user].email,
5
7
  subject: 'Reset Password Notification'
6
8
  )
7
9
  end
10
+
11
+ def verify_email
12
+ verifier = ActiveSupport::MessageVerifier.new(ENV.fetch('RAILS_MASTER_KEY', ''))
13
+
14
+ signature = verifier.generate(verification_verify_url(id: params[:user].id, hash: Digest::SHA1.hexdigest(params[:user].email)), expires_in: 60.minutes.from_now.to_i)
15
+
16
+ @verification_url = verification_verify_url(id: params[:user].id, hash: Digest::SHA1.hexdigest(params[:user].email), signature: signature)
17
+
18
+ mail(
19
+ to: params[:user].email,
20
+ subject: 'Verify Email Address'
21
+ )
22
+ end
8
23
  end
@@ -1,5 +1,5 @@
1
1
  module CanResetPassword
2
2
  def send_password_reset_notification(token)
3
- UserMailer.with(user: self, token: token).reset_password.deliver_later
3
+ UserMailer.with(user: self).reset_password(token).deliver_later
4
4
  end
5
5
  end
@@ -0,0 +1,15 @@
1
+ module MustVerifyEmail
2
+ extend ActiveSupport::Concern
3
+
4
+ def has_verified_email?
5
+ !email_verified_at.nil?
6
+ end
7
+
8
+ def mark_email_as_verified
9
+ self.update(email_verified_at: Time.now)
10
+ end
11
+
12
+ def send_email_verification_notification
13
+ UserMailer.with(user: self).verify_email.deliver_later
14
+ end
15
+ end
@@ -1,5 +1,6 @@
1
1
  class User < ApplicationRecord
2
2
  include CanResetPassword
3
+ # include MustVerifyEmail
3
4
 
4
5
  has_secure_password
5
6
 
@@ -12,7 +12,7 @@
12
12
  <table border="0" cellpadding="0" cellspacing="0" role="presentation">
13
13
  <tr>
14
14
  <td>
15
- <a href="<%= password_reset_url(token: params[:token]) %>" class="button button-primary" target="_blank" rel="noopener">Reset Password</a>
15
+ <a href="<%= @reset_url %>" class="button button-primary" target="_blank" rel="noopener">Reset Password</a>
16
16
  </td>
17
17
  </tr>
18
18
  </table>
@@ -30,5 +30,5 @@
30
30
  <%= ENV.fetch("APP_NAME", "Rails") %></p>
31
31
  <!-- Subcopy -->
32
32
  <% content_for :subcopy do %>
33
- <p>If you're having trouble clicking the "Reset Password" button, copy and paste the URL below into your web browser: <span class="break-all"><%= link_to password_reset_url(token: params[:token]), password_reset_url(token: params[:token]) %><span></p>
33
+ <p>If you're having trouble clicking the "Reset Password" button, copy and paste the URL below into your web browser: <span class="break-all"><%= link_to @reset_url, @reset_url %><span></p>
34
34
  <% end %>
@@ -0,0 +1,33 @@
1
+ <!-- Greeting -->
2
+ <h1>Hello!</h1>
3
+ <!-- Intro Lines -->
4
+ <p>Please click the button below to verify your email address.</p>
5
+ <!-- Action Button -->
6
+ <table class="action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
7
+ <tr>
8
+ <td align="center">
9
+ <table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
10
+ <tr>
11
+ <td align="center">
12
+ <table border="0" cellpadding="0" cellspacing="0" role="presentation">
13
+ <tr>
14
+ <td>
15
+ <a href="<%= @verification_url %>" class="button button-primary" target="_blank" rel="noopener">Verify Email Address</a>
16
+ </td>
17
+ </tr>
18
+ </table>
19
+ </td>
20
+ </tr>
21
+ </table>
22
+ </td>
23
+ </tr>
24
+ </table>
25
+ <!-- Outro Lines -->
26
+ <p>If you did not create an account, no further action is required.</p>
27
+ <!-- Salutation -->
28
+ <p>Regards,<br>
29
+ <%= ENV.fetch("APP_NAME", "Rails") %></p>
30
+ <!-- Subcopy -->
31
+ <% content_for :subcopy do %>
32
+ <p>If you're having trouble clicking the "Verify Email Address" button, copy and paste the URL below into your web browser: <span class="break-all"><%= link_to @verification_url, @verification_url %><span></p>
33
+ <% end %>
@@ -15,6 +15,10 @@ Rails.application.routes.draw do
15
15
  get 'reset-password/:token', to: 'auth/new_password#new', as: :password_reset
16
16
  post 'reset-password', to: 'auth/new_password#create', as: :password_store
17
17
 
18
+ get 'verify-email', to: 'auth/email_verification_notification#new', as: :verification_notice
19
+ post 'verify-email', to: 'auth/email_verification_notification#create', as: :verification_send
20
+ get 'verify-email/:id/:hash', to: 'auth/verified_email#create', as: :verification_verify
21
+
18
22
  post 'logout', to: 'auth/authenticated_session#destroy', as: :logout
19
23
 
20
24
  get 'dashboard', to: 'dashboard#index', as: :dashboard
@@ -3,6 +3,7 @@ class CreateUsers < ActiveRecord::Migration
3
3
  create_table :users do |table|
4
4
  table.string :name
5
5
  table.string :email
6
+ table.timestamp :email_verified_at, null: true
6
7
  table.string :password_digest
7
8
  table.string :remember_token, null: true
8
9
  table.timestamps
@@ -2,6 +2,11 @@ FactoryBot.define do
2
2
  factory :user do
3
3
  name { Faker::Name.name }
4
4
  email { Faker::Internet.email }
5
+ email_verified_at { Time.now }
5
6
  password { 'password' }
7
+
8
+ trait :unverified do
9
+ email_verified_at { nil }
10
+ end
6
11
  end
7
12
  end
@@ -8,7 +8,7 @@ class Auth::AuthenticationTest < ActionDispatch::IntegrationTest
8
8
  end
9
9
 
10
10
  test 'users can authenticate using the login screen' do
11
- user = FactoryBot.create :user
11
+ user = FactoryBot.create(:user)
12
12
 
13
13
  post login_path, params: {
14
14
  email: user.email,
@@ -20,7 +20,7 @@ class Auth::AuthenticationTest < ActionDispatch::IntegrationTest
20
20
  end
21
21
 
22
22
  test 'users cannot authenticate with invalid password' do
23
- user = FactoryBot.create :user
23
+ user = FactoryBot.create(:user)
24
24
 
25
25
  post login_path, params: {
26
26
  email: user.email,
@@ -31,11 +31,9 @@ class Auth::AuthenticationTest < ActionDispatch::IntegrationTest
31
31
  end
32
32
 
33
33
  test 'users can logout' do
34
- user = FactoryBot.create :user
34
+ user = FactoryBot.create(:user)
35
35
 
36
- acting_as user
37
-
38
- post logout_path
36
+ acting_as(user).post logout_path
39
37
 
40
38
  assert_guest
41
39
  assert_redirected_to '/'
@@ -0,0 +1,40 @@
1
+ require 'test_helper'
2
+
3
+ class EmailVerificationTest < ActionDispatch::IntegrationTest
4
+ if User.include?(MustVerifyEmail)
5
+ test 'email verification screen can be rendered' do
6
+ user = FactoryBot.create(:user, :unverified)
7
+
8
+ acting_as(user).get verification_notice_path
9
+
10
+ assert_response :success
11
+ end
12
+
13
+ test 'email can be verified' do
14
+ user = FactoryBot.create(:user, :unverified)
15
+
16
+ acting_as(user).get verification_url(id: user.id, hash: Digest::SHA1.hexdigest(user.email))
17
+
18
+ assert user.reload.has_verified_email?
19
+ assert_redirected_to dashboard_path(verified: '1')
20
+ end
21
+
22
+ test 'email is not verified with invalid hash' do
23
+ user = FactoryBot.create(:user, :unverified)
24
+
25
+ acting_as(user).get verification_url(id: user.id, hash: Digest::SHA1.hexdigest('wrong-email'))
26
+
27
+ assert_not user.reload.has_verified_email?
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def verification_url(params)
34
+ verifier = ActiveSupport::MessageVerifier.new(ENV.fetch('RAILS_MASTER_KEY', ''))
35
+
36
+ signature = verifier.generate(verification_verify_url(params), expires_in: 60.minutes.from_now.to_i)
37
+
38
+ verification_verify_url(params.merge(signature: signature))
39
+ end
40
+ end
@@ -8,7 +8,7 @@ class Auth::PasswordResetTest < ActionDispatch::IntegrationTest
8
8
  end
9
9
 
10
10
  test 'reset password link can be requested' do
11
- user = FactoryBot.create :user
11
+ user = FactoryBot.create(:user)
12
12
  token = user.generate_token_for(:password_reset)
13
13
  email = UserMailer.with(user: user, token: token).reset_password
14
14
 
@@ -20,7 +20,7 @@ class Auth::PasswordResetTest < ActionDispatch::IntegrationTest
20
20
  end
21
21
 
22
22
  test 'reset password screen can be rendered' do
23
- user = FactoryBot.create :user
23
+ user = FactoryBot.create(:user)
24
24
 
25
25
  get password_reset_path(token: user.generate_token_for(:password_reset))
26
26
 
@@ -28,7 +28,7 @@ class Auth::PasswordResetTest < ActionDispatch::IntegrationTest
28
28
  end
29
29
 
30
30
  test 'password can be reset_with_valid_token' do
31
- user = FactoryBot.create :user
31
+ user = FactoryBot.create(:user)
32
32
 
33
33
  post password_store_path, params: {
34
34
  token: user.generate_token_for(:password_reset),
@@ -2,7 +2,7 @@ require 'test_helper'
2
2
 
3
3
  class PasswordUpdateTest < ActionDispatch::IntegrationTest
4
4
  test 'password can be updated' do
5
- user = FactoryBot.create :user
5
+ user = FactoryBot.create(:user)
6
6
 
7
7
  acting_as(user).put password_update_path, params: {
8
8
  current_password: 'password',
@@ -15,7 +15,7 @@ class PasswordUpdateTest < ActionDispatch::IntegrationTest
15
15
  end
16
16
 
17
17
  test 'correct password must be provided to update password' do
18
- user = FactoryBot.create :user
18
+ user = FactoryBot.create(:user)
19
19
 
20
20
  acting_as(user).put password_update_path, params: {
21
21
  current_password: 'wrong-password',
@@ -2,7 +2,7 @@ require 'test_helper'
2
2
 
3
3
  class ProfileTest < ActionDispatch::IntegrationTest
4
4
  test 'profile page is displayed' do
5
- user = FactoryBot.create :user
5
+ user = FactoryBot.create(:user)
6
6
 
7
7
  acting_as(user).get profile_edit_path
8
8
 
@@ -10,7 +10,7 @@ class ProfileTest < ActionDispatch::IntegrationTest
10
10
  end
11
11
 
12
12
  test 'profile information can be updated' do
13
- user = FactoryBot.create :user
13
+ user = FactoryBot.create(:user)
14
14
 
15
15
  acting_as(user).patch profile_edit_path, params: {
16
16
  name: 'Test User',
@@ -25,8 +25,20 @@ class ProfileTest < ActionDispatch::IntegrationTest
25
25
  assert_equal 'test@example.com', user.email
26
26
  end
27
27
 
28
+ test 'email verification status is unchanged when the email address is unchanged' do
29
+ user = FactoryBot.create(:user)
30
+
31
+ acting_as(user).patch profile_edit_path, params: {
32
+ name: 'Test User',
33
+ email: user.email
34
+ }
35
+
36
+ assert_redirected_to profile_edit_path
37
+ assert_not user.reload.email_verified_at.blank?
38
+ end
39
+
28
40
  test 'user can delete their account' do
29
- user = FactoryBot.create :user
41
+ user = FactoryBot.create(:user)
30
42
 
31
43
  acting_as(user).delete profile_destroy_path, params: {
32
44
  password: 'password'
@@ -39,7 +51,7 @@ class ProfileTest < ActionDispatch::IntegrationTest
39
51
  end
40
52
 
41
53
  test 'correct password must be provided to delete account' do
42
- user = FactoryBot.create :user
54
+ user = FactoryBot.create(:user)
43
55
 
44
56
  acting_as(user).delete profile_destroy_path, params: {
45
57
  password: 'wrong-password'
@@ -3,11 +3,11 @@ class ModalComponent < ViewComponent::Base
3
3
  @name = attributes[:name]
4
4
  @show = attributes[:show] || false
5
5
  @max_width = {
6
- :sm => 'sm:max-w-sm',
7
- :md => 'sm:max-w-md',
8
- :lg => 'sm:max-w-lg',
9
- :xl => 'sm:max-w-xl',
10
- '2xl' => 'sm:max-w-2xl'
6
+ sm: 'sm:max-w-sm',
7
+ md: 'sm:max-w-md',
8
+ lg: 'sm:max-w-lg',
9
+ xl: 'sm:max-w-xl',
10
+ '2xl': 'sm:max-w-2xl'
11
11
  }[attributes[:max_width] || '2xl']
12
12
  @attributes = attributes.without(:name, :show, :max_width)
13
13
  end
@@ -2,7 +2,8 @@ class Auth::AuthenticatedSessionController < ApplicationController
2
2
  include RedirectIfAuthenticated
3
3
 
4
4
  skip_authenticate only: %i[new create]
5
- skip_redirect_if_authenticated only: %i[destroy]
5
+ skip_redirect_if_authenticated only: :destroy
6
+ skip_ensure_email_is_verified only: :destroy
6
7
 
7
8
  layout 'guest'
8
9
 
@@ -13,7 +14,7 @@ class Auth::AuthenticatedSessionController < ApplicationController
13
14
  end
14
15
 
15
16
  def create
16
- @form = Auth::LoginForm.new params.permit(:email, :password, :remember)
17
+ @form = Auth::LoginForm.new(params.permit(:email, :password, :remember))
17
18
 
18
19
  @form.authenticate
19
20
 
@@ -0,0 +1,21 @@
1
+ class Auth::EmailVerificationNotificationController < ApplicationController
2
+ skip_ensure_email_is_verified
3
+
4
+ before_action { redirect_to dashboard_path unless User.include?(MustVerifyEmail) }
5
+
6
+ layout 'guest'
7
+
8
+ def new
9
+ return redirect_to dashboard_path if Current.auth.user.has_verified_email?
10
+
11
+ render 'auth/verify_email'
12
+ end
13
+
14
+ def create
15
+ return redirect_to dashboard_path if Current.auth.user.has_verified_email?
16
+
17
+ Current.auth.user.send_email_verification_notification
18
+
19
+ redirect_back_or_to verification_notice_path, flash: { status: 'verification-link-sent' }
20
+ end
21
+ end
@@ -6,13 +6,13 @@ class Auth::NewPasswordController < ApplicationController
6
6
  layout 'guest'
7
7
 
8
8
  def new
9
- @form = Auth::NewPasswordForm.new params.permit(:token)
9
+ @form = Auth::NewPasswordForm.new(params.permit(:token))
10
10
 
11
11
  render 'auth/reset_password'
12
12
  end
13
13
 
14
14
  def create
15
- @form = Auth::NewPasswordForm.new params.permit(:token, :password, :password_confirmation)
15
+ @form = Auth::NewPasswordForm.new(params.permit(:token, :password, :password_confirmation))
16
16
 
17
17
  return redirect_to login_path, flash: { status: 'Your password has been reset.' } if @form.reset?
18
18
 
@@ -12,7 +12,7 @@ class Auth::PasswordResetLinkController < ApplicationController
12
12
  end
13
13
 
14
14
  def create
15
- @form = Auth::SendPasswordResetLinkForm.new params.permit(:email)
15
+ @form = Auth::SendPasswordResetLinkForm.new(params.permit(:email))
16
16
 
17
17
  return redirect_back_or_to password_request_path, flash: { status: 'We have emailed your password reset link.' } if @form.send_reset_link?
18
18
 
@@ -12,13 +12,17 @@ class Auth::RegisteredUserController < ApplicationController
12
12
  end
13
13
 
14
14
  def create
15
- @form = Auth::RegisterForm.new params.permit(:name, :email, :password, :password_confirmation)
15
+ @form = Auth::RegisterForm.new(params.permit(:name, :email, :password, :password_confirmation))
16
16
 
17
17
  return render 'auth/register', status: :unprocessable_entity if @form.invalid?
18
18
 
19
19
  user = User.create(name: @form.name, email: @form.email, password: @form.password)
20
20
 
21
- Current.auth.login user
21
+ if User.include?(MustVerifyEmail) && !user.has_verified_email?
22
+ user.send_email_verification_notification
23
+ end
24
+
25
+ Current.auth.login(user)
22
26
 
23
27
  redirect_to dashboard_path
24
28
  end
@@ -0,0 +1,23 @@
1
+ class Auth::VerifiedEmailController < ApplicationController
2
+ include ValidateSignature
3
+
4
+ skip_ensure_email_is_verified
5
+
6
+ before_action { redirect_to dashboard_path unless User.include?(MustVerifyEmail) }
7
+
8
+ def create
9
+ return redirect_to dashboard_path(verified: '1') if Current.auth.user.has_verified_email?
10
+
11
+ Current.auth.user.mark_email_as_verified if email_verification_request_is_authorized?
12
+
13
+ redirect_to dashboard_path(verified: '1')
14
+ end
15
+
16
+ private
17
+
18
+ def email_verification_request_is_authorized?
19
+ return false if !ActiveSupport::SecurityUtils.secure_compare Current.auth.user.id.to_s, params[:id]
20
+
21
+ ActiveSupport::SecurityUtils.secure_compare Digest::SHA1.hexdigest(Current.auth.user.email), params[:hash]
22
+ end
23
+ end
@@ -3,11 +3,17 @@ module Authenticate
3
3
 
4
4
  included do
5
5
  before_action :authenticate!
6
+ before_action :ensure_email_is_verified! if User.include?(MustVerifyEmail)
6
7
  end
7
8
 
8
9
  class_methods do
9
10
  def skip_authenticate(**options)
10
11
  skip_before_action :authenticate!, **options
12
+ skip_ensure_email_is_verified(**options)
13
+ end
14
+
15
+ def skip_ensure_email_is_verified(**options)
16
+ skip_before_action :ensure_email_is_verified!, **options if User.include?(MustVerifyEmail)
11
17
  end
12
18
  end
13
19
 
@@ -16,4 +22,8 @@ module Authenticate
16
22
  def authenticate!
17
23
  redirect_to login_path unless Current.auth.check?
18
24
  end
25
+
26
+ def ensure_email_is_verified!
27
+ redirect_to verification_notice_path unless Current.auth.user && Current.auth.user.has_verified_email?
28
+ end
19
29
  end
@@ -0,0 +1,17 @@
1
+ module ValidateSignature
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_action do
6
+ render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found unless has_valid_signature?
7
+ end
8
+ end
9
+
10
+ private
11
+
12
+ def has_valid_signature?
13
+ request.original_url.split('?')[0] == ActiveSupport::MessageVerifier.new(ENV.fetch('RAILS_MASTER_KEY', '')).verify(params[:signature])
14
+ rescue
15
+ false
16
+ end
17
+ end
@@ -1,6 +1,8 @@
1
1
  class PasswordController < ApplicationController
2
+ skip_ensure_email_is_verified
3
+
2
4
  def update
3
- @update_password_form = UpdatePasswordForm.new params.permit(:current_password, :password, :password_confirmation)
5
+ @update_password_form = UpdatePasswordForm.new(params.permit(:current_password, :password, :password_confirmation))
4
6
 
5
7
  return render partial: 'profile/partials/update_password_form', status: :unprocessable_entity if @update_password_form.invalid?
6
8
 
@@ -1,4 +1,6 @@
1
1
  class ProfileController < ApplicationController
2
+ skip_ensure_email_is_verified
3
+
2
4
  def edit
3
5
  @update_profile_information_form = UpdateProfileInformationForm.new(name: Current.auth.user.name, email: Current.auth.user.email)
4
6
  @update_password_form = UpdatePasswordForm.new
@@ -8,17 +10,17 @@ class ProfileController < ApplicationController
8
10
  end
9
11
 
10
12
  def update
11
- @update_profile_information_form = UpdateProfileInformationForm.new params.permit(:name, :email)
13
+ @update_profile_information_form = UpdateProfileInformationForm.new(params.permit(:name, :email))
12
14
 
13
15
  return render partial: 'profile/partials/update_profile_information_form', status: :unprocessable_entity if @update_profile_information_form.invalid?
14
16
 
15
- Current.auth.user.update(name: @update_profile_information_form.name, email: @update_profile_information_form.email)
17
+ @update_profile_information_form.update
16
18
 
17
19
  redirect_to profile_edit_path, flash: { status: 'profile-updated' }
18
20
  end
19
21
 
20
22
  def destroy
21
- @delete_user_form = DeleteUserForm.new params.permit(:password)
23
+ @delete_user_form = DeleteUserForm.new(params.permit(:password))
22
24
 
23
25
  return render partial: 'profile/partials/delete_user_form', status: :unprocessable_entity if @delete_user_form.invalid?
24
26