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,83 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin password renewal", type: :request do
4
+ let(:admin) { users.create(role: :admin) }
5
+ let(:editor) { users.create(role: :editor) }
6
+
7
+ def sign_in(user, password: users.password)
8
+ post admin_user_session_path, params: { email: user.email, password: }
9
+ end
10
+
11
+ it "lets an admin ask every user to renew their password" do
12
+ editor
13
+ sign_in(admin)
14
+
15
+ post require_password_renewal_admin_users_path
16
+
17
+ expect(editor.reload.password_renewal_required).to be(true)
18
+ end
19
+
20
+ it "doesn't let an editor ask for it" do
21
+ admin
22
+ sign_in(editor)
23
+
24
+ post require_password_renewal_admin_users_path
25
+
26
+ expect(admin.reload.password_renewal_required).to be(false)
27
+ end
28
+
29
+ context "when the password must be renewed" do
30
+ before do
31
+ editor
32
+ User.require_password_renewal!
33
+ end
34
+
35
+ it "sends the user to the new password form after signing in" do
36
+ sign_in(editor)
37
+ follow_redirect!
38
+
39
+ expect(response).to redirect_to(%r{/admin/user_password_resets/[^/]+/edit\z})
40
+ end
41
+
42
+ it "keeps the user signed out until then" do
43
+ sign_in(editor)
44
+ follow_redirect!
45
+ get admin_root_path
46
+
47
+ expect(response).to redirect_to(new_admin_user_session_path)
48
+ end
49
+
50
+ it "lets the user back in with the new password" do
51
+ sign_in(editor)
52
+ follow_redirect!
53
+ patch response.location.delete_suffix("/edit"), params: { password: "a renewed passphrase here" }
54
+ sign_in(editor, password: "a renewed passphrase here")
55
+
56
+ expect(response).to redirect_to(admin_root_url)
57
+ end
58
+ end
59
+
60
+ context "when the password has expired" do
61
+ around do |example|
62
+ User.password_expires_after = 1.year
63
+ example.run
64
+ ensure
65
+ User.password_expires_after = nil
66
+ end
67
+
68
+ it "sends the user to the new password form after signing in" do
69
+ editor.update!(password_changed_at: 2.years.ago)
70
+ sign_in(editor)
71
+ follow_redirect!
72
+
73
+ expect(response).to redirect_to(%r{/admin/user_password_resets/[^/]+/edit\z})
74
+ end
75
+
76
+ it "lets a recent password through" do
77
+ sign_in(editor)
78
+ follow_redirect!
79
+
80
+ expect(response).to have_http_status(:ok)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,95 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin sign-in lockout", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ let(:editor) { users.create(role: :editor) }
7
+
8
+ def sign_in(user = editor, password: users.password)
9
+ post admin_user_session_path, params: { email: user.email, password: }
10
+ end
11
+
12
+ def fail_sign_ins(count, user = editor)
13
+ count.times { sign_in(user, password: "not the password") }
14
+ end
15
+
16
+ def lock_then_extend
17
+ fail_sign_ins(User.sign_in_lockout_after)
18
+ travel(User.sign_in_lockout_duration + 1.second) { fail_sign_ins(1) }
19
+ end
20
+
21
+ describe "per account" do
22
+ it "refuses the right password once failures lock signing in" do
23
+ fail_sign_ins(User.sign_in_lockout_after)
24
+
25
+ sign_in
26
+
27
+ expect(response).to have_http_status(:unprocessable_entity)
28
+ end
29
+
30
+ it "answers a locked account as a wrong password, so it can't be told apart" do
31
+ fail_sign_ins(User.sign_in_lockout_after)
32
+ sign_in(password: "not the password")
33
+ wrong_password = [response.status, flash[:alert]]
34
+
35
+ sign_in
36
+
37
+ expect([response.status, flash[:alert]]).to eq(wrong_password)
38
+ end
39
+
40
+ it "lets the user in once the lock is over" do
41
+ fail_sign_ins(User.sign_in_lockout_after)
42
+
43
+ travel(User.sign_in_lockout_duration + 1.second) { sign_in }
44
+
45
+ expect(response).to redirect_to(admin_root_url)
46
+ end
47
+
48
+ it "lets the user in before failures lock signing in" do
49
+ fail_sign_ins(User.sign_in_lockout_after - 1)
50
+
51
+ sign_in
52
+
53
+ expect(response).to redirect_to(admin_root_url)
54
+ end
55
+
56
+ it "starts the count over after a successful sign-in" do
57
+ fail_sign_ins(User.sign_in_lockout_after - 1)
58
+ sign_in
59
+ delete admin_user_session_path
60
+
61
+ fail_sign_ins(1)
62
+
63
+ expect(editor.reload.sign_in_locked?).to be(false)
64
+ end
65
+
66
+ it "emails the user once when it locks, not on the failures that extend it" do
67
+ editor
68
+
69
+ expect { perform_enqueued_jobs { lock_then_extend } }.to change(ActionMailer::Base.deliveries, :size).by(1)
70
+ end
71
+ end
72
+
73
+ describe "per IP address, whatever the account" do
74
+ # The app's test cache is a null store, under which nothing is ever counted
75
+ before { allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) }
76
+
77
+ let(:limit) { Admin::UserSessionsController::FAILED_SIGN_INS_PER_IP }
78
+
79
+ it "refuses any sign-in after too many failures" do
80
+ limit.times { |i| sign_in(users.create(email: "user#{i}@example.com", role: :editor), password: "wrong") }
81
+
82
+ sign_in
83
+
84
+ expect(response).to have_http_status(:too_many_requests)
85
+ end
86
+
87
+ it "lets sign-ins from the address in again after the window" do
88
+ limit.times { |i| sign_in(users.create(email: "user#{i}@example.com", role: :editor), password: "wrong") }
89
+
90
+ travel(Admin::UserSessionsController::FAILED_SIGN_INS_WINDOW + 1.second) { sign_in }
91
+
92
+ expect(response).to redirect_to(admin_root_url)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,93 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe "Admin sign-in notices", type: :request do
4
+ include ActiveSupport::Testing::TimeHelpers
5
+
6
+ let(:editor) { users.create(role: :editor) }
7
+ let(:firefox) { "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0" }
8
+
9
+ def sign_in(user, user_agent: firefox)
10
+ post admin_user_session_path, params: { email: user.email, password: users.password }, headers: { "User-Agent" => user_agent }
11
+ end
12
+
13
+ def sign_out
14
+ delete admin_user_session_path
15
+ end
16
+
17
+ describe "the previous sign-in" do
18
+ it "keeps the sign-in notice short" do
19
+ sign_in(editor)
20
+ sign_out
21
+
22
+ sign_in(editor)
23
+
24
+ expect(flash[:notice]).to eq(I18n.t("admin.user_sessions.create.success"))
25
+ end
26
+
27
+ it "speaks the user's language, not the app's default one" do
28
+ other_locale = (I18n.available_locales - [I18n.default_locale]).first
29
+ skip "the app has a single locale" unless other_locale
30
+ user = users.create(role: :editor, locale: other_locale)
31
+
32
+ sign_in(user)
33
+
34
+ expect(flash[:notice]).to eq(I18n.t("admin.user_sessions.create.success", locale: other_locale))
35
+ end
36
+
37
+ it "records the current sign-in as the previous one for the next" do
38
+ sign_in(editor)
39
+ travel 1.day
40
+ sign_in(editor)
41
+
42
+ expect(editor.reload.last_sign_in_at).to be < editor.current_sign_in_at
43
+ end
44
+
45
+ it "shows the previous sign-in's date, browser and address on the sessions tab" do
46
+ sign_in(editor)
47
+ sign_out
48
+ sign_in(editor)
49
+
50
+ get admin_user_path(editor)
51
+
52
+ expect(response.body).to include("Firefox on Windows").and include("127.0.0.1")
53
+ end
54
+ end
55
+
56
+ it "offers to change the password when the previous sign-in wasn't the user's" do
57
+ sign_in(editor)
58
+ sign_out
59
+ sign_in(editor)
60
+
61
+ get admin_user_path(editor)
62
+
63
+ expect(response.body).to include(new_admin_user_password_reset_path(email: editor.email))
64
+ end
65
+
66
+ describe "the email on a sign-in from a new browser" do
67
+ it "isn't sent on the very first sign-in" do
68
+ expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
69
+ end
70
+
71
+ it "isn't sent from a browser already used to sign in" do
72
+ sign_in(editor)
73
+ sign_out
74
+
75
+ expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
76
+ end
77
+
78
+ it "is sent from a browser never used to sign in" do
79
+ sign_in(editor)
80
+ cookies.delete("trek_known_users")
81
+
82
+ expect { sign_in(editor) }.to have_enqueued_mail(AdminMailer, :new_sign_in)
83
+ end
84
+
85
+ it "isn't sent when the app turns it off" do
86
+ allow(User).to receive(:new_sign_in_email).and_return(false)
87
+ sign_in(editor)
88
+ cookies.delete("trek_known_users")
89
+
90
+ expect { sign_in(editor) }.not_to have_enqueued_mail(AdminMailer, :new_sign_in)
91
+ end
92
+ end
93
+ end
@@ -9,20 +9,48 @@ RSpec.describe "Admin password resets", type: :request do
9
9
  expect(response).to have_http_status(:ok)
