trek 2.3.1 → 2.4.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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
  4. data/.yarnrc.yml +3 -5
  5. data/AGENTS.md +7 -4
  6. data/CHANGELOG.md +55 -0
  7. data/Gemfile.lock +35 -1
  8. data/Makefile +12 -0
  9. data/app/assets/icons/trek/passkey.svg +5 -0
  10. data/app/assets/stylesheets/trek/base.css +6 -0
  11. data/app/components/trek/entries/item_component/item_component.css +6 -0
  12. data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
  13. data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
  14. data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
  15. data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
  16. data/app/components/trek/form/password_strength_component.rb +29 -0
  17. data/app/components/trek/gate_component/gate_component.css +7 -0
  18. data/app/components/trek/icon/sprite_component.rb +3 -2
  19. data/app/components/trek/section_component/section_component.css +22 -0
  20. data/app/components/trek/section_component/section_component.html.slim +5 -0
  21. data/app/components/trek/section_component/section_component.js +1 -0
  22. data/app/components/trek/section_component.rb +14 -0
  23. data/app/controllers/concerns/trek/auditable.rb +103 -0
  24. data/app/controllers/concerns/trek/filters.rb +2 -0
  25. data/app/controllers/concerns/trek/passkeys.rb +37 -0
  26. data/app/controllers/concerns/trek/scopes.rb +9 -2
  27. data/app/controllers/trek/csp_reports_controller.rb +62 -0
  28. data/app/controllers/trek/resource_controller.rb +7 -1
  29. data/app/helpers/trek/audit_logs_helper.rb +21 -0
  30. data/app/helpers/trek/user_agents_helper.rb +36 -0
  31. data/app/javascript/controllers/index.js +3 -0
  32. data/app/javascript/controllers/passkey_controller.js +118 -0
  33. data/app/javascript/trek.js +4 -0
  34. data/app/jobs/trek/audit_log/purge_job.rb +18 -0
  35. data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
  36. data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
  37. data/app/models/concerns/trek/audit_log.rb +30 -0
  38. data/app/models/concerns/trek/users/passkeys.rb +75 -0
  39. data/app/models/concerns/trek/users/password_history.rb +51 -0
  40. data/app/models/concerns/trek/users/password_policy.rb +85 -0
  41. data/app/models/concerns/trek/users/password_renewal.rb +51 -0
  42. data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
  43. data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
  44. data/app/models/concerns/trek/users/sign_ins.rb +31 -0
  45. data/app/models/concerns/trek/users/validations.rb +3 -2
  46. data/app/models/trek/feature.rb +6 -0
  47. data/app/models/trek/pwned_passwords.rb +40 -0
  48. data/app/models/trek/user_session.rb +10 -0
  49. data/app/policies/trek/audit_log_policy.rb +34 -0
  50. data/app/policies/trek/user_policy.rb +5 -0
  51. data/app/validators/not_disposable_validator.rb +18 -0
  52. data/config/brakeman.ignore +22 -0
  53. data/config/i18n-tasks.yml +7 -0
  54. data/config/locales/trek.en.yml +144 -39
  55. data/config/locales/trek.fr.yml +145 -40
  56. data/docs/.vitepress/config.mjs +8 -3
  57. data/docs/deploy.md +2 -0
  58. data/docs/proxies.md +53 -0
  59. data/docs/reference/components.md +1 -0
  60. data/docs/reference/controllers.md +1 -1
  61. data/docs/reference/environment-variables.md +2 -0
  62. data/docs/reference/generators/admin-user.md +1 -1
  63. data/docs/reference/generators/audit-log.md +113 -0
  64. data/docs/reference/generators/install.md +12 -3
  65. data/docs/reference/generators/security-headers.md +109 -0
  66. data/docs/reference/index.md +3 -0
  67. data/docs/reference/models/tag.md +4 -2
  68. data/docs/reference/models/user.md +123 -3
  69. data/docs/reference/validators.md +94 -0
  70. data/docs/security.md +90 -0
  71. data/lefthook.yml +8 -0
  72. data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
  73. data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
  74. data/lib/generators/trek/install/admin/users_generator.rb +23 -0
  75. data/lib/generators/trek/install/audit_log_generator.rb +56 -0
  76. data/lib/generators/trek/install/authentication_generator.rb +77 -3
  77. data/lib/generators/trek/install/esbuild_generator.rb +1 -2
  78. data/lib/generators/trek/install/mailer_generator.rb +18 -0
  79. data/lib/generators/trek/install/model_specs_generator.rb +6 -4
  80. data/lib/generators/trek/install/postcss_generator.rb +1 -4
  81. data/lib/generators/trek/install/security_headers_generator.rb +49 -0
  82. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  83. data/lib/generators/trek/install_generator.rb +62 -54
  84. data/lib/generators/trek/taxonomies_generator.rb +5 -0
  85. data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
  86. data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
  87. data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
  88. data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
  89. data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
  90. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
  91. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
  92. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
  93. data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
  94. data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
  95. data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
  96. data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
  97. data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
  98. data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
  99. data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
  100. data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
  101. data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
  102. data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
  103. data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
  104. data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
  105. data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
  106. data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
  107. data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
  108. data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
  109. data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
  110. data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
  111. data/lib/generators/trek/templates/models/audit_log.rb +3 -0
  112. data/lib/generators/trek/templates/models/tag_category.rb +2 -1
  113. data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
  114. data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
  115. data/lib/generators/trek/templates/models/user/session.rb +8 -0
  116. data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
  117. data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
  118. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
  119. data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
  120. data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
  121. data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
  122. data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
  123. data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
  128. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
  129. data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
  130. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +55 -6
  131. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +31 -2
  132. data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
  133. data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
  134. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +17 -7
  135. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +3 -2
  136. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +28 -2
  137. data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
  138. data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
  139. data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
  140. data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
  141. data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
  142. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
  143. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
  144. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
  145. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
  146. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
  147. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
  148. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
  149. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
  150. data/lib/trek/admin_ip_allowlist.rb +52 -0
  151. data/lib/trek/csp_reports.rb +99 -0
  152. data/lib/trek/data/common_passwords.txt +857 -0
  153. data/lib/trek/data/passkey_providers.json +404 -0
  154. data/lib/trek/disposable_email_domains.rb +53 -0
  155. data/lib/trek/engine.rb +12 -0
  156. data/lib/trek/passkey_providers.rb +19 -0
  157. data/lib/trek/reverse_dns.rb +27 -0
  158. data/lib/trek/version.rb +1 -1
  159. data/lib/trek.rb +13 -0
  160. data/package.json +3 -5
  161. data/renovate.json +11 -1
  162. data/trek.gemspec +3 -0
  163. data/yarn.lock +1757 -2019
  164. metadata +132 -4
  165. data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
  166. data/.yarn/releases/yarn-3.8.0.cjs +0 -875
  167. data/lib/generators/trek/templates/models/user_session.rb +0 -3
