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,126 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "<%= human_name.pluralize %>' accounts", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ <% if code? -%>
7
+ let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com") }
8
+
9
+ def emailed_code
10
+ ActionMailer::Base.deliveries.last.text_part.body.to_s[/\b\d{6}\b/]
11
+ end
12
+
13
+ def change_email_to(email)
14
+ perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: } }
15
+ patch <%= route_prefix %>_email_change_path, params: { otp: emailed_code }
16
+ end
17
+
18
+ def sign_in_as(<%= file_name %>)
19
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email } }
20
+ post <%= route_prefix %>_sign_in_code_path, params: { otp: emailed_code }
21
+ end
22
+ <% else -%>
23
+ let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password: "correct horse battery staple") }
24
+
25
+ def emailed_link
26
+ ActionMailer::Base.deliveries.last.text_part.body.to_s[%r{https?://\S+}]
27
+ end
28
+
29
+ def change_email_to(email)
30
+ perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: } }
31
+ get emailed_link
32
+ end
33
+
34
+ def sign_in_as(<%= file_name %>)
35
+ post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email, password: "correct horse battery staple" }
36
+ end
37
+ <% end -%>
38
+
39
+ it "asks to sign in" do
40
+ get <%= route_prefix %>_account_path
41
+
42
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
43
+ end
44
+
45
+ it "shows the account" do
46
+ sign_in_as(<%= file_name %>)
47
+ get <%= route_prefix %>_account_path
48
+
49
+ expect(response.body).to include(<%= file_name %>.email)
50
+ end
51
+
52
+ it "changes the name" do
53
+ sign_in_as(<%= file_name %>)
54
+ patch <%= route_prefix %>_account_path, params: { <%= file_name %>: { name: "Ada Lovelace" } }
55
+
56
+ expect(<%= file_name %>.reload.name).to eq("Ada Lovelace")
57
+ end
58
+
59
+ it "changes the email once the <%= code? ? "code" : "link" %> sent to the new one is <%= code? ? "typed" : "followed" %>", :aggregate_failures do
60
+ sign_in_as(<%= file_name %>)
61
+ change_email_to("ada@lovelace.org")
62
+
63
+ expect(ActionMailer::Base.deliveries.last.to).to eq(["ada@lovelace.org"])
64
+ expect(<%= file_name %>.reload.email).to eq("ada@lovelace.org")
65
+ end
66
+
67
+ it "keeps the email when another account has the new one", :aggregate_failures do
68
+ <%= class_name %>.create!(email: "taken@example.com"<% if password? %>, password: "correct horse battery staple"<% end %>)
69
+ sign_in_as(<%= file_name %>)
70
+ change_email_to("taken@example.com")
71
+
72
+ expect(<%= file_name %>.reload.email).to eq("ada@example.com")
73
+ expect(flash[:alert]).to be_present
74
+ end
75
+
76
+ it "signs the other browsers out", :aggregate_failures do
77
+ other = <%= file_name %>.sessions.create!
78
+ sign_in_as(<%= file_name %>)
79
+
80
+ delete others_<%= route_prefix %>_active_sessions_path
81
+
82
+ expect(<%= class_name %>::Session.exists?(other.id)).to be(false)
83
+ expect(<%= file_name %>.sessions.count).to eq(1)
84
+ end
85
+
86
+ it "deletes the account after a recent sign-in" do
87
+ sign_in_as(<%= file_name %>)
88
+
89
+ expect { delete <%= route_prefix %>_account_path }.to change(<%= class_name %>, :count).by(-1)
90
+ end
91
+
92
+ it "asks to sign in again before deleting the account", :aggregate_failures do
93
+ sign_in_as(<%= file_name %>)
94
+ travel <%= class_name %>::Session::RECENT + 1.minute
95
+
96
+ expect { delete <%= route_prefix %>_account_path }.not_to change(<%= class_name %>, :count)
97
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
98
+ end
99
+ <% if password? -%>
100
+
101
+ it "sends the confirmation link again" do
102
+ sign_in_as(<%= file_name %>)
103
+
104
+ perform_enqueued_jobs { post <%= route_prefix %>_email_confirmation_path }
105
+
106
+ expect(ActionMailer::Base.deliveries.last.to).to eq([<%= file_name %>.email])
107
+ end
108
+
109
+ it "changes the password after a recent sign-in, and signs the other browsers out", :aggregate_failures do
110
+ other = <%= file_name %>.sessions.create!
111
+ sign_in_as(<%= file_name %>)
112
+
113
+ patch <%= route_prefix %>_password_path, params: { <%= file_name %>: { password: "a brand new password 1!" } }
114
+
115
+ expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
116
+ expect(<%= class_name %>::Session.exists?(other.id)).to be(false)
117
+ end
118
+
119
+ it "refuses a password breaking the rules" do
120
+ sign_in_as(<%= file_name %>)
121
+ patch <%= route_prefix %>_password_path, params: { <%= file_name %>: { password: "short" } }
122
+
123
+ expect(response).to have_http_status(:unprocessable_content)
124
+ end
125
+ <% end -%>
126
+ end
@@ -0,0 +1,43 @@
1
+ require "rails_helper"
2
+
3
+ # What any page of the site knows about who is signed in (<%= accounts_module %>::Authentication).
4
+ RSpec.describe ApplicationController, type: :controller do
5
+ controller do
6
+ def index
7
+ render plain: [current_<%= file_name %>&.email, staff_user&.email].inspect
8
+ end
9
+ end
10
+
11
+ it "knows nobody by default" do
12
+ get :index
13
+
14
+ expect(response.body).to eq("[nil, nil]")
15
+ end
16
+
17
+ it "knows the signed-in <%= human_name.downcase %>" do
18
+ <%= file_name %>_session = <%= class_name %>.create!(email: "ada@example.com"<% if password? %>, password: "correct horse battery staple"<% end %>).sessions.create!
19
+ cookies.signed[<%= accounts_module %>::Authentication::COOKIE] = <%= file_name %>_session.id
20
+
21
+ get :index
22
+
23
+ expect(response.body).to eq('["ada@example.com", nil]')
24
+ end
25
+
26
+ it "knows a staff member signed in to the admin, without signing in a <%= human_name.downcase %>" do
27
+ editor = users.create(role: :editor)
28
+ cookies.signed[:session_id] = editor.sessions.create!.id
29
+
30
+ get :index
31
+
32
+ expect(response.body).to eq("[nil, #{editor.email.inspect}]")
33
+ end
34
+
35
+ it "ignores an admin session of a user who isn't staff anymore" do
36
+ reader = users.create(role: :reader)
37
+ cookies.signed[:session_id] = reader.sessions.create!.id
38
+
39
+ get :index
40
+
41
+ expect(response.body).to eq("[nil, nil]")
42
+ end
43
+ end
@@ -0,0 +1,215 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "<%= human_name.pluralize %>' sign-ins", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ <% if code? -%>
7
+ def emailed_code
8
+ ActionMailer::Base.deliveries.last.text_part.body.to_s[/\b\d{6}\b/]
9
+ end
10
+
11
+ def ask_for_code(email)
12
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: } }
13
+ end
14
+
15
+ # The status and the notice, without the email it repeats
16
+ def answer_to_code_request(email)
17
+ ask_for_code(email)
18
+ [response.status, flash[:notice]&.sub(email, "EMAIL")]
19
+ end
20
+
21
+ def sign_in(email)
22
+ ask_for_code(email)
23
+ post <%= route_prefix %>_sign_in_code_path, params: { otp: emailed_code }
24
+ end
25
+
26
+ it "renders the sign-in form" do
27
+ get <%= route_prefix %>_sign_in_path
28
+
29
+ expect(response).to have_http_status(:ok)
30
+ end
31
+
32
+ it "emails a code to an email without an account, with the same answer as for an account", :aggregate_failures do
33
+ <%= class_name %>.create!(email: "ada@example.com")
34
+
35
+ expect(answer_to_code_request("grace@example.com")).to eq(answer_to_code_request("ada@example.com"))
36
+ expect(ActionMailer::Base.deliveries.flat_map(&:to)).to eq(%w[grace@example.com ada@example.com])
37
+ end
38
+
39
+ it "refuses an invalid email" do
40
+ post <%= route_prefix %>_sign_in_path, params: { email: "not an email" }
41
+
42
+ expect(response).to have_http_status(:unprocessable_content)
43
+ end
44
+
45
+ it "creates the account and signs in with the emailed code", :aggregate_failures do
46
+ ask_for_code("grace@example.com")
47
+
48
+ expect { post <%= route_prefix %>_sign_in_code_path, params: { otp: emailed_code } }.to change(<%= class_name %>, :count).by(1)
49
+ expect(response).to redirect_to(<%= route_prefix %>_account_url)
50
+ expect(cookies[<%= accounts_module %>::Authentication::COOKIE]).to be_present
51
+ end
52
+
53
+ it "signs in to the email's account", :aggregate_failures do
54
+ <%= file_name %> = <%= class_name %>.create!(email: "ada@example.com")
55
+ ask_for_code("Ada@Example.com")
56
+
57
+ expect { post <%= route_prefix %>_sign_in_code_path, params: { otp: emailed_code } }.not_to change(<%= class_name %>, :count)
58
+ expect(<%= file_name %>.sessions.count).to eq(1)
59
+ end
60
+
61
+ it "refuses a wrong code", :aggregate_failures do
62
+ ask_for_code("ada@example.com")
63
+ post <%= route_prefix %>_sign_in_code_path, params: { otp: "000000" }
64
+
65
+ expect(response).to have_http_status(:unprocessable_content)
66
+ expect(<%= class_name %>.count).to eq(0)
67
+ end
68
+
69
+ it "asks for the email first" do
70
+ get <%= route_prefix %>_sign_in_code_path
71
+
72
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
73
+ end
74
+ <% else -%>
75
+ let(:password) { "correct horse battery staple" }
76
+ let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password:) }
77
+
78
+ def sign_in(email, password: self.password)
79
+ post <%= route_prefix %>_sign_in_path, params: { email:, password: }
80
+ end
81
+
82
+ def sign_up(email)
83
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_up_path, params: { <%= file_name %>: { email:, password: } } }
84
+ end
85
+
86
+ it "renders the sign-in and sign-up forms", :aggregate_failures do
87
+ get <%= route_prefix %>_sign_in_path
88
+ expect(response).to have_http_status(:ok)
89
+
90
+ get <%= route_prefix %>_sign_up_path
91
+ expect(response).to have_http_status(:ok)
92
+ end
93
+
94
+ it "signs up and in, and emails a link to confirm the email", :aggregate_failures do
95
+ expect { sign_up("grace@example.com") }.to change(<%= class_name %>, :count).by(1)
96
+ expect(response).to redirect_to(<%= route_prefix %>_account_path)
97
+ expect(ActionMailer::Base.deliveries.last.to).to eq(["grace@example.com"])
98
+ end
99
+
100
+ it "confirms the email with the emailed link", :aggregate_failures do
101
+ sign_up("grace@example.com")
102
+ link = ActionMailer::Base.deliveries.last.text_part.body.to_s[%r{https?://\S+}]
103
+
104
+ get link
105
+ expect(<%= class_name %>.find_by(email: "grace@example.com")).to be_confirmed
106
+ end
107
+
108
+ it "refuses a password breaking the rules on signing up" do
109
+ post <%= route_prefix %>_sign_up_path, params: { <%= file_name %>: { email: "grace@example.com", password: "short" } }
110
+
111
+ expect(response).to have_http_status(:unprocessable_content)
112
+ end
113
+
114
+ it "signs in", :aggregate_failures do
115
+ sign_in(<%= file_name %>.email)
116
+
117
+ expect(response).to redirect_to(<%= route_prefix %>_account_url)
118
+ expect(<%= file_name %>.sessions.count).to eq(1)
119
+ end
120
+
121
+ it "refuses a wrong password, and an unknown email, with the same answer" do
122
+ sign_in(<%= file_name %>.email, password: "wrong horse battery staple")
123
+ wrong_password = [response.status, flash[:alert]]
124
+ sign_in("nobody@example.com")
125
+
126
+ expect([response.status, flash[:alert]]).to eq(wrong_password)
127
+ end
128
+
129
+ it "locks signing in after failures in a row, and emails the account", :aggregate_failures do
130
+ perform_enqueued_jobs { <%= class_name %>.sign_in_lockout_after.times { sign_in(<%= file_name %>.email, password: "wrong horse battery staple") } }
131
+ sign_in(<%= file_name %>.email)
132
+
133
+ expect(response).to have_http_status(:unprocessable_content)
134
+ expect(ActionMailer::Base.deliveries.last.to).to eq([<%= file_name %>.email])
135
+ end
136
+
137
+ describe "password reset" do
138
+ def ask_for_reset(email)
139
+ perform_enqueued_jobs { post <%= route_prefix %>_password_reset_path, params: { email: } }
140
+ end
141
+
142
+ it "emails a link to an account, with the same answer as for an unknown email", :aggregate_failures do
143
+ ask_for_reset("nobody@example.com")
144
+ unknown = [response.status, flash[:notice]]
145
+ ask_for_reset(<%= file_name %>.email)
146
+
147
+ expect([response.status, flash[:notice]]).to eq(unknown)
148
+ expect(ActionMailer::Base.deliveries.flat_map(&:to)).to eq([<%= file_name %>.email])
149
+ end
150
+
151
+ it "sets the new password, and signs every session out", :aggregate_failures do
152
+ <%= file_name %>.sessions.create!
153
+ token = <%= file_name %>.password_reset_token
154
+
155
+ patch <%= route_prefix %>_password_reset_path, params: { token:, <%= file_name %>: { password: "a brand new password 1!" } }
156
+
157
+ expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
158
+ expect(<%= file_name %>.sessions).to be_empty
159
+ end
160
+
161
+ it "refuses a wrong link" do
162
+ get edit_<%= route_prefix %>_password_reset_path(token: "wrong")
163
+
164
+ expect(response).to redirect_to(new_<%= route_prefix %>_password_reset_path)
165
+ end
166
+ end
167
+ <% end -%>
168
+
169
+ it "goes back to the page that asked to sign in" do
170
+ get <%= route_prefix %>_account_path
171
+ sign_in(<% if code? %>"ada@example.com"<% else %><%= file_name %>.email<% end %>)
172
+
173
+ expect(response).to redirect_to(<%= route_prefix %>_account_url)
174
+ end
175
+
176
+ it "signs out", :aggregate_failures do
177
+ sign_in(<% if code? %>"ada@example.com"<% else %><%= file_name %>.email<% end %>)
178
+
179
+ expect { delete <%= route_prefix %>_sign_out_path }.to change(<%= class_name %>::Session, :count).by(-1)
180
+ get <%= route_prefix %>_account_path
181
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
182
+ end
183
+
184
+ it "signs in again after the session's idle timeout" do
185
+ sign_in(<% if code? %>"ada@example.com"<% else %><%= file_name %>.email<% end %>)
186
+
187
+ travel <%= class_name %>::Session.idle_timeout + 1.minute
188
+ get <%= route_prefix %>_account_path
189
+
190
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
191
+ end
192
+ <% if audit_log? -%>
193
+
194
+ it "records the sign-in in the audit log" do
195
+ sign_in(<% if code? %>"ada@example.com"<% else %><%= file_name %>.email<% end %>)
196
+
197
+ expect(AuditLog.where(action: "<%= controllers_path %>/sessions#create", actor: <%= class_name %>.last)).to exist
198
+ end
199
+ <% end -%>
200
+
201
+ it "doesn't sign in to the admin" do
202
+ sign_in(<% if code? %>"ada@example.com"<% else %><%= file_name %>.email<% end %>)
203
+
204
+ get admin_root_path
205
+ expect(response).to redirect_to(new_admin_user_session_path)
206
+ end
207
+
208
+ it "isn't signed in by the admin's session" do
209
+ editor = users.create(role: :editor)
210
+ post admin_user_session_path, params: { email: editor.email, password: users.password }
211
+
212
+ get <%= route_prefix %>_account_path
213
+ expect(response).to redirect_to(<%= route_prefix %>_sign_in_path)
214
+ end
215
+ end
@@ -0,0 +1,10 @@
1
+ p Hello,
2
+ p
3
+ | To use this email address for your #{t("application_name")} account,
4
+ =< link_to "follow this link",
5
+ @url,
6
+ target: "_blank",
7
+ rel: "noopener"
8
+ | . It is valid for #{@validity}.
9
+ p If you didn't ask for it, ignore this email: your address won't be used.
10
+ p The #{t("application_name")} team
@@ -0,0 +1,8 @@
1
+ Hello,
2
+
3
+ To use this email address for your <%%= t("application_name") %> account, follow this link. It is valid for <%%= @validity %>:
4
+ <%%= @url %>
5
+
6
+ If you didn't ask for it, ignore this email: your address won't be used.
7
+
8
+ The <%%= t("application_name") %> team
@@ -0,0 +1,13 @@
1
+ p Bonjour,
2
+ p
3
+ | Pour utiliser cette adresse e-mail pour votre compte
4
+ #{t("application_name")},
5
+ =< link_to "suivez ce lien",
6
+ @url,
7
+ target: "_blank",
8
+ rel: "noopener"
9
+ | . Il est valable #{@validity}.
10
+ p
11
+ | Si vous ne l'avez pas demandé, ignorez cet e-mail : votre adresse ne
12
+ sera pas utilisée.
13
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,8 @@
1
+ Bonjour,
2
+
3
+ Pour utiliser cette adresse e-mail pour votre compte <%%= t("application_name") %>, suivez ce lien. Il est valable <%%= @validity %> :
4
+ <%%= @url %>
5
+
6
+ Si vous ne l'avez pas demandé, ignorez cet e-mail : votre adresse ne sera pas utilisée.
7
+
8
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,9 @@
1
+ p Hello,
2
+ p
3
+ | Here is your code to confirm this email address for your
4
+ #{t("application_name")} account:
5
+ p
6
+ strong(style="font-size: 28px; letter-spacing: 4px;") = @code
7
+ p It is valid for #{@validity}.
8
+ p If you didn't ask for it, ignore this email: your address won't be used.
9
+ p The #{t("application_name")} team
@@ -0,0 +1,11 @@
1
+ Hello,
2
+
3
+ Here is your code to confirm this email address for your <%%= t("application_name") %> account:
4
+
5
+ <%%= @code %>
6
+
7
+ It is valid for <%%= @validity %>.
8
+
9
+ If you didn't ask for it, ignore this email: your address won't be used.
10
+
11
+ The <%%= t("application_name") %> team
@@ -0,0 +1,11 @@
1
+ p Bonjour,
2
+ p
3
+ | Voici votre code pour confirmer cette adresse e-mail pour votre compte
4
+ #{t("application_name")} :
5
+ p
6
+ strong(style="font-size: 28px; letter-spacing: 4px;") = @code
7
+ p Il est valable #{@validity}.
8
+ p
9
+ | Si vous ne l'avez pas demandé, ignorez cet e-mail : votre adresse ne
10
+ sera pas utilisée.
11
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,11 @@
1
+ Bonjour,
2
+
3
+ Voici votre code pour confirmer cette adresse e-mail pour votre compte <%%= t("application_name") %> :
4
+
5
+ <%%= @code %>
6
+
7
+ Il est valable <%%= @validity %>.
8
+
9
+ Si vous ne l'avez pas demandé, ignorez cet e-mail : votre adresse ne sera pas utilisée.
10
+
11
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,10 @@
1
+ p Hello,
2
+ p
3
+ | Welcome to #{t("application_name")}! Confirm your email address
4
+ =< link_to "with this link",
5
+ @url,
6
+ target: "_blank",
7
+ rel: "noopener"
8
+ | , valid for #{@validity}.
9
+ p If you didn't create this account, ignore this email.
10
+ p The #{t("application_name")} team
@@ -0,0 +1,8 @@
1
+ Hello,
2
+
3
+ Welcome to <%%= t("application_name") %>! Confirm your email address with this link, valid for <%%= @validity %>:
4
+ <%%= @url %>
5
+
6
+ If you didn't create this account, ignore this email.
7
+
8
+ The <%%= t("application_name") %> team
@@ -0,0 +1,10 @@
1
+ p Bonjour,
2
+ p
3
+ | Bienvenue sur #{t("application_name")} ! Confirmez votre adresse e-mail
4
+ =< link_to "avec ce lien",
5
+ @url,
6
+ target: "_blank",
7
+ rel: "noopener"
8
+ | , valable #{@validity}.
9
+ p Si vous n'avez pas créé ce compte, ignorez cet e-mail.
10
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,8 @@
1
+ Bonjour,
2
+
3
+ Bienvenue sur <%%= t("application_name") %> ! Confirmez votre adresse e-mail avec ce lien, valable <%%= @validity %> :
4
+ <%%= @url %>
5
+
6
+ Si vous n'avez pas créé ce compte, ignorez cet e-mail.
7
+
8
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,10 @@
1
+ p Hello,
2
+ p
3
+ | To choose a new password for your #{t("application_name")} account,
4
+ =< link_to "follow this link",
5
+ @url,
6
+ target: "_blank",
7
+ rel: "noopener"
8
+ | . It is valid for #{@validity}, once.
9
+ p If you didn't ask for it, ignore this email: your password stays the same.
10
+ p The #{t("application_name")} team
@@ -0,0 +1,8 @@
1
+ Hello,
2
+
3
+ To choose a new password for your <%%= t("application_name") %> account, follow this link. It is valid for <%%= @validity %>, once:
4
+ <%%= @url %>
5
+
6
+ If you didn't ask for it, ignore this email: your password stays the same.
7
+
8
+ The <%%= t("application_name") %> team
@@ -0,0 +1,13 @@
1
+ p Bonjour,
2
+ p
3
+ | Pour choisir un nouveau mot de passe pour votre compte
4
+ #{t("application_name")},
5
+ =< link_to "suivez ce lien",
6
+ @url,
7
+ target: "_blank",
8
+ rel: "noopener"
9
+ | . Il est valable #{@validity}, une seule fois.
10
+ p
11
+ | Si vous ne l'avez pas demandé, ignorez cet e-mail : votre mot de passe
12
+ reste le même.
13
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,8 @@
1
+ Bonjour,
2
+
3
+ Pour choisir un nouveau mot de passe pour votre compte <%%= t("application_name") %>, suivez ce lien. Il est valable <%%= @validity %>, une seule fois :
4
+ <%%= @url %>
5
+
6
+ Si vous ne l'avez pas demandé, ignorez cet e-mail : votre mot de passe reste le même.
7
+
8
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,9 @@
1
+ p Hello,
2
+ p Here is your code to sign in to #{t("application_name")}:
3
+ p
4
+ strong(style="font-size: 28px; letter-spacing: 4px;") = @code
5
+ p It is valid for #{@validity}, for this sign-in only.
6
+ p
7
+ | If you didn't ask for it, ignore this email: nobody can sign in without
8
+ the code.
9
+ p The #{t("application_name")} team
@@ -0,0 +1,11 @@
1
+ Hello,
2
+
3
+ Here is your code to sign in to <%%= t("application_name") %>:
4
+
5
+ <%%= @code %>
6
+
7
+ It is valid for <%%= @validity %>, for this sign-in only.
8
+
9
+ If you didn't ask for it, ignore this email: nobody can sign in without the code.
10
+
11
+ The <%%= t("application_name") %> team
@@ -0,0 +1,9 @@
1
+ p Bonjour,
2
+ p Voici votre code pour vous connecter à #{t("application_name")} :
3
+ p
4
+ strong(style="font-size: 28px; letter-spacing: 4px;") = @code
5
+ p Il est valable #{@validity}, pour cette connexion uniquement.
6
+ p
7
+ | Si vous ne l'avez pas demandé, ignorez cet e-mail : personne ne peut se
8
+ connecter sans le code.
9
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,11 @@
1
+ Bonjour,
2
+
3
+ Voici votre code pour vous connecter à <%%= t("application_name") %> :
4
+
5
+ <%%= @code %>
6
+
7
+ Il est valable <%%= @validity %>, pour cette connexion uniquement.
8
+
9
+ Si vous ne l'avez pas demandé, ignorez cet e-mail : personne ne peut se connecter sans le code.
10
+
11
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,23 @@
1
+ p Hello #{@<%= file_name %>},
2
+ p
3
+ | Several attempts in a row to sign in to #{t("application_name")} with
4
+ your email failed, with a wrong password. To keep your account safe,
5
+ signing in is paused for a few minutes, a little longer after each new
6
+ failure.
7
+ <% unless web? -%>
8
+ p
9
+ | If it was you, wait a few minutes before trying again, or reset your
10
+ password from the app.
11
+ <% else -%>
12
+ p
13
+ | If it was you, wait a few minutes before trying again, or
14
+ =< link_to "choose a new password",
15
+ @reset_url,
16
+ target: "_blank",
17
+ rel: "noopener"
18
+ | .
19
+ <% end -%>
20
+ p
21
+ | If it wasn't you, someone may be trying to guess your password: if it's
22
+ short or you use it elsewhere, choose a new one.
23
+ p The #{t("application_name")} team
@@ -0,0 +1,14 @@
1
+ Hello <%%= @<%= file_name %> %>,
2
+
3
+ Several attempts in a row to sign in to <%%= t("application_name") %> with your email failed, with a wrong password. To keep your account safe, signing in is paused for a few minutes, a little longer after each new failure.
4
+
5
+ <% unless web? -%>
6
+ If it was you, wait a few minutes before trying again, or reset your password from the app.
7
+ <% else -%>
8
+ If it was you, wait a few minutes before trying again, or choose a new password:
9
+ <%%= @reset_url %>
10
+ <% end -%>
11
+
12
+ If it wasn't you, someone may be trying to guess your password: if it's short or you use it elsewhere, choose a new one.
13
+
14
+ The <%%= t("application_name") %> team