10
10
  end
11
11
 
12
+ it "fills the reset request form with the email of the link" do
13
+ get new_admin_user_password_reset_path(email: "ada@example.com")
14
+
15
+ expect(response.body).to include('value="ada@example.com"')
16
+ end
17
+
18
+ it "keeps the reset request actions in the card, out of the sticky action bar" do
19
+ get new_admin_user_password_reset_path
20
+
21
+ expect(response.body).to include("c-trek--form--actions-aspect-seamless")
22
+ end
23
+
12
24
  it "emails a reset link to a privileged user" do
13
- expect { post admin_user_password_resets_path, params: { email: editor.email } }
14
- .to have_enqueued_mail(Admin::PasswordsMailer, :reset)
25
+ perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: editor.email } }
26
+
27
+ expect(ActionMailer::Base.deliveries.last.to).to eq([editor.email])
15
28
  end
16
29
 
17
30
  it "sends nothing for an unknown email" do
18
- expect { post admin_user_password_resets_path, params: { email: "nobody@example.com" } }
19
- .not_to have_enqueued_mail
31
+ expect { perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: "nobody@example.com" } } }
32
+ .not_to change(ActionMailer::Base.deliveries, :count)
33
+ end
34
+
35
+ it "sends nothing to a user who is not privileged" do
36
+ reader = users.create(role: :reader)
37
+
38
+ expect { perform_enqueued_jobs { post admin_user_password_resets_path, params: { email: reader.email } } }
39
+ .not_to change(ActionMailer::Base.deliveries, :count)
20
40
  end