@@ -0,0 +1,49 @@
1
+ # The Content Security Policy: what pages may load and run, so a script injected into a page (an XSS)
2
+ # can't run, nor send data elsewhere. See
3
+ # https://guides.rubyonrails.org/security.html#content-security-policy-header
4
+ #
5
+ # It only reports violations for now (`content_security_policy_report_only`): check the browser's
6
+ # console, and the violations reported to AppSignal (the "csp" namespace) or the log, on every page,
7
+ # then set it to false to enforce it.
8
+ #
9
+ # Add the hosts the front office uses, e.g. for analytics `policy.script_src :self, "https://plausible.io"`
10
+ # and `policy.connect_src :self, "https://plausible.io"`, or for videos `policy.frame_src "https://www.youtube-nocookie.com"`.
11
+ Rails.application.configure do
12
+ # Where uploaded files are served from: the store's public URL, and the bucket's endpoint for files
13
+ # just uploaded (config/initializers/shrine.rb). Origins only, as CSP sources.
14
+ upload_hosts = ENV.values_at("S3_BASE_URL", "S3_ENDPOINT").compact_blank.map do |url|
15
+ URI(url).tap { |uri| uri.path = "" }.to_s
16
+ end
17
+
18
+ config.content_security_policy do |policy|
19
+ policy.default_src :self
20
+ policy.base_uri :self
21
+ policy.form_action :self
22
+ # Framed by its own pages only (Lookbook's previews), against clickjacking
23
+ policy.frame_ancestors :self
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
27
+ # Inline `style` attributes stay allowed: Trek's components and Backpack's set some (colors,
28
+ # custom properties)
29
+ policy.style_src :self, :unsafe_inline
30
+ policy.font_src :self, :data
31
+ # For fonts from Google Fonts (the admin's Inter is served by the app):
32
+ # policy.style_src :self, :unsafe_inline, "https://fonts.googleapis.com"
33
+ # policy.font_src :self, :data, "https://fonts.gstatic.com"
34
+ policy.img_src :self, :data, :blob, *upload_hosts
35
+ policy.media_src :self, :blob, *upload_hosts
36
+ policy.connect_src :self
37
+ # Trek's endpoint logs the violations, and reports them to AppSignal when the app uses it
38
+ policy.report_uri ENV.fetch("CSP_REPORT_URI", "/csp-reports")
39
+ end
40
+
41
+ # A nonce for the scripts written in a page (`javascript_tag nonce: true`), per session rather than
42
+ # per request: Turbo renders the next pages under the first one's policy, but with their nonce. A
43
+ # visitor without a session yet gets a random one, as an empty nonce is invalid: prefer script files
44
+ # to inline scripts on pages Turbo visits without a session.
45
+ config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }
46
+ config.content_security_policy_nonce_directives = %w[script-src]
47
+
48
+ config.content_security_policy_report_only = true
49
+ end
@@ -0,0 +1,19 @@
1
+ # Turns off the browser features the app doesn't use (the Permissions-Policy header), so a script
2
+ # injected into a page can't use them either. Allow one the front office needs, e.g. `geolocation=(self)`
3
+ # for a map.
4
+ #
5
+ # Not Rails' `config.permissions_policy`, which still sends the former Feature-Policy header.
6
+ Rails.application.config.action_dispatch.default_headers["Permissions-Policy"] = %w[
7
+ accelerometer=()
8
+ camera=()
9
+ display-capture=()
10
+ geolocation=()
11
+ gyroscope=()
12
+ hid=()
13
+ magnetometer=()
14
+ microphone=()
15
+ midi=()
16
+ payment=()
17
+ serial=()
18
+ usb=()
19
+ ].join(", ")
@@ -0,0 +1,9 @@
1
+ # Emails a password reset link to a privileged user, and nothing for any other email. The reset
2
+ # request form queues it whatever the email, so the response takes as long whether an account
3
+ # exists or not.
4
+ class Admin::PasswordResetJob < ApplicationJob
5
+ def perform(email)
6
+ user = User.privileged.find_by(email:)
7
+ Admin::PasswordsMailer.reset(user).deliver_now if user
8
+ end
9
+ end
@@ -12,11 +12,6 @@ html(lang=I18n.locale class=class_for_theme)
12
12
  link(rel="manifest" href="/site.webmanifest")
