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
@@ -31,12 +31,3 @@
31
31
  size: :narrow,
32
32
  data: remove_button_data_attributes,
33
33
  )
34
- / The uploaded file itself, the preview being a thumbnail
35
- = render Trek::ButtonComponent.new( \
36
- text: t("admin.actions.see_original"),
37
- size: :narrow,
38
- href: image.url,
39
- target: "_blank",
40
- rel: "noopener",
41
- ) do |button|
42
- - button.with_icon("trek/external")
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "content_disposition"
4
+
3
5
  module Trek
4
6
  module Form
5
7
  class ImageFieldComponent < ViewComponent::Form::FileFieldComponent
@@ -16,16 +18,29 @@ module Trek
16
18
 
17
19
  private
18
20
 
21
+ # The thumbnail, with links opening the uploaded file in a new tab and downloading it. A file just picked,
22
+ # not yet saved, has no URL: the JS replaces the preview with its own thumbnail.
19
23
  def preview
20
- if image&.exists?
21
- # TODO: connect panel
22
- # link_to edit_admin_image_path(image.id),
23
- # 'data-action': "admin--layout#openPanelWhenLoaded",
24
- # 'data-turbo-frame': "admin--layout-panel" do
25
- image_tag image_url, loading: "lazy"
26
- # end
27
- else
28
- render Trek::IconComponent.new("trek/pic")
24
+ return render(Trek::IconComponent.new("trek/pic")) unless image&.exists?
25
+
26
+ tag.div(class: class_for("original")) do
27
+ safe_join([
28
+ image_tag(image_url, loading: "lazy", alt: ""),
29
+ tag.div(class: class_for("original-actions")) do
30
+ safe_join([
31
+ original_link(image.url, "trek/external", t("admin.actions.see_original"),
32
+ target: "_blank", rel: "noopener"),
33
+ original_link(download_url, "trek/download", t("admin.actions.download_original"),
34
+ download: image.original_filename.to_s)
35
+ ])
36
+ end
37
+ ])
38
+ end
39
+ end
40
+
41
+ def original_link(url, icon, label, **options)
42
+ link_to url, class: class_for("original-action"), title: label, "aria-label": label, **options do
43
+ render Trek::IconComponent.new(icon, aria_hidden: true)
29
44
  end
30
45
  end
31
46
 
@@ -33,6 +48,18 @@ module Trek
33
48
  Trek::ThumbnailHelper.thumbnail_url(image, 300, 150)
34
49
  end
35
50
 
51
+ # The `download` attribute only applies to a file on the admin's own host. On S3, the file is served from
52
+ # the bucket's host, so it's a URL signed to answer as an attachment: signed for the bucket itself, without
53
+ # the `url_options` host (a CDN's), and valid for a day, as the page may stay open a while.
54
+ def download_url
55
+ image.url(
56
+ public: false,
57
+ host: nil,
58
+ expires_in: 1.day.to_i,
59
+ response_content_disposition: ContentDisposition.attachment(image.original_filename)
60
+ )
61
+ end
62
+
36
63
  def image
37
64
  object.public_send(:"#{method_name}")
38
65
  end