21
41
 
22
42
  it "answers an unknown email as a known one, so it can't be used to find accounts" do
43
+ post admin_user_password_resets_path, params: { email: editor.email }
44
+ known = [response.location, flash[:notice]]
45
+
23
46
  post admin_user_password_resets_path, params: { email: "nobody@example.com" }
24
47
 
25
- expect(response).to redirect_to(new_admin_user_session_path)
48
+ expect([response.location, flash[:notice]]).to eq(known)
49
+ end
50
+
51
+ it "queues the same job for an unknown email, so the response time doesn't tell it apart" do
52
+ expect { post admin_user_password_resets_path, params: { email: "nobody@example.com" } }
53
+ .to have_enqueued_job(Admin::PasswordResetJob).with("nobody@example.com")
26
54
  end
27
55
 
28
56
  it "renders the new password form from a reset link" do
@@ -31,6 +59,20 @@ RSpec.describe "Admin password resets", type: :request do
31
59
  expect(response).to have_http_status(:ok)
32
60
  end
33
61
 
62
+ it "states the password rules on the new password form" do
63
+ get edit_admin_user_password_reset_path(editor.password_reset_token)
64
+
65
+ rules = I18n.t("admin.user_password_resets.edit.rules", min: User.password_min_length, passphrase: User.passphrase_length)
66
+
67
+ expect(response.body).to include(CGI.escapeHTML(rules))
68
+ end
69
+
70
+ it "keeps the new password actions in the card, out of the sticky action bar" do
71
+ get edit_admin_user_password_reset_path(editor.password_reset_token)
72
+
73
+ expect(response.body).to include("c-trek--form--actions-aspect-seamless")
74
+ end
75
+
34
76
  it "renders the new password form from an invitation link" do
35
77
  get edit_admin_user_password_reset_path(editor.generate_token_for(:invite))
36
78
 
