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.
- checksums.yaml +4 -4
- data/.node-version +1 -1
- data/AGENTS.md +7 -4
- data/CHANGELOG.md +37 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +16 -3
- data/Makefile +2 -0
- data/app/components/trek/dashboard/tile_component/tile_component.css +9 -0
- data/app/components/trek/dashboard/tile_component/tile_component.html.slim +4 -2
- data/app/components/trek/dashboard/tile_component.rb +12 -2
- data/app/components/trek/form/content_editor_component/content_editor_component.css +5 -0
- data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +19 -93
- data/app/components/trek/form/content_editor_component/content_editor_component.js +94 -251
- data/app/components/trek/form/content_editor_component/extensions/block_view.js +40 -0
- data/app/components/trek/form/content_editor_component/extensions/image_block.js +28 -26
- data/app/components/trek/form/content_editor_component/extensions/prompts_block.js +15 -26
- data/app/components/trek/form/content_editor_component/extensions/table_of_contents.js +1 -3
- data/app/components/trek/form/content_editor_component.rb +15 -0
- data/app/components/trek/form/image_field_component/image_field_component.css +40 -1
- data/app/components/trek/form/image_field_component/image_field_component.html.slim +0 -9
- data/app/components/trek/form/image_field_component.rb +36 -9
- data/app/content/trek/content/cleaner.rb +53 -0
- data/app/content/trek/content/document.rb +2 -2
- data/app/content/trek/content/prompts_block.rb +1 -2
- data/app/content/trek/content.rb +6 -0
- data/app/controllers/concerns/trek/reorderable.rb +24 -6
- data/app/controllers/trek/csp_reports_controller.rb +14 -1
- data/app/controllers/trek/panels/prompts_controller.rb +1 -1
- data/app/controllers/trek/resource_controller.rb +5 -5
- data/app/jobs/trek/purge_expired_sessions_job.rb +4 -3
- data/app/models/concerns/trek/contentable.rb +15 -1
- data/app/models/concerns/trek/users/sign_in_code.rb +70 -0
- data/app/models/concerns/trek/users/sign_in_lockout.rb +4 -2
- data/app/views/trek/panels/links/_form.html.slim +2 -4
- data/app/views/trek/panels/prompts/create.turbo_stream.slim +0 -1
- data/config/locales/trek.en.yml +26 -11
- data/config/locales/trek.fr.yml +26 -11
- data/docs/.vitepress/config.mjs +4 -1
- data/docs/background-jobs.md +69 -0
- data/docs/content-editor.md +219 -0
- data/docs/customize.md +2 -0
- data/docs/deploy.md +2 -2
- data/docs/getting-started.md +1 -1
- data/docs/index.md +1 -1
- data/docs/reference/components.md +2 -2
- data/docs/reference/concerns/contentable.md +10 -4
- data/docs/reference/concerns/formattable.md +1 -1
- data/docs/reference/environment-variables.md +1 -0
- data/docs/reference/formatters.md +2 -30
- data/docs/reference/forms.md +1 -1
- data/docs/reference/generators/audit-log.md +2 -2
- data/docs/reference/generators/install.md +3 -2
- data/docs/reference/generators/public-accounts.md +141 -0
- data/docs/reference/generators/security-headers.md +1 -1
- data/docs/reference/index.md +2 -1
- data/docs/reference/models/user.md +3 -1
- data/docs/what-is-trek.md +1 -1
- data/lib/generators/trek/audit_log_generator.rb +54 -0
- data/lib/generators/trek/install/admin/users_generator.rb +6 -1
- data/lib/generators/trek/install/audit_log_generator.rb +9 -45
- data/lib/generators/trek/install/good_job_generator.rb +161 -0
- data/lib/generators/trek/install/model_specs_generator.rb +3 -3
- data/lib/generators/trek/install/ruby_generator.rb +1 -1
- data/lib/generators/trek/install/scalingo_generator.rb +7 -45
- data/lib/generators/trek/install_generator.rb +15 -0
- data/lib/generators/trek/public_accounts_generator.rb +267 -0
- data/lib/generators/trek/scalingo_generator.rb +55 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
- data/lib/generators/trek/templates/controllers/admin/password_renewals_controller.rb +11 -0
- data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +1 -1
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +0 -8
- data/lib/generators/trek/templates/initializers/content_security_policy.rb +3 -2
- data/lib/generators/trek/templates/initializers/good_job.rb.tt +38 -0
- data/lib/generators/trek/templates/jobs/mail_delivery_job.rb +6 -0
- data/lib/generators/trek/templates/layouts/admin.html.slim +5 -0
- data/lib/generators/trek/templates/mailers/admin_mailer.rb +4 -2
- data/lib/generators/trek/templates/package.json.tt +1 -1
- data/lib/generators/trek/templates/public_accounts/api/authentication.rb.tt +76 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/accounts_controller.rb.tt +22 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/active_sessions_controller.rb.tt +21 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/base_controller.rb.tt +71 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/email_changes_controller.rb.tt +55 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/email_confirmations_controller.rb.tt +19 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/password_resets_controller.rb.tt +28 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/passwords_controller.rb.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/registrations_controller.rb.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/sessions_controller.rb.tt +45 -0
- data/lib/generators/trek/templates/public_accounts/api/controllers/sign_in_codes_controller.rb.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/jobs/sign_in_code_job.rb.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/locales/accounts.en.yml.tt +205 -0
- data/lib/generators/trek/templates/public_accounts/locales/accounts.fr.yml.tt +205 -0
- data/lib/generators/trek/templates/public_accounts/mailers/account_mailer.rb.tt +85 -0
- data/lib/generators/trek/templates/public_accounts/migrations/create_accounts.rb.tt +46 -0
- data/lib/generators/trek/templates/public_accounts/models/account/session.rb.tt +47 -0
- data/lib/generators/trek/templates/public_accounts/models/account/sign_in_code.rb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/models/account.rb.tt +73 -0
- data/lib/generators/trek/templates/public_accounts/spec/api/accounts_spec.rb.tt +90 -0
- data/lib/generators/trek/templates/public_accounts/spec/api/sign_ins_spec.rb.tt +158 -0
- data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt +56 -0
- data/lib/generators/trek/templates/public_accounts/spec/models/account_spec.rb.tt +109 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/accounts_spec.rb.tt +126 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/authentication_spec.rb.tt +43 -0
- data/lib/generators/trek/templates/public_accounts/spec/web/sign_ins_spec.rb.tt +215 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.html.slim.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.html.slim.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.html.slim.tt +10 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.html.slim.tt +13 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.text.erb.tt +8 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.html.slim.tt +9 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.text.erb.tt +11 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.html.slim.tt +23 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.text.erb.tt +14 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.html.slim.tt +24 -0
- data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.text.erb.tt +14 -0
- data/lib/generators/trek/templates/public_accounts/web/authentication.rb.tt +97 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/accounts_controller.rb.tt +33 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/active_sessions_controller.rb.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/base_controller.rb.tt +52 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/email_changes_controller.rb.tt +85 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/email_confirmations_controller.rb.tt +19 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/password_resets_controller.rb.tt +41 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/passwords_controller.rb.tt +23 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/registrations_controller.rb.tt +24 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/sessions_controller.rb.tt +78 -0
- data/lib/generators/trek/templates/public_accounts/web/controllers/sign_in_codes_controller.rb.tt +27 -0
- data/lib/generators/trek/templates/public_accounts/web/views/_flash.html.slim.tt +5 -0
- data/lib/generators/trek/templates/public_accounts/web/views/accounts/show.html.slim.tt +52 -0
- data/lib/generators/trek/templates/public_accounts/web/views/email_changes/edit.html.slim.tt +17 -0
- data/lib/generators/trek/templates/public_accounts/web/views/email_changes/new.html.slim.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/views/password_resets/edit.html.slim.tt +26 -0
- data/lib/generators/trek/templates/public_accounts/web/views/password_resets/new.html.slim.tt +15 -0
- data/lib/generators/trek/templates/public_accounts/web/views/passwords/edit.html.slim.tt +25 -0
- data/lib/generators/trek/templates/public_accounts/web/views/registrations/new.html.slim.tt +28 -0
- data/lib/generators/trek/templates/public_accounts/web/views/sessions/new.html.slim.tt +38 -0
- data/lib/generators/trek/templates/public_accounts/web/views/sign_in_codes/new.html.slim.tt +17 -0
- data/lib/generators/trek/templates/spec/jobs/mail_delivery_job_spec.rb +11 -0
- data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +10 -0
- data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/good_job_spec.rb +34 -0
- data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/panels_spec.rb +16 -0
- data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +2 -2
- data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +1 -1
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +2 -2
- data/lib/generators/trek/templates/views/admin/users/index.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +1 -1
- data/lib/trek/csp_reports.rb +22 -4
- data/lib/trek/typography_formatter.rb +35 -4
- data/lib/trek/version.rb +1 -1
- data/package.json +12 -8
- data/trek.gemspec +1 -1
- data/vitest.config.mjs +24 -0
- data/yarn.lock +1004 -288
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
@@ -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
|
|
17
|
+
a(href:, target: @target, title: @title) { @text }
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
end
|
data/app/content/trek/content.rb
CHANGED
|
@@ -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
|
|
18
|
-
#
|
|
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
|
-
|
|
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(
|
|
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}"
|
|
@@ -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: :
|
|
40
|
+
render :new, status: :unprocessable_content
|
|
41
41
|
end
|
|
42
|
-
format.json { render json: @object.errors, status: :
|
|
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: :
|
|
57
|
+
render :edit, status: :unprocessable_content
|
|
58
58
|
end
|
|
59
|
-
format.json { render json: @object.errors, status: :
|
|
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: :
|
|
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
|
-
|
|
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&.
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
data/config/locales/trek.en.yml
CHANGED
|
@@ -6,23 +6,23 @@ en:
|
|
|
6
6
|
alt: Alternative text
|
|
7
7
|
credit: Credit
|
|
8
8
|
file: Image
|
|
9
|
-
size:
|
|
10
|
-
text:
|
|
9
|
+
size: Display width
|
|
10
|
+
text: Image caption
|
|
11
11
|
trek/content_editor/link:
|
|
12
|
-
href:
|
|
13
|
-
target: Open in
|
|
14
|
-
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:
|
|
18
|
-
target: Open in
|
|
19
|
-
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:
|
|
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"
|
data/config/locales/trek.fr.yml
CHANGED
|
@@ -6,23 +6,23 @@ fr:
|
|
|
6
6
|
alt: Texte alternatif
|
|
7
7
|
credit: Crédit
|
|
8
8
|
file: Image
|
|
9
|
-
size:
|
|
10
|
-
text: Légende
|
|
9
|
+
size: Largeur d'affichage
|
|
10
|
+
text: Légende de l'image
|
|
11
11
|
trek/content_editor/link:
|
|
12
|
-
href:
|
|
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:
|
|
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:
|
|
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"
|
data/docs/.vitepress/config.mjs
CHANGED
|
@@ -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
|
},
|