@@ -0,0 +1,53 @@
1
+ module Trek
2
+ module Content
3
+ # Removes from a document what rendering skips (Paragraph, ListItem, BulletList, OrderedList): empty
4
+ # paragraphs, empty list items, and lists left without items. Trek::Contentable calls it before saving.
5
+ #
6
+ # The document stays one the editor can open, without the schema adding anything back: an empty
7
+ # document keeps one paragraph, a list item keeps its first paragraph, which it needs before a nested
8
+ # list, and a node left with only empty paragraphs, like a quote, is kept as it was. Whatever the schema
9
+ # doesn't know, node types or attributes of other Tiptap extensions, is kept.
10
+ class Cleaner
11
+ LISTS = %i[bullet_list ordered_list].freeze
12
+
13
+ # A Prosemirror::Document, cleaned
14
+ def call(document)
15
+ root = document.root
16
+ content = clean_content(root.content)
17
+ content = [Prosemirror::Node.new(type: :paragraph)] if content.empty?
18
+ Prosemirror::Document.new(content == root.content ? root : root.with(content:), schema: document.schema)
19
+ end
20
+
21
+ private
22
+
23
+ def clean_content(nodes)
24
+ nodes.filter_map { |node| clean(node) }
25
+ end
26
+
27
+ # The node, cleaned, or nil to remove it
28
+ def clean(node)
29
+ return if empty_paragraph?(node)
30
+ return node if node.text? || node.content.empty?
31
+ return clean_list_item(node) if node.type == :list_item
32
+
33
+ content = clean_content(node.content)
34
+ return content.presence && node.with(content:) if LISTS.include?(node.type)
35
+ return node if content.empty? || content == node.content
36
+
37
+ node.with(content:)
38
+ end
39
+
40
+ def clean_list_item(item)
41
+ return if ListItem.empty?(item)
42
+
43
+ first, *rest = item.content
44
+ content = [empty_paragraph?(first) ? first : clean(first), *clean_content(rest)]
45
+ content == item.content ? item : item.with(content:)
46
+ end
47
+
48
+ def empty_paragraph?(node)
49
+ node.type == :paragraph && node.content.empty?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -19,8 +19,8 @@ module Trek
19
19
  @record = record
20
20
  end
21
21
 
22
- def to_phlex
23
- Renderer.new(self)
22
+ def to_phlex(heading_ids: false)
23
+ Renderer.new(self, heading_ids:)
24
24
  end
25
25
 
26
26
  class Renderer < Prosemirror::HTMLRenderer
@@ -9,13 +9,12 @@ module Trek
9
9
  prop :href, _String?
10
10
  prop :target, _String?
11
11
  prop :title, _String?
12
- prop :color, _String?(/\A[\w-]+\z/)
13
12
 
14
13
  def view_template
15
14
  href = @href.present? && safe_url(@href)
16
15
  return if !href || @text.blank?
17
16
 
18
- a(href:, target: @target, title: @title, data_color: @color) { @text }
17
+ a(href:, target: @target, title: @title) { @text }
19
18
  end
20
19
  end
21
20
  end
@@ -20,6 +20,12 @@ module Trek
20
20
  @default_schema = nil
21
21
  end
22
22
 
23
+ # The content without what rendering skips (see Cleaner), as a Hash. Problems with the content are
24
+ # reported when it's rendered, not when it's cleaned.
25
+ def clean(json)
26
+ Cleaner.new.call(schema.with_options(on_invalid: nil).parse(json)).to_h
27
+ end
28
+
23
29
  def default_schema