13
13
  = csrf_meta_tags
14
14
  = csp_meta_tag
15
- link(rel="preconnect" href="https://fonts.googleapis.com")
16
- link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
17
- / slim-lint:disable LineLength
18
- link(href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet")
19
- / slim-lint:enable LineLength
20
15
  = stylesheet_link_tag "admin",
21
16
  media: "all",
22
17
  'data-turbo-track': "reload"
@@ -12,9 +12,6 @@ html(lang=I18n.locale class=class_for_theme)
12
12
  link(rel="manifest" href="/site.webmanifest")
13
13
  = csrf_meta_tags
14
14
  = csp_meta_tag
15
- link(rel="preconnect" href="https://fonts.googleapis.com")
16
- link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
17
- = admin_webfonts_link_tag
18
15
  = stylesheet_link_tag "admin",
19
16
  media: "all",
20
17
  "data-turbo-track": "reload"
@@ -4,9 +4,9 @@ class Admin::PasswordsMailer < ApplicationMailer
4
4
  def reset(user)
5
5
  @user = user
6
6
  @url = edit_admin_user_password_reset_url(@user.generate_token_for(:password_reset))
7
- @expiration_delay = distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in)
8
7
 
9
8
  I18n.with_locale(@user.locale || I18n.default_locale) do
9
+ @expiration_delay = distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in)
10
10
  mail to: @user.email
11
11
  end
12
12
  end
@@ -1,10 +1,37 @@
1
1
  class AdminMailer < ApplicationMailer
2
2
  include ActionView::Helpers::DateHelper
3
3
 
4
+ helper Trek::UserAgentsHelper
5
+
4
6
  def invite(user)
