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,85 @@
1
+ class <%= class_name %>Mailer < ApplicationMailer
2
+ include ActionView::Helpers::DateHelper
3
+ <% if code? -%>
4
+
5
+ # The code to sign in with: to an email that may have no account yet.
6
+ def sign_in_code(email, code)
7
+ @code = code
8
+ @validity = distance_of_time_in_words(0, <%= class_name %>::SignInCode.validity)
9
+
10
+ mail to: email
11
+ end
12
+
13
+ # The code confirming a new email address for an account, sent to that address.
14
+ def email_change_code(email, code)
15
+ @code = code
16
+ @validity = distance_of_time_in_words(0, <%= class_name %>::SignInCode.validity)
17
+
18
+ mail to: email
19
+ end
20
+ <% else -%>
21
+
22
+ # The link confirming the email given on signing up. Its token is made here, in the job sending
23
+ # the email, rather than in the request.
24
+ def email_confirmation(<%= file_name %>)
25
+ @<%= file_name %> = <%= file_name %>
26
+ @url = link_to_confirm(<%= file_name %>.generate_token_for(:email_confirmation))
27
+ @validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:email_confirmation].expires_in)
28
+
29
+ mail to: <%= file_name %>.email
30
+ end
31
+
32
+ # The link to choose a new password; nothing is sent to an email without an account.
33
+ def password_reset(<%= file_name %>)
34
+ @<%= file_name %> = <%= file_name %>
35
+ @url = link_to_reset(<%= file_name %>.password_reset_token)
36
+ @validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:password_reset].expires_in)
37
+
38
+ mail to: <%= file_name %>.email
39
+ end
40
+
41
+ # The link confirming a new email (unconfirmed_email), sent to that address.
42
+ def email_change(<%= file_name %>)
43
+ @<%= file_name %> = <%= file_name %>
44
+ @url = link_to_change_email(<%= file_name %>.generate_token_for(:email_change))
45
+ @validity = distance_of_time_in_words(0, <%= class_name %>.token_definitions[:email_change].expires_in)
46
+
47
+ mail to: <%= file_name %>.unconfirmed_email
48
+ end
49
+
50
+ # Signing in is locked after failed tries in a row (Trek::Users::SignInLockout): the
51
+ # <%= human_name.downcase %> waits, resets the password, or learns someone may be guessing it.
52
+ def sign_in_locked(<%= file_name %>)
53
+ @<%= file_name %> = <%= file_name %>
54
+ <% if web? -%>
55
+ @reset_url = new_<%= route_prefix %>_password_reset_url
56
+ <% end -%>
57
+
58
+ mail to: <%= file_name %>.email
59
+ end
60
+
61
+ private
62
+
63
+ <% if model_only? -%>
64
+ # The app's own sign-in handles these links: a page of the site, or the mobile app opening them (iOS
65
+ # universal links, Android app links). They point at the app's domain, or at <%= accounts_path.upcase %>_APP_LINKS_URL.
66
+ <% elsif api? -%>
67
+ # The mobile app opens these links (iOS universal links, Android app links) and sends their token
68
+ # to the API. They point at the app's domain, or at <%= accounts_path.upcase %>_APP_LINKS_URL.
69
+ <% end -%>
70
+ <% unless web? -%>
71
+ def link_to_confirm(token) = app_link("account/confirm", token)
72
+ def link_to_reset(token) = app_link("password-reset", token)
73
+ def link_to_change_email(token) = app_link("account/email", token)
74
+
75
+ def app_link(path, token)
76
+ base = ENV.fetch("<%= accounts_path.upcase %>_APP_LINKS_URL") { "https://#{default_url_options.fetch(:host)}<%= "/#{options[:path]}" if options[:path].present? %>" }
77
+ "#{base}/#{path}?#{{ token: }.to_query}"
78
+ end
79
+ <% else -%>
80
+ def link_to_confirm(token) = <%= route_prefix %>_email_confirmation_url(token:)
81
+ def link_to_reset(token) = edit_<%= route_prefix %>_password_reset_url(token:)
82
+ def link_to_change_email(token) = <%= route_prefix %>_email_change_url(token:)
83
+ <% end -%>
84
+ <% end -%>
85
+ end
@@ -0,0 +1,46 @@
1
+ class TrekCreate<%= class_name.pluralize %> < <%= migration_class_name %>
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :email, null: false, index: { unique: true }
5
+ t.string :name
6
+ <% if password? -%>
7
+ t.string :password_digest, null: false
8
+ t.integer :failed_sign_in_count, null: false, default: 0
9
+ t.datetime :sign_in_locked_until
10
+ # When the email was confirmed by its link; nil until then
11
+ t.datetime :confirmed_at
12
+ # A new email, until its link confirms it
13
+ t.string :unconfirmed_email
14
+ <% end -%>
15
+ # The last request, at most every 5 minutes, e.g. to delete the accounts nobody uses
16
+ t.datetime :last_activity_at, index: true
17
+
18
+ t.timestamps
19
+ end
20
+
21
+ create_table :<%= file_name %>_sessions do |t|
22
+ t.belongs_to :<%= file_name %>, null: false, foreign_key: true
23
+ t.string :user_agent
24
+ t.string :ip_address
25
+ # The host name of the IP address (Trek::ReverseDns)
26
+ t.string :ip_hostname
27
+ t.datetime :last_activity_at, null: false
28
+
29
+ t.timestamps
30
+ end
31
+ <% if code? -%>
32
+
33
+ create_table :<%= file_name %>_sign_in_codes do |t|
34
+ t.string :email, null: false
35
+ t.string :purpose, null: false
36
+ t.belongs_to :<%= file_name %>, foreign_key: true
37
+ t.string :code_digest, null: false
38
+ t.integer :attempts, null: false, default: 0
39
+ t.datetime :expires_at, null: false, index: true
40
+
41
+ t.datetime :created_at, null: false
42
+ end
43
+ add_index :<%= file_name %>_sign_in_codes, %i[email purpose]
44
+ <% end -%>
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ <% if api? -%>
2
+ # A device of the mobile app signed in as the <%= human_name.downcase %>, found through the JWT it sends
3
+ # (Authorization: Bearer). The token only names the session: signing out deletes the session, and the
4
+ # token stops working with it.
5
+ <% elsif model_only? -%>
6
+ # A signed-in <%= human_name.downcase %>: the app's own sign-in creates the session, finds it on each request
7
+ # (Current.<%= file_name %>_session) and deletes it on signing out.
8
+ <% else -%>
9
+ # A browser signed in as the <%= human_name.downcase %>, found through a signed <%= file_name %>_session_id cookie.
10
+ <% end -%>
11
+ class <%= class_name %>::Session < ApplicationRecord
12
+ # Expires after idle_timeout without a request, and lifetime after signing in; longer than the admin's.
13
+ include Trek::Users::SessionLifetime
14
+ self.idle_timeout = 30.days
15
+ self.lifetime = 1.year
16
+
17
+ # Changing the password or deleting the account needs a sign-in this recent.
18
+ RECENT = 15.minutes
19
+
20
+ belongs_to :<%= file_name %>
21
+
22
+ def recent?
23
+ created_at > RECENT.ago
24
+ end
25
+ <% if api? -%>
26
+
27
+ # Signed with a key of its own, derived from the app's secret_key_base.
28
+ TOKEN_ALGORITHM = "HS256".freeze
29
+
30
+ def self.token_key
31
+ Rails.application.key_generator.generate_key("<%= accounts_path %>/session_tokens")
32
+ end
33
+
34
+ # The session the token names, unless the token is forged or past the session's lifetime.
35
+ def self.from_token(token)
36
+ payload, = JWT.decode(token, token_key, true, algorithm: TOKEN_ALGORITHM)
37
+ find_by(id: payload["sub"])
38
+ rescue JWT::DecodeError
39
+ nil
40
+ end
41
+
42
+ # The JWT the app keeps and sends with each request; it expires with the session's lifetime.
43
+ def token
44
+ JWT.encode({ sub: id, iat: created_at.to_i, exp: (created_at + lifetime).to_i }, self.class.token_key, TOKEN_ALGORITHM)
45
+ end
46
+ <% end -%>
47
+ end
@@ -0,0 +1,8 @@
1
+ # A code emailed to sign in, or to confirm a new email address (Trek::Users::SignInCode): kept by
2
+ # email, as one is sent whether or not the email has an account.
3
+ class <%= class_name %>::SignInCode < ApplicationRecord
4
+ include Trek::Users::SignInCode
5
+
6
+ # The account changing its email to this one; nil when signing in.
7
+ belongs_to :<%= file_name %>, optional: true
8
+ end
@@ -0,0 +1,73 @@
1
+ <% if code? -%>
2
+ # A <%= human_name.downcase %>'s account, apart from the admin's User: it signs in with a code emailed to it
3
+ # (<%= class_name %>::SignInCode), which creates the account the first time.
4
+ <% else -%>
5
+ # A <%= human_name.downcase %>'s account, apart from the admin's User: it signs up and in with a password, its
6
+ # email confirmed by a link (confirmed_at), and a forgotten password reset by one.
7
+ <% end -%>
8
+ class <%= class_name %> < ApplicationRecord
9
+ <% if password? -%>
10
+ # Required, at most 72 bytes; password_reset_token (15 minutes, used up by a new password).
11
+ has_secure_password
12
+ # The password rules (length, complexity, common passwords…); set password_min_length to change them.
13
+ include Trek::Users::PasswordPolicy
14
+ # Locks signing in for a while after 5 failures in a row, longer on each further failure.
15
+ include Trek::Users::SignInLockout
16
+ self.sign_in_lockout_mailer = "<%= class_name %>Mailer"
17
+
18
+ # The link confirming the email, used up once it is confirmed or changed.
19
+ generates_token_for :email_confirmation, expires_in: 1.day do
20
+ [email, confirmed_at]
21
+ end
22
+
23
+ # The link confirming a new email (unconfirmed_email), used up once it is changed.
24
+ generates_token_for :email_change, expires_in: 1.hour do
25
+ [email, unconfirmed_email]
26
+ end
27
+
28
+ <% end -%>
29
+ has_many :sessions, dependent: :delete_all
30
+ <% if code? -%>
31
+ has_many :sign_in_codes, dependent: :delete_all
32
+ <% end -%>
33
+
34
+ # Also applies to lookups (find_by(email:)), so signing in works whatever the case typed.
35
+ normalizes :email<% if password? %>, :unconfirmed_email<% end %>, with: ->(email) { email.strip.downcase }
36
+
37
+ validates :email, presence: true, uniqueness: { case_sensitive: false }, email: { mode: :strict }
38
+ <% if password? -%>
39
+ validates :unconfirmed_email, email: { mode: :strict }, allow_nil: true
40
+ <% end -%>
41
+
42
+ # The accounts nobody used since `time`, e.g. to delete them: they can only be <%= human_name.pluralize.downcase %>.
43
+ scope :inactive_since, ->(time) { where(last_activity_at: ...time).or(where(last_activity_at: nil, created_at: ...time)) }
44
+ <% if code? -%>
45
+
46
+ # The account of `email`, created if it has none: the code it was sent proved the email is theirs.
47
+ def self.for_verified_email!(email)
48
+ find_by(email:) || create!(email:)
49
+ rescue ActiveRecord::RecordNotUnique
50
+ # Created in parallel, e.g. a double submit
51
+ find_by!(email:)
52
+ end
53
+ <% else -%>
54
+
55
+ def confirmed?
56
+ confirmed_at.present?
57
+ end
58
+
59
+ def confirm_email!
60
+ update!(confirmed_at: Time.current)
61
+ end
62
+
63
+ # The new email once its link is followed, confirmed by it. False when another account took it
64
+ # in the meantime.
65
+ def change_email!
66
+ update(email: unconfirmed_email, unconfirmed_email: nil, confirmed_at: Time.current)
67
+ end
68
+ <% end -%>
69
+
70
+ def to_s
71
+ name.presence || email
72
+ end
73
+ end
@@ -0,0 +1,90 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "<%= human_name.pluralize %>' accounts through the API", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ <% if code? -%>
7
+ let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com") }
8
+ <% else -%>
9
+ let(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password: "correct horse battery staple") }
10
+ <% end -%>
11
+ let(:token) { sign_in(<%= file_name %>) }
12
+ let(:headers) { { "Authorization" => "Bearer #{token}" } }
13
+
14
+ <% if code? -%>
15
+ def emailed_code
16
+ ActionMailer::Base.deliveries.last.text_part.body.to_s[/\b\d{6}\b/]
17
+ end
18
+
19
+ def sign_in(<%= file_name %>)
20
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email }, as: :json }
21
+ post <%= route_prefix %>_sign_in_code_path, params: { email: <%= file_name %>.email, otp: emailed_code }, as: :json
22
+ response.parsed_body["token"]
23
+ end
24
+
25
+ def change_email_to(email)
26
+ perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: }, headers:, as: :json }
27
+ patch <%= route_prefix %>_email_change_path, params: { email:, otp: emailed_code }, headers:, as: :json
28
+ end
29
+ <% else -%>
30
+ def emailed_token
31
+ CGI.unescape(ActionMailer::Base.deliveries.last.text_part.body.to_s[/token=([^\s&]+)/, 1].to_s)
32
+ end
33
+
34
+ def sign_in(<%= file_name %>)
35
+ post <%= route_prefix %>_sign_in_path, params: { email: <%= file_name %>.email, password: "correct horse battery staple" }, as: :json
36
+ response.parsed_body["token"]
37
+ end
38
+
39
+ def change_email_to(email)
40
+ perform_enqueued_jobs { post <%= route_prefix %>_email_change_path, params: { email: }, headers:, as: :json }
41
+ patch <%= route_prefix %>_email_change_path, params: { token: emailed_token }, as: :json
42
+ end
43
+ <% end -%>
44
+
45
+ it "changes the name" do
46
+ patch <%= route_prefix %>_account_path, params: { name: "Ada Lovelace" }, headers:, as: :json
47
+
48
+ expect(response.parsed_body["name"]).to eq("Ada Lovelace")
49
+ end
50
+
51
+ it "changes the email once the new one is confirmed", :aggregate_failures do
52
+ change_email_to("ada@lovelace.org")
53
+
54
+ expect(ActionMailer::Base.deliveries.last.to).to eq(["ada@lovelace.org"])
55
+ expect(<%= file_name %>.reload.email).to eq("ada@lovelace.org")
56
+ end
57
+
58
+ it "lists the signed-in devices, and signs the others out", :aggregate_failures do
59
+ other = <%= file_name %>.sessions.create!
60
+ get <%= route_prefix %>_active_sessions_path, headers: headers
61
+
62
+ expect(response.parsed_body.pluck("current")).to contain_exactly(true, false)
63
+ delete others_<%= route_prefix %>_active_sessions_path, headers: headers
64
+ expect(<%= class_name %>::Session.exists?(other.id)).to be(false)
65
+ end
66
+
67
+ it "deletes the account after a recent sign-in", :aggregate_failures do
68
+ headers
69
+
70
+ expect { delete <%= route_prefix %>_account_path, headers: }.to change(<%= class_name %>, :count).by(-1)
71
+ expect(response).to have_http_status(:no_content)
72
+ end
73
+
74
+ it "asks to sign in again before deleting the account", :aggregate_failures do
75
+ headers
76
+ travel <%= class_name %>::Session::RECENT + 1.minute
77
+
78
+ expect { delete <%= route_prefix %>_account_path, headers: }.not_to change(<%= class_name %>, :count)
79
+ expect(response.parsed_body["error"]).to eq("recent_sign_in_needed")
80
+ end
81
+ <% if password? -%>
82
+
83
+ it "changes the password after a recent sign-in", :aggregate_failures do
84
+ patch <%= route_prefix %>_password_path, params: { password: "a brand new password 1!" }, headers:, as: :json
85
+
86
+ expect(response).to have_http_status(:no_content)
87
+ expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
88
+ end
89
+ <% end -%>
90
+ end
@@ -0,0 +1,158 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "<%= human_name.pluralize %>' sign-ins through the API", 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
+ # The response's status
12
+ def ask_for_code(email)
13
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_in_path, params: { email: }, as: :json }
14
+ response.status
15
+ end
16
+
17
+ def sign_in(email)
18
+ ask_for_code(email)
19
+ post <%= route_prefix %>_sign_in_code_path, params: { email:, otp: emailed_code }, as: :json
20
+ response.parsed_body["token"]
21
+ end
22
+
23
+ it "emails a code to an email without an account, with the same answer as for an account", :aggregate_failures do
24
+ <%= class_name %>.create!(email: "ada@example.com")
25
+
26
+ expect(ask_for_code("grace@example.com")).to eq(ask_for_code("ada@example.com")).and eq(202)
27
+ expect(ActionMailer::Base.deliveries.flat_map(&:to)).to eq(%w[grace@example.com ada@example.com])
28
+ end
29
+
30
+ it "refuses an invalid email", :aggregate_failures do
31
+ post <%= route_prefix %>_sign_in_path, params: { email: "not an email" }, as: :json
32
+
33
+ expect(response).to have_http_status(:unprocessable_content)
34
+ expect(response.parsed_body["error"]).to eq("invalid_email")
35
+ end
36
+
37
+ it "creates the account and answers with a token", :aggregate_failures do
38
+ token = nil
39
+
40
+ expect { token = sign_in("grace@example.com") }.to change(<%= class_name %>, :count).by(1)
41
+ expect(response).to have_http_status(:created)
42
+ expect(token).to be_present
43
+ end
44
+
45
+ it "refuses a wrong code", :aggregate_failures do
46
+ ask_for_code("ada@example.com")
47
+ post <%= route_prefix %>_sign_in_code_path, params: { email: "ada@example.com", otp: "000000" }, as: :json
48
+
49
+ expect(response).to have_http_status(:unauthorized)
50
+ expect(response.parsed_body["error"]).to eq("invalid_code")
51
+ end
52
+ <% else -%>
53
+ let(:password) { "correct horse battery staple" }
54
+ let!(:<%= file_name %>) { <%= class_name %>.create!(email: "ada@example.com", password:) }
55
+
56
+ def emailed_token
57
+ CGI.unescape(ActionMailer::Base.deliveries.last.text_part.body.to_s[/token=([^\s&]+)/, 1].to_s)
58
+ end
59
+
60
+ def sign_in(email, password: self.password)
61
+ post <%= route_prefix %>_sign_in_path, params: { email:, password: }, as: :json
62
+ response.parsed_body["token"]
63
+ end
64
+
65
+ it "signs up and in, answering with a token, and emails a link to confirm the email", :aggregate_failures do
66
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_up_path, params: { email: "grace@example.com", password: }, as: :json }
67
+
68
+ expect(response).to have_http_status(:created)
69
+ expect(response.parsed_body["token"]).to be_present
70
+ expect(ActionMailer::Base.deliveries.last.to).to eq(["grace@example.com"])
71
+ end
72
+
73
+ it "confirms the email with the token of the emailed link" do
74
+ perform_enqueued_jobs { post <%= route_prefix %>_sign_up_path, params: { email: "grace@example.com", password: }, as: :json }
75
+
76
+ patch <%= route_prefix %>_email_confirmation_path, params: { token: emailed_token }, as: :json
77
+
78
+ expect(<%= class_name %>.find_by(email: "grace@example.com")).to be_confirmed
79
+ end
80
+
81
+ it "answers with a token" do
82
+ expect(sign_in(<%= file_name %>.email)).to be_present
83
+ end
84
+
85
+ it "refuses a wrong password", :aggregate_failures do
86
+ sign_in(<%= file_name %>.email, password: "wrong horse battery staple")
87
+
88
+ expect(response).to have_http_status(:unauthorized)
89
+ expect(response.parsed_body["error"]).to eq("invalid_password")
90
+ end
91
+
92
+ it "resets the password with the token of the emailed link", :aggregate_failures do
93
+ perform_enqueued_jobs { post <%= route_prefix %>_password_reset_path, params: { email: <%= file_name %>.email }, as: :json }
94
+ expect(response).to have_http_status(:accepted)
95
+
96
+ patch <%= route_prefix %>_password_reset_path, params: { token: emailed_token, password: "a brand new password 1!" }, as: :json
97
+ expect(<%= file_name %>.reload.authenticate("a brand new password 1!")).to be_truthy
98
+ end
99
+
100
+ it "answers a password reset the same for an unknown email, and sends nothing", :aggregate_failures do
101
+ perform_enqueued_jobs { post <%= route_prefix %>_password_reset_path, params: { email: "nobody@example.com" }, as: :json }
102
+
103
+ expect(response).to have_http_status(:accepted)
104
+ expect(ActionMailer::Base.deliveries).to be_empty
105
+ end
106
+ <% end -%>
107
+
108
+ <% if audit_log? -%>
109
+ it "records the sign-in in the audit log" do
110
+ sign_in("ada@example.com")
111
+
112
+ expect(AuditLog.where(action: "<%= controllers_path %>/sessions#create", actor: <%= class_name %>.last)).to exist
113
+ end
114
+
115
+ <% end -%>
116
+ it "uses the token for the next requests" do
117
+ token = sign_in("ada@example.com")
118
+
119
+ get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
120
+
121
+ expect(response.parsed_body["email"]).to eq("ada@example.com")
122
+ end
123
+
124
+ it "refuses a request without a token, or with a forged one", :aggregate_failures do
125
+ get <%= route_prefix %>_account_path
126
+ expect(response).to have_http_status(:unauthorized)
127
+
128
+ token = sign_in("ada@example.com")
129
+ get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token.chop}x" }
130
+ expect(response).to have_http_status(:unauthorized)
131
+ end
132
+
133
+ it "ends the token's session when signing out" do
134
+ token = sign_in("ada@example.com")
135
+
136
+ delete <%= route_prefix %>_session_path, headers: { "Authorization" => "Bearer #{token}" }
137
+ get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
138
+
139
+ expect(response).to have_http_status(:unauthorized)
140
+ end
141
+
142
+ it "refuses the token after the session's idle timeout" do
143
+ token = sign_in("ada@example.com")
144
+
145
+ travel <%= class_name %>::Session.idle_timeout + 1.minute
146
+ get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{token}" }
147
+
148
+ expect(response).to have_http_status(:unauthorized)
149
+ end
150
+
151
+ it "refuses a token signed with another key" do
152
+ forged = JWT.encode({ sub: <%= class_name %>::Session.maximum(:id).to_i + 1 }, "wrong key", "HS256")
153
+
154
+ get <%= route_prefix %>_account_path, headers: { "Authorization" => "Bearer #{forged}" }
155
+
156
+ expect(response).to have_http_status(:unauthorized)
157
+ end
158
+ end
@@ -0,0 +1,56 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %>::SignInCode, type: :model do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ it "keeps a digest of the code, not the code", :aggregate_failures do
7
+ sign_in_code = described_class.issue("ada@example.com")
8
+
9
+ expect(sign_in_code.code).to match(/\A\d{6}\z/)
10
+ expect(described_class.find(sign_in_code.id).code_digest).not_to include(sign_in_code.code)
11
+ end
12
+
13
+ it "signs in once with the right code", :aggregate_failures do
14
+ code = described_class.issue("ada@example.com").code
15
+
16
+ expect(described_class.redeem("Ada@example.com", code)).to be_present
17
+ expect(described_class.redeem("ada@example.com", code)).to be_nil
18
+ end
19
+
20
+ it "accepts the code with spaces" do
21
+ code = described_class.issue("ada@example.com").code
22
+
23
+ expect(described_class.redeem("ada@example.com", "#{code[0, 3]} #{code[3..]}")).to be_present
24
+ end
25
+
26
+ it "replaces the email's earlier code" do
27
+ first = described_class.issue("ada@example.com").code
28
+ described_class.issue("ada@example.com")
29
+
30
+ expect(described_class.redeem("ada@example.com", first)).to be_nil
31
+ end
32
+
33
+ it "stops working after its validity" do
34
+ code = described_class.issue("ada@example.com").code
35
+
36
+ travel described_class.validity + 1.second do
37
+ expect(described_class.redeem("ada@example.com", code)).to be_nil
38
+ end
39
+ end
40
+
41
+ it "stops working after too many wrong tries, even with the right code" do
42
+ code = described_class.issue("ada@example.com").code
43
+ wrong = code.succ.rjust(6, "0")[-6..]
44
+ described_class.max_attempts.times { described_class.redeem("ada@example.com", wrong) }
45
+
46
+ expect(described_class.redeem("ada@example.com", code)).to be_nil
47
+ end
48
+
49
+ it "doesn't sign in with a code confirming a new email", :aggregate_failures do
50
+ <%= file_name %> = <%= class_name %>.create!(email: "ada@example.com")
51
+ code = described_class.issue("new@example.com", purpose: "email_change", <%= file_name %>:).code
52
+
53
+ expect(described_class.redeem("new@example.com", code)).to be_nil
54
+ expect(described_class.redeem("new@example.com", code, purpose: "email_change", <%= file_name %>:)).to be_present
55
+ end
56
+ end
@@ -0,0 +1,109 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %>, type: :model do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ <% if password? -%>
7
+ let(:password) { "correct horse battery staple" }
8
+
9
+ <% end -%>
10
+ def create_<%= file_name %>(email, **attributes)
11
+ described_class.create!(email:<% if password? %>, password:<% end %>, **attributes)
12
+ end
13
+
14
+ it "keeps emails in lower case, without spaces" do
15
+ expect(create_<%= file_name %>(" Ada@Example.com ").email).to eq("ada@example.com")
16
+ end
17
+
18
+ it "refuses an email another account has, whatever its case" do
19
+ create_<%= file_name %>("ada@example.com")
20
+
21
+ expect(described_class.new(email: "ADA@example.com")).not_to be_valid
22
+ end
23
+ <% if code? -%>
24
+
25
+ it "creates the account of a verified email once" do
26
+ first = described_class.for_verified_email!("ada@example.com")
27
+
28
+ expect(described_class.for_verified_email!("Ada@example.com")).to eq(first)
29
+ end
30
+ <% end -%>
31
+
32
+ it "finds the accounts nobody used since a date", :aggregate_failures do
33
+ used = create_<%= file_name %>("used@example.com", last_activity_at: 1.day.ago)
34
+ idle = create_<%= file_name %>("idle@example.com", last_activity_at: 2.years.ago)
35
+ never = travel_to(2.years.ago) { create_<%= file_name %>("never@example.com") }
36
+
37
+ expect(described_class.inactive_since(1.year.ago)).to contain_exactly(idle, never)
38
+ expect(described_class.inactive_since(1.year.ago)).not_to include(used)
39
+ end
40
+
41
+ it "deletes its sessions with it" do
42
+ <%= file_name %> = create_<%= file_name %>("ada@example.com")
43
+ <%= file_name %>.sessions.create!
44
+
45
+ expect { <%= file_name %>.destroy }.to change(<%= class_name %>::Session, :count).by(-1)
46
+ end
47
+
48
+ it "loses its expired sessions to the daily purge" do
49
+ <%= file_name %> = create_<%= file_name %>("ada@example.com")
50
+ <%= file_name %>.sessions.create!(created_at: 2.years.ago, last_activity_at: 2.years.ago)
51
+ current = <%= file_name %>.sessions.create!
52
+
53
+ Trek::PurgeExpiredSessionsJob.perform_now("<%= class_name %>::Session")
54
+
55
+ expect(<%= file_name %>.sessions.reload).to contain_exactly(current)
56
+ end
57
+ <% if password? -%>
58
+
59
+ describe "password" do
60
+ it "is required" do
61
+ expect(described_class.new(email: "ada@example.com")).not_to be_valid
62
+ end
63
+
64
+ it "follows the password rules" do
65
+ expect(described_class.new(email: "ada@example.com", password: "short")).not_to be_valid
66
+ end
67
+ end
68
+
69
+ describe "email confirmation" do
70
+ let(:<%= file_name %>) { create_<%= file_name %>("ada@example.com") }
71
+
72
+ it "is pending after signing up" do
73
+ expect(<%= file_name %>).not_to be_confirmed
74
+ end
75
+
76
+ it "finds the account by its link, once", :aggregate_failures do
77
+ token = <%= file_name %>.generate_token_for(:email_confirmation)
78
+
79
+ expect(described_class.find_by_token_for(:email_confirmation, token)).to eq(<%= file_name %>)
80
+ <%= file_name %>.confirm_email!
81
+ expect(described_class.find_by_token_for(:email_confirmation, token)).to be_nil
82
+ end
83
+ end
84
+
85
+ describe "email change" do
86
+ let(:<%= file_name %>) { create_<%= file_name %>("ada@example.com", unconfirmed_email: "ada@lovelace.org") }
87
+
88
+ it "takes the new email, confirmed", :aggregate_failures do
89
+ <%= file_name %>.change_email!
90
+
91
+ expect(<%= file_name %>.reload.email).to eq("ada@lovelace.org")
92
+ expect(<%= file_name %>).to be_confirmed
93
+ end
94
+
95
+ it "keeps the email when another account has the new one" do
96
+ create_<%= file_name %>("ada@lovelace.org")
97
+
98
+ expect(<%= file_name %>.change_email!).to be(false)
99
+ end
100
+
101
+ it "uses its link up once the email changed" do
102
+ token = <%= file_name %>.generate_token_for(:email_change)
103
+ <%= file_name %>.change_email!
104
+
105
+ expect(described_class.find_by_token_for(:email_change, token)).to be_nil
106
+ end
107
+ end
108
+ <% end -%>
109
+ end