trek 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/AGENTS.md +7 -4
  4. data/CHANGELOG.md +37 -0
  5. data/Gemfile +5 -0
  6. data/Gemfile.lock +16 -3
  7. data/Makefile +2 -0
  8. data/app/components/trek/dashboard/tile_component/tile_component.css +9 -0
  9. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +4 -2
  10. data/app/components/trek/dashboard/tile_component.rb +12 -2
  11. data/app/components/trek/form/content_editor_component/content_editor_component.css +5 -0
  12. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +19 -93
  13. data/app/components/trek/form/content_editor_component/content_editor_component.js +94 -251
  14. data/app/components/trek/form/content_editor_component/extensions/block_view.js +40 -0
  15. data/app/components/trek/form/content_editor_component/extensions/image_block.js +28 -26
  16. data/app/components/trek/form/content_editor_component/extensions/prompts_block.js +15 -26
  17. data/app/components/trek/form/content_editor_component/extensions/table_of_contents.js +1 -3
  18. data/app/components/trek/form/content_editor_component.rb +15 -0
  19. data/app/components/trek/form/image_field_component/image_field_component.css +40 -1
  20. data/app/components/trek/form/image_field_component/image_field_component.html.slim +0 -9
  21. data/app/components/trek/form/image_field_component.rb +36 -9
  22. data/app/content/trek/content/cleaner.rb +53 -0
  23. data/app/content/trek/content/document.rb +2 -2
  24. data/app/content/trek/content/prompts_block.rb +1 -2
  25. data/app/content/trek/content.rb +6 -0
  26. data/app/controllers/concerns/trek/reorderable.rb +24 -6
  27. data/app/controllers/trek/csp_reports_controller.rb +14 -1
  28. data/app/controllers/trek/panels/prompts_controller.rb +1 -1
  29. data/app/controllers/trek/resource_controller.rb +5 -5
  30. data/app/jobs/trek/purge_expired_sessions_job.rb +4 -3
  31. data/app/models/concerns/trek/contentable.rb +15 -1
  32. data/app/models/concerns/trek/users/sign_in_code.rb +70 -0
  33. data/app/models/concerns/trek/users/sign_in_lockout.rb +4 -2
  34. data/app/views/trek/panels/links/_form.html.slim +2 -4
  35. data/app/views/trek/panels/prompts/create.turbo_stream.slim +0 -1
  36. data/config/locales/trek.en.yml +26 -11
  37. data/config/locales/trek.fr.yml +26 -11
  38. data/docs/.vitepress/config.mjs +4 -1
  39. data/docs/background-jobs.md +69 -0
  40. data/docs/content-editor.md +219 -0
  41. data/docs/customize.md +2 -0
  42. data/docs/deploy.md +2 -2
  43. data/docs/getting-started.md +1 -1
  44. data/docs/index.md +1 -1
  45. data/docs/reference/components.md +2 -2
  46. data/docs/reference/concerns/contentable.md +10 -4
  47. data/docs/reference/concerns/formattable.md +1 -1
  48. data/docs/reference/environment-variables.md +1 -0
  49. data/docs/reference/formatters.md +2 -30
  50. data/docs/reference/forms.md +1 -1
  51. data/docs/reference/generators/audit-log.md +2 -2
  52. data/docs/reference/generators/install.md +3 -2
  53. data/docs/reference/generators/public-accounts.md +141 -0
  54. data/docs/reference/generators/security-headers.md +1 -1
  55. data/docs/reference/index.md +2 -1
  56. data/docs/reference/models/user.md +3 -1
  57. data/docs/what-is-trek.md +1 -1
  58. data/lib/generators/trek/audit_log_generator.rb +54 -0
  59. data/lib/generators/trek/install/admin/users_generator.rb +6 -1
  60. data/lib/generators/trek/install/audit_log_generator.rb +9 -45
  61. data/lib/generators/trek/install/good_job_generator.rb +161 -0
  62. data/lib/generators/trek/install/model_specs_generator.rb +3 -3
  63. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  64. data/lib/generators/trek/install/scalingo_generator.rb +7 -45
  65. data/lib/generators/trek/install_generator.rb +15 -0
  66. data/lib/generators/trek/public_accounts_generator.rb +267 -0
  67. data/lib/generators/trek/scalingo_generator.rb +55 -0
  68. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  69. data/lib/generators/trek/templates/controllers/admin/password_renewals_controller.rb +11 -0
  70. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +1 -1
  71. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +0 -8
  72. data/lib/generators/trek/templates/initializers/content_security_policy.rb +3 -2
  73. data/lib/generators/trek/templates/initializers/good_job.rb.tt +38 -0
  74. data/lib/generators/trek/templates/jobs/mail_delivery_job.rb +6 -0
  75. data/lib/generators/trek/templates/layouts/admin.html.slim +5 -0
  76. data/lib/generators/trek/templates/mailers/admin_mailer.rb +4 -2
  77. data/lib/generators/trek/templates/package.json.tt +1 -1
  78. data/lib/generators/trek/templates/public_accounts/api/authentication.rb.tt +76 -0
  79. data/lib/generators/trek/templates/public_accounts/api/controllers/accounts_controller.rb.tt +22 -0
  80. data/lib/generators/trek/templates/public_accounts/api/controllers/active_sessions_controller.rb.tt +21 -0
  81. data/lib/generators/trek/templates/public_accounts/api/controllers/base_controller.rb.tt +71 -0
  82. data/lib/generators/trek/templates/public_accounts/api/controllers/email_changes_controller.rb.tt +55 -0
  83. data/lib/generators/trek/templates/public_accounts/api/controllers/email_confirmations_controller.rb.tt +19 -0
  84. data/lib/generators/trek/templates/public_accounts/api/controllers/password_resets_controller.rb.tt +28 -0
  85. data/lib/generators/trek/templates/public_accounts/api/controllers/passwords_controller.rb.tt +17 -0
  86. data/lib/generators/trek/templates/public_accounts/api/controllers/registrations_controller.rb.tt +17 -0
  87. data/lib/generators/trek/templates/public_accounts/api/controllers/sessions_controller.rb.tt +45 -0
  88. data/lib/generators/trek/templates/public_accounts/api/controllers/sign_in_codes_controller.rb.tt +15 -0
  89. data/lib/generators/trek/templates/public_accounts/jobs/sign_in_code_job.rb.tt +13 -0
  90. data/lib/generators/trek/templates/public_accounts/locales/accounts.en.yml.tt +205 -0
  91. data/lib/generators/trek/templates/public_accounts/locales/accounts.fr.yml.tt +205 -0
  92. data/lib/generators/trek/templates/public_accounts/mailers/account_mailer.rb.tt +85 -0
  93. data/lib/generators/trek/templates/public_accounts/migrations/create_accounts.rb.tt +46 -0
  94. data/lib/generators/trek/templates/public_accounts/models/account/session.rb.tt +47 -0
  95. data/lib/generators/trek/templates/public_accounts/models/account/sign_in_code.rb.tt +8 -0
  96. data/lib/generators/trek/templates/public_accounts/models/account.rb.tt +73 -0
  97. data/lib/generators/trek/templates/public_accounts/spec/api/accounts_spec.rb.tt +90 -0
  98. data/lib/generators/trek/templates/public_accounts/spec/api/sign_ins_spec.rb.tt +158 -0
  99. data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt +56 -0
  100. data/lib/generators/trek/templates/public_accounts/spec/models/account_spec.rb.tt +109 -0
  101. data/lib/generators/trek/templates/public_accounts/spec/web/accounts_spec.rb.tt +126 -0
  102. data/lib/generators/trek/templates/public_accounts/spec/web/authentication_spec.rb.tt +43 -0
  103. data/lib/generators/trek/templates/public_accounts/spec/web/sign_ins_spec.rb.tt +215 -0
  104. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.html.slim.tt +10 -0
  105. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.text.erb.tt +8 -0
  106. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.html.slim.tt +13 -0
  107. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.text.erb.tt +8 -0
  108. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.html.slim.tt +9 -0
  109. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.text.erb.tt +11 -0
  110. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.html.slim.tt +11 -0
  111. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.text.erb.tt +11 -0
  112. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.html.slim.tt +10 -0
  113. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.text.erb.tt +8 -0
  114. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.html.slim.tt +10 -0
  115. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.text.erb.tt +8 -0
  116. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.html.slim.tt +10 -0
  117. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.text.erb.tt +8 -0
  118. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.html.slim.tt +13 -0
  119. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.text.erb.tt +8 -0
  120. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.html.slim.tt +9 -0
  121. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.text.erb.tt +11 -0
  122. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.html.slim.tt +9 -0
  123. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.text.erb.tt +11 -0
  124. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.html.slim.tt +23 -0
  125. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.text.erb.tt +14 -0
  126. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.html.slim.tt +24 -0
  127. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.text.erb.tt +14 -0
  128. data/lib/generators/trek/templates/public_accounts/web/authentication.rb.tt +97 -0
  129. data/lib/generators/trek/templates/public_accounts/web/controllers/accounts_controller.rb.tt +33 -0
  130. data/lib/generators/trek/templates/public_accounts/web/controllers/active_sessions_controller.rb.tt +15 -0
  131. data/lib/generators/trek/templates/public_accounts/web/controllers/base_controller.rb.tt +52 -0
  132. data/lib/generators/trek/templates/public_accounts/web/controllers/email_changes_controller.rb.tt +85 -0
  133. data/lib/generators/trek/templates/public_accounts/web/controllers/email_confirmations_controller.rb.tt +19 -0
  134. data/lib/generators/trek/templates/public_accounts/web/controllers/password_resets_controller.rb.tt +41 -0
  135. data/lib/generators/trek/templates/public_accounts/web/controllers/passwords_controller.rb.tt +23 -0
  136. data/lib/generators/trek/templates/public_accounts/web/controllers/registrations_controller.rb.tt +24 -0
  137. data/lib/generators/trek/templates/public_accounts/web/controllers/sessions_controller.rb.tt +78 -0
  138. data/lib/generators/trek/templates/public_accounts/web/controllers/sign_in_codes_controller.rb.tt +27 -0
  139. data/lib/generators/trek/templates/public_accounts/web/views/_flash.html.slim.tt +5 -0
  140. data/lib/generators/trek/templates/public_accounts/web/views/accounts/show.html.slim.tt +52 -0
  141. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/edit.html.slim.tt +17 -0
  142. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/new.html.slim.tt +15 -0
  143. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/edit.html.slim.tt +26 -0
  144. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/new.html.slim.tt +15 -0
  145. data/lib/generators/trek/templates/public_accounts/web/views/passwords/edit.html.slim.tt +25 -0
  146. data/lib/generators/trek/templates/public_accounts/web/views/registrations/new.html.slim.tt +28 -0
  147. data/lib/generators/trek/templates/public_accounts/web/views/sessions/new.html.slim.tt +38 -0
  148. data/lib/generators/trek/templates/public_accounts/web/views/sign_in_codes/new.html.slim.tt +17 -0
  149. data/lib/generators/trek/templates/spec/jobs/mail_delivery_job_spec.rb +11 -0
  150. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +10 -0
  151. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +1 -1
  152. data/lib/generators/trek/templates/spec/requests/admin/good_job_spec.rb +34 -0
  153. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +1 -1
  154. data/lib/generators/trek/templates/spec/requests/admin/panels_spec.rb +16 -0
  155. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +2 -2
  156. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +1 -1
  157. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +2 -2
  158. data/lib/generators/trek/templates/views/admin/users/index.html.slim +1 -1
  159. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +1 -1
  160. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +1 -1
  161. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +1 -1
  162. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +1 -1
  163. data/lib/trek/csp_reports.rb +22 -4
  164. data/lib/trek/typography_formatter.rb +35 -4
  165. data/lib/trek/version.rb +1 -1
  166. data/package.json +12 -8
  167. data/trek.gemspec +1 -1
  168. data/vitest.config.mjs +24 -0
  169. data/yarn.lock +1004 -288
  170. metadata +90 -3