5
7
  @user = user
6
8
  @url = edit_admin_user_password_reset_url(@user.generate_token_for(:invite))
7
- @expiration_delay = distance_of_time_in_words(0, User.token_definitions[:invite].expires_in)
9
+
10
+ I18n.with_locale(@user.locale || I18n.default_locale) do
11
+ @expiration_delay = distance_of_time_in_words(0, User.token_definitions[:invite].expires_in)
12
+ mail to: @user.email
13
+ end
14
+ end
15
+
16
+ # A sign-in from a browser that never signed in as the user, so they can react if it wasn't them
17
+ def new_sign_in(user, at:, ip:, user_agent:)
18
+ @user = user
19
+ @at = at
20
+ @ip = ip
21
+ @user_agent = user_agent
22
+ @sessions_url = admin_user_url(user, anchor: "sessions")
23
+ @reset_url = new_admin_user_password_reset_url
24
+
25
+ I18n.with_locale(@user.locale || I18n.default_locale) do
26
+ mail to: @user.email
27
+ end
28
+ end
29
+
30
+ # Signing in is locked after failed sign-ins in a row (Trek::Users::SignInLockout): the user waits,
31
+ # or resets their password if they forgot it, or learns someone is guessing it.
32
+ def sign_in_locked(user)
33
+ @user = user
34
+ @reset_url = new_admin_user_password_reset_url
8
35
 
9
36
  I18n.with_locale(@user.locale || I18n.default_locale) do
10
37
  mail to: @user.email
@@ -0,0 +1,27 @@
1
+ class TrekAuditLogs < <%= migration_class_name %>
2
+ def change
3
+ create_table :audit_logs do |t|
4
+ # Who made the request: nil when nobody is signed in. index: false, see the index below.
5
+ t.references :actor, polymorphic: true, index: false
6
+ # The record the request worked on, when there is one.
7
+ t.references :auditable, polymorphic: true
8
+
9
+ t.string :action, null: false
10
+ t.string :http_method, null: false
11
+ t.string :path, null: false
12
+ t.integer :http_status
13
+ t.jsonb :params, null: false, default: {}
14
+ t.string :ip_address
15
+ t.string :user_agent
16
+ t.string :request_id
17
+ t.string :error_class
18
+
19
+ # Rows are never updated: no updated_at.
20
+ t.datetime :created_at, null: false
21
+ end
22
+
23
+ add_index :audit_logs, [:actor_type, :actor_id, :created_at]
24
+ add_index :audit_logs, :created_at
25
+ add_index :audit_logs, :request_id
26
+ end
27
+ end
@@ -1,13 +1,18 @@
1
1
  class TrekTags < <%= migration_class_name %>
2
2
  def change
3
3
  create_table :tags do |t|
4
- t.string :key, index: { unique: true }
4
+ t.string :key
5
5
  t.string :name
6
6
  t.integer :position, null: false
7
7
  t.references :category, foreign_key: { to_table: :tag_categories }
8
8
  t.integer :taggings_count, null: false, default: 0
9
9
 
10
10
  t.timestamps
11
+
12
+ # A key is unique within its category, as Tag validates it
13
+ t.index %i[category_id key], unique: true
14
+ # and among the tags without one, which the index above lets through: NULLs never collide
15
+ t.index :key, unique: true, where: "category_id IS NULL", name: "index_tags_on_key_without_category"
11
16
  end
12
17
  end
13
18
  end