@@ -53,7 +95,7 @@ RSpec.describe "Admin password resets", type: :request do
53
95
  post admin_user_session_path, params: { email: editor.email, password: users.password }
54
96
  patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
55
97
 
56
- expect(editor.user_sessions).to be_empty
98
+ expect(editor.sessions).to be_empty
57
99
  end
58
100
 
59
101
  it "sends an empty password back to the form rather than pretending to change it" do
@@ -75,4 +117,11 @@ RSpec.describe "Admin password resets", type: :request do
75
117
 
76
118
  expect(editor.reload.authenticate(users.password)).to be_truthy
77
119
  end
120
+
121
+ it "sends a recent password back to the form, saying why" do
122
+ token = editor.password_reset_token
123
+ patch admin_user_password_reset_path(token), params: { password: users.password }
124
+
125
+ expect(flash[:alert]).to include(editor.errors.generate_message(:password, :recently_used, count: User.password_history_size))
126
+ end
78
127
  end
@@ -9,6 +9,12 @@ RSpec.describe "Admin sign-in", type: :request do
9
9
  expect(response).to have_http_status(:ok)
10
10
  end
11
11
 
12
+ it "keeps the sign-in actions in the card, out of the sticky action bar" do
13
+ get new_admin_user_session_path
14
+
15
+ expect(response.body).to include("c-trek--form--actions-aspect-seamless")
16
+ end
17
+
12
18
  it "signs a privileged user in and opens the dashboard" do
13
19
  post admin_user_session_path, params: { email: editor.email, password: users.password }
14
20
  follow_redirect!
@@ -25,14 +31,37 @@ RSpec.describe "Admin sign-in", type: :request do
25
31
  it "refuses a wrong password" do
26
32
  post admin_user_session_path, params: { email: editor.email, password: "not the password" }
27
33
 
28
- expect(response).to redirect_to(new_admin_user_session_path)
34
+ expect(response).to have_http_status(:unprocessable_entity)
35
+ end
36
+
37
+ it "keeps the email typed after a wrong password" do
38
+ post admin_user_session_path, params: { email: editor.email, password: "not the password" }
39
+
40
+ expect(response.body).to include(%(value="#{editor.email}"))
29
41
  end
30
42
 
31
43
  it "refuses a user who is not privileged" do
32
44
  reader = users.create(role: :reader)
33
45
  post admin_user_session_path, params: { email: reader.email, password: users.password }
34
46
 
35
- expect(response).to redirect_to(new_admin_user_session_path)
47
+ expect(response).to have_http_status(:unprocessable_entity)
48
+ end
49
+
50
+ it "answers an unknown email as a wrong password, so it can't be used to find accounts" do
51
+ post admin_user_session_path, params: { email: editor.email, password: "not the password" }
52
+ wrong_password = [response.status, flash[:alert]]
53
+
54
+ post admin_user_session_path, params: { email: "nobody@example.com", password: "not the password" }
55
+
56
+ expect([response.status, flash[:alert]]).to eq(wrong_password)
57
+ end
58
+
59
+ it "still hashes the password typed with an unknown email, so it takes as long as a wrong password" do
60
+ allow(BCrypt::Password).to receive(:create).and_call_original
61
+
62
+ post admin_user_session_path, params: { email: "nobody@example.com", password: "not the password" }
63
+
64
+ expect(BCrypt::Password).to have_received(:create)
36
65
  end
37
66
 
38
67
  it "sends a signed-out visitor to the sign-in form" do