@@ -0,0 +1,24 @@
1
+ p Bonjour #{@<%= file_name %>},
2
+ p
3
+ | Plusieurs tentatives de connexion à #{t("application_name")} avec votre
4
+ adresse e-mail ont échoué d'affilée, avec un mauvais mot de passe. Pour
5
+ protéger votre compte, la connexion est suspendue quelques minutes, un
6
+ peu plus longtemps après chaque nouvel échec.
7
+ <% unless web? -%>
8
+ p
9
+ | Si c'était vous, patientez quelques minutes avant de réessayer, ou
10
+ choisissez un nouveau mot de passe depuis l'application.
11
+ <% else -%>
12
+ p
13
+ | Si c'était vous, patientez quelques minutes avant de réessayer, ou
14
+ =< link_to "choisissez un nouveau mot de passe",
15
+ @reset_url,
16
+ target: "_blank",
17
+ rel: "noopener"
18
+ | .
19
+ <% end -%>
20
+ p
21
+ | Si ce n'était pas vous, quelqu'un essaie peut-être de deviner votre mot
22
+ de passe : s'il est court ou que vous l'utilisez ailleurs,
23
+ choisissez-en un nouveau.
24
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,14 @@
1
+ Bonjour <%%= @<%= file_name %> %>,
2
+
3
+ Plusieurs tentatives de connexion à <%%= t("application_name") %> avec votre adresse e-mail ont échoué d'affilée, avec un mauvais mot de passe. Pour protéger votre compte, la connexion est suspendue quelques minutes, un peu plus longtemps après chaque nouvel échec.
4
+
5
+ <% unless web? -%>
6
+ Si c'était vous, patientez quelques minutes avant de réessayer, ou choisissez un nouveau mot de passe depuis l'application.
7
+ <% else -%>
8
+ Si c'était vous, patientez quelques minutes avant de réessayer, ou choisissez un nouveau mot de passe :
9
+ <%%= @reset_url %>
10
+ <% end -%>
11
+
12
+ Si ce n'était pas vous, quelqu'un essaie peut-être de deviner votre mot de passe : s'il est court ou que vous l'utilisez ailleurs, choisissez-en un nouveau.
13
+
14
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,97 @@
1
+ # Signs <%= human_name.pluralize.downcase %> in on the site, through a signed <%= file_name %>_session_id cookie: apart from
2
+ # the admin's session_id cookie (Trek::Authentication), so signing in or out of one never touches
3
+ # the other. Included in ApplicationController, so every page may show who is signed in; the pages
4
+ # that need a <%= human_name.downcase %> call `before_action :require_<%= file_name %>`.
5
+ module <%= accounts_module %>::Authentication
6
+ extend ActiveSupport::Concern
7
+
8
+ COOKIE = :<%= file_name %>_session_id
9
+
10
+ included do
11
+ after_action :track_<%= file_name %>_activity
12
+ helper_method :current_<%= file_name %>, :<%= file_name %>_signed_in?, :staff_user
13
+ end
14
+
15
+ private
16
+
17
+ def current_<%= file_name %>
18
+ resume_<%= file_name %>_session&.<%= file_name %>
19
+ end
20
+
21
+ def <%= file_name %>_signed_in?
22
+ current_<%= file_name %>.present?
23
+ end
24
+
25
+ def require_<%= file_name %>
26
+ return if resume_<%= file_name %>_session
27
+
28
+ session[:return_to_after_<%= file_name %>_sign_in] = request.url if request.get?
29
+ redirect_to <%= route_prefix %>_sign_in_path, notice: t("<%= accounts_path %>.authentication.sign_in_needed")
30
+ end
31
+
32
+ # Changing the password or deleting the account asks to sign in again when the session isn't recent.
33
+ def require_recent_<%= file_name %>_sign_in
34
+ return if Current.<%= file_name %>_session.recent?
35
+
36
+ session[:return_to_after_<%= file_name %>_sign_in] = request.referer || <%= route_prefix %>_account_url
37
+ redirect_to <%= route_prefix %>_sign_in_path, notice: t("<%= accounts_path %>.authentication.recent_sign_in_needed")
38
+ end
39
+
40
+ def resume_<%= file_name %>_session
41
+ return Current.<%= file_name %>_session if Current.<%= file_name %>_session
42
+ return unless cookies.signed[COOKIE]
43
+
44
+ # A session past its idle timeout or lifetime is dropped: the <%= human_name.downcase %> signs in again.
45
+ <%= file_name %>_session = <%= class_name %>::Session.find_by(id: cookies.signed[COOKIE])
46
+ return Current.<%= file_name %>_session = <%= file_name %>_session unless <%= file_name %>_session&.expired?
47
+
48
+ <%= file_name %>_session.destroy
49
+ cookies.delete(COOKIE)
50
+ nil
51
+ end
52
+
53
+ # A new session row and cookie: nothing about the sign-in lives in Rails' own session, which the
54
+ # admin's sign-in uses too.
55
+ def start_<%= file_name %>_session_for(<%= file_name %>)
56
+ <%= file_name %>.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |<%= file_name %>_session|
57
+ Current.<%= file_name %>_session = <%= file_name %>_session
58
+ Trek::ResolveSessionHostnameJob.perform_later(<%= file_name %>_session)
59
+ cookies.signed[COOKIE] = {
60
+ value: <%= file_name %>_session.id, expires: <%= class_name %>::Session.lifetime.from_now, httponly: true, same_site: :lax
61
+ }
62
+ end
63
+ end
64
+
65
+ def after_<%= file_name %>_sign_in_url
66
+ session.delete(:return_to_after_<%= file_name %>_sign_in) || <%= route_prefix %>_account_url
67
+ end
68
+
69
+ def terminate_<%= file_name %>_session
70
+ resume_<%= file_name %>_session&.destroy
71
+ Current.<%= file_name %>_session = nil
72
+ cookies.delete(COOKIE)
73
+ end
74
+
75
+ # At most once every <%= class_name %>::Session.activity_interval (5 minutes), and only on the requests
76
+ # that looked the <%= human_name.downcase %> up.
77
+ def track_<%= file_name %>_activity
78
+ <%= file_name %>_session = Current.<%= file_name %>_session
79
+ return unless <%= file_name %>_session&.persisted?
80
+
81
+ <%= file_name %>_session.record_activity
82
+ <%= file_name %> = <%= file_name %>_session.<%= file_name %>
83
+ return if <%= file_name %>.last_activity_at&.after?(<%= class_name %>::Session.activity_interval.ago)
84
+
85
+ <%= file_name %>.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
86
+ end
87
+
88
+ # The staff member signed in to the admin in this browser, if any, for previews and links to the
89
+ # admin on the site. Read only: it neither extends nor ends their admin session, and it never
90
+ # signs a <%= human_name.downcase %> in.
91
+ def staff_user
92
+ return @staff_user if defined?(@staff_user)
93
+
94
+ staff_session = User::Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
95
+ @staff_user = staff_session.user if staff_session && !staff_session.expired? && staff_session.user.privileged?
96
+ end
97
+ end
@@ -0,0 +1,33 @@
1
+ # The <%= human_name.downcase %>'s own account: its name, email, signed-in browsers, and deleting it.
2
+ class <%= controllers_module %>::AccountsController < <%= controllers_module %>::BaseController
3
+ before_action :require_<%= file_name %>
4
+ before_action :require_recent_<%= file_name %>_sign_in, only: :destroy
5
+ before_action :set_<%= file_name %>
6
+
7
+ helper Trek::UserAgentsHelper
8
+
9
+ def show
10
+ end
11
+
12
+ def update
13
+ if @<%= file_name %>.update(params.expect(<%= file_name %>: [:name]))
14
+ redirect_to <%= route_prefix %>_account_path, notice: t(".success")
15
+ else
16
+ render :show, status: :unprocessable_content
17
+ end
18
+ end
19
+
20
+ def destroy
21
+ @<%= file_name %>.destroy!
22
+ Current.<%= file_name %>_session = nil
23
+ cookies.delete(<%= accounts_module %>::Authentication::COOKIE)
24
+ redirect_to "/", notice: t(".success"), status: :see_other
25
+ end
26
+
27
+ private
28
+
29
+ def set_<%= file_name %>
30
+ @<%= file_name %> = Current.<%= file_name %>
31
+ @sessions = @<%= file_name %>.sessions.active.recent_first
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # Signs out browsers signed in as the current <%= human_name.downcase %>, listed on their account page. Only
2
+ # theirs: every query goes through Current.<%= file_name %>.sessions.
3
+ class <%= controllers_module %>::ActiveSessionsController < <%= controllers_module %>::BaseController
4
+ before_action :require_<%= file_name %>
5
+
6
+ def destroy
7
+ Current.<%= file_name %>.sessions.find(params[:id]).destroy
8
+ redirect_to <%= route_prefix %>_account_path(anchor: "sessions"), notice: t(".success"), status: :see_other
9
+ end
10
+
11
+ def destroy_others
12
+ Current.<%= file_name %>.sessions.where.not(id: Current.<%= file_name %>_session.id).destroy_all
13
+ redirect_to <%= route_prefix %>_account_path(anchor: "sessions"), notice: t(".success"), status: :see_other
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # The account pages' controllers: <%= human_name.pluralize.downcase %> only, never the admin's users.
2
+ class <%= controllers_module %>::BaseController < ApplicationController
3
+ # Sign-ins, <% if code? %>code requests<% else %>sign-ups, password resets<% end %> and account changes go to the audit log, when the app has one.
4
+ include Trek::Auditable if defined?(::AuditLog)
5
+
6
+ # Emails sent to one address (<% if code? %>codes<% else %>password resets, confirmations<% end %>), whatever the IP address: rate_limit
7
+ # counts per IP address.
8
+ EMAILS_PER_ADDRESS = 5
9
+ EMAILS_PER_ADDRESS_WINDOW = 1.hour
10
+
11
+ # Failed sign-ins from one IP address, whatever the account, over a longer window than rate_limit's.
12
+ FAILED_SIGN_INS_PER_IP = 20
13
+ FAILED_SIGN_INS_WINDOW = 1.hour
14
+
15
+ private
16
+
17
+ def normalized_email(email)
18
+ <%= class_name %>.normalize_value_for(:email, email.to_s)
19
+ end
20
+
21
+ def valid_email?(email)
22
+ email.match?(URI::MailTo::EMAIL_REGEXP)
23
+ end
24
+
25
+ # Counted in Rails.cache, as rate_limit is, under a digest rather than the email itself.
26
+ def too_many_emails_to?(email)
27
+ key = "<%= accounts_path %>/emails/#{Digest::SHA256.hexdigest(email)}"
28
+ Rails.cache.increment(key, 1, expires_in: EMAILS_PER_ADDRESS_WINDOW).to_i > EMAILS_PER_ADDRESS
29
+ end
30
+
31
+ def too_many_failed_sign_ins_from_ip?
32
+ Rails.cache.read(failed_sign_ins_from_ip_key).to_i >= FAILED_SIGN_INS_PER_IP
33
+ end
34
+
35
+ def count_failed_sign_in_from_ip
36
+ Rails.cache.increment(failed_sign_ins_from_ip_key, 1, expires_in: FAILED_SIGN_INS_WINDOW)
37
+ end
38
+
39
+ def failed_sign_ins_from_ip_key
40
+ "<%= accounts_path %>/failed_sign_ins/#{request.remote_ip}"
41
+ end
42
+
43
+ # For the audit log (Trek::Auditable): the signed-in <%= human_name.downcase %>, nil before signing in
44
+ def audit_actor
45
+ Current.<%= file_name %>
46
+ end
47
+
48
+ # The account signed in to, tried, or changed
49
+ def audit_target
50
+ @<%= file_name %> || Current.<%= file_name %>
51
+ end
52
+ end
@@ -0,0 +1,85 @@
1
+ <% if code? -%>
2
+ # Changing the account's email: a code sent to the new address confirms it's theirs.
3
+ <% else -%>
4
+ # Changing the account's email: a link sent to the new address confirms it's theirs.
5
+ <% end -%>
6
+ class <%= controllers_module %>::EmailChangesController < <%= controllers_module %>::BaseController
7
+ rate_limit to: 10, within: 15.minutes, only: %i[create<% if code? %> update<% end %>], with: -> { redirect_to <%= route_prefix %>_account_path, alert: t("<%= accounts_path %>.authentication.rate_limited") }
8
+
9
+ before_action :require_<%= file_name %><% if password? %>, except: :show<% end %>
10
+ <% if code? -%>
11
+ before_action :set_email, only: %i[edit update]
12
+ <% end -%>
13
+
14
+ def new
15
+ end
16
+
17
+ def create
18
+ @email = normalized_email(params[:email])
19
+ return render_email_form t(".invalid_email") unless valid_email?(@email) && @email != Current.<%= file_name %>.email
20
+ return render_email_form t("<%= accounts_path %>.authentication.rate_limited") if too_many_emails_to?(@email)
21
+
22
+ <% if code? -%>
23
+ <%= accounts_module %>::SignInCodeJob.perform_later(@email, purpose: "email_change", <%= file_name %>: Current.<%= file_name %>)
24
+ session[:<%= file_name %>_new_email] = @email
25
+ redirect_to edit_<%= route_prefix %>_email_change_path, notice: t(".sent", email: @email)
26
+ end
27
+
28
+ def edit
29
+ end
30
+
31
+ # Taken by another account is only told once the code proved the address is theirs.
32
+ def update
33
+ return render_code_form t("<%= accounts_path %>.authentication.rate_limited") if too_many_failed_sign_ins_from_ip?
34
+
35
+ unless <%= class_name %>::SignInCode.redeem(@email, params[:otp], purpose: "email_change", <%= file_name %>: Current.<%= file_name %>)
36
+ count_failed_sign_in_from_ip
37
+ return render_code_form t(".failure")
38
+ end
39
+
40
+ session.delete(:<%= file_name %>_new_email)
41
+ if Current.<%= file_name %>.update(email: @email)
42
+ redirect_to <%= route_prefix %>_account_path, notice: t(".success")
43
+ else
44
+ redirect_to <%= route_prefix %>_account_path, alert: t(".taken")
45
+ end
46
+ end
47
+ <% else -%>
48
+ Current.<%= file_name %>.update_column(:unconfirmed_email, @email) # rubocop:disable Rails/SkipsModelValidations -- checked above
49
+ <%= class_name %>Mailer.email_change(Current.<%= file_name %>).deliver_later
50
+ redirect_to <%= route_prefix %>_account_path, notice: t(".sent", email: @email)
51
+ end
52
+
53
+ # Opened from the email, possibly in another browser: the token is enough. Taken by another
54
+ # account is only told once the link proved the address is theirs.
55
+ def show
56
+ @<%= file_name %> = <%= class_name %>.find_by_token_for(:email_change, params[:token].to_s)
57
+ return redirect_to "/", alert: t(".invalid") unless @<%= file_name %>
58
+
59
+ if @<%= file_name %>.change_email!
60
+ redirect_to <%= file_name %>_signed_in? ? <%= route_prefix %>_account_path : <%= route_prefix %>_sign_in_path, notice: t(".success")
61
+ else
62
+ redirect_to "/", alert: t(".taken")
63
+ end
64
+ end
65
+ <% end -%>
66
+
67
+ private
68
+ <% if code? -%>
69
+
70
+ def set_email
71
+ @email = session[:<%= file_name %>_new_email]
72
+ redirect_to new_<%= route_prefix %>_email_change_path unless @email
73
+ end
74
+
75
+ def render_code_form(alert)
76
+ flash.now[:alert] = alert
77
+ render :edit, status: :unprocessable_content
78
+ end
79
+ <% end -%>
80
+
81
+ def render_email_form(alert)
82
+ flash.now[:alert] = alert
83
+ render :new, status: :unprocessable_content
84
+ end
85
+ end
@@ -0,0 +1,19 @@
1
+ # The link confirming the account's email (show), and sending it again (create).
2
+ class <%= controllers_module %>::EmailConfirmationsController < <%= controllers_module %>::BaseController
3
+ before_action :require_<%= file_name %>, only: :create
4
+
5
+ # Opened from the email, possibly in another browser: the token is enough.
6
+ def show
7
+ @<%= file_name %> = <%= class_name %>.find_by_token_for(:email_confirmation, params[:token].to_s)
8
+ return redirect_to "/", alert: t(".invalid") unless @<%= file_name %>
9
+
10
+ @<%= file_name %>.confirm_email!
11
+ redirect_to <%= file_name %>_signed_in? ? <%= route_prefix %>_account_path : <%= route_prefix %>_sign_in_path, notice: t(".success")
12
+ end
13
+
14
+ def create
15
+ <%= file_name %> = Current.<%= file_name %>
16
+ <%= class_name %>Mailer.email_confirmation(<%= file_name %>).deliver_later unless <%= file_name %>.confirmed? || too_many_emails_to?(<%= file_name %>.email)
17
+ redirect_to <%= route_prefix %>_account_path, notice: t(".sent", email: <%= file_name %>.email)
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ # A forgotten password: a link emailed to the account, if the email has one, with the same answer
2
+ # either way, then the new password, which signs out every session.
3
+ class <%= controllers_module %>::PasswordResetsController < <%= controllers_module %>::BaseController
4
+ rate_limit to: 10, within: 15.minutes, only: %i[create update], with: -> { redirect_to new_<%= route_prefix %>_password_reset_path, alert: t("<%= accounts_path %>.authentication.rate_limited") }
5
+
6
+ before_action :set_<%= file_name %>_by_token, only: %i[edit update]
7
+
8
+ def new
9
+ end
10
+
11
+ def create
12
+ email = normalized_email(params[:email])
13
+ @<%= file_name %> = <%= class_name %>.find_by(email:) if valid_email?(email)
14
+ <%= class_name %>Mailer.password_reset(@<%= file_name %>).deliver_later if @<%= file_name %> && !too_many_emails_to?(email)
15
+
16
+ redirect_to <%= route_prefix %>_sign_in_path, notice: t(".sent")
17
+ end
18
+
19
+ def edit
20
+ end
21
+
22
+ def update
23
+ @<%= file_name %>.password = params.expect(<%= file_name %>: [:password])[:password].to_s
24
+
25
+ if @<%= file_name %>.save
26
+ @<%= file_name %>.sessions.delete_all
27
+ redirect_to <%= route_prefix %>_sign_in_path, notice: t(".success")
28
+ else
29
+ render :edit, status: :unprocessable_content
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # The token of the link, used up by the new password
36
+ def set_<%= file_name %>_by_token
37
+ @token = params[:token].to_s
38
+ @<%= file_name %> = <%= class_name %>.find_by_token_for(:password_reset, @token)
39
+ redirect_to new_<%= route_prefix %>_password_reset_path, alert: t("<%= accounts_path %>.password_resets.invalid") unless @<%= file_name %>
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # Changing the account's password, after a recent sign-in; a forgotten one goes through
2
+ # PasswordResetsController.
3
+ class <%= controllers_module %>::PasswordsController < <%= controllers_module %>::BaseController
4
+ before_action :require_<%= file_name %>
5
+ before_action :require_recent_<%= file_name %>_sign_in
6
+
7
+ def edit
8
+ @<%= file_name %> = Current.<%= file_name %>
9
+ end
10
+
11
+ # Signs out the account's other browsers: whoever knew the old password is out.
12
+ def update
13
+ @<%= file_name %> = Current.<%= file_name %>
14
+ @<%= file_name %>.password = params.expect(<%= file_name %>: [:password])[:password].to_s
15
+
16
+ if @<%= file_name %>.save
17
+ @<%= file_name %>.sessions.where.not(id: Current.<%= file_name %>_session.id).delete_all
18
+ redirect_to <%= route_prefix %>_account_path, notice: t(".success")
19
+ else
20
+ render :edit, status: :unprocessable_content
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # Signing up with an email and a password: signed in at once, the email confirmed later by a link
2
+ # (confirmed_at), so the app decides what an unconfirmed account may do. An email that already has
3
+ # an account is refused by its uniqueness validation, as on most sites.
4
+ class <%= controllers_module %>::RegistrationsController < <%= controllers_module %>::BaseController
5
+ rate_limit to: 5, within: 15.minutes, only: :create, with: -> { redirect_to <%= route_prefix %>_sign_up_path, alert: t("<%= accounts_path %>.authentication.rate_limited") }
6
+
7
+ def new
8
+ return redirect_to after_<%= file_name %>_sign_in_url if <%= file_name %>_signed_in?
9
+
10
+ @<%= file_name %> = <%= class_name %>.new
11
+ end
12
+
13
+ def create
14
+ @<%= file_name %> = <%= class_name %>.new(params.expect(<%= file_name %>: %i[email name password]))
15
+
16
+ if @<%= file_name %>.save
17
+ <%= class_name %>Mailer.email_confirmation(@<%= file_name %>).deliver_later
18
+ start_<%= file_name %>_session_for @<%= file_name %>
19
+ redirect_to <%= route_prefix %>_account_path, notice: t(".success", email: @<%= file_name %>.email)
20
+ else
21
+ render :new, status: :unprocessable_content
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,78 @@
1
+ <% if code? -%>
2
+ # Step 2 of signing in: the emailed code, which signs in to the email's account, created if it had
3
+ # none. Also signs out.
4
+ <% else -%>
5
+ # Signing in with the email and password, and out.
6
+ <% end -%>
7
+ class <%= controllers_module %>::SessionsController < <%= controllers_module %>::BaseController
8
+ rate_limit to: <%= code? ? 10 : 5 %>, within: 3.minutes, only: :create, with: -> { render_sign_in_form t("<%= accounts_path %>.authentication.rate_limited"), status: :too_many_requests }
9
+
10
+ <% if code? -%>
11
+ before_action :set_email, only: %i[new create]
12
+
13
+ def new
14
+ end
15
+
16
+ def create
17
+ return render_sign_in_form t("<%= accounts_path %>.authentication.rate_limited"), status: :too_many_requests if too_many_failed_sign_ins_from_ip?
18
+
19
+ if <%= class_name %>::SignInCode.redeem(@email, params[:otp])
20
+ @<%= file_name %> = <%= class_name %>.for_verified_email!(@email)
21
+ session.delete(:<%= file_name %>_sign_in_email)
22
+ url = after_<%= file_name %>_sign_in_url
23
+ start_<%= file_name %>_session_for @<%= file_name %>
24
+ redirect_to url, notice: t(".success")
25
+ else
26
+ count_failed_sign_in_from_ip
27
+ render_sign_in_form t(".failure"), status: :unprocessable_content
28
+ end
29
+ end
30
+ <% else -%>
31
+ def new
32
+ redirect_to after_<%= file_name %>_sign_in_url if <%= file_name %>_signed_in?
33
+ end
34
+
35
+ def create
36
+ return render_sign_in_form t("<%= accounts_path %>.authentication.rate_limited"), status: :too_many_requests if too_many_failed_sign_ins_from_ip?
37
+
38
+ # Hashes the password typed even when no account matches, so the response takes as long
39
+ <%= file_name %> = <%= class_name %>.authenticate_by(email: params[:email].to_s, password: params[:password].to_s)
40
+ @<%= file_name %> = <%= file_name %> || <%= class_name %>.find_by(email: params[:email].to_s)
41
+
42
+ # A locked account is refused even with the right password, with the same answer as a wrong one
43
+ if <%= file_name %> && !<%= file_name %>.sign_in_locked?
44
+ <%= file_name %>.record_successful_sign_in!
45
+ url = after_<%= file_name %>_sign_in_url
46
+ start_<%= file_name %>_session_for <%= file_name %>
47
+ redirect_to url, notice: t(".success")
48
+ else
49
+ @<%= file_name %>&.record_failed_sign_in!
50
+ count_failed_sign_in_from_ip
51
+ render_sign_in_form t(".failure"), status: :unprocessable_content
52
+ end
53
+ end
54
+ <% end -%>
55
+
56
+ def destroy
57
+ terminate_<%= file_name %>_session
58
+ redirect_to "/", notice: t(".success"), status: :see_other
59
+ end
60
+
61
+ private
62
+ <% if code? -%>
63
+
64
+ # The email of step 1, kept in Rails' session rather than the URL
65
+ def set_email
66
+ @email = session[:<%= file_name %>_sign_in_email]
67
+ redirect_to <%= route_prefix %>_sign_in_path unless @email
68
+ end
69
+ <% end -%>
70
+
71
+ def render_sign_in_form(alert, status:)
72
+ <% if password? -%>
73
+ @email = params[:email]
74
+ <% end -%>
75
+ flash.now[:alert] = alert
76
+ render :new, status:
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ # Step 1 of signing in: an email, to which a code is sent. Whether or not it has an account, the
2
+ # answer is the same; the code creates the account on its first use.
3
+ class <%= controllers_module %>::SignInCodesController < <%= controllers_module %>::BaseController
4
+ rate_limit to: 10, within: 15.minutes, only: :create, with: -> { render_email_form t("<%= accounts_path %>.authentication.rate_limited"), status: :too_many_requests }
5
+
6
+ def new
7
+ redirect_to after_<%= file_name %>_sign_in_url if <%= file_name %>_signed_in?
8
+ end
9
+
10
+ def create
11
+ email = normalized_email(params[:email])
12
+ return render_email_form t(".invalid_email"), status: :unprocessable_content unless valid_email?(email)
13
+ return render_email_form t("<%= accounts_path %>.authentication.rate_limited"), status: :too_many_requests if too_many_emails_to?(email)
14
+
15
+ <%= accounts_module %>::SignInCodeJob.perform_later(email)
16
+ session[:<%= file_name %>_sign_in_email] = email
17
+ redirect_to <%= route_prefix %>_sign_in_code_path, notice: t(".sent", email:)
18
+ end
19
+
20
+ private
21
+
22
+ def render_email_form(alert, status:)
23
+ @email = params[:email]
24
+ flash.now[:alert] = alert
25
+ render :new, status:
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ / The account pages' notices. Move them to the site's layout if it shows
2
+ / flash messages.
3
+ - flash.each do |type, message|
4
+ p(role=(type == "alert" ? "alert" : "status") class="flash-#{type}")
5
+ = message
@@ -0,0 +1,52 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+
5
+ = form_with model: @<%= file_name %>, url: <%= route_prefix %>_account_path, method: :patch do |f|
6
+ - if @<%= file_name %>.errors.any?
7
+ ul(role="alert")
8
+ - @<%= file_name %>.errors.full_messages.each do |message|
9
+ li = message
10
+ = f.label :name
11
+ = f.text_field :name, autocomplete: "name"
12
+ = f.submit t(".save")
13
+
14
+ h2 = <%= class_name %>.human_attribute_name(:email)
15
+ p
16
+ = @<%= file_name %>.email
17
+ =< link_to t(".change_email"), new_<%= route_prefix %>_email_change_path
18
+ <% if password? -%>
19
+ - unless @<%= file_name %>.confirmed?
20
+ p = t(".unconfirmed")
21
+ = button_to t(".resend_confirmation"), <%= route_prefix %>_email_confirmation_path
22
+ - if @<%= file_name %>.unconfirmed_email
23
+ p = t(".pending_email", email: @<%= file_name %>.unconfirmed_email)
24
+
25
+ h2 = <%= class_name %>.human_attribute_name(:password)
26
+ p = link_to t(".change_password"), edit_<%= route_prefix %>_password_path
27
+ <% end -%>
28
+
29
+ h2#sessions = t(".sessions")
30
+ ul
31
+ - @sessions.each do |<%= file_name %>_session|
32
+ li
33
+ = user_agent_summary(<%= file_name %>_session.user_agent)
34
+ =< t(".signed_in", date: l(<%= file_name %>_session.created_at, format: :long))
35
+ - if <%= file_name %>_session == Current.<%= file_name %>_session
36
+ =< t(".this_browser")
37
+ - else
38
+ = button_to t(".sign_out_session"),
39
+ <%= route_prefix %>_active_session_path(<%= file_name %>_session),
40
+ method: :delete
41
+ - if @sessions.size > 1
42
+ = button_to t(".sign_out_others"),
43
+ others_<%= route_prefix %>_active_sessions_path,
44
+ method: :delete
45
+
46
+ = button_to t(".sign_out"), <%= route_prefix %>_sign_out_path, method: :delete
47
+
48
+ h2 = t(".delete_title")
49
+ p = t(".delete_intro")
50
+ = button_to t(".delete"), <%= route_prefix %>_account_path,
51
+ method: :delete,
52
+ form: { data: { turbo_confirm: t(".delete_confirm") } }
@@ -0,0 +1,17 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ p = t(".intro", email: @email)
5
+
6
+ = form_with url: <%= route_prefix %>_email_change_path, method: :patch do |f|
7
+ = f.label :otp, t(".code")
8
+ = f.text_field :otp,
9
+ required: true,
10
+ autofocus: true,
11
+ autocomplete: "one-time-code",
12
+ inputmode: "numeric",
13
+ pattern: "[0-9 ]*",
14
+ maxlength: 7
15
+ = f.submit t(".submit")
16
+
17
+ p = link_to t(".back"), <%= route_prefix %>_account_path
@@ -0,0 +1,15 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+ p = t(".intro")
5
+
6
+ = form_with url: <%= route_prefix %>_email_change_path do |f|
7
+ = f.label :email, t(".email")
8
+ = f.email_field :email,
9
+ value: @email,
10
+ required: true,
11
+ autofocus: true,
12
+ autocomplete: "email"
13
+ = f.submit t(".submit")
14
+
15
+ p = link_to t(".back"), <%= route_prefix %>_account_path
@@ -0,0 +1,26 @@
1
+ = render "<%= accounts_path %>/flash"
2
+
3
+ h1 = t(".title")
4
+
5
+ = form_with model: @<%= file_name %>,
6
+ url: <%= route_prefix %>_password_reset_path,
7
+ method: :patch do |f|
8
+ - if @<%= file_name %>.errors.any?
9
+ ul(role="alert")
10
+ - @<%= file_name %>.errors.full_messages.each do |message|
11
+ li = message
12
+ = hidden_field_tag :token, @token
13
+ = f.label :password
14
+ ruby:
15
+ rules = {
16
+ min: @<%= file_name %>.password_min_length,
17
+ passphrase: @<%= file_name %>.passphrase_length
18
+ }
19
+ p = t(".rules", **rules)
20
+ = f.password_field :password,
21
+ required: true,
22
+ autofocus: true,
23
+ autocomplete: "new-password",
24
+ maxlength: 72,
25
+ minlength: @<%= file_name %>.password_min_length
26
+ = f.submit t(".submit")