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,267 @@
1
+ require "rails/generators/named_base"
2
+ require "rails/generators/migration"
3
+ require "generators/trek/helpers"
4
+
5
+ module Trek
6
+ module Generators
7
+ # Accounts for the site's or the mobile app's users (customers, members…), apart from the admin's
8
+ # User: their own model, table, sessions and sign-in, so nothing public can reach a staff account.
9
+ #
10
+ # They sign in one way, picked with --sign-in:
11
+ #
12
+ # - `code` (default): a code emailed to them, which creates the account the first time;
13
+ # - `password`: a password chosen on signing up, the email confirmed by a link, and reset by one.
14
+ #
15
+ # By default for a website: pages, and sessions kept in a cookie. With --api, for a mobile app:
16
+ # JSON endpoints, and sessions held through a JWT instead. With --model-only, neither: the models,
17
+ # mails and specs, for an app whose sign-in is its own (e.g. an API ported from another app).
18
+ class PublicAccountsGenerator < Rails::Generators::NamedBase
19
+ # Taken by the admin's account and Trek's own models, or planned for Trek 3 (Staff::User).
20
+ RESERVED_NAMES = %w[User Staff Session Current Page PagePath PageVersion Fragment MenuNode Tag Tagging TagCategory
21
+ ExternalLink AuditLog Dashboard].freeze
22
+ SIGN_INS = %w[code password].freeze
23
+ JWT_VERSION = "~> 3.1".freeze
24
+
25
+ include Rails::Generators::Migration
26
+ include Trek::Generators::Helpers
27
+
28
+ desc "Create accounts for the site's or the app's users, apart from the admin's: " \
29
+ "rails g trek:public_accounts Customer [--sign-in=code|password] [--api] [--path=customers] [--model-only]"
30
+
31
+ class_option :sign_in, type: :string, default: "code", enum: SIGN_INS,
32
+ desc: "How accounts sign in: with a code emailed to them, or with a password"
33
+ class_option :api, type: :boolean, default: false,
34
+ desc: "JSON endpoints and JWTs for a mobile app, instead of pages and a cookie"
35
+ class_option :path, type: :string, default: nil,
36
+ desc: "A path to put the routes under, e.g. members for /members/sign-in"
37
+ class_option :model_only, type: :boolean, default: false,
38
+ desc: "Only the models, mails and specs: no controllers, pages, routes nor tokens, the app signs in its own way"
39
+
40
+ source_root File.expand_path("templates/public_accounts", __dir__)
41
+
42
+ def check_name
43
+ return unless class_name.include?("::") || RESERVED_NAMES.include?(class_name)
44
+
45
+ raise Thor::Error, "#{class_name} can't be a public account model: pick a top-level name of the app's domain " \
46
+ "(Customer, Member, Reader…), other than #{RESERVED_NAMES.to_sentence(last_word_connector: " or ")}."
47
+ end
48
+
49
+ def check_options
50
+ return unless model_only? && (api? || options[:path].present?)
51
+
52
+ raise Thor::Error, "--model-only adds no endpoints nor routes: drop --api and --path."
53
+ end
54
+
55
+ def add_jwt_gem
56
+ return unless api?
57
+ return if in_gemfile?("jwt")
58
+
59
+ gem "jwt", JWT_VERSION
60
+ bundle_install
61
+ end
62
+
63
+ def copy_models
64
+ template "models/account.rb.tt", "app/models/#{file_name}.rb"
65
+ template "models/account/session.rb.tt", "app/models/#{file_name}/session.rb"
66
+ template "models/account/sign_in_code.rb.tt", "app/models/#{file_name}/sign_in_code.rb" if code?
67
+ end
68
+
69
+ def copy_migration
70
+ return unless defined?(ActiveRecord)
71
+
72
+ migration_template "migrations/create_accounts.rb.tt", "db/migrate/trek_create_#{table_name}.rb",
73
+ migration_class_name:
74
+ end
75
+
76
+ def add_current_attributes
77
+ inject_into_class "app/models/current.rb", "Current", <<-RUBY
78
+ # The signed-in #{human_name.downcase} (#{model_only? ? "set by the app's own sign-in" : "#{accounts_module}::Authentication"})
79
+ attribute :#{file_name}_session
80
+ delegate :#{file_name}, to: :#{file_name}_session, allow_nil: true
81
+
82
+ RUBY
83
+ end
84
+
85
+ def copy_authentication_concern
86
+ return if model_only?
87
+
88
+ template "#{mode}/authentication.rb.tt", "app/controllers/concerns/#{accounts_path}/authentication.rb"
89
+ end
90
+
91
+ # Every page of the site may show who is signed in. The API's controllers include it in their
92
+ # base controller instead.
93
+ def include_authentication_in_application_controller
94
+ return unless web?
95
+
96
+ inject_into_class "app/controllers/application_controller.rb", "ApplicationController",
97
+ " include #{accounts_module}::Authentication\n"
98
+ end
99
+
100
+ def copy_controllers
101
+ return if model_only?
102
+
103
+ controllers.each do |controller|
104
+ template "#{mode}/controllers/#{controller}_controller.rb.tt", "app/controllers/#{controllers_path}/#{controller}_controller.rb"
105
+ end
106
+ end
107
+
108
+ def copy_views
109
+ return unless web?
110
+
111
+ template "web/views/_flash.html.slim.tt", "app/views/#{controllers_path}/_flash.html.slim"
112
+ views.each do |view|
113
+ template "web/views/#{view}.html.slim.tt", "app/views/#{controllers_path}/#{view}.html.slim"
114
+ end
115
+ end
116
+
117
+ def copy_job
118
+ template "jobs/sign_in_code_job.rb.tt", "app/jobs/#{accounts_path}/sign_in_code_job.rb" if code?
119
+ end
120
+
121
+ def copy_mailer
122
+ template "mailers/account_mailer.rb.tt", "app/mailers/#{file_name}_mailer.rb"
123
+ mails.product(%w[en fr], %w[html.slim text.erb]).each do |mail, locale, format|
124
+ template "views/mailer/#{mail}.#{locale}.#{format}.tt", "app/views/#{file_name}_mailer/#{mail}.#{locale}.#{format}"
125
+ end
126
+ end
127
+
128
+ def copy_locales
129
+ %w[en fr].each do |locale|
130
+ template "locales/accounts.#{locale}.yml.tt", "config/locales/#{accounts_path}.#{locale}.yml"
131
+ end
132
+ end
133
+
134
+ def add_routes
135
+ route routes unless model_only?
136
+ end
137
+
138
+ def copy_specs
139
+ return unless File.directory?(File.join(destination_root, "spec"))
140
+
141
+ template "spec/models/account_spec.rb.tt", "spec/models/#{file_name}_spec.rb"
142
+ template "spec/models/account/sign_in_code_spec.rb.tt", "spec/models/#{file_name}/sign_in_code_spec.rb" if code?
143
+ return if model_only?
144
+
145
+ template "spec/#{mode}/sign_ins_spec.rb.tt", "spec/requests/#{controllers_path}/sign_ins_spec.rb"
146
+ template "spec/#{mode}/accounts_spec.rb.tt", "spec/requests/#{controllers_path}/accounts_spec.rb"
147
+ return if api?
148
+
149
+ template "spec/web/authentication_spec.rb.tt", "spec/controllers/application_controller_#{accounts_path}_spec.rb"
150
+ end
151
+
152
+ private
153
+
154
+ def code? = options[:sign_in] == "code"
155
+ def password? = options[:sign_in] == "password"
156
+ def api? = options[:api]
157
+ def model_only? = options[:model_only]
158
+ # Pages and a cookie: neither --api nor --model-only
159
+ def web? = !api? && !model_only?
160
+ def mode = api? ? "api" : "web"
161
+ # With trek:install:audit_log, which the account controllers write to
162
+ def audit_log? = File.exist?(File.join(destination_root, "app/models/audit_log.rb"))
163
+
164
+ # Customers: the module of the authentication concern, and the folder of its files.
165
+ def accounts_module = class_name.pluralize
166
+ def accounts_path = file_name.pluralize
167
+
168
+ # The controllers' module and folder: under Api::V1:: for the API, the first version of it, so that a
169
+ # later version can be served next to it while older releases of the mobile app still use this one.
170
+ def controllers_module = api? ? "Api::V1::#{accounts_module}" : accounts_module
171
+ def controllers_path = api? ? "api/v1/#{accounts_path}" : accounts_path
172
+
173
+ # The route helpers' prefix: customer_sign_in_path, api_v1_customer_sign_in_path…
174
+ def route_prefix = api? ? "api_v1_#{file_name}" : file_name
175
+
176
+ # Where the routes go: /sign-in, /members/sign-in, /api/v1/sign-in…
177
+ def routes_path = [("api/v1" if api?), options[:path].presence].compact.join("/").presence
178
+
179
+ def controllers
180
+ names = code? ? %w[sign_in_codes sessions] : %w[registrations sessions password_resets email_confirmations passwords]
181
+ %w[base] + names + %w[accounts email_changes active_sessions]
182
+ end
183
+
184
+ def views
185
+ shared = %w[sessions/new accounts/show email_changes/new]
186
+ if code?
187
+ shared + %w[sign_in_codes/new email_changes/edit]
188
+ else
189
+ shared + %w[registrations/new password_resets/new password_resets/edit passwords/edit]
190
+ end
191
+ end
192
+
193
+ def mails
194
+ code? ? %w[sign_in_code email_change_code] : %w[email_confirmation password_reset email_change sign_in_locked]
195
+ end
196
+
197
+ # On paths of their own rather than the model's name, so renaming the model keeps them.
198
+ def routes
199
+ scope = [(%("#{routes_path}") if routes_path), "module: \"#{controllers_path}\"", "as: :#{route_prefix}"].compact
200
+ scope << "defaults: { format: :json }" if api?
201
+
202
+ <<~RUBY
203
+ # #{human_name.pluralize}' accounts (#{accounts_module}::Authentication)
204
+ scope #{scope.join(", ")} do
205
+ #{(api? ? api_routes : web_routes).gsub(/^/, " ")}end
206
+ RUBY
207
+ end
208
+
209
+ def web_routes
210
+ sign_in = if code?
211
+ <<~RUBY
212
+ get "sign-in", to: "sign_in_codes#new", as: :sign_in
213
+ post "sign-in", to: "sign_in_codes#create"
214
+ get "sign-in/code", to: "sessions#new", as: :sign_in_code
215
+ post "sign-in/code", to: "sessions#create"
216
+ RUBY
217
+ else
218
+ <<~RUBY
219
+ get "sign-in", to: "sessions#new", as: :sign_in
220
+ post "sign-in", to: "sessions#create"
221
+ get "sign-up", to: "registrations#new", as: :sign_up
222
+ post "sign-up", to: "registrations#create"
223
+ resource :password_reset, only: %i[new create edit update], path: "password-reset"
224
+ get "account/confirm", to: "email_confirmations#show", as: :email_confirmation
225
+ post "account/confirm", to: "email_confirmations#create"
226
+ resource :password, only: %i[edit update], path: "account/password"
227
+ RUBY
228
+ end
229
+
230
+ sign_in + <<~RUBY
231
+ delete "sign-out", to: "sessions#destroy", as: :sign_out
232
+ resource :account, only: %i[show update destroy]
233
+ resource :email_change, only: %i[new create #{code? ? "edit update" : "show"}], path: "account/email"
234
+ resources :active_sessions, only: :destroy, path: "account/sessions" do
235
+ delete :others, action: :destroy_others, on: :collection
236
+ end
237
+ RUBY
238
+ end
239
+
240
+ def api_routes
241
+ sign_in = if code?
242
+ <<~RUBY
243
+ post "sign-in", to: "sign_in_codes#create", as: :sign_in
244
+ post "sign-in/code", to: "sessions#create", as: :sign_in_code
245
+ RUBY
246
+ else
247
+ <<~RUBY
248
+ post "sign-in", to: "sessions#create", as: :sign_in
249
+ post "sign-up", to: "registrations#create", as: :sign_up
250
+ resource :password_reset, only: %i[create update], path: "password-reset"
251
+ resource :email_confirmation, only: %i[create update], path: "account/confirmation"
252
+ resource :password, only: :update, path: "account/password"
253
+ RUBY
254
+ end
255
+
256
+ sign_in + <<~RUBY
257
+ delete "session", to: "sessions#destroy", as: :session
258
+ resource :account, only: %i[show update destroy]
259
+ resource :email_change, only: %i[create update], path: "account/email"
260
+ resources :active_sessions, only: %i[index destroy], path: "account/sessions" do
261
+ delete :others, action: :destroy_others, on: :collection
262
+ end
263
+ RUBY
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,55 @@
1
+ require "rails/generators/actions"
2
+ require "generators/trek/helpers"
3
+
4
+ module Trek
5
+ module Generators
6
+ class ScalingoGenerator < Rails::Generators::Base
7
+ BUILDPACK_URL = "https://github.com/etaminstudio/trek-buildpack/archive/refs/tags/v0.1.0.tar.gz".freeze
8
+
9
+ include Trek::Generators::Helpers
10
+
11
+ desc "Create Scalingo files."
12
+
13
+ # The trek-buildpack replaces the apt + ruby buildpack combination. It
14
+ # pins Node from the .node-version that `rails new` already generates.
15
+ def remove_buildpacks
16
+ remove_file ".buildpacks"
17
+ end
18
+
19
+ def create_aptfile
20
+ create_file "Aptfile" do
21
+ <<~TEXT
22
+ libjemalloc2
23
+ libvips-tools --no-install-recommends
24
+ libwebp-dev
25
+ TEXT
26
+ end
27
+ end
28
+
29
+ def create_procfile
30
+ create_file "Procfile" do
31
+ <<~TEXT
32
+ web: bundle exec rails server -p $PORT -e $RAILS_ENV -b 0.0.0.0
33
+ # A worker is only needed once jobs outgrow the web container
34
+ # (config.good_job.execution_mode = :async):
35
+ # worker: bundle exec good_job start
36
+ postdeploy: bundle exec rails db:migrate
37
+ TEXT
38
+ end
39
+ end
40
+
41
+ def final_instructions
42
+ say ""
43
+ say "Configure the Scalingo app environment:"
44
+ say ""
45
+ say " scalingo --app <app> env-set BUILDPACK_URL=#{BUILDPACK_URL}"
46
+ say " scalingo --app <app> env-set 'DATABASE_URL=$SCALINGO_POSTGRESQL_URL'"
47
+ say ""
48
+ say "and make sure config/database.yml reads DATABASE_URL in production."
49
+ say ""
50
+ say "See https://trek.etamin.studio/deploy for details."
51
+ say ""
52
+ end
53
+ end
54
+ end
55
+ end
@@ -20,7 +20,7 @@ stages:
20
20
 
21
21
  # Defaults
22
22
  default:
23
- image: registry.git.etamin.studio/devops/ci-ruby/node-24:4.0.7
23
+ image: registry.git.etamin.studio/devops/ci-ruby/node-24:4.0.9
24
24
 
25
25
  services:
26
26
  - postgres:16
@@ -0,0 +1,11 @@
1
+ module Admin
2
+ # After a suspected compromise: every user, this admin included, must choose a new password at
3
+ # their next request (User.require_password_renewal!).
4
+ class PasswordRenewalsController < Admin::BaseController
5
+ def create
6
+ authorize! User, to: :require_password_renewal?
7
+ User.require_password_renewal!
8
+ redirect_to admin_users_path, notice: t(".success")
9
+ end
10
+ end
11
+ end
@@ -39,7 +39,7 @@ module Admin
39
39
  else
40
40
  @user&.record_failed_sign_in!
41
41
  count_failed_sign_in_from_ip
42
- render_sign_in_form t(".failure"), status: :unprocessable_entity
42
+ render_sign_in_form t(".failure"), status: :unprocessable_content
43
43
  end
44
44
  end
45
45
 
@@ -24,14 +24,6 @@ module Admin
24
24
  @object.password = SecureRandom.alphanumeric(24) if @object.password.blank?
25
25
  end
26
26
 
27
- # After a suspected compromise: every user, this admin included, must choose a new password at
28
- # their next request.
29
- def require_password_renewal
30
- authorize! User, to: :require_password_renewal?
31
- User.require_password_renewal!
32
- redirect_to admin_users_path, notice: t(".success")
33
- end
34
-
35
27
  def theme
36
28
  value = params[:theme].to_s
37
29
  Current.user.update!(theme: value) if User.themes.key?(value)
@@ -22,8 +22,9 @@ Rails.application.configure do
22
22
  # Framed by its own pages only (Lookbook's previews), against clickjacking
23
23
  policy.frame_ancestors :self
24
24
  policy.object_src :none
25
- # No inline script: the app's own files, and the scripts given the request's nonce
26
- policy.script_src :self
25
+ # No inline script: the app's own files, and the scripts given the request's nonce. A blocked
26
+ # inline script's first 40 characters go with the report (`'report-sample'`), to tell what it was
27
+ policy.script_src :self, :report_sample
27
28
  # Inline `style` attributes stay allowed: Trek's components and Backpack's set some (colors,
28
29
  # custom properties)
29
30
  policy.style_src :self, :unsafe_inline
@@ -0,0 +1,38 @@
1
+ Rails.application.configure do
2
+ # One scheduler for every queue. Give a queue its own lane when it must not wait behind the
3
+ # others, e.g. "mailers:2;*:3" — the thread counts have to stay within GOOD_JOB_MAX_THREADS,
4
+ # which config/database.yml turns into database connections.
5
+ config.good_job.queues = "*"
6
+
7
+ # Cron runs wherever a scheduler runs, which in :async mode means every web container. Keep it
8
+ # to production so a development machine never fires a scheduled job.
9
+ config.good_job.enable_cron = Rails.env.production?
10
+ config.good_job.cron = {
11
+ # purge_old_sessions: {
12
+ # cron: "0 3 * * *", # every day at 3am
13
+ # class: "PurgeOldSessionsJob",
14
+ # description: "Delete the user sessions that expired more than a month ago"
15
+ # }
16
+ }
17
+
18
+ # An error raised outside a job's own execution (in the scheduler thread) has no retry handler
19
+ # to catch it. Report it through Rails.error, like the rest of the app.
20
+ config.good_job.on_thread_error = ->(error) { Rails.error.report(error, handled: false) }
21
+ end
22
+
23
+ # The dashboard is mounted at /admin/good_job, outside Admin::BaseController and therefore outside
24
+ # Trek's authentication. Guard it with the same signed session cookie, and answer 404 rather than
25
+ # redirecting: the route stays invisible to anyone who is not an administrator.
26
+ ActiveSupport.on_load(:good_job_application_controller) do
27
+ before_action do
28
+ raise ActionController::RoutingError, "Not Found" unless good_job_dashboard_user&.admin?
29
+ end
30
+
31
+ private
32
+
33
+ def good_job_dashboard_user
34
+ return unless cookies.signed[:session_id]
35
+
36
+ <%= session_model %>.find_by(id: cookies.signed[:session_id])&.user
37
+ end
38
+ end
@@ -0,0 +1,6 @@
1
+ # Delivers every `deliver_later` email (config.action_mailer.delivery_job).
2
+ # GoodJob discards a failed job rather than retrying it, so a transient error from the delivery
3
+ # service would lose the email: retry with a growing delay instead.
4
+ class MailDeliveryJob < ActionMailer::MailDeliveryJob
5
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
6
+ end
@@ -56,6 +56,11 @@ html(lang=I18n.locale class=class_for_theme)
56
56
  href: %i[admin users],
57
57
  allowed_to: allowed_to?(:index?, User),
58
58
  )