@@ -0,0 +1,35 @@
1
+ = render Trek::HeadingComponent.new( \
2
+ title: model_name.human(count: 2),
3
+ intro: t(".intro"),
4
+ ) do |c|
5
+ ruby:
6
+ if scope_params&.dig(:failures).present?
7
+ c.with_button(
8
+ href: [:admin, :audit_logs, filters: filter_params],
9
+ text: t("admin.actions.all"),
10
+ )
11
+ else
12
+ c.with_button(
13
+ href: [
14
+ :admin, :audit_logs,
15
+ filters: filter_params, scopes: { failures: 1 }
16
+ ],
17
+ text: t(".failures", count: scope_counts[:failures]),
18
+ )
19
+ end
20
+
21
+ = render Trek::EntriesComponent.new do |entries|
22
+ ruby:
23
+ @objects.each do |log|
24
+ entries.with_item(item: log, action: :show) do |entry|
25
+ entry.with_title(href: [:admin, log]) { log.action }
26
+ entry.with_detail { l(log.created_at, format: :long) }
27
+ entry.with_detail { log.actor.to_s } if log.actor
28
+ entry.with_detail { "#{log.http_method} #{log.http_status}".strip }
29
+ entry.with_detail { log.auditable.to_s } if log.auditable
30
+ entry.with_detail { log.error_class } if log.error_class
31
+ end
32
+ end
33
+
34
+ = render Trek::PaginationComponent.new do
35
+ = paginate @objects
@@ -0,0 +1,50 @@
1
+ = render Trek::HeadingComponent.new( \
2
+ title: @object.to_s,
3
+ intro: t(".intro"),
4
+ ) do |c|
5
+ - c.with_button(href: [:admin, :audit_logs], text: t("admin.actions.cancel"))
6
+
7
+ = render Trek::PropertiesComponent.new(model:) do |p|
8
+ = p.with_item :created_at
9
+ = l(@object.created_at, format: :long)
10
+
11
+ - if @object.actor
12
+ = p.with_item :actor
13
+ = audit_log_record_link(@object.actor)
14
+
15
+ = p.with_item :action
16
+ = @object.action
17
+
18
+ = p.with_item :http_method
19
+ code = @object.http_method
20
+
21
+ = p.with_item :path
22
+ code = @object.path
23
+
24
+ - if @object.http_status
25
+ = p.with_item :http_status
26
+ = @object.http_status
27
+
28
+ - if @object.error_class
29
+ = p.with_item :error_class
30
+ code = @object.error_class
31
+
32
+ - if @object.auditable
33
+ = p.with_item :auditable
34
+ = audit_log_record_link(@object.auditable)
35
+
36
+ - if @object.ip_address
37
+ = p.with_item :ip_address
38
+ code = @object.ip_address
39
+
40
+ - if @object.user_agent
41
+ = p.with_item :user_agent
42
+ = @object.user_agent
43
+
44
+ - if @object.request_id
45
+ = p.with_item :request_id
46
+ code = @object.request_id
47
+
48
+ - if @object.params.present?
49
+ = p.with_item :params
50
+ pre = JSON.pretty_generate(@object.params)
@@ -1,13 +1,23 @@
1
1
  = render Trek::GateComponent.new
2
2
  = form_with url: admin_user_password_reset_path(@token), method: :put do |f|
3
- = f.group :password, model_name: User.model_name
4
- = f.password_field :password,
5
- required: true,
6
- autocomplete: "new-password",
7
- maxlength: 72,
8
- minlength: 8
3
+ ruby:
4
+ rules = t(
5
+ ".rules",
6
+ min: User.password_min_length,
7
+ passphrase: User.passphrase_length,
8
+ )
9
+ = f.group :password, model_name: User.model_name, hint: rules
10
+ = render Trek::Form::PasswordStrengthComponent.new( \
11
+ min_length: User.password_min_length,
12
+ passphrase_length: User.passphrase_length,
13
+ )
14
+ = f.password_field :password,
15
+ required: true,
16
+ autocomplete: "new-password",
17
+ maxlength: 72,
18
+ minlength: User.password_min_length
9
19
 
10
- = render Trek::Form::ActionsComponent.new do |c|
20
+ = render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
11
21
  ruby:
12
22
  c.with_button(
13
23
  type: "submit",
@@ -4,9 +4,10 @@
4
4
  = f.email_field :email,
5
5
  required: true,
6
6
  autofocus: true,
7
- autocomplete: "username"
7
+ autocomplete: "username",
8
+ value: params[:email]
8
9
 
9
- = render Trek::Form::ActionsComponent.new do |c|
10
+ = render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
10
11
  ruby:
11
12
  c.with_button(
12
13
  type: "submit",
@@ -1,10 +1,11 @@
1
1
  = render Trek::GateComponent.new
2
2
  = form_with url: admin_user_session_path do |f|
3
3
  = f.group :email, model_name: User.model_name
4
+ / "webauthn" offers the site's passkeys in the field's autofill
4
5
  = f.email_field :email,
5
6
  required: true,
6
7
  autofocus: @email.nil?,
7
- autocomplete: "username",
8
+ autocomplete: "username webauthn",
8
9
  value: @email
9
10
 
10
11
  = f.group :password, model_name: User.model_name
@@ -15,7 +16,7 @@
15
16
  maxlength: 72,
16
17
  minlength: 8
17
18
 
18
- = render Trek::Form::ActionsComponent.new do |c|
19
+ = render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
19
20
  ruby:
20
21
  c.with_button(
21
22
  type: "submit",
@@ -27,3 +28,28 @@
27
28
  href: %i[new admin user_password_reset],
28
29
  text: t(".forgot_password"),
29
30
  )
31
+
32
+ / Shown by the passkey controller, in browsers that support passkeys
33
+ ruby:
34
+ passkey_form = {
35
+ url: admin_passkey_session_path,
36
+ hidden: true,
37
+ data: {
38
+ controller: "passkey",
39
+ passkey_options_url_value: options_admin_passkey_session_path,
40
+ passkey_conditional_value: true
41
+ }
42
+ }
43
+ = form_with(**passkey_form) do
44
+ = hidden_field_tag :credential, nil, data: { passkey_target: "credential" }
45
+ div(data-passkey-target="error" hidden)
46
+ = render Trek::Form::ErrorsComponent.new(errors: [t(".passkey_failure")])
47
+
48
+ = render Trek::Form::ActionsComponent.new(aspect: :seamless) do |c|
49
+ ruby:
50
+ c.with_button(
51
+ text: t(".passkey"),
52
+ data: { action: "passkey#get" },
53
+ ) do |b|
54
+ b.with_icon("trek/passkey")
55
+ end
@@ -0,0 +1,67 @@
1
+ / Shown by the passkey controller, in browsers that support passkeys: in the
2
+ / section while it's empty, below the list, in an actions bar, once it isn't.
3
+ / Submitting it creates the passkey on the device, then posts it.
4
+ ruby:
5
+ passkey_form = {
6
+ url: admin_passkeys_path,
7
+ hidden: true,
8
+ data: {
9
+ controller: "passkey",
10
+ passkey_options_url_value: options_admin_passkeys_path,
11
+ action: "submit->passkey#submit"
12
+ }
13
+ }
14
+ aspect = passkeys.any? ? :default : :seamless
15
+ - add_passkey = capture do
16
+ = form_with(**passkey_form) do
17
+ = hidden_field_tag :credential, nil, data: { passkey_target: "credential" }
18
+ div(data-passkey-target="error" hidden)
19
+ = render Trek::Form::ErrorsComponent.new(errors: [t(".failure")])
20
+ div(data-passkey-target="alreadyAdded" hidden)
21
+ = render Trek::Form::ErrorsComponent.new(errors: [t(".already_added")])
22
+
23
+ = render Trek::Form::ActionsComponent.new(aspect:) do |c|
24
+ ruby:
25
+ c.with_button(type: "submit", text: t(".add")) do |b|
26
+ b.with_icon("trek/passkey")
27
+ end
28
+
29
+ = render Trek::SectionComponent.new(title: t(".title"), hint: t(".intro"))
30
+ - if passkeys.any?
31
+ = render Trek::EntriesComponent.new do |entries|
32
+ ruby:
33
+ passkeys.each do |passkey|
34
+ entries.with_item(item: passkey) do |entry|
35
+ entry.with_title(href: nil) { passkey.to_s }
36
+ entry.with_detail do
37
+ t(".added", date: l(passkey.created_at, format: :long))
38
+ end
39
+ entry.with_detail do
40
+ if passkey.last_used_at
41
+ time = time_ago_in_words(passkey.last_used_at)
42
+ t(".last_used", time:)
43
+ else
44
+ t(".never_used")
45
+ end
46
+ end
47
+ entry.with_status do
48
+ render Trek::ButtonComponent.new(
49
+ href: admin_passkey_path(passkey),
50
+ text: t(".delete"),
51
+ size: :narrow,
52
+ color: :tomato,
53
+ data: {
54
+ turbo_method: :delete,
55
+ turbo_confirm: t(".delete_confirm"),
56
+ turbo_confirm_button: t(".delete"),
57
+ turbo_confirm_color: "tomato"
58
+ },
59
+ )
60
+ end
61
+ end
62
+ end
63
+ - else
64
+ = add_passkey
65
+
66
+ - if passkeys.any?
67
+ = add_passkey