24
30
  Prosemirror::Schema.tiptap(
25
31
  case: :camel,
@@ -13,14 +13,12 @@ module Trek
13
13
  def reorder
14
14
  authorize!
15
15
  objects = reorderable_objects.index_by(&:id)
16
+ ordered = reorder_params.sort_by { |_id, index| index }.filter_map { |id, _index| objects[id.to_i] }
16
17
 
17
- # Saving the positions one record at a time repeats acts_as_list's queries: Prosopite, in
18
- # development and test, would take them for an N+1
18
+ # Saving the records one at a time repeats queries, which Prosopite, in development and test, would
19
+ # take for an N+1
19
20
  without_n_plus_one_detection do
20
- reorder_params.each do |id, position|
21
- object = objects[id.to_i]
22
- object.update(position:) unless object.position == position
23
- end
21
+ model.transaction { without_list_updates { place_in_order(ordered) } }
24
22
  end
25
23
 
26
24
  redirect_to reorder_success_redirect, notice: reorder_notice
@@ -49,6 +47,26 @@ module Trek
49
47
  [:admin, *model_collection_route]
50
48
  end
51
49
 
50
+ # The JS posts the whole list in its new order. A tree ordered by closure_tree (`numeric_order`, which
51
+ # gives `append_sibling`) renumbers the siblings at each save: the first record takes the place of the
52
+ # current first one, and each other record goes after the previous one. Otherwise, each record gets its
53
+ # position from the top of the list, 1 unless acts_as_list's `top_of_list` says otherwise.
54
+ def place_in_order(ordered)
55
+ if ordered.first.respond_to?(:append_sibling)
56
+ current_first = ordered.min_by(&:position)
57
+ current_first.prepend_sibling(ordered.first) unless current_first == ordered.first
58
+ ordered.each_cons(2) { |previous, object| previous.append_sibling(object) }
59
+ else
60
+ top = ordered.first.respond_to?(:acts_as_list_top) ? ordered.first.acts_as_list_top : 0
61
+ ordered.each.with_index(top) { |object, position| object.update(position:) unless object.position == position }
62
+ end
63
+ end
64
+
65
+ # acts_as_list would shift the other records at each save, moving those already placed
66
+ def without_list_updates(&)
67
+ model.respond_to?(:acts_as_list_no_update) ? model.acts_as_list_no_update(&) : yield
68
+ end
69
+
52
70
  def without_n_plus_one_detection(&)
53
71
  defined?(Prosopite) ? Prosopite.pause(&) : yield
54
72
  end
@@ -17,6 +17,8 @@ module Trek
17
17
  # A report is a few hundred bytes; the Reporting API may group a few
18
18
  MAX_BODY = 64.kilobytes
19
19
  NEW_VIOLATIONS_PER_HOUR = 20
20
+ # AppSignal drops a tag of 256 characters or more, without a warning
21
+ TAG_LENGTH = 255
20
22
 
21
23
  # Browsers send no CSRF token; the endpoint only logs and reports, with no session nor user
22
24
  skip_forgery_protection
@@ -40,10 +42,21 @@ module Trek
40
42
  ::Appsignal.report_error(CspViolation.new(violation.to_s)) do
41
43
  ::Appsignal.set_namespace("csp")
42
44
  ::Appsignal.set_action(incident(violation))
43
- ::Appsignal.add_tags(page: violation.page, disposition: violation.disposition)
45
+ ::Appsignal.add_tags(tags(violation))
44
46
  end
45
47
  end
46
48
 
49
+ # What tells the violation apart, within its incident: where, what, and in which browser
50
+ def tags(violation)
51
+ {
52
+ page: violation.page,
53
+ disposition: violation.disposition,
54
+ source: violation.source,
55
+ sample: violation.sample,
56
+ user_agent: request.user_agent
57
+ }.compact_blank.transform_values { |value| value.truncate(TAG_LENGTH) }
58
+ end
59
+
47
60
  # AppSignal groups the violations by action: the directive and the blocked origin
48
61
  def incident(violation)
49
62
  "#{violation.directive} #{violation.blocked}"
@@ -34,7 +34,7 @@ module Trek
34
34
  end
35
35
 
36
36
  def object_params
37
- params.require(:prompt).permit(:turbo_frame_id, :color, :href, :text, :target, :title)
37
+ params.require(:prompt).permit(:turbo_frame_id, :href, :text, :target, :title)
38
38
  end
39
39
  end
40
40
  end
@@ -37,9 +37,9 @@ module Trek
37
37
  else
38
38
  format.html do
39
39
  flash.now[:alert] = t("admin.errors.message", count: @object.errors.count)
40
- render :new, status: :unprocessable_entity
40
+ render :new, status: :unprocessable_content
41
41
  end
42
- format.json { render json: @object.errors, status: :unprocessable_entity }
42
+ format.json { render json: @object.errors, status: :unprocessable_content }
43
43
  end
44
44
  end
45
45
  end
@@ -54,9 +54,9 @@ module Trek
54
54
  else
55
55
  format.html do
56
56
  flash.now[:alert] = t("admin.errors.message", count: @object.errors.count)
57
- render :edit, status: :unprocessable_entity
57
+ render :edit, status: :unprocessable_content
58
58
  end
59
- format.json { render json: @object.errors, status: :unprocessable_entity }
59
+ format.json { render json: @object.errors, status: :unprocessable_content }
60
60
  end
61
61
  end
62
62
  end
@@ -66,7 +66,7 @@ module Trek
66
66
  unless @object.destroy
67
67
  return respond_to do |format|
68
68
  format.html { redirect_back_or_to [:admin, @object], alert: @object.errors.full_messages.to_sentence }
69
- format.json { render json: @object.errors, status: :unprocessable_entity }
69
+ format.json { render json: @object.errors, status: :unprocessable_content }
70
70
  end
71
71
  end
72
72
 
@@ -3,11 +3,12 @@
3
3
  module Trek
4
4
  # Deletes the signed-in browsers past their idle timeout or lifetime (Trek::Users::SessionLifetime):
5
5
  # they can't sign anyone in anymore. Schedule it daily with the app's Active Job backend, e.g. a
6
- # GoodJob cron entry.
6
+ # GoodJob cron entry. Purges User::Session, or the session model named, e.g. a public account's
7
+ # ("Customer::Session").
7
8
  # rubocop:disable Rails/ApplicationJob -- the gem can't rely on the host app's ApplicationJob
8
9
  class PurgeExpiredSessionsJob < ActiveJob::Base
9
- def perform
10
- ::User::Session.expired.in_batches(of: 1000).delete_all
10
+ def perform(session_class = "User::Session")
11
+ session_class.constantize.expired.in_batches(of: 1000).delete_all
11
12
  end
12
13
  end
13
14
  # rubocop:enable Rails/ApplicationJob
@@ -2,6 +2,10 @@ module Trek
2
2
  module Contentable
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ included do
6
+ before_validation :clean_content
7
+ end
8
+
5
9
  def parsed_content
6
10
  return if content.blank?
7
11
 
@@ -20,8 +24,9 @@ module Trek
20
24
  content_document&.to_html
21
25
  end
22
26
 
27
+ # The plain text, with its blocks separated by a blank line and its hard breaks by a newline
23
28
  def content_text
24
- content_document&.text_content
29
+ content_document&.to_text
25
30
  end
26
31
 
27
32
  def prosemirror_content?
@@ -31,5 +36,14 @@ module Trek
31
36
  else false
32
37
  end
33
38
  end
39
+
40
+ private
41
+
42
+ # Before saving, removes what rendering skips: empty paragraphs, empty list items and lists left empty
43
+ # (Trek::Content::Cleaner). The content editor submits the content as a JSON string: it's stored as a
44
+ # document. Translated content is cleaned in the current locale.
45
+ def clean_content
46
+ self.content = Trek::Content.clean(parsed_content) if prosemirror_content?
47
+ end
34
48
  end
35
49
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trek
4
+ module Users
5
+ # A one-time code emailed to sign in, or to confirm a new email address: 6 digits, valid for
6
+ # `validity` (10 minutes), for `max_attempts` tries (5), then single use. Stored as an HMAC of the
7
+ # code keyed on the app's secret_key_base, never in clear. Kept by email rather than by account,
8
+ # so an email without an account gets one too: asking for a code answers the same whether or not
9
+ # the email has an account (ANSSI guide on passwords, R14), and verifying it creates the account.
10
+ #
11
+ # A new code for an email replaces its earlier ones for the same purpose. Needs the `email`,
12
+ # `purpose`, `code_digest`, `attempts` and `expires_at` columns, and the account's foreign key for
13
+ # an email change.
14
+ module SignInCode
15
+ extend ActiveSupport::Concern
16
+
17
+ PURPOSES = %w[sign_in email_change].freeze
18
+ LENGTH = 6
19
+
20
+ included do
21
+ class_attribute :validity, default: 10.minutes
22
+ class_attribute :max_attempts, default: 5
23
+
24
+ # The code in clear, on the record `issue` returns only: for the email, never stored.
25
+ attribute :code, :string
26
+
27
+ normalizes :email, with: ->(email) { email.strip.downcase }
28
+
29
+ validates :email, presence: true
30
+ validates :purpose, inclusion: { in: PURPOSES }
31
+
32
+ scope :expired, -> { where(expires_at: ...Time.current) }
33
+ end
34
+
35
+ class_methods do
36
+ # A new code for `email`, replacing the earlier ones for `purpose`. Its `code` is the one to send.
37
+ def issue(email, purpose: "sign_in", **attributes)
38
+ code = SecureRandom.random_number(10**LENGTH).to_s.rjust(LENGTH, "0")
39
+
40
+ transaction do
41
+ where(email: normalize_value_for(:email, email), purpose:).delete_all
42
+ # Codes nobody used: they can't sign anyone in anymore
43
+ expired.delete_all
44
+ create!(email:, purpose:, code_digest: code_digest(code), expires_at: validity.from_now, **attributes)
45
+ end.tap { it.code = code }
46
+ end
47
+
48
+ # The code of `email` for `purpose` when `code` matches it, used up; nil otherwise. Every try
49
+ # counts, the right one included, and a code past max_attempts can't sign in anymore.
50
+ def redeem(email, code, purpose: "sign_in", **attributes)
51
+ record = where(email: normalize_value_for(:email, email), purpose:, **attributes)
52
+ .where(expires_at: Time.current..).order(created_at: :desc).first
53
+ return unless record
54
+
55
+ # Counted in the database first, so parallel tries can't go past max_attempts
56
+ counted = where(id: record.id).where(attempts: ...max_attempts).update_all("attempts = attempts + 1") # rubocop:disable Rails/SkipsModelValidations
57
+ return if counted.zero?
58
+ return unless ActiveSupport::SecurityUtils.secure_compare(record.code_digest, code_digest(code.to_s.gsub(/\D/, "")))
59
+
60
+ # Single use: only one of parallel right tries gets it
61
+ record if where(id: record.id).delete_all == 1
62
+ end
63
+
64
+ def code_digest(code)
65
+ OpenSSL::HMAC.hexdigest("SHA256", Rails.application.key_generator.generate_key("trek/sign_in_codes"), code)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -10,7 +10,8 @@ module Trek
10
10
  #
11
11
  # While locked, signing in fails even with the right password, and the attempts don't count. A
12
12
  # successful sign-in or a new password (e.g. through a reset) unlocks it. The user is emailed
13
- # when it first locks (`sign_in_lockout_email`).
13
+ # when it first locks (`sign_in_lockout_email`), by `sign_in_lockout_mailer`'s `sign_in_locked`
14
+ # (AdminMailer).
14
15
  #
15
16
  # Needs the `failed_sign_in_count` and `sign_in_locked_until` columns.
16
17
  module SignInLockout
@@ -21,6 +22,7 @@ module Trek
21
22
  class_attribute :sign_in_lockout_duration, default: 1.minute
22
23
  class_attribute :sign_in_lockout_max, default: 1.hour
23
24
  class_attribute :sign_in_lockout_email, default: true
25
+ class_attribute :sign_in_lockout_mailer, default: "AdminMailer"
24
26
 
25
27
  before_save :unlock_sign_in, if: :password_digest_changed?
26
28
  end
@@ -41,7 +43,7 @@ module Trek
41
43
  update_column(:sign_in_locked_until, sign_in_lockout_delay.from_now) # rubocop:disable Rails/SkipsModelValidations
42
44
  # Once per series of failures, not on each failure that extends it
43
45
  first_lockout = failed_sign_in_count == sign_in_lockout_after
44
- AdminMailer.sign_in_locked(self).deliver_later if sign_in_lockout_email && first_lockout
46
+ sign_in_lockout_mailer.constantize.sign_in_locked(self).deliver_later if sign_in_lockout_email && first_lockout
45
47
  end
46
48
 
47
49
  def record_successful_sign_in!
@@ -20,10 +20,8 @@ h1.c-trek--layout-panel-title = t("admin.panels.links.form.title")
20
20
  = f.select :target,
21
21
  options_for_select(@object.class::TARGETS, @object.target)
22
22
 
23
- / TODO: re-enable when tiptap supports `title`
24
- / see https://github.com/ueberdosis/tiptap/pull/2204
25
- / = f.group :title
26
- / = f.text_field :title
23
+ = f.group :title
24
+ = f.text_field :title
27
25
 
28
26
  = render Trek::Form::ActionsComponent.new do |c|
29
27
  ruby:
@@ -7,7 +7,6 @@
7
7
  data-trek--form--content-editor-target="contentInserter"
8
8
  )