@@ -0,0 +1,24 @@
1
+ class TrekUserPasskeys < <%= migration_class_name %>
2
+ def change
3
+ # The random id a user's passkeys are created for (Trek::Users::Passkeys), set on the first one
4
+ add_column :users, :webauthn_id, :string
5
+ add_index :users, :webauthn_id, unique: true
6
+
7
+ create_table :user_passkeys do |t|
8
+ t.belongs_to :user, null: false, foreign_key: true
9
+ # The browser it was added from, shown when Trek doesn't know its provider (aaguid)
10
+ t.string :name, null: false
11
+ # The credential's id and public key, as the browser sent them
12
+ t.string :external_id, null: false, index: { unique: true }
13
+ t.text :public_key, null: false
14
+ t.bigint :sign_count, null: false, default: 0
15
+ # How the browser reaches the authenticator ("internal", "hybrid", "usb"…)
16
+ t.string :transports, array: true, null: false, default: []
17
+ # The authenticator's model, e.g. a password manager (Trek::PasskeyProviders), when it says
18
+ t.string :aaguid
19
+ t.datetime :last_used_at
20
+
21
+ t.timestamps
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ class TrekUserPasswordRenewal < <%= migration_class_name %>
2
+ def change
3
+ # When the password was last set, for its expiry (Trek::Users::PasswordRenewal)
4
+ add_column :users, :password_changed_at, :datetime
5
+ # When an admin asked everyone for a new password, after a suspected compromise
6
+ add_column :users, :password_renewal_required_at, :datetime
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ class TrekUserPreviousPasswords < <%= migration_class_name %>
2
+ def change
3
+ create_table :user_previous_passwords do |t|
4
+ t.belongs_to :user, null: false, foreign_key: true
5
+ t.string :password_digest, null: false
6
+
7
+ t.datetime :created_at, null: false
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ class TrekUserSessionTracking < <%= migration_class_name %>
2
+ def change
3
+ # The session's last request, for its idle timeout (Trek::Users::SessionLifetime)
4
+ add_column :user_sessions, :last_activity_at, :datetime
5
+ up_only { execute "UPDATE user_sessions SET last_activity_at = updated_at" }
6
+ change_column_null :user_sessions, :last_activity_at, false
7
+
8
+ # The host name of the IP address it signed in from (Trek::ReverseDns)
9
+ add_column :user_sessions, :ip_hostname, :string
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class TrekUserSignInLockout < <%= migration_class_name %>
2
+ def change
3
+ # Failed sign-ins in a row, and until when they lock signing in (Trek::Users::SignInLockout)
4
+ add_column :users, :failed_sign_in_count, :integer, default: 0, null: false
5
+ add_column :users, :sign_in_locked_until, :datetime
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ class TrekUserSignIns < <%= migration_class_name %>
2
+ def change
3
+ # The current and the previous sign-ins (Trek::Users::SignIns), kept when their sessions end
4
+ change_table :users, bulk: true do |t|
5
+ t.datetime :current_sign_in_at
6
+ t.string :current_sign_in_ip
7
+ t.string :current_sign_in_user_agent
8
+ t.datetime :last_sign_in_at
9
+ t.string :last_sign_in_ip
10
+ t.string :last_sign_in_user_agent
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ class AuditLog < ApplicationRecord
2
+ include Trek::AuditLog
3
+ end
@@ -1,7 +1,8 @@
1
1
  class TagCategory < ApplicationRecord
2
+ # Not deleted while it has tags: left without a category, their keys could collide with others'
2
3
  has_many :tags,
3
4
  -> { ordered },
4
- dependent: :nullify,
5
+ dependent: :restrict_with_error,
5
6
  foreign_key: :category_id,
6
7
  inverse_of: :category
7
8
 