59
+ s.with_item(
60
+ text: t("admin.jobs"),
61
+ href: "/admin/good_job",
62
+ allowed_to: Current.user.admin?,
63
+ )
59
64
  end
60
65
  end
61
66
  = render Trek::ToasterComponent.new
@@ -13,11 +13,13 @@ class AdminMailer < ApplicationMailer
13
13
  end
14
14
  end
15
15
 
16
- # A sign-in from a browser that never signed in as the user, so they can react if it wasn't them
16
+ # A sign-in from a browser that never signed in as the user, so they can react if it wasn't them.
17
+ # The IP address comes with its host name, as on the active sessions: sent later, the email can
18
+ # wait for the lookup.
17
19
  def new_sign_in(user, at:, ip:, user_agent:)
18
20
  @user = user
19
21
  @at = at
20
- @ip = ip
22
+ @address = [ip, Trek::ReverseDns.hostname(ip)&.then { |hostname| "(#{hostname})" }].compact.join(" ")
21
23
  @user_agent = user_agent
22
24
  @sessions_url = admin_user_url(user, anchor: "sessions")
23
25
  @reset_url = new_admin_user_password_reset_url
@@ -4,7 +4,7 @@
4
4
  "dependencies": {
5
5
  "@hotwired/stimulus": "^3.2.2",
6
6
  "@hotwired/turbo-rails": "^8.0.0",
7
- "trek": "^<%= Trek::VERSION %>"
7
+ "trek": "<%= ENV.fetch("TREK_NPM_PACKAGE") { "^#{Trek::VERSION}" } %>"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "node esbuild.config.js"
@@ -0,0 +1,76 @@
1
+ # Signs <%= human_name.pluralize.downcase %> in to the API through a JWT the mobile app sends with each request
2
+ # (Authorization: Bearer). The token only names a <%= class_name %>::Session: signing out deletes the session and
3
+ # the token with it, and the session's idle timeout and lifetime apply as on the web. Apart from the
4
+ # admin's users and sessions altogether.
5
+ module <%= accounts_module %>::Authentication
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :require_<%= file_name %>
10
+ after_action :track_<%= file_name %>_activity
11
+ end
12
+
13
+ class_methods do
14
+ def allow_unauthenticated_access(**options)
15
+ skip_before_action :require_<%= file_name %>, **options
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def require_<%= file_name %>
22
+ render_api_error :unauthorized, :sign_in_needed unless resume_<%= file_name %>_session
23
+ end
24
+
25
+ # Changing the password or deleting the account asks to sign in again when the session isn't recent.
26
+ def require_recent_<%= file_name %>_sign_in
27
+ render_api_error :forbidden, :recent_sign_in_needed unless Current.<%= file_name %>_session.recent?
28
+ end
29
+
30
+ def resume_<%= file_name %>_session
31
+ Current.<%= file_name %>_session ||= find_<%= file_name %>_session_by_token
32
+ end
33
+
34
+ # A session past its idle timeout or lifetime is dropped: the <%= human_name.downcase %> signs in again.
35
+ def find_<%= file_name %>_session_by_token
36
+ token = request.authorization.to_s[/\ABearer (.+)\z/, 1]
37
+ return unless token
38
+
39
+ <%= file_name %>_session = <%= class_name %>::Session.from_token(token)
40
+ return <%= file_name %>_session unless <%= file_name %>_session&.expired?
41
+
42
+ <%= file_name %>_session.destroy
43
+ nil
44
+ end
45
+
46
+ # The session the token of the response will name
47
+ def start_<%= file_name %>_session_for(<%= file_name %>)
48
+ <%= file_name %>.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |<%= file_name %>_session|
49
+ Current.<%= file_name %>_session = <%= file_name %>_session
50
+ Trek::ResolveSessionHostnameJob.perform_later(<%= file_name %>_session)
51
+ end
52
+ end
53
+
54
+ def terminate_<%= file_name %>_session
55
+ Current.<%= file_name %>_session.destroy
56
+ Current.<%= file_name %>_session = nil
57
+ end
58
+
59
+ # At most once every <%= class_name %>::Session.activity_interval (5 minutes), so using the app doesn't write on
60
+ # every request.
61
+ def track_<%= file_name %>_activity
62
+ <%= file_name %>_session = Current.<%= file_name %>_session
63
+ return unless <%= file_name %>_session&.persisted?
64
+
65
+ <%= file_name %>_session.record_activity
66
+ <%= file_name %> = <%= file_name %>_session.<%= file_name %>
67
+ return if <%= file_name %>.last_activity_at&.after?(<%= class_name %>::Session.activity_interval.ago)
68
+
69
+ <%= file_name %>.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
70
+ end
71
+
72
+ # An error the app can act on (`error`), with a message it may show (`message`).
73
+ def render_api_error(status, error, **details)
74
+ render json: { error:, message: I18n.t("<%= accounts_path %>.api.errors.#{error}"), **details }, status:
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # The signed-in <%= human_name.downcase %>'s account: GET, PATCH { name }, and DELETE after a recent sign-in.
2
+ class <%= controllers_module %>::AccountsController < <%= controllers_module %>::BaseController
3
+ before_action :require_recent_<%= file_name %>_sign_in, only: :destroy
4
+
5
+ def show
6
+ render json: <%= file_name %>_json(Current.<%= file_name %>)
7
+ end
8
+
9
+ def update
10
+ if Current.<%= file_name %>.update(params.permit(:name))
11
+ render json: <%= file_name %>_json(Current.<%= file_name %>)
12
+ else
13
+ render_api_error :unprocessable_content, :invalid, errors: Current.<%= file_name %>.errors
14
+ end
15
+ end
16
+
17
+ def destroy
18
+ Current.<%= file_name %>.destroy!
19
+ Current.<%= file_name %>_session = nil
20
+ head :no_content
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # The devices signed in as the current <%= human_name.downcase %>: GET lists them, DELETE signs one or the others out.
2
+ # Only theirs: every query goes through Current.<%= file_name %>.sessions.
3
+ class <%= controllers_module %>::ActiveSessionsController < <%= controllers_module %>::BaseController
4
+ def index
5
+ sessions = Current.<%= file_name %>.sessions.active.recent_first.map do |<%= file_name %>_session|
6
+ <%= file_name %>_session.as_json(only: %i[id user_agent ip_address ip_hostname created_at last_activity_at])
7
+ .merge("current" => <%= file_name %>_session == Current.<%= file_name %>_session)
8
+ end
9
+ render json: sessions
10
+ end
11
+
12
+ def destroy
13
+ Current.<%= file_name %>.sessions.find(params[:id]).destroy
14
+ head :no_content
15
+ end
16
+
17
+ def destroy_others
18
+ Current.<%= file_name %>.sessions.where.not(id: Current.<%= file_name %>_session.id).destroy_all
19
+ head :no_content
20
+ end
21
+ end
@@ -0,0 +1,71 @@
1
+ # The API's account endpoints: <%= human_name.pluralize.downcase %> only, never the admin's users.
2
+ class <%= controllers_module %>::BaseController < ActionController::API
3
+ include <%= accounts_module %>::Authentication
4
+ # 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.
5
+ include Trek::Auditable if defined?(::AuditLog)
6
+
7
+ # Emails sent to one address (<% if code? %>codes<% else %>password resets, confirmations<% end %>), whatever the IP address: rate_limit
8
+ # counts per IP address.
9
+ EMAILS_PER_ADDRESS = 5
10
+ EMAILS_PER_ADDRESS_WINDOW = 1.hour
11
+
12
+ # Failed sign-ins from one IP address, whatever the account, over a longer window than rate_limit's.
13
+ FAILED_SIGN_INS_PER_IP = 20
14
+ FAILED_SIGN_INS_WINDOW = 1.hour
15
+
16
+ private
17
+
18
+ def normalized_email(email)
19
+ <%= class_name %>.normalize_value_for(:email, email.to_s)
20
+ end
21
+
22
+ def valid_email?(email)
23
+ email.match?(URI::MailTo::EMAIL_REGEXP)
24
+ end
25
+
26
+ # Counted in Rails.cache, as rate_limit is, under a digest rather than the email itself.
27
+ def too_many_emails_to?(email)
28
+ key = "<%= accounts_path %>/emails/#{Digest::SHA256.hexdigest(email)}"
29
+ Rails.cache.increment(key, 1, expires_in: EMAILS_PER_ADDRESS_WINDOW).to_i > EMAILS_PER_ADDRESS
30
+ end
31
+
32
+ def too_many_failed_sign_ins_from_ip?
33
+ Rails.cache.read(failed_sign_ins_from_ip_key).to_i >= FAILED_SIGN_INS_PER_IP
34
+ end
35
+
36
+ def count_failed_sign_in_from_ip
37
+ Rails.cache.increment(failed_sign_ins_from_ip_key, 1, expires_in: FAILED_SIGN_INS_WINDOW)
38
+ end
39
+
40
+ def failed_sign_ins_from_ip_key
41
+ "<%= accounts_path %>/failed_sign_ins/#{request.remote_ip}"
42
+ end
43
+
44
+ def render_rate_limited
45
+ render_api_error :too_many_requests, :rate_limited
46
+ end
47
+
48
+ def <%= file_name %>_json(<%= file_name %>)
49
+ <% if password? -%>
50
+ <%= file_name %>.as_json(only: %i[id email unconfirmed_email name confirmed_at created_at])
51
+ <% else -%>
52
+ <%= file_name %>.as_json(only: %i[id email name created_at])
53
+ <% end -%>
54
+ end
55
+
56
+ # A new session, and the token naming it, for the app to send with each request
57
+ def render_new_session(<%= file_name %>, status: :created)
58
+ <%= file_name %>_session = start_<%= file_name %>_session_for(<%= file_name %>)
59
+ render json: { token: <%= file_name %>_session.token, <%= file_name %>: <%= file_name %>_json(<%= file_name %>) }, status:
60
+ end
61
+
62
+ # For the audit log (Trek::Auditable): the signed-in <%= human_name.downcase %>, nil before signing in
63
+ def audit_actor
64
+ Current.<%= file_name %>
65
+ end
66
+
67
+ # The account signed in to, tried, or changed
68
+ def audit_target
69
+ @<%= file_name %> || Current.<%= file_name %>
70
+ end
71
+ end