9
9
  prompts-block(
10
- color=@object.color
11
10
  href=@object.href
12
11
  target=@object.target
13
12
  text=@object.text
@@ -6,23 +6,23 @@ en:
6
6
  alt: Alternative text
7
7
  credit: Credit
8
8
  file: Image
9
- size: Size
10
- text: Caption
9
+ size: Display width
10
+ text: Image caption
11
11
  trek/content_editor/link:
12
- href: Address
13
- target: Open in
14
- text: Text
12
+ href: Link to
13
+ target: Open this link in…
14
+ text: Link text
15
15
  title: Title
16
16
  trek/content_editor/prompt:
17
- href: Address
18
- target: Open in
19
- text: Text
17
+ href: Link to
18
+ target: Open this link in…
19
+ text: Button text
20
20
  title: Title
21
21
  options:
22
22
  trek/content_editor/image:
23
23
  size:
24
24
  default: As wide as the text
25
- larger: Wider
25
+ larger: Extends past the sides
26
26
  activerecord:
27
27
  attributes:
28
28
  audit_log:
@@ -94,6 +94,7 @@ en:
94
94
  browse: Browse…
95
95
  cancel: Cancel
96
96
  delete: Delete
97
+ download_original: Download original
97
98
  edit: Edit
98
99
  logout: Log out
99
100
  manage: Manage
@@ -178,6 +179,7 @@ en:
178
179
  intro: Fragment
179
180
  update:
180
181
  success: Fragment updated
182
+ jobs: Background jobs
181
183
  metadata: Metadata
182
184
  navigation: Navigation
183
185
  pages:
@@ -228,6 +230,9 @@ en:
228
230
  success: Passkey added
229
231
  destroy:
230
232
  success: Passkey deleted
233
+ password_renewals:
234
+ create:
235
+ success: Every user will have to choose a new password
231
236
  passwords_mailer:
232
237
  reset:
233
238
  subject: Reset your password
@@ -289,8 +294,6 @@ en:
289
294
  last_used: Last used %{time} ago
290
295
  never_used: Never used
291
296
  title: Sign in without a password
292
- require_password_renewal:
293
- success: Every user will have to choose a new password
294
297
  search:
295
298
  alert: No user found with email
296
299
  sessions:
@@ -324,6 +327,18 @@ en:
324
327
  errors:
325
328
  messages:
326
329
  disposable: is a disposable email address, use a permanent one
330
+ helpers:
331
+ hint:
332
+ trek/content_editor/image:
333
+ alt: For accessibility and SEO, describe what the image shows, without repeating the caption
334
+ credit: The author's name
335
+ text: Shown under the image
336
+ trek/content_editor/link:
337
+ href: Choose a page or enter a URL
338
+ title: Shown on hover, useful for accessibility and SEO
339
+ trek/content_editor/prompt:
340
+ href: Choose a page or enter a URL
341
+ title: Shown on hover, useful for accessibility and SEO
327
342
  notices:
328
343
  authentication_needed: Please log in
329
344
  password_renewal_needed: "Your password must be renewed: choose a new one"
@@ -6,23 +6,23 @@ fr:
6
6
  alt: Texte alternatif
7
7
  credit: Crédit
8
8
  file: Image
9
- size: Taille
10
- text: Légende
9
+ size: Largeur d'affichage
10
+ text: Légende de l'image
11
11
  trek/content_editor/link:
12
- href: Adresse
13
- target: Ouvrir dans
14
- text: Texte
12
+ href: Lien vers
13
+ target: Ouvrir ce lien dans…
14
+ text: Texte du lien
15
15
  title: Titre
16
16
  trek/content_editor/prompt:
17
- href: Adresse
18
- target: Ouvrir dans
19
- text: Texte
17
+ href: Lien vers
18
+ target: Ouvrir ce lien dans…
19
+ text: Texte du bouton
20
20
  title: Titre
21
21
  options:
22
22
  trek/content_editor/image:
23
23
  size:
24
24
  default: Comme la colonne de texte
25
- larger: Plus large
25
+ larger: Dépasse sur les côtés
26
26
  activerecord:
27
27
  attributes:
28
28
  audit_log:
@@ -94,6 +94,7 @@ fr:
94
94
  browse: Parcourir…
95
95
  cancel: Annuler
96
96
  delete: Supprimer
97
+ download_original: Télécharger l'original
97
98
  edit: Éditer
98
99
  logout: Se déconnecter
99
100
  manage: Gérer
@@ -187,6 +188,7 @@ fr:
187
188
  intro: Fragment
188
189
  update:
189
190
  success: Fragment mis à jour
191
+ jobs: Tâches de fond
190
192
  metadata: Métadonnées
191
193
  navigation: Navigation
192
194
  pages:
@@ -237,6 +239,9 @@ fr:
237
239
  success: Clé d'accès ajoutée
238
240
  destroy:
239
241
  success: Clé d'accès supprimée
242
+ password_renewals:
243
+ create:
244
+ success: Chaque utilisateur devra choisir un nouveau mot de passe
240
245
  passwords_mailer:
241
246
  reset:
242
247
  subject: Nouveau mot de passe
@@ -298,8 +303,6 @@ fr:
298
303
  last_used: Utilisée il y a %{time}
299
304
  never_used: Jamais utilisée
300
305
  title: Se connecter sans mot de passe
301
- require_password_renewal:
302
- success: Chaque utilisateur devra choisir un nouveau mot de passe
303
306
  search:
304
307
  alert: Utilisateur non trouvé avec cette adresse e-mail
305
308
  sessions:
@@ -333,6 +336,18 @@ fr:
333
336
  errors:
334
337
  messages:
335
338
  disposable: est une adresse e-mail jetable, utilisez-en une permanente
339
+ helpers:
340
+ hint:
341
+ trek/content_editor/image:
342
+ alt: Pour l'accessibilité et le référencement, décrire le contenu de l'image, sans répéter le texte de la légende
343
+ credit: Indiquer le nom de l'auteur
344
+ text: Sera affichée sous l'image
345
+ trek/content_editor/link:
346
+ href: Choisir une page ou entrer une URL
347
+ title: Affiché au survol, utile pour l'accessibilité et le référencement
348
+ trek/content_editor/prompt:
349
+ href: Choisir une page ou entrer une URL
350
+ title: Affiché au survol, utile pour l'accessibilité et le référencement
336
351
  notices:
337
352
  authentication_needed: Veuillez vous connecter
338
353
  password_renewal_needed: "Votre mot de passe doit être renouvelé : choisissez-en un nouveau"
@@ -53,9 +53,11 @@ export default defineConfig({
53
53
  text: 'Guides',
54
54
  items: [
55
55
  { text: 'How to customize Trek', link: '/customize' },
56
+ { text: 'Content editor', link: '/content-editor' },
56
57
  { text: 'Deploying to Scalingo', link: '/deploy' },
57
58
  { text: 'IP addresses behind a proxy', link: '/proxies' },
58
- { text: 'Security and compliance', link: '/security' }
59
+ { text: 'Security and compliance', link: '/security' },
60
+ { text: 'Background jobs', link: '/background-jobs' }
59
61
  ]
60
62
  },
61
63
  {
@@ -77,6 +79,7 @@ export default defineConfig({
77
79
  { text: 'Scaffold', link: '/reference/generators/scaffold' },
78
80
  { text: 'Taxonomies', link: '/reference/generators/taxonomies' },
79
81
  { text: 'Audit log', link: '/reference/generators/audit-log' },
82
+ { text: 'Public accounts', link: '/reference/generators/public-accounts' },
80
83
  { text: 'Security headers', link: '/reference/generators/security-headers' }
81
84
  ]
82
85
  },