@@ -0,0 +1,17 @@
1
+ # A passkey the user signs in with instead of their password (Trek::Users::Passkeys): its private
2
+ # key stays on their device or in their password manager, Trek keeps the public one.
3
+ class User::Passkey < ApplicationRecord
4
+ belongs_to :user
5
+
6
+ validates :name, presence: true, length: { maximum: 100 }
7
+
8
+ # Its provider, or else the browser it was added from (`name`)
9
+ def to_s
10
+ provider || name
11
+ end
12
+
13
+ # The password manager, platform or security key keeping it, when Trek knows it
14
+ def provider
15
+ Trek::PasskeyProviders[aaguid]
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ # A user's previous password digest, so Trek::Users::PasswordHistory can refuse a recent password.
2
+ class User::PreviousPassword < ApplicationRecord
3
+ belongs_to :user
4
+ end
@@ -0,0 +1,8 @@
1
+ # A browser signed in as the user, found through a signed session_id cookie.
2
+ class User::Session < ApplicationRecord
3
+ # Expires after 48 hours without a request, and 30 days after signing in; set idle_timeout and
4
+ # lifetime to change it.
5
+ include Trek::Users::SessionLifetime
6
+
7
+ belongs_to :user
8
+ end
@@ -0,0 +1,4 @@
1
+ module Admin
2
+ class AuditLogPolicy < Trek::AuditLogPolicy
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Admin::PasswordsMailer do
4
+ include ActionView::Helpers::DateHelper
5
+
6
+ # Not the app's default locale, so the email can't be right by chance
7
+ let(:locale) { (I18n.available_locales - [I18n.default_locale]).first }
8
+
9
+ it "formats the reset link's expiry in the recipient's locale" do
10
+ skip "the app has a single locale" unless locale
11
+ user = users.create(role: :editor, locale: locale)
12
+ expiry = I18n.with_locale(locale) { distance_of_time_in_words(0, User.token_definitions[:password_reset].expires_in) }
13
+
14
+ expect(described_class.reset(user).text_part.body.to_s).to include(expiry)
15
+ end
16
+ end
@@ -0,0 +1,50 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe AdminMailer do
4
+ include ActionView::Helpers::DateHelper
5
+
6
+ # Not the app's default locale, so the email can't be right by chance
7
+ let(:locale) { (I18n.available_locales - [I18n.default_locale]).first }
8
+
9
+ it "formats the invite link's expiry in the recipient's locale" do
10
+ skip "the app has a single locale" unless locale
11
+ user = users.create(role: :editor, locale: locale)
12
+ expiry = I18n.with_locale(locale) { distance_of_time_in_words(0, User.token_definitions[:invite].expires_in) }
13
+
14
+ expect(described_class.invite(user).text_part.body.to_s).to include(expiry)
15
+ end
16
+
17
+ describe "the new sign-in email" do
18
+ let(:user) { users.create(role: :editor) }
19
+ let(:mail) do
20
+ described_class.new_sign_in(
21
+ user,
22
+ at: Time.zone.parse("2026-10-13 08:30"),
23
+ ip: "203.0.113.7",
24
+ user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0"
25
+ )
26
+ end
27
+
28
+ it "tells the user which browser and address signed in" do
29
+ expect(mail.text_part.body.to_s).to include("Firefox on Windows").and include("203.0.113.7")
30
+ end
31
+
32
+ it "links to the active sessions and to a password reset" do
33
+ expect(mail.html_part.body.to_s).to include("#sessions").and include("user_password_resets/new")
34
+ end
35
+ end
36
+
37
+ it "tells about a sign-in lock in the recipient's locale" do
38
+ skip "the app has a single locale" unless locale
39
+ user = users.create(role: :editor, locale: locale)
40
+ subject = I18n.with_locale(locale) { I18n.t("admin_mailer.sign_in_locked.subject") }
41
+
42
+ expect(described_class.sign_in_locked(user).subject).to eq(subject)
43
+ end
44
+
45
+ it "links a sign-in lock to a password reset" do
46
+ user = users.create(role: :editor)
47
+
48
+ expect(described_class.sign_in_locked(user).text_part.body.to_s).to include("/admin/user_password_resets/new")
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe AuditLog, type: :model do
4
+ it "lists the failures: an error status or a raised error" do
5
+ server_error = create_log(http_status: 500)
6
+ raised = create_log(error_class: "ActiveRecord::RecordNotFound")
7
+ create_log(http_status: 302)
8
+
9
+ expect(described_class.failures).to contain_exactly(server_error, raised)
10
+ end
11
+
12
+ it "orders the most recent rows first" do
13
+ old = create_log(created_at: 2.days.ago)
14
+ recent = create_log(created_at: 1.day.ago)
15
+
16
+ expect(described_class.ordered).to eq([recent, old])
17
+ end
18
+
19
+ it "can't be changed once written" do
20
+ log = create_log
21
+
22
+ expect { log.update!(http_status: 200) }.to raise_error(ActiveRecord::ReadOnlyRecord)
23
+ end
24
+
25
+ it "reads as its date, action and status" do
26
+ log = create_log(created_at: Time.zone.parse("2026-10-12 10:00"), http_status: 302)
27
+
28
+ expect(log.to_s).to eq("#{log.created_at.iso8601} – admin/pages#update – 302")
29
+ end
30
+
31
+ it "purges the rows older than the retention window" do
32
+ create_log(created_at: (Trek::AuditLog::PurgeJob.retention + 1.day).ago)
33
+ recent = create_log(created_at: (Trek::AuditLog::PurgeJob.retention - 1.day).ago)
34
+
35
+ Trek::AuditLog::PurgeJob.perform_now
36
+
37
+ expect(described_class.all).to contain_exactly(recent)
38
+ end
39
+
40
+ def create_log(**attributes)
41
+ described_class.create!(action: "admin/pages#update", http_method: "PATCH", path: "/admin/pages/1", **attributes)
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe Tag do
4
+ let(:colors) { TagCategory.create!(name: "Colors", key: "colors") }
5
+ let(:fruits) { TagCategory.create!(name: "Fruits", key: "fruits") }
6
+
7
+ describe "key" do
8
+ it "may be the same in two categories" do
9
+ described_class.create!(name: "Orange", key: "orange", category: colors)
10
+
11
+ expect(described_class.create(name: "Orange", key: "orange", category: fruits)).to be_persisted
12
+ end
13
+
14
+ it "is unique within a category" do
15
+ described_class.create!(name: "Orange", key: "orange", category: colors)
16
+
17
+ expect(described_class.new(name: "Orange", key: "orange", category: colors).tap(&:validate).errors.details[:key]).to include(error: :taken, value: "orange")
18
+ end
19
+
20
+ it "is unique among the tags without a category" do
21
+ described_class.create!(name: "Orange", key: "orange")
22
+
23
+ expect(described_class.new(name: "Orange", key: "orange").tap(&:validate).errors.details[:key]).to include(error: :taken, value: "orange")
24
+ end
25
+
26
+ # The database holds the rule too, against two saves at once
27
+ it "is unique within a category in the database" do
28
+ described_class.create!(name: "Orange", key: "orange", category: colors)
29
+
30
+ expect { described_class.new(name: "Orange", key: "orange", category: colors).save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique)
31
+ end
32
+
33
+ it "is unique among the tags without a category in the database" do
34
+ described_class.create!(name: "Orange", key: "orange")
35
+
36
+ expect { described_class.new(name: "Orange", key: "orange").save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique)
37
+ end
38
+ end
39
+
40
+ describe "category" do
41
+ # Its tags would be left without a category, where their keys could collide
42
+ it "can't be deleted while it has tags" do
43
+ described_class.create!(name: "Orange", key: "orange", category: colors)
44
+
45
+ expect(colors.destroy).to be(false)
46
+ end
47
+
48
+ it "can be deleted once empty" do
49
+ expect(colors.destroy).to be_destroyed
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,68 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe User::Session do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ let(:user) { users.create }
7
+ let(:session) { user.sessions.create! }
8
+
9
+ def come_back_after(duration)
10
+ travel duration
11
+ session.record_activity
12
+ end
13
+
14
+ it "is active right after signing in" do
15
+ expect(described_class.active).to include(session)
16
+ end
17
+
18
+ it "expires after idle_timeout without a request" do
19
+ session
20
+ travel described_class.idle_timeout + 1.minute
21
+
22
+ expect(session.reload).to be_expired
23
+ end
24
+
25
+ it "stays active while requests come in, until its lifetime" do
26
+ session
27
+ 5.times { come_back_after(described_class.idle_timeout - 1.hour) }
28
+
29
+ expect(session.reload).not_to be_expired
30
+ end
31
+
32
+ it "expires after its lifetime, even when in use" do
33
+ session
34
+ travel_to (described_class.lifetime + 1.day).from_now do
35
+ session.record_activity
36
+
37
+ expect(session).to be_expired
38
+ end
39
+ end
40
+
41
+ it "records a request without touching updated_at" do
42
+ session
43
+ travel 1.hour
44
+
45
+ expect { session.record_activity }.not_to(change { session.reload.updated_at })
46
+ end
47
+
48
+ it "records a request at most once every activity_interval" do
49
+ session
50
+ travel 1.minute
51
+
52
+ expect { session.record_activity }.not_to(change { session.reload.last_activity_at })
53
+ end
54
+
55
+ it "purges the expired sessions" do
56
+ old = user.sessions.create!(created_at: 1.year.ago, last_activity_at: 1.year.ago)
57
+
58
+ Trek::PurgeExpiredSessionsJob.perform_now
59
+
60
+ expect(described_class.where(id: [old.id, session.id]).pluck(:id)).to eq([session.id])
61
+ end
62
+
63
+ it "lists the expired sessions, for the purge" do
64
+ old = user.sessions.create!(created_at: 1.year.ago, last_activity_at: 1.year.ago)
65
+
66
+ expect(described_class.expired).to contain_exactly(old)
67
+ end
68
+ end